NextMyDay/internal/core/web/web_test.go
Claus Lohmar dd865d4f27 feat(web): add the web shell and the PWA (SPEC-009)
- One base layout (layout.html) with the header, the nav from Registry.Nav()
  (D-038), a content slot and the CSRF token; every page renders inside it and
  a module supplies content only
- Embedded same-origin assets (app.css · app.js · webauthn.js · sw.js ·
  manifest.webmanifest · icons) served by the reserved `static` workflow
- webauthn.js drives the three ceremonies from data-* attributes — no inline
  script, so the CSP `script-src 'self'` holds (AC-3)
- manifest start_url "/" (D-011); the service worker never caches an
  authenticated response or /{GUID} (A5)
2026-10-14 10:53:57 +00:00

342 lines
12 KiB
Go

package web_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// fakeNav is the NavLister seam (SPEC-008 §7): the enabled module names.
type fakeNav []string
func (f fakeNav) Nav() []string { return f }
// renderShell renders a page and returns the body.
func renderShell(t *testing.T, p web.Page) string {
t.Helper()
rec := httptest.NewRecorder()
if err := web.Render(rec, http.StatusOK, p); err != nil {
t.Fatalf("web.Render: %v", err)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Errorf("Content-Type = %q, want text/html", got)
}
return rec.Body.String()
}
// ── the shell (SPEC-009 §4) ──────────────────────────────────────────────────
// TestShellRendersContentAndNav: every page renders inside the shell, and the
// nav comes from the injected NavLister — the registry seam.
func TestShellRendersContentAndNav(t *testing.T) {
web.SetNav(fakeNav{"home", "time", "absence"})
t.Cleanup(func() { web.SetNav(nil) })
body := renderShell(t, web.Page{
Title: "Dashboard",
GUID: "guid-1",
Active: "time",
CSRF: "tok-123",
Content: web.Markup(`<h1>Hello</h1>`),
})
for _, want := range []string{
"<!doctype html>",
"<title>Dashboard</title>",
`<h1>Hello</h1>`,
`meta name="csrf-token" content="tok-123"`,
`href="/static/app.css"`,
`href="/static/manifest.webmanifest"`,
"<nav",
`href="/guid-1/home"`,
`href="/guid-1/time"`,
`href="/guid-1/absence"`,
`aria-current="page"`,
} {
if !strings.Contains(body, want) {
t.Errorf("the shell is missing %q:\n%s", want, body)
}
}
}
// TestShellActiveNavEntry: the current module is marked, and only it.
func TestShellActiveNavEntry(t *testing.T) {
web.SetNav(fakeNav{"home", "time"})
t.Cleanup(func() { web.SetNav(nil) })
body := renderShell(t, web.Page{GUID: "g", Active: "home", Content: web.Markup("x")})
if got := strings.Count(body, `aria-current="page"`); got != 1 {
t.Errorf("aria-current appears %d times, want exactly 1:\n%s", got, body)
}
// The marked entry is `home`.
idx := strings.Index(body, `aria-current="page"`)
if !strings.Contains(body[idx-40:idx+40], "/g/home") {
t.Errorf("the wrong nav entry is marked as current:\n%s", body)
}
}
// TestShellGuestHasNoNav: an anonymous screen (enroll/login/setup/access) has
// no identity, so it has no nav.
func TestShellGuestHasNoNav(t *testing.T) {
web.SetNav(fakeNav{"home"})
t.Cleanup(func() { web.SetNav(nil) })
body := renderShell(t, web.Page{Guest: true, GUID: "g", Content: web.Markup("x")})
if strings.Contains(body, "<nav") {
t.Errorf("a guest page rendered a nav:\n%s", body)
}
}
// TestShellNoNavWithoutASource: with no installed NavLister (e.g. a unit test,
// or a core page before wiring) the shell renders without a nav rather than
// failing.
func TestShellNoNavWithoutASource(t *testing.T) {
web.SetNav(nil)
body := renderShell(t, web.Page{GUID: "g", Content: web.Markup("x")})
if strings.Contains(body, "<nav") {
t.Errorf("the shell invented a nav with no NavLister installed:\n%s", body)
}
}
// TestShellEscapesAndTrustsOnlyMarkup: everything the shell interpolates is
// escaped except the explicitly converted Markup — the flash, the title and the
// CSRF token cannot inject markup.
func TestShellEscapesAndTrustsOnlyMarkup(t *testing.T) {
body := renderShell(t, web.Page{
Title: `A "title" <script>alert(1)</script>`,
Flash: `bad <b>news</b>`,
FlashKind: "error",
CSRF: `"><script>alert(2)</script>`,
Content: web.Markup(`<h1>trusted</h1>`),
})
if strings.Contains(body, `<script>alert(1)</script>`) {
t.Error("the title was not escaped")
}
if strings.Contains(body, "bad <b>news</b>") {
t.Error("the flash was not escaped")
}
if strings.Contains(body, `<script>alert(2)</script>`) {
t.Error("the CSRF token was not escaped into the meta tag")
}
if !strings.Contains(body, "<h1>trusted</h1>") {
t.Error("explicit Markup was escaped — the content slot must render it")
}
// The banner carries its kind as a class.
if !strings.Contains(body, `class="flash error"`) {
t.Errorf("the error banner lost its kind:\n%s", body)
}
}
// ── AC-3: no inline scripts ──────────────────────────────────────────────────
// TestShellHasNoInlineScripts: the layout references same-origin files only.
// The CSP is `script-src 'self'` (SPEC-003 §4) — one inline script would force
// weakening it, so there must never be one (AC-3).
func TestShellHasNoInlineScripts(t *testing.T) {
body := renderShell(t, web.Page{GUID: "g", Content: web.Markup(`<h1>x</h1>`)})
for _, m := range scriptTag.FindAllString(body, -1) {
if !strings.Contains(m, "src=") {
t.Errorf("the shell emitted an inline <script>: %q", m)
}
}
if got := strings.Count(body, `<script`); got != 2 {
t.Errorf("the shell emitted %d script tags, want exactly the 2 same-origin files:\n%s", got, body)
}
if inlineHandler.MatchString(body) {
t.Errorf("the shell emitted an inline event handler:\n%s", body)
}
}
var (
scriptTag = regexp.MustCompile(`(?is)<script\b[^>]*>`)
inlineHandler = regexp.MustCompile(`(?is)\son[a-z]+\s*=`)
)
// ── AC-4: the manifest ───────────────────────────────────────────────────────
// manifestFile is the subset of the web app manifest this slice depends on.
type manifestFile struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
ThemeColor string `json:"theme_color"`
Background string `json:"background_color"`
Icons []struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
} `json:"icons"`
}
// TestAC4_ManifestIsValidAndStartsAtRoot: the PWA manifest parses and installs,
// and `start_url` is "/" (D-011): the door resolves the session and redirects
// to the CURRENT GUID, so an installed app survives identity rotation.
func TestAC4_ManifestIsValidAndStartsAtRoot(t *testing.T) {
raw, err := web.StaticFS.ReadFile(web.StaticDir + "/manifest.webmanifest")
if err != nil {
t.Fatalf("the manifest is not embedded: %v", err)
}
var m manifestFile
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("the manifest is not valid JSON: %v\n%s", err, raw)
}
if m.StartURL != "/" {
t.Errorf("start_url = %q, want %q (D-011)", m.StartURL, "/")
}
if m.Name == "" || m.ShortName == "" {
t.Error("the manifest has no name/short_name")
}
if m.Display != "standalone" {
t.Errorf("display = %q, want standalone", m.Display)
}
if m.Scope != "/" {
t.Errorf("scope = %q, want /", m.Scope)
}
if m.ThemeColor == "" || m.Background == "" {
t.Error("the manifest has no theme_color/background_color")
}
// Both install sizes are declared, and each icon is really embedded (and
// really a PNG).
sizes := map[string]bool{}
for _, icon := range m.Icons {
sizes[icon.Sizes] = true
if icon.Type != "image/png" {
t.Errorf("icon %s type = %q, want image/png", icon.Src, icon.Type)
}
if !strings.HasPrefix(icon.Src, web.StaticPath) {
t.Errorf("icon %s is not under %s", icon.Src, web.StaticPath)
}
data, err := web.StaticFS.ReadFile(web.StaticDir + "/" + strings.TrimPrefix(icon.Src, web.StaticPath))
if err != nil {
t.Errorf("icon %s is not embedded: %v", icon.Src, err)
continue
}
if len(data) < 8 || string(data[1:4]) != "PNG" {
t.Errorf("icon %s is not a PNG (first bytes %q)", icon.Src, data[:min(8, len(data))])
}
}
for _, want := range []string{"192x192", "512x512"} {
if !sizes[want] {
t.Errorf("the manifest declares no %s icon (AC-4); got %v", want, sizes)
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ── AC-5: the service worker's cache policy (A5) ─────────────────────────────
// TestAC5_ServiceWorkerNeverCachesAuthenticatedOrGUIDRequests asserts the
// policy: ONLY static assets are cacheable. An authenticated response and a
// /{GUID} page never are — including behind a traversal.
func TestAC5_ServiceWorkerNeverCachesAuthenticatedOrGUIDRequests(t *testing.T) {
cases := []struct {
name string
method string
path string
want bool
}{
{"static stylesheet", "GET", "/static/app.css", true},
{"static script", "GET", "/static/webauthn.js", true},
{"the worker itself", "GET", "/static/sw.js", true},
{"nested static asset", "GET", "/static/icons/deep.png", true},
{"a GUID root", "GET", "/AbCdEf123456", false},
{"a GUID page", "GET", "/AbCdEf123456/home", false},
{"a GUID module page", "GET", "/AbCdEf123456/core/modules", false},
{"the app root", "GET", "/", false},
{"the landing page", "GET", "/home", false},
{"the setup wizard", "GET", "/setup", false},
{"the recovery entry", "GET", "/access", false},
{"an action", "GET", "/AbCdEf123456?do=auth&action=passkey.add", false},
// Traversal must not smuggle an authenticated path into the cache.
{"traversal out of static", "GET", "/static/../AbCdEf123456", false},
{"traversal to the root", "GET", "/static/../..", false},
// Only GET: a mutation is never cached.
{"POST of a static path", "POST", "/static/app.css", false},
{"HEAD of a static path", "HEAD", "/static/app.css", false},
// A lookalike prefix is not the static namespace.
{"lookalike prefix", "GET", "/staticly", false},
{"static without a file", "GET", "/static", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := web.CacheablePath(tc.method, tc.path); got != tc.want {
t.Errorf("CacheablePath(%s, %s) = %v, want %v (A5)", tc.method, tc.path, got, tc.want)
}
})
}
}
// TestAC5_ServiceWorkerMirrorsThePolicy: the worker's own allowlist is the SAME
// list as the Go policy — there is no second, drifting copy of the rule — and
// the worker refuses everything else BEFORE it can reach a cache (A5).
func TestAC5_ServiceWorkerMirrorsThePolicy(t *testing.T) {
raw, err := web.StaticFS.ReadFile(web.StaticDir + "/sw.js")
if err != nil {
t.Fatalf("the service worker is not embedded: %v", err)
}
src := string(raw)
// The declared allowlist must equal the Go source of truth.
want := `var CACHEABLE_PREFIXES = ["` + strings.Join(web.StaticCachePrefixes, `", "`) + `"];`
if !strings.Contains(src, want) {
t.Errorf("the service worker's allowlist does not match web.StaticCachePrefixes.\nwant: %s\ngot:\n%s", want, src)
}
if len(web.StaticCachePrefixes) != 1 || web.StaticCachePrefixes[0] != web.StaticPath {
t.Errorf("StaticCachePrefixes = %v, want exactly [%q]", web.StaticCachePrefixes, web.StaticPath)
}
// The gate: a non-cacheable request is NOT intercepted, so an authenticated
// response cannot be served from — or written to — any cache.
if !strings.Contains(src, "if (!isCacheable(event.request)) {") {
t.Error("the fetch handler does not gate on isCacheable (A5)")
}
// The only way into a cache is inside that gate.
if got := strings.Count(src, "cache.put("); got != 1 {
t.Errorf("the worker writes to a cache in %d places, want exactly 1 (guarded) — A5", got)
}
// A cached response is only stored when the browser classified it as a
// basic (same-origin) response, and the cache is never populated at install
// time (offline is out of scope).
if strings.Contains(src, "cache.addAll") || strings.Contains(src, "caches.open(CACHE_NAME).then(function (cache) {\n return cache.addAll") {
t.Error("the worker precaches at install time; offline is out of scope (SPEC-009 §2)")
}
if !strings.Contains(src, `response.type === "basic"`) {
t.Error("the worker caches a response without checking it is same-origin")
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func TestModuleLabel(t *testing.T) {
cases := map[string]string{
"home": "Home",
"time": "Time",
"time_off": "Time off",
"hr-data": "Hr data",
"": "",
}
for in, want := range cases {
if got := web.ModuleLabel(in); got != want {
t.Errorf("ModuleLabel(%q) = %q, want %q", in, got, want)
}
}
}