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(`

Hello

`), }) for _, want := range []string{ "", "Dashboard", `

Hello

`, `meta name="csrf-token" content="tok-123"`, `href="/static/app.css"`, `href="/static/manifest.webmanifest"`, "alert(1)`, Flash: `bad news`, FlashKind: "error", CSRF: `">`, Content: web.Markup(`

trusted

`), }) if strings.Contains(body, ``) { t.Error("the title was not escaped") } if strings.Contains(body, "bad news") { t.Error("the flash was not escaped") } if strings.Contains(body, ``) { t.Error("the CSRF token was not escaped into the meta tag") } if !strings.Contains(body, "

trusted

") { 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) } } // ── SPEC-010: the role-gated core nav and the passkey banner ───────────────── // TestShellCoreNavIsRoleGated: Home · Account appear for everyone; Users · // Modules appear only when the Page marks an admin (SPEC-010 §3). func TestShellCoreNavIsRoleGated(t *testing.T) { web.SetNav(fakeNav{"home"}) t.Cleanup(func() { web.SetNav(nil) }) admin := renderShell(t, web.Page{GUID: "g", Active: "home", Admin: true, Content: web.Markup("x")}) for _, want := range []string{ `href="/g/home"`, `href="/g/account"`, `href="/g/users"`, `href="/g/core/modules"`, } { if !strings.Contains(admin, want) { t.Errorf("an admin's nav is missing %q:\n%s", want, admin) } } // The discovered `home` module is collapsed onto the core Home entry — the // nav never lists the same target twice (the brand link also points home, // so we count the labelled nav entry). if got := strings.Count(admin, ">Home"); got != 1 { t.Errorf("the nav lists Home %d times, want 1", got) } member := renderShell(t, web.Page{GUID: "g", Active: "home", Content: web.Markup("x")}) if !strings.Contains(member, `href="/g/home"`) || !strings.Contains(member, `href="/g/account"`) { t.Errorf("a non-admin is missing Home/Account:\n%s", member) } if strings.Contains(member, `href="/g/users"`) || strings.Contains(member, `href="/g/core/modules"`) { t.Errorf("a non-admin sees the admin nav entries (SPEC-010 §3):\n%s", member) } } // TestShellPasskeyBanner: the prominent prompt renders with its account link // only when the Page asks for it (SPEC-010 §6). func TestShellPasskeyBanner(t *testing.T) { web.SetNav(fakeNav{"home"}) t.Cleanup(func() { web.SetNav(nil) }) on := renderShell(t, web.Page{GUID: "g", PasskeyBanner: true, Content: web.Markup("x")}) if !strings.Contains(on, "You have no passkey") { t.Errorf("the banner is missing:\n%s", on) } if !strings.Contains(on, `href="/g/account"`) { t.Errorf("the banner does not link to the account page:\n%s", on) } off := renderShell(t, web.Page{GUID: "g", Content: web.Markup("x")}) if strings.Contains(off, "You have no passkey") { t.Errorf("the banner rendered without being asked:\n%s", off) } } // TestPageForReadsTheShellState: PageFor copies the door-attached role + banner // onto the Page, and leaves a guest page untouched (SPEC-010 §3/§6). func TestPageForReadsTheShellState(t *testing.T) { r := httptest.NewRequest("GET", "/g/home", nil) r = r.WithContext(web.WithShell(r.Context(), true, true)) got := web.PageFor(r, web.Page{GUID: "g"}) if !got.Admin || !got.PasskeyBanner { t.Errorf("PageFor did not apply the shell state: %+v", got) } guest := web.PageFor(r, web.Page{Guest: true}) if guest.Admin || guest.PasskeyBanner { t.Errorf("PageFor decorated a guest page: %+v", guest) } } // ── 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(`

x

`)}) for _, m := range scriptTag.FindAllString(body, -1) { if !strings.Contains(m, "src=") { t.Errorf("the shell emitted an inline