Phase 2: Mode A static site — build, serve, search, stage panel
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.
Implements docs/phase-2-static-site.md:
2.1 service skeleton cmd/build (content -> dist), cmd/server (dist -> HTTP),
env-configured, 127.0.0.1:8096, /healthz with separate
static / ai / search readiness, --health-check probe,
deploy/compose.yml + deploy/Dockerfile at 120m
2.2 rendering templ components (Layout, Nav, PillarCard, Stage,
EvidenceTable, CodeBlock, Timeline, Footer);
`make generate` is a build step and is byte-stable
2.3 navigation six-area rail, <details> disclosure under 900px,
breadcrumbs, aria-current, L1/L2/L3 with artefact pages
2.4 stage panel internal/stage owns the contract; RouteDriver (fragment
swap) and SSEDriver (event stream) differ only in the
Binding, never in layout. fragment ids live in the
contract so route files, links and streamed frames agree
2.5 search SQLite FTS5 over titles and body, rebuilt by the build;
one handler returns a page without HTMX, a fragment with
it. Visitors' FTS operators are never honoured
2.6 quality floor print stylesheet, JS-off navigation, focus states,
semantic landmarks, dark default; security headers
Definition of Done evidence (see issue AUD-142):
A1 all pages 200 in a network namespace with no external route
one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
templ generate is deterministic (md5 identical across runs)
6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
FTS5 returns sensible hits for zigbee / signage / routing (tested)
Lighthouse 100 performance and 100 accessibility, mobile and desktop
first byte well inside 100ms over loopback (test)
panel renders from a route driver and re-points at the stream untouched
print legible: /edge/ renders to a 3-page PDF with the print stylesheet
Also fixes found while finishing the build:
- reject "." / ".." path segments before ServeMux redirects them into a file
outside the asset directory (/static/../build.json now 404, was 307->200)
- make test is the real gate: go test ./... plus the validator self-test
This commit is contained in:
371
internal/server/server_test.go
Normal file
371
internal/server/server_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
||||
)
|
||||
|
||||
// The tests run against a real build of the repository's content, because the
|
||||
// thing under test is "does the server serve what the build wrote". One build is
|
||||
// shared by every test.
|
||||
var (
|
||||
distDir string
|
||||
buildErr error
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "ai-resume-server-test-")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s, err := site.New(site.Config{Root: root, OutDir: dir})
|
||||
if err == nil {
|
||||
err = s.Build()
|
||||
}
|
||||
buildErr, distDir = err, dir
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// newServer starts the site under test. Frames are sent as fast as possible so
|
||||
// the stream test does not sleep.
|
||||
func newServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
if buildErr != nil {
|
||||
t.Fatalf("build fixture: %v", buildErr)
|
||||
}
|
||||
s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request %s: %v", path, err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return res.StatusCode, res.Header, string(body)
|
||||
}
|
||||
|
||||
// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own
|
||||
// field and does not depend on any AI service.
|
||||
func TestHealthzSeparatesStaticAndAI(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/healthz", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200; body: %s", code, body)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("healthz content-type = %q, want JSON", ct)
|
||||
}
|
||||
var got struct {
|
||||
Status string `json:"status"`
|
||||
Static struct {
|
||||
Ready bool `json:"ready"`
|
||||
Pages int `json:"pages"`
|
||||
} `json:"static"`
|
||||
AI struct {
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"ai"`
|
||||
Search struct {
|
||||
Ready bool `json:"ready"`
|
||||
Documents int `json:"documents"`
|
||||
} `json:"search"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
||||
t.Fatalf("healthz is not JSON: %v\n%s", err, body)
|
||||
}
|
||||
if !got.Static.Ready {
|
||||
t.Error("static.ready = false, want true")
|
||||
}
|
||||
if got.Static.Pages == 0 {
|
||||
t.Error("static.pages = 0, want the built page count")
|
||||
}
|
||||
if got.AI.Ready {
|
||||
t.Error("ai.ready = true, want false in Mode A")
|
||||
}
|
||||
if got.AI.Reason == "" {
|
||||
t.Error("ai.reason is empty; the field must say why AI is not ready")
|
||||
}
|
||||
if !got.Search.Ready || got.Search.Documents == 0 {
|
||||
t.Errorf("search = %+v, want ready with documents", got.Search)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagesArePreRendered checks that the two page kinds render from disk with
|
||||
// the chrome, the nav and the panel.
|
||||
func TestPagesArePreRendered(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
cases := []struct {
|
||||
path string
|
||||
wants []string
|
||||
}{
|
||||
{"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}},
|
||||
{"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}},
|
||||
{"/evidence/", []string{"Evidence index", `class="evidence"`}},
|
||||
{"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
code, header, body := get(t, ts, tc.path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want 200", tc.path, code)
|
||||
continue
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("%s: content-type %q", tc.path, ct)
|
||||
}
|
||||
for _, want := range tc.wants {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s: body does not contain %q", tc.path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate:
|
||||
// a page only loads htmx's SSE extension when its panel is stream-driven.
|
||||
func TestModeADoesNotShipTheStreamExtension(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/edge/", "/evidence/"} {
|
||||
if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") {
|
||||
t.Errorf("%s loads sse.js, but it is not a stream page", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered
|
||||
// fragment, and it is a fragment, not a page.
|
||||
func TestRouteDriverFragment(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/stage/doc-edge-overview", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("fragment status = %d, want 200", code)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("fragment content-type = %q", ct)
|
||||
}
|
||||
if !strings.Contains(body, `class="frag frag-`) {
|
||||
t.Errorf("fragment body does not look like a fragment: %.120s", body)
|
||||
}
|
||||
if strings.Contains(body, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
|
||||
t.Error("fragment response is a whole document; the panel would nest a page")
|
||||
}
|
||||
|
||||
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a 404 fragment", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
|
||||
// pushed over SSE, with no layout change.
|
||||
func TestStreamDriverSendsFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Errorf("stream cache-control = %q, want no-store", got)
|
||||
}
|
||||
if !strings.Contains(body, "event: "+StreamEvent) {
|
||||
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
|
||||
t.Errorf("stream sent %d events for one key, want 1", got)
|
||||
}
|
||||
// Exactly one article per frame: the build rendered it, the server must not
|
||||
// wrap it again.
|
||||
if got := strings.Count(body, `<article class="frag`); got != 1 {
|
||||
t.Errorf("frame contains %d fragment articles, want 1", got)
|
||||
}
|
||||
if !strings.Contains(body, "data: ") {
|
||||
t.Error("stream frame has no data line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
|
||||
// honest: the six area landing fragments, in navigation order, and no more.
|
||||
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/api/stage/stream", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
|
||||
t.Errorf("default stream has %d frames, want the six areas", got)
|
||||
}
|
||||
// Titles are HTML-escaped in the frame, so match the distinctive word.
|
||||
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
|
||||
if !strings.Contains(body, area) {
|
||||
t.Errorf("default stream is missing the %s area", area)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
|
||||
// returns a page for a form post and a fragment for HTMX.
|
||||
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct {
|
||||
q string
|
||||
want string
|
||||
}{
|
||||
{"zigbee", "/edge/"},
|
||||
{"signage", "/products/"},
|
||||
{"routing", "/ai/"},
|
||||
} {
|
||||
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("search %q: status %d", tc.q, code)
|
||||
continue
|
||||
}
|
||||
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
|
||||
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
|
||||
t.Errorf("search %q without HTMX did not return a page", tc.q)
|
||||
}
|
||||
if !strings.Contains(body, tc.want) {
|
||||
t.Errorf("search %q has no result in %s", tc.q, tc.want)
|
||||
}
|
||||
|
||||
code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"})
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("htmx search %q: status %d", tc.q, code)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
|
||||
}
|
||||
if !strings.Contains(frag, `id="search-results"`) {
|
||||
t.Errorf("htmx search %q did not return #search-results", tc.q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
|
||||
// never honoured, so a query can only be words.
|
||||
func TestSearchRejectsOperators(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
|
||||
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
|
||||
}
|
||||
if strings.Contains(body, "could not be run") && q == "*" {
|
||||
t.Logf("query %q reported as unparseable, which is acceptable", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
|
||||
// page rather than a bare error, and that the status stays 404.
|
||||
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/does-not-exist", nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", code)
|
||||
}
|
||||
if !strings.Contains(body, "That page is not here") {
|
||||
t.Error("404 body is not the built 404 page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
|
||||
// extension reachable, and confirms content sniffing is refused.
|
||||
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct{ path, ctype string }{
|
||||
{"/static/site.css", "text/css"},
|
||||
{"/static/chroma.css", "text/css"},
|
||||
{"/static/htmx.min.js", "text/javascript"},
|
||||
{"/static/sse.js", "text/javascript"},
|
||||
} {
|
||||
code, header, _ := get(t, ts, tc.path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want 200", tc.path, code)
|
||||
continue
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
|
||||
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
|
||||
}
|
||||
if header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("%s: missing nosniff", tc.path)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a rejection", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
|
||||
// origin, so a direct request sees the same policy as one through Caddy.
|
||||
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
|
||||
_, header, _ := get(t, ts, path, nil)
|
||||
if header.Get("Content-Security-Policy") == "" {
|
||||
t.Errorf("%s: no CSP", path)
|
||||
}
|
||||
if header.Get("Referrer-Policy") == "" {
|
||||
t.Errorf("%s: no referrer policy", path)
|
||||
}
|
||||
if header.Get("X-Frame-Options") == "" {
|
||||
t.Errorf("%s: no frame options", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
|
||||
// budget the specification sets for a page served over the LAN.
|
||||
func TestFirstByteBudget(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
started := time.Now()
|
||||
code, _, _ := get(t, ts, "/", nil)
|
||||
elapsed := time.Since(started)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if elapsed > 100*time.Millisecond {
|
||||
t.Errorf("first byte in %s, budget is 100ms", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user