// SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "comail/internal/buildinfo" ) // writeDist seeds a minimal Astro-style dist tree under a temp dir and // returns its absolute path. The shape mirrors what `astro build` // produces. Phase 3 adds the new pages the static handler now // allowlists (marketing, legal, docs). func writeDist(t *testing.T) string { t.Helper() root := t.TempDir() files := map[string]string{ "index.html": "", "404.html": "", "500.html": "", "privacy/index.html": "", "privacy/data-deletion/index.html": "", "security/index.html": "", // internal ref — Phase 3.5 legal/info pages migrated to Astro. "terms/index.html": "", "aup/index.html": "", "about/index.html": "", "faq/index.html": "", // internal ref — /pricing is gated by FeaturePaidTiers. // Seeded so TestStaticHandler_PricingFlagGated can exercise the // flag-on serving branch (flag-off falls through before serveFile // touches the file, so the fixture only matters for the on path). "pricing/index.html": "", "docs/index.html": "", "docs/smtp/index.html": "", "docs/send-api/index.html": "", "account/index.html": "", "account/domains/_/index.html": "", // internal ref: the verify wizard shell. Pre-fix this file // did NOT land in dist (the Astro shell at // web-frontend/src/pages/account/domains/[domain]/verify.astro // wasn't authored, so `astro build` had nothing to emit). The // static handler's domainVerifyShell intercept fell through to // the Go handler, which 303'd back to the same URL — the loop // browsers surfaced as ERR_TOO_MANY_REDIRECTS. Seeding the // fixture here mirrors the post-fix dist tree. "account/domains/_/verify/index.html": "", // internal ref / Gitea #655 — GDPR account-deletion shells. "account/settings/delete/index.html": "", "account/settings/delete/scheduled/index.html": "", // Phase 2 surfaces (internal ref). "signup/index.html": "", "enroll/verify/index.html": "", "enroll/credentials/show/index.html": "", "enroll/attest/published/index.html": "", "_astro/app.abc123.js": "console.log('astro-asset');", "_astro/app.abc123.css": "body{color:red}", "favicon.ico": "ico-bytes", // Top-level public assets — Astro copies web-frontend/public/* // to the dist root unhashed. Both Cammie variants are referenced // by the chrome (webp as favicon + hero, png as the .ico-class // fallback that Starlight + older browsers prefer over webp). "cammie.webp": "RIFF--webp-bytes", "cammie.png": "\x89PNG-png-bytes", } for rel, body := range files { full := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("mkdir %s: %v", full, err) } if err := os.WriteFile(full, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", full, err) } } return root } // htmlGET is a GET request that announces it accepts HTML — what // browsers send, what the static handler keys off to decide whether // to intercept marketing/legal/docs routes (so curl with default // Accept and API clients keep getting Templ). func htmlGET(target string) *http.Request { req := httptest.NewRequest(http.MethodGet, target, nil) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") return req } // fallbackHandler is a stand-in for the Templ-served routes. It writes // a recognizable body so tests can assert "this fell through" without // inspecting the live mux. func fallbackHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("FALLBACK:" + r.URL.Path)) }) } // TestStaticHandler_Account verifies /account serves the Astro-built // HTML page with text/html content-type and no-cache headers. func TestStaticHandler_Account(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("Content-Type=%q, want text/html prefix", ct) } if cc := rr.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") { t.Errorf("Cache-Control=%q, want no-cache", cc) } if !strings.Contains(rr.Body.String(), "data-test=account") { t.Errorf("body did not contain the account HTML marker: %q", rr.Body.String()) } } // TestStaticHandler_AccountTrailingSlash — /account/ should serve the // same page as /account. ServeMux treats them differently, so we test // both. func TestStaticHandler_AccountTrailingSlash(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "data-test=account") { t.Errorf("body did not contain the account HTML marker: %q", rr.Body.String()) } } // TestStaticHandler_DomainDetail — any /account/domains/{name} path // must serve the single sentinel SPA shell. Specifically NOT // /account/domains (the list page is still Templ in Phase 1). func TestStaticHandler_DomainDetail(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/domains/example.com", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "data-test=domain") { t.Errorf("expected the SPA shell body, got %q", rr.Body.String()) } } // TestStaticHandler_DomainsListFallsThrough — /account/domains // (no trailing segment, no trailing slash) must NOT be served by the // Astro handler in Phase 1. It belongs to the existing Templ // /account/domains handler. func TestStaticHandler_DomainsListFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/domains", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("expected fallback handler to serve /account/domains, got %q (status=%d)", body, rr.Code) } } // TestStaticHandler_DomainAddFallsThrough — /account/domains/add is // the Templ-served add-domain wizard, NOT a per-domain detail page. // The handler must let it fall through. func TestStaticHandler_DomainAddFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/domains/add", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("expected fallback for /account/domains/add, got %q (status=%d)", body, rr.Code) } } // TestStaticHandler_DomainCancelPendingFallsThrough — internal ref. // /account/domains/cancel-pending is the JSON POST endpoint for // cancelling an in-flight enrollment. Before the fix, the // /account/domains/{x} branch treated "cancel-pending" as a domain // name and served the per-domain SPA shell (text/html), which caused // the SPA's apiFetch to surface "The string did not match the // expected pattern" when parsing the HTML as JSON. func TestStaticHandler_DomainCancelPendingFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, method := range []string{http.MethodPost, http.MethodGet} { req := httptest.NewRequest(method, "/account/domains/cancel-pending", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("expected fallback for %s /account/domains/cancel-pending, got %q (status=%d)", method, body, rr.Code) } } } // TestStaticHandler_DomainVerifyShellServed — internal ref regression // guard. Pins TWO contracts that, together, prevent the // ERR_TOO_MANY_REDIRECTS loop that broke every new enrollment between // the merge of #719 (which deleted the Templ verify renderer) and the // fix of #814: // // 1. /account/domains/{x}/verify on GET/HEAD MUST be served by the // Astro static handler (HTTP 200, text/html, the verify shell body) // — NOT fall through to the Go AccountHandler.handleDomainVerify // redirect. // 2. The dist fixture writeDist() seeds must contain the file at // account/domains/_/verify/index.html. Pre-fix this fixture entry // didn't exist either; adding it here pins the build-output // contract so a future deletion of the Astro shell trips this // test before it ships. // // POST /verify still falls through — DNS resolution + /admin/enroll // proxy stays in Go (covered by the existing nested-subpath fallthrough // test below). func TestStaticHandler_DomainVerifyShellServed(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, method := range []string{http.MethodGet, http.MethodHead} { req := httptest.NewRequest(method, "/account/domains/example.com/verify", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("%s: expected 200 (shell served), got %d (body=%q) — the redirect loop is back", method, rr.Code, rr.Body.String()) } // Status alone isn't enough: a 302/303 also writes a 200-ish // body in some httptest paths. Pin the Location header is // absent AND the body is the shell (HEAD bodies are empty by // http.ServeContent contract — only assert body for GET). if loc := rr.Header().Get("Location"); loc != "" { t.Errorf("%s: unexpected Location header %q — static handler must serve in-place, not redirect", method, loc) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("%s: Content-Type=%q, want text/html prefix", method, ct) } if method == http.MethodGet && !strings.Contains(rr.Body.String(), "data-test=domain-verify") { t.Errorf("GET: expected verify shell body, got %q", rr.Body.String()) } } } // TestStaticHandler_DomainVerifyShellMissing_FallsThrough — paired with // the above. Confirms that when the dist file genuinely IS missing // (e.g. an incomplete frontend build), the static handler falls through // instead of writing a partial response. This is the regression class // that took down production: a fall-through against a Go handler that // 303s back to the same path produces ERR_TOO_MANY_REDIRECTS. The // failure mode is recoverable here because the fallback handler under // test is a stub, but the test name + comment document the trap so a // future maintainer can't accidentally remove the dist-file fixture // in writeDist() and still see the success path pass. func TestStaticHandler_DomainVerifyShellMissing_FallsThrough(t *testing.T) { // Build a dist without the verify shell. Same shape writeDist // produces minus the one file — proves the static handler doesn't // hard-code a 200 response somewhere upstream of the file check. root := t.TempDir() files := map[string]string{ "index.html": "", "account/domains/_/index.html": "", // deliberately omitted: account/domains/_/verify/index.html } for rel, body := range files { full := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("mkdir %s: %v", full, err) } if err := os.WriteFile(full, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", full, err) } } h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/domains/example.com/verify", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Fatalf("expected fall-through when dist file missing, got %q (status=%d). "+ "If the static handler now hard-codes a response for missing files, the regression "+ "surfaced in internal ref (verify-loop) will be invisible to this test.", body, rr.Code) } } // TestStaticHandler_DomainNestedSubpathFallsThrough — once the SPA // shell handles /account/domains/{x}, nested paths like // /account/domains/{x}/regenerate-key must still hit the Templ // fallback (POST forms, etc). func TestStaticHandler_DomainNestedSubpathFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account/domains/example.com/regenerate-key", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("expected fallback for nested per-domain path, got %q (status=%d)", body, rr.Code) } } // TestStaticHandler_SettingsDeleteWarning — internal ref / // Gitea #655. Browser GET /account/settings/delete must serve the // Astro warning shell. POST on the same path (the schedule-deletion // request) must fall through so the backend form handler owns it. func TestStaticHandler_SettingsDeleteWarning(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/account/settings/delete", nil)) if rr.Code != http.StatusOK { t.Fatalf("GET: expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "data-test=delete-warning") { t.Errorf("GET: expected warning shell, got %q", rr.Body.String()) } // POST /account/settings/delete is the request endpoint — // must NOT be intercepted by the static handler. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/account/settings/delete/request", nil)) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("POST /delete/request: expected fallback, got %q", rr.Body.String()) } } // TestStaticHandler_SettingsDeleteScheduled — Gitea #655. Browser GET // /account/settings/delete/scheduled serves the cooldown landing. // POST /account/settings/delete/reactivate falls through so the // backend reactivate handler owns it. GET /data-export also falls // through so the JSON archive streams from the Go handler. func TestStaticHandler_SettingsDeleteScheduled(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/account/settings/delete/scheduled", nil)) if rr.Code != http.StatusOK { t.Fatalf("GET: expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "data-test=delete-scheduled") { t.Errorf("GET: expected scheduled shell, got %q", rr.Body.String()) } for _, path := range []string{ "/account/settings/delete/reactivate", "/account/settings/data-export", } { rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, path, nil)) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("path %s: expected fallback, got %q", path, rr.Body.String()) } } } // TestStaticHandler_AstroAsset — hashed assets under /_astro/ must // be served with the immutable cache header. func TestStaticHandler_AstroAsset(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/_astro/app.abc123.js", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } cc := rr.Header().Get("Cache-Control") if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") { t.Errorf("Cache-Control=%q, want immutable max-age=31536000", cc) } } // TestStaticHandler_FallsThroughForUnallowlisted — paths outside the // allowlist must reach the fallback. Phase 2 + Phase 3 expanded the // allowlist to include /, /privacy, /security, /docs[/{slug}], // /enroll/verify, /enroll/credentials/show, /enroll/attest/published. // /signup was previously in this list too; the consolidation PR // removed it so GET /signup falls through to SignupHandler's 302→/. // The remaining pass-through paths are the Templ-served account-action // routes (sign-out, JSON APIs that aren't /api/v1/*), the legacy // /enroll wizard landing, and the OAuth dance paths // /enroll/attest/{start,callback}. Truly unknown paths fall through // here too unless they trigger the 404.html browser-GET fallback // (exercised separately). func TestStaticHandler_FallsThroughForUnallowlisted(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, path := range []string{ "/enroll", "/enroll/attest/start", "/enroll/attest/callback", "/account/sign-out", "/api/v1/me", } { req := httptest.NewRequest(http.MethodGet, path, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("path %s: expected fallback, got status=%d body=%q", path, rr.Code, body) } } } // TestStaticHandler_Marketing — GET / from a browser receives the // Astro-built marketing landing. func TestStaticHandler_Marketing(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/")) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if !strings.Contains(rr.Body.String(), "data-test=marketing") { t.Errorf("body did not contain marketing marker: %q", rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("Content-Type=%q, want text/html", ct) } } // TestStaticHandler_MarketingNonBrowserServes — internal ref Phase // 5: the marketing landing's isBrowserGET gate was dropped to match // /privacy /security /terms etc. Scripted clients (curl with default // Accept, link previewers, OG-image scrapers) now get the Astro shell // instead of the Templ MarketingLanding fallback. With /api/v1/signup // carrying the OAuth kickoff, the Templ render path is no longer the // canonical surface for ANY client shape that accepts HTML — keeping // it as a non-browser fallback was a now-obsolete graceful-degrade. func TestStaticHandler_MarketingNonBrowserServes(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) // No Accept header at all (curl default). req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "data-test=marketing") { t.Errorf("no-Accept GET /: expected Astro marketing shell, got %q", rr.Body.String()) } // JSON-only Accept. req = httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept", "application/json") rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "data-test=marketing") { t.Errorf("json-Accept GET /: expected Astro marketing shell, got %q", rr.Body.String()) } // POST must still fall through (the static handler doesn't serve // POST; the Templ MarketingLanding handler returns 405 on POST). req = httptest.NewRequest(http.MethodPost, "/", nil) rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("POST /: expected fallback (POST must NOT be served by static handler), got %q", rr.Body.String()) } } // TestStaticHandler_Legal verifies /privacy and /security each serve // their Astro page on a browser GET. func TestStaticHandler_Legal(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) cases := []struct { path string marker string }{ {"/privacy", "data-test=privacy"}, {"/privacy/", "data-test=privacy"}, {"/security", "data-test=security"}, {"/security/", "data-test=security"}, // internal ref — Phase 3.5 legal/info pages on Astro. {"/terms", "data-test=terms"}, {"/terms/", "data-test=terms"}, {"/aup", "data-test=aup"}, {"/aup/", "data-test=aup"}, {"/about", "data-test=about"}, {"/about/", "data-test=about"}, {"/faq", "data-test=faq"}, {"/faq/", "data-test=faq"}, // /pricing intentionally NOT here — gated by FeaturePaidTiers, // covered by TestStaticHandler_PricingFlagGated below. } for _, c := range cases { rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET(c.path)) if rr.Code != http.StatusOK { t.Errorf("%s: expected 200, got %d", c.path, rr.Code) continue } if !strings.Contains(rr.Body.String(), c.marker) { t.Errorf("%s: body missing %q: got %q", c.path, c.marker, rr.Body.String()) } } } // TestStaticHandler_PricingFlagGated verifies the /pricing kill switch // (internal ref). With FeaturePaidTiers off — today's prod // default — /pricing falls through to next so the mux returns 404 and // the commercial-tier surface stays dark. With the flag on, the same // request serves the Astro shell from dist without a redeploy. This is // the formerly-dead on-path that account.SetPaidTiersEnabled wiring // activated; prove both states before anyone flips the flag for a // real customer. func TestStaticHandler_PricingFlagGated(t *testing.T) { root := writeDist(t) // Flag OFF (default): /pricing must fall through to the Templ // handler, which the test stub renders as "FALLBACK:" — the live // mux returns http.NotFound here instead. off := newStaticHandler(root, fallbackHandler()) off.paidTiersOn = func(context.Context) bool { return false } rr := httptest.NewRecorder() off.ServeHTTP(rr, htmlGET("/pricing")) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("flag off /pricing: expected fallthrough to next, got %q", rr.Body.String()) } // Flag ON (live override / env): /pricing serves the Astro shell. Inject // the gate directly — no dependence on the process-global provider. on := newStaticHandler(root, fallbackHandler()) on.paidTiersOn = func(context.Context) bool { return true } rr = httptest.NewRecorder() on.ServeHTTP(rr, htmlGET("/pricing")) if rr.Code != http.StatusOK { t.Errorf("flag on /pricing: expected 200, got %d", rr.Code) } if !strings.Contains(rr.Body.String(), "data-test=pricing") { t.Errorf("flag on /pricing: body missing pricing marker, got %q", rr.Body.String()) } } // TestStaticHandler_LegalNonBrowserServes — internal ref regression. // Before the fix, /privacy and /security gated on isBrowserGET (Accept: // text/html). curl without an Accept header, link previewers, uptime // probes, and deletion-email link clicks via privacy-respecting // clients all 404'd. Legal pages must be unconditionally reachable. // internal ref extends the same guarantee to /terms /aup /about /faq. func TestStaticHandler_LegalNonBrowserServes(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) cases := []struct { path string marker string }{ {"/privacy", "data-test=privacy"}, {"/security", "data-test=security"}, {"/terms", "data-test=terms"}, {"/aup", "data-test=aup"}, {"/about", "data-test=about"}, {"/faq", "data-test=faq"}, } for _, c := range cases { // No Accept header at all (curl default). req := httptest.NewRequest(http.MethodGet, c.path, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("no-Accept GET %s: expected 200, got %d body=%q", c.path, rr.Code, rr.Body.String()) continue } if !strings.Contains(rr.Body.String(), c.marker) { t.Errorf("no-Accept GET %s: body missing %q: got %q", c.path, c.marker, rr.Body.String()) } // JSON-only Accept (some scripted probes). req = httptest.NewRequest(http.MethodGet, c.path, nil) req.Header.Set("Accept", "application/json") rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("json-Accept GET %s: expected 200, got %d", c.path, rr.Code) } } } // TestStaticHandler_PrivacyDataDeletion — internal ref. The nested // /privacy/data-deletion page is the target of the deletion-executed // email link (gdpr.URLBuilder.Retention) and 404'd in prod after // PR #714 added the page content but didn't wire the route. func TestStaticHandler_PrivacyDataDeletion(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, path := range []string{"/privacy/data-deletion", "/privacy/data-deletion/"} { rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET(path)) if rr.Code != http.StatusOK { t.Errorf("browser GET %s: status=%d body=%q", path, rr.Code, rr.Body.String()) continue } if !strings.Contains(rr.Body.String(), "data-test=privacy-data-deletion") { t.Errorf("browser GET %s: body=%q, want data-deletion marker", path, rr.Body.String()) } // Non-browser request (email link from a privacy-respecting client). req := httptest.NewRequest(http.MethodGet, path, nil) rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("no-Accept GET %s: expected 200, got %d", path, rr.Code) } } } // TestStaticHandler_PrivacyUnknownSlugFallsThrough — /privacy/{slug} // is gated to the privacySlugs allowlist so unknown slugs fall through // to the Templ handler instead of disclosing the dist layout via // timing differences or surprise 200s on a future build artifact. func TestStaticHandler_PrivacyUnknownSlugFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/privacy/not-a-page", nil) req.Header.Set("Accept", "text/html") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for unknown privacy slug, got status=%d body=%q", rr.Code, rr.Body.String()) } } // TestStaticHandler_Phase2GETServesAstro — the Phase 2 /enroll/* // GET surfaces (internal ref) resolve to the Astro shells. /signup // used to be in this list too, but the signup/landing consolidation // removed it: GET /signup now falls through to SignupHandler.handleEntry // which 302-redirects to /. POST /signup also still falls through // (covered by TestStaticHandler_Phase2POSTFallsThrough). func TestStaticHandler_Phase2GETServesAstro(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) cases := []struct { path string wantTest string }{ {"/enroll/verify", "data-test=enroll-verify"}, {"/enroll/credentials/show", "data-test=credentials-show"}, {"/enroll/attest/published", "data-test=attest-published"}, } for _, c := range cases { req := httptest.NewRequest(http.MethodGet, c.path, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("GET %s: status=%d body=%q", c.path, rr.Code, rr.Body.String()) continue } if !strings.Contains(rr.Body.String(), c.wantTest) { t.Errorf("GET %s: body=%q, want contains %q", c.path, rr.Body.String(), c.wantTest) } } } // TestStaticHandler_SignupFallsThrough — both GET and POST /signup // now reach SignupHandler. GET 302-redirects to /; POST kicks the // OAuth round-trip. The static_handler must not intercept either. func TestStaticHandler_SignupFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, c := range []struct { method string path string }{ {http.MethodGet, "/signup"}, {http.MethodGet, "/signup/"}, {http.MethodPost, "/signup"}, {http.MethodPost, "/signup/"}, } { req := httptest.NewRequest(c.method, c.path, nil) if c.method == http.MethodGet { req.Header.Set("Accept", "text/html") } rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("%s %s: expected fallback, got status=%d body=%q", c.method, c.path, rr.Code, rr.Body.String()) } } } // TestStaticHandler_DocsOverview — GET /docs serves the docs landing. func TestStaticHandler_DocsOverview(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs")) if !strings.Contains(rr.Body.String(), "data-test=docs-overview") { t.Errorf("expected docs overview, got %q (status=%d)", rr.Body.String(), rr.Code) } rr = httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs/")) if !strings.Contains(rr.Body.String(), "data-test=docs-overview") { t.Errorf("expected docs overview (trailing slash), got %q", rr.Body.String()) } } // TestStaticHandler_DocsKnownSlug — GET /docs/smtp serves the SMTP // page; the known-slug allowlist is what gates this. func TestStaticHandler_DocsKnownSlug(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs/smtp")) if !strings.Contains(rr.Body.String(), "data-test=docs-smtp") { t.Errorf("expected docs/smtp page, got %q (status=%d)", rr.Body.String(), rr.Code) } } // TestStaticHandler_DocsUnknownSlugFallsThrough — slugs Astro hasn't // emitted must fall through so the Templ docs renderer can return its // own response (or 404). func TestStaticHandler_DocsUnknownSlugFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs/this-slug-does-not-exist")) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for unknown docs slug, got %q (status=%d)", rr.Body.String(), rr.Code) } } // TestStaticHandler_DocsNestedPathFallsThrough — nested paths under a // known slug (none exist today, but a future doc might grow sub-pages) // fall through. func TestStaticHandler_DocsNestedPathFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs/smtp/troubleshoot")) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for nested docs path, got %q", rr.Body.String()) } } // TestStaticHandler_DocsArbitrarySlugFromDist — internal ref // regression test. Any /docs/{slug} whose dist/docs/{slug}/index.html // exists must serve, even if the slug was never added to a hardcoded // allowlist. Pre-fix this test would fail because the old docsSlugs // map only knew about the 8 original pages; adding glossary, // categories, quickstart-{node,python,go}, etc. required a Go change. // Post-fix the handler stats the dist file directly, so new pages // ship without touching this file. func TestStaticHandler_DocsArbitrarySlugFromDist(t *testing.T) { root := writeDist(t) // Seed a slug the handler has never heard of by name. The test // asserts the handler doesn't care about the name — only that the // file exists under dist/. novel := filepath.Join(root, "docs", "brand-new-page", "index.html") if err := os.MkdirAll(filepath.Dir(novel), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(novel, []byte(""), 0o644); err != nil { t.Fatalf("write: %v", err) } h := newStaticHandler(root, fallbackHandler()) rr := httptest.NewRecorder() h.ServeHTTP(rr, htmlGET("/docs/brand-new-page")) if !strings.Contains(rr.Body.String(), "data-test=brand-new") { t.Errorf("expected brand-new docs page from dist, got %q (status=%d)", rr.Body.String(), rr.Code) } } // TestStaticHandler_MarketingPOSTFallsThrough — the marketing page is // GET-only. POSTs to / must reach the Templ mux (whether it has a // handler registered or not is Templ's problem). func TestStaticHandler_MarketingPOSTFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("")) req.Header.Set("Accept", "text/html") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for POST /, got %q (status=%d)", rr.Body.String(), rr.Code) } } // TestStaticHandler_Phase2POSTFallsThrough — POSTs on the Phase 2 // paths MUST reach the Templ handlers; intercepting them on Astro // would break the existing form submissions (handle-only signup, // DNS verify). func TestStaticHandler_Phase2POSTFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) for _, path := range []string{ "/signup", "/enroll/verify", } { req := httptest.NewRequest(http.MethodPost, path, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("POST %s: expected fallback, got status=%d body=%q", path, rr.Code, body) } } } // TestStaticHandler_CredentialsShowJSONFallsThrough — the credentials // page has a JSON twin served by the Go handler. Requests carrying // `?format=json` or `Accept: application/json` must pass through so // the JSON endpoint answers; the Astro shell itself fetches that // JSON variant to populate. func TestStaticHandler_CredentialsShowJSONFallsThrough(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) cases := []struct { name string req func() *http.Request }{ { name: "format=json query", req: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/enroll/credentials/show?token=t&format=json", nil) }, }, { name: "Accept: application/json header", req: func() *http.Request { r := httptest.NewRequest(http.MethodGet, "/enroll/credentials/show?token=t", nil) r.Header.Set("Accept", "application/json") return r }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, c.req()) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("%s: expected fallback, got status=%d body=%q", c.name, rr.Code, body) } }) } } // TestStaticHandler_RejectsTraversal — path traversal must not // escape the dist root. The handler should fall through (or 404), // never serve files outside dist/. func TestStaticHandler_RejectsTraversal(t *testing.T) { root := writeDist(t) // Put a secret one level up from the dist root — if the handler // resolves the traversal, this is what an attacker would get. secretDir := filepath.Dir(root) secretPath := filepath.Join(secretDir, "secret.txt") if err := os.WriteFile(secretPath, []byte("TOPSECRET"), 0o644); err != nil { t.Fatalf("seed secret: %v", err) } t.Cleanup(func() { _ = os.Remove(secretPath) }) h := newStaticHandler(root, fallbackHandler()) for _, path := range []string{ "/_astro/../../secret.txt", "/account/../../secret.txt", "/_astro/%2e%2e/secret.txt", } { req := httptest.NewRequest(http.MethodGet, path, nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if strings.Contains(rr.Body.String(), "TOPSECRET") { t.Errorf("path %s leaked the secret file: body=%q", path, rr.Body.String()) } } } // TestStaticHandler_DisabledWhenRootMissing — when dist/ doesn't // exist on disk (CI build skipped, deploy mis-staged), the handler // must degrade to a pure pass-through. cmd/web continues to serve the // existing Templ routes; only the new Astro routes 404 via fallback. func TestStaticHandler_DisabledWhenRootMissing(t *testing.T) { h := newStaticHandler("/nonexistent/path/that/should/not/exist", fallbackHandler()) req := httptest.NewRequest(http.MethodGet, "/account", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() if !strings.HasPrefix(body, "FALLBACK:") { t.Errorf("expected fallback when dist root missing, got %q (status=%d)", body, rr.Code) } } // TestStaticHandler_HTMLRevalidates pins the contract that broke Scott // on 2026-05-16: Nix-built dist files have mtime=epoch (reproducibility // feature), so serving HTML with info.ModTime() + Cache-Control: // no-cache produced a permanent 304 revalidation loop. Members never // saw the updated HTML shell across deploys even though hashed JS // bundles were re-fetched. // // The fix: HTML responses (no-cache) use buildinfo.BuildDate as the // ModTime so each deploy invalidates the cache; non-HTML responses // (immutable hashed assets) keep info.ModTime() because their cache // directive already eliminates revalidation. // // This test asserts: // 1. A simulated revalidation request with If-Modified-Since at the // file's epoch mtime does NOT 304 (the bug class). // 2. A simulated revalidation request with If-Modified-Since equal // to the build date DOES 304 (cache still works within a deploy). func TestStaticHandler_HTMLRevalidates(t *testing.T) { // Pin a known BuildDate for the duration of the test so the assert // can compare against a stable value. time.RFC3339 because that's // what buildinfo.go expects. const fixedBuildDate = "2026-05-16T20:00:00Z" saved := buildinfo.BuildDate buildinfo.BuildDate = fixedBuildDate t.Cleanup(func() { buildinfo.BuildDate = saved }) root := writeDist(t) // Force the on-disk file to have mtime = epoch, mimicking the Nix // store layout that produced the original bug. epoch := time.Unix(1, 0) if err := os.Chtimes(filepath.Join(root, "account", "index.html"), epoch, epoch); err != nil { t.Fatalf("chtimes: %v", err) } h := newStaticHandler(root, fallbackHandler()) // (1) Revalidation against the on-disk epoch mtime must NOT 304 — // the handler should report Last-Modified at the build date, not // the file's mtime, so the browser's stale-cache check fails and // fresh HTML is returned. t.Run("epoch_if_modified_since_does_not_304", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/account", nil) req.Header.Set("If-Modified-Since", epoch.UTC().Format(http.TimeFormat)) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code == http.StatusNotModified { t.Errorf("revalidation against epoch If-Modified-Since returned 304 — Nix-mtime cache bug regressed") } if rr.Code != http.StatusOK { t.Errorf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } lastMod := rr.Header().Get("Last-Modified") want, _ := time.Parse(time.RFC3339, fixedBuildDate) if lastMod != want.UTC().Format(http.TimeFormat) { t.Errorf("Last-Modified=%q, want %q (build date)", lastMod, want.UTC().Format(http.TimeFormat)) } }) // (2) Revalidation against the build date SHOULD 304 — the cache // still works within a deploy; we only invalidate when the build // date bumps. t.Run("build_date_if_modified_since_returns_304", func(t *testing.T) { buildTime, _ := time.Parse(time.RFC3339, fixedBuildDate) req := httptest.NewRequest(http.MethodGet, "/account", nil) req.Header.Set("If-Modified-Since", buildTime.UTC().Format(http.TimeFormat)) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusNotModified { t.Errorf("revalidation against build-date If-Modified-Since returned %d, want 304", rr.Code) } }) } // TestStaticHandler_TopLevelPublicAssets covers the Astro public/ // passthrough that Cammie's favicon + hero image rely on. Before the // fix, /cammie.webp 404'd in prod because the handler had explicit // route cases for /favicon.ico and /_astro/* but nothing for arbitrary // top-level files in dist (which is exactly what Astro produces from // web-frontend/public/). The handler now serves single-segment paths // ending in a safe image extension from the dist root, but rejects // nested paths, unknown extensions, and missing files (so a request // for /cammie.js doesn't accidentally become a static-file XSS vector // and /missing.webp falls through to the Templ 404 instead of // returning a confusing dist-empty error). func TestStaticHandler_TopLevelPublicAssets(t *testing.T) { root := writeDist(t) h := newStaticHandler(root, fallbackHandler()) t.Run("cammie_webp_served", func(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/cammie.webp", nil)) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/webp") { t.Errorf("Content-Type=%q, want image/webp prefix", ct) } if cc := rr.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=") { t.Errorf("Cache-Control=%q, want a max-age directive", cc) } if !strings.Contains(rr.Body.String(), "webp-bytes") { t.Errorf("body did not contain the cammie.webp marker: %q", rr.Body.String()) } }) t.Run("cammie_png_served", func(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/cammie.png", nil)) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/png") { t.Errorf("Content-Type=%q, want image/png prefix", ct) } }) t.Run("missing_image_falls_through", func(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/does-not-exist.webp", nil)) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for missing image, got %q (status=%d)", rr.Body.String(), rr.Code) } }) t.Run("nested_path_falls_through", func(t *testing.T) { // Two reasons to reject nested paths: (a) the public/ folder // is flat by Astro convention, and (b) the existing /docs/*, // /privacy/*, /account/* cases own their own subtrees — we // must not shadow them with a wildcard image-serving rule. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/foo/cammie.webp", nil)) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("expected fallback for nested image path, got %q (status=%d)", rr.Body.String(), rr.Code) } }) t.Run("unsafe_extension_falls_through", func(t *testing.T) { // The allowlist is image-only. A request for /cammie.js or // /something.html must NOT be served by this case, even if // such a file happens to live at the dist root, because the // existing route cases (/, /_astro/*) own HTML/JS serving and // MIME-sniffing arbitrary top-level files is an XSS footgun. for _, ext := range []string{"js", "html", "json", "txt", "xml"} { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/cammie."+ext, nil)) if !strings.HasPrefix(rr.Body.String(), "FALLBACK:") { t.Errorf("ext=%q: expected fallback, got %q (status=%d)", ext, rr.Body.String(), rr.Code) } } }) }