The shell now renders the CORE nav entries — Home · Account for everyone, Users · Modules only for an admin — alongside the registry entries, with the discovered `home` dashboard collapsed onto the core Home entry (SPEC-010 §3). A signed-in user with no passkey, who has not opted out and is not on a loopback origin, now sees a prominent "Add a passkey" banner linking to the Account page (SPEC-010 §6). This closes the D-041 gap where the wizard's direct session mint bypasses the TOTP-login nudge, so the admin was never prompted. The door computes (admin, banner) in serveGUID — it already holds the session, identity and user — and attaches it to the request context; the new web.PageFor copies it onto every authenticated page (RequestContext is untouched, so identification stays separate from authorisation, D-003).
410 lines
15 KiB
Go
410 lines
15 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)
|
|
}
|
|
}
|
|
|
|
// ── 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</a>"); 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(`<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)
|
|
}
|
|
}
|
|
}
|