From cf2f847c8ce8673cf5d8086b77fd745f25f82179 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 14 Oct 2026 17:55:07 -0500 Subject: [PATCH] test(core): pin the shell's one use of browser storage, not ban it CI failed in `identity-verification-boundaries.test.ts`: its guard that a reset token never reaches browser storage was a blanket ban on the word `localStorage` anywhere in the shell. The sidebar preference (ADR 0058) legitimately reads it in the entry point. The guarantee is unchanged: the reset token stays in React state for the life of one form. The guard is now precise: - `localStorage` appears only in `main.tsx`, only to hand it to the sidebar preference; - the shell's only storage write is the sidebar's own key and a validated mode; - the files that ever hold the token (`auth-protocol.ts`, `ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all; - `sessionStorage` and `document.cookie` stay banned everywhere. A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails it. The runtime reset-token test in `auth-recovery.test.tsx` now runs with the production localStorage-backed preference and asserts storage holds nothing but the sidebar key. It passed locally only because Turbo restored `@ekklesia/core:test` from cache: that test reads shell source that Core's task hash does not include. Co-Authored-By: Claude Opus 5.5 --- .../identity-verification-boundaries.test.ts | 36 ++++++++++++++++--- docs/development/testing.md | 5 ++- surfaces/shell/tests/auth-recovery.test.tsx | 13 ++++++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/core/tests/identity-verification-boundaries.test.ts b/core/tests/identity-verification-boundaries.test.ts index 948907b..0ad01dc 100644 --- a/core/tests/identity-verification-boundaries.test.ts +++ b/core/tests/identity-verification-boundaries.test.ts @@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => { }); it("never puts a reset token in browser storage", async () => { - for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) { + // A reset token is a bearer credential for one account; the shell holds it in React state for + // the life of one form and nowhere else. The shell's one use of browser storage is the desktop + // sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is + // named only by the entry point, and only to hand it to the sidebar preference, whose one write + // is its own fixed key and a validated mode. + const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src")); + const writes: string[] = []; + for (const file of files) { const code = withoutComments(file.text); - // A reset token is a bearer credential for one account; the shell holds it in React state for - // the life of one form and nowhere else. - expect(code, file.path).not.toContain("localStorage"); expect(code, file.path).not.toContain("sessionStorage"); expect(code, file.path).not.toContain("document.cookie"); + writes.push( + ...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map( + (match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`, + ), + ); + if (file.path.endsWith(path.join("browser", "main.tsx"))) { + expect(code.match(/localStorage/gu), file.path).toHaveLength(1); + expect(code).toMatch( + /createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u, + ); + } else { + expect(code, file.path).not.toContain("localStorage"); + } + } + expect(writes).toEqual([ + `${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`, + ]); + // The files that ever hold the token touch no storage API at all. + for (const file of files.filter((candidate) => + ["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) => + candidate.path.endsWith(name), + ), + )) { + expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u); } }); }); diff --git a/docs/development/testing.md b/docs/development/testing.md index ffcb303..2912186 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia schema gained a token column and no identity code hashes or compares a token, that nothing interpolates a token, link, address, or body into a log line, that a device has no email, password, -or verification concept, and that no browser source imports Better Auth or Core's mail. +or verification concept, that no browser source imports Better Auth or Core's mail, and that the +shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named +only by the entry point, one write of one fixed key, and no storage API in any file that holds the +reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an injected in-memory provider and never touch the network. They prove the parts the protocol cannot: diff --git a/surfaces/shell/tests/auth-recovery.test.tsx b/surfaces/shell/tests/auth-recovery.test.tsx index 6c21877..08d64cc 100644 --- a/surfaces/shell/tests/auth-recovery.test.tsx +++ b/surfaces/shell/tests/auth-recovery.test.tsx @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { HostApp } from "../src/browser/HostApp.js"; import { createMemoryHostHistory } from "../src/browser/host-history.js"; +import { + createLocalSidebarPreference, + SIDEBAR_PREFERENCE_KEY, +} from "../src/browser/sidebar-preference.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js"; /** @@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding) , ); @@ -463,7 +470,11 @@ describe("reset password", () => { // A reset token is a bearer credential for one account. It lives in React state for the life of // the form and nowhere else. - expect(localStorage.length).toBe(0); + // The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058). + for (let index = 0; index < localStorage.length; index += 1) { + expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY); + } + expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc"); expect(sessionStorage.length).toBe(0); expect(text()).not.toContain("reset-token-abc"); expect(container.innerHTML).not.toContain("reset-token-abc");