feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058) #104
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never puts a reset token in browser storage", async () => {
|
it("never puts a reset token in browser storage", async () => {
|
||||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
|
||||||
const code = withoutComments(file.text);
|
|
||||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
// 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 life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||||
expect(code, file.path).not.toContain("localStorage");
|
// 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);
|
||||||
expect(code, file.path).not.toContain("sessionStorage");
|
expect(code, file.path).not.toContain("sessionStorage");
|
||||||
expect(code, file.path).not.toContain("document.cookie");
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
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
|
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,
|
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
|
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:
|
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
import { createMemoryHostHistory } from "../src/browser/host-history.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";
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding)
|
|||||||
<HostApp
|
<HostApp
|
||||||
config={config}
|
config={config}
|
||||||
history={createMemoryHostHistory("/")}
|
history={createMemoryHostHistory("/")}
|
||||||
|
// The production store over this browser's real storage, so the assertions below cover
|
||||||
|
// the shell's one use of it (ADR 0058) as well.
|
||||||
|
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
|
||||||
{...(authLanding ? { authLanding } : {})}
|
{...(authLanding ? { authLanding } : {})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -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
|
// A reset token is a bearer credential for one account. It lives in React state for the life of
|
||||||
// the form and nowhere else.
|
// 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(sessionStorage.length).toBe(0);
|
||||||
expect(text()).not.toContain("reset-token-abc");
|
expect(text()).not.toContain("reset-token-abc");
|
||||||
expect(container.innerHTML).not.toContain("reset-token-abc");
|
expect(container.innerHTML).not.toContain("reset-token-abc");
|
||||||
|
|||||||
Reference in New Issue
Block a user