825 lines
28 KiB
Go
825 lines
28 KiB
Go
package boardwalk
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path"
|
|
"regexp"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
const testPrefix = "/acme-admin"
|
|
|
|
var (
|
|
assetRef = regexp.MustCompile(`(?:src|href)="([^"]+)"`)
|
|
scriptTag = regexp.MustCompile(`<script\b[^>]*>`)
|
|
)
|
|
|
|
type apiSpy struct{ calls int }
|
|
|
|
func (s *apiSpy) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
|
s.calls++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Not found","details":{}}}`))
|
|
}
|
|
|
|
func newTestHandler(t *testing.T) (http.Handler, *apiSpy) {
|
|
t.Helper()
|
|
spy := &apiSpy{}
|
|
h, err := Handler(testPrefix, spy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h, spy
|
|
}
|
|
|
|
func get(h http.Handler, target string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
|
return rec
|
|
}
|
|
|
|
func TestIndexRewrite(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
for _, target := range []string{testPrefix, testPrefix + "/", testPrefix + "/index.html"} {
|
|
rec := get(h, target)
|
|
body := rec.Body.String()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s status=%d", target, rec.Code)
|
|
}
|
|
if !strings.Contains(body, `<meta name="summer-admin-base" content="`+testPrefix+`"`) {
|
|
t.Fatalf("%s index has no injected base: %s", target, body)
|
|
}
|
|
if strings.Contains(body, BaseToken) || strings.Contains(body, `="./`) {
|
|
t.Fatalf("%s index was not rewritten: %s", target, body)
|
|
}
|
|
if !strings.Contains(body, `src="`+testPrefix+`/assets/`) {
|
|
t.Fatalf("%s index script is not under the prefix: %s", target, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRewriteIndexRequiresToken(t *testing.T) {
|
|
if _, err := RewriteIndex([]byte(`<html><script src="./assets/a.js"></script></html>`), testPrefix); err == nil {
|
|
t.Fatal("index without the base token was accepted")
|
|
}
|
|
root := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
|
if _, err := newHandler(root, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
|
|
t.Fatalf("stale dist accepted: %v", err)
|
|
}
|
|
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil {
|
|
t.Fatal("dist without index.html accepted")
|
|
}
|
|
if _, err := Handler(testPrefix, nil); err == nil {
|
|
t.Fatal("nil API not-found handler accepted")
|
|
}
|
|
if _, err := Handler("acme-admin", &apiSpy{}); err == nil {
|
|
t.Fatal("prefix without a leading slash accepted")
|
|
}
|
|
}
|
|
|
|
func TestRewriteIndexEscapesPrefix(t *testing.T) {
|
|
out, err := RewriteIndex([]byte(`<meta content="`+BaseToken+`"><script src="./a.js"></script>`), `/a"b`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(out), `"/a"b`) || !strings.Contains(string(out), `/a"b`) {
|
|
t.Fatalf("prefix not escaped: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestEveryReferencedAssetIsEmbedded(t *testing.T) {
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := fs.ReadFile(root, "index.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index, err := RewriteIndex(raw, testPrefix)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refs := assetRef.FindAllStringSubmatch(string(index), -1)
|
|
if len(refs) == 0 {
|
|
t.Fatal("index references no assets")
|
|
}
|
|
h, _ := newTestHandler(t)
|
|
for _, ref := range refs {
|
|
target := ref[1]
|
|
if !strings.HasPrefix(target, testPrefix+"/") {
|
|
t.Fatalf("reference %q is not under the prefix", target)
|
|
}
|
|
name := strings.TrimPrefix(target, testPrefix+"/")
|
|
if _, err := fs.Stat(root, name); err != nil {
|
|
t.Fatalf("index references %s, missing from the embedded dist: %v", name, err)
|
|
}
|
|
if rec := get(h, target); rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s status=%d", target, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNoInlineScript(t *testing.T) {
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := fs.ReadFile(root, "index.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tags := scriptTag.FindAllString(string(raw), -1)
|
|
if len(tags) == 0 {
|
|
t.Fatal("index has no module script")
|
|
}
|
|
for _, tag := range tags {
|
|
if !strings.Contains(tag, " src=") {
|
|
t.Fatalf("inline script in index.html: %s", tag)
|
|
}
|
|
}
|
|
if strings.Contains(strings.ToLower(string(raw)), "javascript:") {
|
|
t.Fatal("index.html contains a javascript: URL")
|
|
}
|
|
}
|
|
|
|
func TestAPIPathsAreDelegated(t *testing.T) {
|
|
h, spy := newTestHandler(t)
|
|
for _, target := range []string{testPrefix + "/api", testPrefix + "/api/", testPrefix + "/api/v1/nope", testPrefix + "/api/v1/auth/login"} {
|
|
before := spy.calls
|
|
rec := get(h, target)
|
|
if spy.calls != before+1 || rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
|
t.Fatalf("%s was not delegated: status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
before := spy.calls
|
|
if rec := get(h, testPrefix+"/apiary"); rec.Code != http.StatusOK || spy.calls != before {
|
|
t.Fatalf("/apiary is a client route, status=%d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMissingFileWithExtensionIs404(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/missing.ico", testPrefix + "/golem/missing.css"} {
|
|
rec := get(h, target)
|
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "summer-admin-base") {
|
|
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
for _, target := range []string{testPrefix + "/acme/demo/widgets", testPrefix + "/acme/demo/widgets/12", testPrefix + "/login"} {
|
|
rec := get(h, target)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
|
|
t.Fatalf("client route %s status=%d", target, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTraversalIsCleaned(t *testing.T) {
|
|
root := fstest.MapFS{
|
|
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
|
|
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
|
|
}
|
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, target := range []string{
|
|
testPrefix + "/../../../go.mod",
|
|
testPrefix + "/assets/../../boardwalk.go",
|
|
testPrefix + "/%2e%2e/%2e%2e/etc/passwd.txt",
|
|
} {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.URL.Path = target
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "module ") || strings.Contains(rec.Body.String(), "package ") {
|
|
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.URL.Path = testPrefix + "/assets/../index.html"
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), BaseToken) {
|
|
t.Fatalf("cleaned index path served the raw index: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDirectoryIsNeverListed(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := fs.ReadDir(root, "assets")
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("embedded assets: %v", err)
|
|
}
|
|
for _, target := range []string{testPrefix + "/assets", testPrefix + "/assets/"} {
|
|
rec := get(h, target)
|
|
body := rec.Body.String()
|
|
if strings.Contains(body, entries[0].Name()) || !strings.Contains(body, "summer-admin-base") {
|
|
t.Fatalf("%s listed the directory or skipped the shell: %s", target, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContentTypesAndCaching(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]string{
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".woff2": "font/woff2",
|
|
".woff": "font/woff",
|
|
}
|
|
seen := map[string]bool{}
|
|
err = fs.WalkDir(root, "assets", func(name string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
ext := path.Ext(name)
|
|
ct, ok := want[ext]
|
|
if !ok || seen[ext] {
|
|
return nil
|
|
}
|
|
seen[ext] = true
|
|
rec := get(h, testPrefix+"/"+name)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != ct {
|
|
t.Fatalf("%s status=%d type=%q, want %q", name, rec.Code, rec.Header().Get("Content-Type"), ct)
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
|
t.Fatalf("%s Cache-Control=%q", name, cc)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for ext := range want {
|
|
if !seen[ext] {
|
|
t.Fatalf("embedded dist has no %s asset", ext)
|
|
}
|
|
}
|
|
index := get(h, testPrefix)
|
|
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("index headers = %v", index.Header())
|
|
}
|
|
if got := ContentType("x.svg"); got != "image/svg+xml" {
|
|
t.Fatalf("svg type %q", got)
|
|
}
|
|
favicon := get(h, testPrefix+"/favicon.ico")
|
|
if favicon.Code != http.StatusOK || favicon.Header().Get("Content-Type") != "image/x-icon" || favicon.Header().Get("Cache-Control") != "no-cache" {
|
|
t.Fatalf("favicon headers = status %d, %v", favicon.Code, favicon.Header())
|
|
}
|
|
if got := ContentType("x.json"); got != "application/json" {
|
|
t.Fatalf("json type %q", got)
|
|
}
|
|
if got := ContentType("x.unknown-ext"); got != "application/octet-stream" {
|
|
t.Fatalf("unknown type %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersOnEveryResponse(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := fs.ReadDir(root, "assets")
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("embedded assets: %v", err)
|
|
}
|
|
for _, target := range []string{
|
|
testPrefix,
|
|
testPrefix + "/acme/demo/widgets",
|
|
testPrefix + "/assets/" + entries[0].Name(),
|
|
testPrefix + "/missing.js",
|
|
testPrefix + "/api/v1/nope",
|
|
} {
|
|
rec := get(h, target)
|
|
for header, want := range map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "same-origin",
|
|
"X-Frame-Options": "DENY",
|
|
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'",
|
|
"X-Robots-Tag": "noindex, nofollow",
|
|
} {
|
|
if got := rec.Header().Get(header); got != want {
|
|
t.Fatalf("%s %s=%q, want %q", target, header, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPhase10BoardwalkServing covers the remaining serving branches: HEAD,
|
|
// query strings, encoded traversal, the index requested by name, a nested
|
|
// prefix, deep client routes, MIME fallback for unknown extensions and the
|
|
// constructor's error paths.
|
|
func TestPhase10BoardwalkServing(t *testing.T) {
|
|
h, spy := newTestHandler(t)
|
|
|
|
t.Run("HEAD answers headers without a body", func(t *testing.T) {
|
|
for _, target := range []string{testPrefix, testPrefix + "/acme/demo/widgets"} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, target, nil))
|
|
if rec.Code != http.StatusOK || rec.Body.Len() != 0 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("HEAD %s: status=%d len=%d type=%q", target, rec.Code, rec.Body.Len(), rec.Header().Get("Content-Type"))
|
|
}
|
|
if rec.Header().Get("Content-Length") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
|
|
t.Fatalf("HEAD %s headers = %v", target, rec.Header())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("query strings do not change what is served", func(t *testing.T) {
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := fs.ReadDir(root, "assets")
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("assets: %v", err)
|
|
}
|
|
asset := get(h, testPrefix+"/assets/"+entries[0].Name()+"?v=2")
|
|
if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
|
t.Fatalf("asset with query: status=%d headers=%v", asset.Code, asset.Header())
|
|
}
|
|
route := get(h, testPrefix+"/acme/demo/widgets?search=blue&page=2")
|
|
if route.Code != http.StatusOK || !strings.Contains(route.Body.String(), "summer-admin-base") {
|
|
t.Fatalf("client route with query: status=%d", route.Code)
|
|
}
|
|
before := spy.calls
|
|
if rec := get(h, testPrefix+"/api/v1/nope?x=1"); rec.Code != http.StatusNotFound || spy.calls != before+1 {
|
|
t.Fatalf("api with query not delegated: %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("encoded traversal never escapes the build", func(t *testing.T) {
|
|
for _, target := range []string{
|
|
testPrefix + "/%2e%2e/%2e%2e/go.mod",
|
|
testPrefix + "/assets/..%2f..%2fboardwalk.go",
|
|
testPrefix + "/%2E%2E%2F%2E%2E%2Fgo.sum",
|
|
} {
|
|
rec := get(h, target)
|
|
body := rec.Body.String()
|
|
if rec.Code != http.StatusNotFound || strings.Contains(body, "module ") || strings.Contains(body, "package boardwalk") {
|
|
t.Fatalf("%s status=%d body=%.80s", target, rec.Code, body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("index.html by name is the rewritten index", func(t *testing.T) {
|
|
byName := get(h, testPrefix+"/index.html")
|
|
root := get(h, testPrefix+"/")
|
|
if byName.Code != http.StatusOK || byName.Body.String() != root.Body.String() {
|
|
t.Fatalf("index.html differs from the root index")
|
|
}
|
|
if strings.Contains(byName.Body.String(), BaseToken) || byName.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("index.html served raw or cacheable: %v", byName.Header())
|
|
}
|
|
})
|
|
|
|
t.Run("nested prefix with a trailing slash", func(t *testing.T) {
|
|
nested, err := Handler("/ops/admin/", &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := get(nested, "/ops/admin/acme/demo/widgets/12")
|
|
body := rec.Body.String()
|
|
if rec.Code != http.StatusOK || !strings.Contains(body, `content="/ops/admin"`) || !strings.Contains(body, `src="/ops/admin/assets/`) {
|
|
t.Fatalf("nested prefix index: %d %s", rec.Code, body)
|
|
}
|
|
if rec := get(nested, "/ops/admin/api/v1/x"); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
|
t.Fatalf("nested prefix api not delegated: %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("deep extensionless paths are client routes", func(t *testing.T) {
|
|
for _, target := range []string{testPrefix + "/a/b/c/d/e/f", testPrefix + "/settings/mail", testPrefix + "/assets"} {
|
|
rec := get(h, target)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
|
|
t.Fatalf("%s status=%d", target, rec.Code)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("unknown extensions fall back to the mime table, then octet-stream", func(t *testing.T) {
|
|
root := fstest.MapFS{
|
|
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
|
|
"robots.txt": &fstest.MapFile{Data: []byte("User-agent: *\n")},
|
|
"assets/logo.png": &fstest.MapFile{Data: []byte("\x89PNG")},
|
|
"assets/blob.zzzext": &fstest.MapFile{Data: []byte("x")},
|
|
}
|
|
mapped, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for target, want := range map[string]string{
|
|
"/robots.txt": "text/plain; charset=utf-8",
|
|
"/assets/logo.png": "image/png",
|
|
"/assets/blob.zzzext": "application/octet-stream",
|
|
} {
|
|
rec := get(mapped, testPrefix+target)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != want {
|
|
t.Fatalf("%s type=%q, want %q", target, rec.Header().Get("Content-Type"), want)
|
|
}
|
|
}
|
|
if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" {
|
|
t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc)
|
|
}
|
|
if got := ContentType("UPPER.JS"); got != "text/javascript; charset=utf-8" {
|
|
t.Fatalf("extension case: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("constructor rejects a nil API handler, a relative prefix and a build without index", func(t *testing.T) {
|
|
if _, err := Handler(testPrefix, nil); err == nil {
|
|
t.Fatal("nil notFoundAPI accepted")
|
|
}
|
|
if _, err := Handler("backend", &apiSpy{}); err == nil {
|
|
t.Fatal("relative prefix accepted")
|
|
}
|
|
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), "index.html") {
|
|
t.Fatalf("missing index: %v", err)
|
|
}
|
|
stale := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
|
if _, err := newHandler(stale, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
|
|
t.Fatalf("stale index: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPhase101BoardwalkExports covers the two helpers cabana reuses for
|
|
// plugin assets (Phase 10.1 D-16): ContentType's explicit JavaScript and CSS
|
|
// types and its fallbacks, and SetSecurityHeaders' full admin header set.
|
|
func TestPhase101BoardwalkExports(t *testing.T) {
|
|
for name, want := range map[string]string{
|
|
"assets/js/lookup.js": "text/javascript; charset=utf-8",
|
|
"assets/js/lookup.MJS": "text/javascript; charset=utf-8",
|
|
"assets/css/gadgets.css": "text/css; charset=utf-8",
|
|
"fonts/inter.woff2": "font/woff2",
|
|
"file.no-such-extension": "application/octet-stream",
|
|
"no-extension": "application/octet-stream",
|
|
} {
|
|
if got := ContentType(name); got != want {
|
|
t.Fatalf("ContentType(%q) = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
|
|
h := http.Header{}
|
|
h.Set("Content-Security-Policy", "default-src *")
|
|
SetSecurityHeaders(h)
|
|
want := map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "same-origin",
|
|
"X-Frame-Options": "DENY",
|
|
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'",
|
|
"X-Robots-Tag": "noindex, nofollow",
|
|
}
|
|
if len(h) != len(want) {
|
|
t.Fatalf("headers = %v", h)
|
|
}
|
|
for key, value := range want {
|
|
if got := h.Values(key); len(got) != 1 || got[0] != value {
|
|
t.Fatalf("%s = %v, want %q", key, got, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildFixture is an in-memory dist whose index carries both tokens.
|
|
func buildFixture() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"index.html": &fstest.MapFile{Data: []byte(`<html><head>` +
|
|
`<meta name="summer-admin-base" content="` + BaseToken + `" />` +
|
|
`<meta name="summer-admin-build" content="` + BuildToken + `" />` +
|
|
`<script type="module" src="./assets/app.js"></script></head></html>`)},
|
|
"assets/app.js": &fstest.MapFile{Data: []byte(`console.log("acme")`)},
|
|
}
|
|
}
|
|
|
|
func TestBuildSmoke(t *testing.T) {
|
|
root := buildFixture()
|
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want, err := BuildID(root, buildInfo())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index := get(h, testPrefix+"/")
|
|
if !strings.Contains(index.Body.String(), `<meta name="summer-admin-build" content="`+want+`" />`) {
|
|
t.Fatalf("index has no build meta %q: %s", want, index.Body.String())
|
|
}
|
|
build := get(h, testPrefix+"/"+BuildPath)
|
|
if build.Body.String() != `{"build":"`+want+`"}` {
|
|
t.Fatalf("build.json = %s, want %s", build.Body.String(), want)
|
|
}
|
|
for _, rec := range []*httptest.ResponseRecorder{index, build} {
|
|
if got := rec.Header().Get(BuildHeader); got != want {
|
|
t.Fatalf("%s = %q, want %q", BuildHeader, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
var hexID = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
|
|
|
func mustBuildID(t *testing.T, root fs.FS, info *debug.BuildInfo) string {
|
|
t.Helper()
|
|
id, err := BuildID(root, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !hexID.MatchString(id) {
|
|
t.Fatalf("build id %q is not 12 lowercase hex characters", id)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestBuildID(t *testing.T) {
|
|
root := buildFixture()
|
|
base := mustBuildID(t, root, nil)
|
|
if again := mustBuildID(t, buildFixture(), nil); again != base {
|
|
t.Fatalf("same tree gave %q and %q", base, again)
|
|
}
|
|
|
|
changed := buildFixture()
|
|
changed["assets/app.js"] = &fstest.MapFile{Data: []byte(`console.log("acme!")`)}
|
|
added := buildFixture()
|
|
added["assets/extra.css"] = &fstest.MapFile{Data: []byte(`body{}`)}
|
|
renamed := buildFixture()
|
|
renamed["assets/app2.js"] = renamed["assets/app.js"]
|
|
delete(renamed, "assets/app.js")
|
|
for name, tree := range map[string]fstest.MapFS{"changed file": changed, "added file": added, "renamed file": renamed} {
|
|
if id := mustBuildID(t, tree, nil); id == base {
|
|
t.Fatalf("%s kept the id %q", name, id)
|
|
}
|
|
}
|
|
|
|
if _, err := BuildID(fstest.MapFS{}, nil); err != nil {
|
|
t.Fatalf("empty tree: %v", err)
|
|
}
|
|
mustBuildID(t, fstest.MapFS{}, nil)
|
|
}
|
|
|
|
// buildInfoFixture is a hand-built build info; tests never depend on the test
|
|
// binary's real build information for exact values.
|
|
func buildInfoFixture() *debug.BuildInfo {
|
|
return &debug.BuildInfo{
|
|
GoVersion: "go1.26.0",
|
|
Path: "example.com/acme/cmd/acme",
|
|
Main: debug.Module{Path: "example.com/acme", Version: "v1.2.3", Sum: "h1:main="},
|
|
Deps: []*debug.Module{
|
|
{Path: "example.com/blog", Version: "v0.4.0", Sum: "h1:blog="},
|
|
{
|
|
Path: "git.golem15.com/golem15/summercms", Version: "v0.9.0", Sum: "h1:framework=",
|
|
Replace: &debug.Module{Path: "../summercms.go", Version: "", Sum: ""},
|
|
},
|
|
},
|
|
Settings: []debug.BuildSetting{
|
|
{Key: "GOOS", Value: "linux"},
|
|
{Key: "vcs.revision", Value: "abc123"},
|
|
{Key: "vcs.modified", Value: "false"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestBuildIDBuildInfo(t *testing.T) {
|
|
root := buildFixture()
|
|
distOnly := mustBuildID(t, root, nil)
|
|
base := mustBuildID(t, root, buildInfoFixture())
|
|
if base == distOnly {
|
|
t.Fatal("build info did not change the id")
|
|
}
|
|
if again := mustBuildID(t, root, buildInfoFixture()); again != base {
|
|
t.Fatalf("same info gave %q and %q", base, again)
|
|
}
|
|
reversed := buildInfoFixture()
|
|
slices.Reverse(reversed.Deps)
|
|
if id := mustBuildID(t, root, reversed); id != base {
|
|
t.Fatalf("dependency order changed the id: %q vs %q", id, base)
|
|
}
|
|
|
|
setting := func(info *debug.BuildInfo, key, value string) {
|
|
for i := range info.Settings {
|
|
if info.Settings[i].Key == key {
|
|
info.Settings[i].Value = value
|
|
}
|
|
}
|
|
}
|
|
changes := map[string]func(*debug.BuildInfo){
|
|
"vcs.revision": func(i *debug.BuildInfo) { setting(i, "vcs.revision", "def456") },
|
|
"vcs.modified": func(i *debug.BuildInfo) { setting(i, "vcs.modified", "true") },
|
|
"main version": func(i *debug.BuildInfo) { i.Main.Version = "v1.2.4" },
|
|
"main sum": func(i *debug.BuildInfo) { i.Main.Sum = "h1:other=" },
|
|
"go version": func(i *debug.BuildInfo) { i.GoVersion = "go1.26.1" },
|
|
"dependency version": func(i *debug.BuildInfo) { i.Deps[0].Version = "v0.4.1" },
|
|
"dependency sum": func(i *debug.BuildInfo) { i.Deps[0].Sum = "h1:blog2=" },
|
|
"added dependency": func(i *debug.BuildInfo) {
|
|
i.Deps = append(i.Deps, &debug.Module{Path: "example.com/shop", Version: "v1.0.0", Sum: "h1:shop="})
|
|
},
|
|
"replace version": func(i *debug.BuildInfo) { i.Deps[1].Replace.Version = "v0.9.1" },
|
|
}
|
|
for name, change := range changes {
|
|
info := buildInfoFixture()
|
|
change(info)
|
|
if id := mustBuildID(t, root, info); id == base {
|
|
t.Fatalf("%s kept the id %q", name, id)
|
|
}
|
|
}
|
|
|
|
unrelated := buildInfoFixture()
|
|
setting(unrelated, "GOOS", "darwin")
|
|
unrelated.Settings = append(unrelated.Settings, debug.BuildSetting{Key: "-tags", Value: "acme"})
|
|
if id := mustBuildID(t, root, unrelated); id != base {
|
|
t.Fatalf("an unrelated setting changed the id: %q vs %q", id, base)
|
|
}
|
|
|
|
novcs := buildInfoFixture()
|
|
novcs.Settings = []debug.BuildSetting{{Key: "GOOS", Value: "linux"}}
|
|
mustBuildID(t, root, novcs)
|
|
}
|
|
|
|
func stubBuildInfo(t *testing.T, info *debug.BuildInfo, ok bool) {
|
|
t.Helper()
|
|
saved := readBuildInfo
|
|
readBuildInfo = func() (*debug.BuildInfo, bool) { return info, ok }
|
|
t.Cleanup(func() { readBuildInfo = saved })
|
|
}
|
|
|
|
// servedBuild returns the build id the handler serves in the meta, the
|
|
// header and build.json, failing when they disagree.
|
|
func servedBuild(t *testing.T, h http.Handler) string {
|
|
t.Helper()
|
|
index := get(h, testPrefix+"/")
|
|
meta := regexp.MustCompile(`<meta name="summer-admin-build" content="([^"]*)"`).FindStringSubmatch(index.Body.String())
|
|
if meta == nil {
|
|
t.Fatalf("index has no build meta: %s", index.Body.String())
|
|
}
|
|
rec := get(h, testPrefix+"/"+BuildPath)
|
|
var body struct{ Build string }
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("build.json %s: %v", rec.Body.String(), err)
|
|
}
|
|
header := index.Header().Get(BuildHeader)
|
|
if meta[1] != body.Build || header != body.Build {
|
|
t.Fatalf("meta %q, build.json %q and header %q disagree", meta[1], body.Build, header)
|
|
}
|
|
return body.Build
|
|
}
|
|
|
|
func TestBuildInfoFallback(t *testing.T) {
|
|
root := buildFixture()
|
|
|
|
stubBuildInfo(t, nil, false)
|
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := servedBuild(t, h), mustBuildID(t, root, nil); got != want {
|
|
t.Fatalf("without build info served %q, want the dist id %q", got, want)
|
|
}
|
|
|
|
fixture := buildInfoFixture()
|
|
stubBuildInfo(t, fixture, true)
|
|
h, err = newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := servedBuild(t, h)
|
|
if want := mustBuildID(t, root, fixture); first != want {
|
|
t.Fatalf("with build info served %q, want %q", first, want)
|
|
}
|
|
|
|
redeploy := buildInfoFixture()
|
|
redeploy.Settings[1].Value = "def456"
|
|
stubBuildInfo(t, redeploy, true)
|
|
h, err = newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second := servedBuild(t, h); second == first {
|
|
t.Fatalf("a new vcs.revision served the same id %q", second)
|
|
}
|
|
}
|
|
|
|
func TestBuildHeaderOnEveryResponse(t *testing.T) {
|
|
h, spy := newTestHandler(t)
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := mustBuildID(t, root, buildInfo())
|
|
entries, err := fs.ReadDir(root, "assets")
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("embedded assets: %v", err)
|
|
}
|
|
for _, target := range []string{
|
|
testPrefix + "/",
|
|
testPrefix + "/index.html",
|
|
testPrefix + "/acme/demo/widgets",
|
|
testPrefix + "/assets/" + entries[0].Name(),
|
|
testPrefix + "/missing.js",
|
|
testPrefix + "/" + BuildPath,
|
|
testPrefix + "/api/v1/nope",
|
|
} {
|
|
if got := get(h, target).Header().Get(BuildHeader); got != want {
|
|
t.Fatalf("%s %s=%q, want %q", target, BuildHeader, got, want)
|
|
}
|
|
}
|
|
if spy.calls != 1 {
|
|
t.Fatalf("api miss reached the spy %d times", spy.calls)
|
|
}
|
|
}
|
|
|
|
func TestBuildEndpoint(t *testing.T) {
|
|
root := buildFixture()
|
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := get(h, testPrefix+"/"+BuildPath)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
|
t.Fatalf("Content-Type=%q", got)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
|
t.Fatalf("Cache-Control=%q", got)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("body %s: %v", rec.Body.String(), err)
|
|
}
|
|
if len(body) != 1 || body["build"] != mustBuildID(t, root, buildInfo()) {
|
|
t.Fatalf("body = %v", body)
|
|
}
|
|
|
|
// The build route wins over a dist file of the same name.
|
|
shadowed := buildFixture()
|
|
shadowed[BuildPath] = &fstest.MapFile{Data: []byte(`{"build":"stale"}`)}
|
|
h, err = newHandler(shadowed, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := get(h, testPrefix+"/"+BuildPath).Body.String(); strings.Contains(got, "stale") {
|
|
t.Fatalf("dist build.json shadowed the route: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildToken(t *testing.T) {
|
|
root := buildFixture()
|
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := get(h, testPrefix+"/").Body.String()
|
|
if strings.Contains(body, BuildToken) {
|
|
t.Fatalf("token left in the index: %s", body)
|
|
}
|
|
if !strings.Contains(body, `content="`+mustBuildID(t, root, buildInfo())+`"`) {
|
|
t.Fatalf("meta not replaced: %s", body)
|
|
}
|
|
|
|
raw := []byte(`<html><meta name="summer-admin-base" content="` + BaseToken + `" /><script src="./assets/app.js"></script></html>`)
|
|
plain := fstest.MapFS{
|
|
"index.html": &fstest.MapFile{Data: raw},
|
|
"assets/app.js": &fstest.MapFile{Data: []byte(`1`)},
|
|
}
|
|
h, err = newHandler(plain, testPrefix, &apiSpy{})
|
|
if err != nil {
|
|
t.Fatalf("index without the build token: %v", err)
|
|
}
|
|
want, err := RewriteIndex(raw, testPrefix)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := get(h, testPrefix+"/").Body.String(); got != string(want) {
|
|
t.Fatalf("index without the token changed:\n%s\nwant\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestEmbeddedBuild(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
root, err := Dist()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := servedBuild(t, h), mustBuildID(t, root, buildInfo()); got != want {
|
|
t.Fatalf("embedded build served %q, want %q", got, want)
|
|
}
|
|
}
|