src/lib/storage-keys.test.ts
Rama main · réplica de lectura del repositorio original.
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
COVERED_KEYS,
EXCLUDED_KEYS,
deviceMemoryClass,
} from "./device-memory";
// The rule: a browser-storage key that production code writes owes §10 of the privacy
// page a row in the same change. A rule in DECISIONS.md is one nobody reads on the way
// past, so this is that rule with teeth, in four checks that are deliberately dumb:
//
// 1. every `opendiving:`-prefixed string literal in production code under `src/`
// appears verbatim in `app/privacy/page.tsx`;
// 2. the set of production modules that write browser storage is exactly the set
// listed below - so storage cannot appear in a new module without this test
// failing and sending whoever added it to §10;
// 3. the two mechanisms §10.4 names that check (2) cannot see - IndexedDB and
// service workers - are in fact unused. §10.4's third, session storage, is
// covered by check (2) instead; see the note on `ABSENT_MECHANISMS` for why.
// 4. every key check (1) finds is classified by `lib/device-memory.ts` as either
// covered by the device-memory switch or deliberately excluded from it - so a
// new key cannot ship without somebody deciding whether the objection switch
// reaches it. `theme` is asserted covered by hand, since it has no literal for
// check (1) to find.
//
// Check (2) is a tripwire, not an analysis, and that is a correction rather than a
// shortcut. It began as a regex that read `setItem("literal")`, which missed the house
// style (every key here is a module-level constant passed by name); the fix for that
// resolved `const NAME = "string"` bindings tree-wide, and review then found four ways
// that machinery was wrong - a flat unscoped map masking a real key when two modules
// share a const name, the same map falsely accusing a file when a name collided with a
// local, a template literal being read as an offender, and the whole check passing
// green if either regex ever stopped matching. Resolving identifiers is type-graph work
// and regexes are the wrong tool for it. A list of filenames is none of those four
// things wrong: it never masks a key behind a name collision, never accuses a file over
// one, and cannot pass vacuously - the expected list is non-empty, so a broken walk
// fails rather than skips. What it protects is the thing that matters here: a module
// that did not write browser storage before cannot start without this failing.
//
// Scoped to production code anywhere under `src/`, not to `src/lib/`. Every key happens
// to live there today, but nothing requires it to, so a key added under `src/hooks/`,
// `src/contexts/` or a component would slip a narrower guard silently - the exact
// failure this exists to close.
//
// **What it still cannot see**, stated plainly rather than left as a silent hole, since
// a guard trusted past its reach is worse than one nobody trusts:
//
// - `theme`, which belongs to next-themes and is configured in `app/layout.tsx`
// without a `storageKey` override, so there is no literal in this tree to find. §10
// lists it by hand; if that provider ever grows an explicit key, this covers it for
// free.
// - a key assembled at run time (`PREFIX + name`, a value read from config), because
// no single literal spells it and check (1) has nothing to match. Check (2) does not
// close this either; it only guarantees that the module doing it is one somebody
// already had to think about.
// - a *second* key added inside a module already on the list below, if it is named
// without the `opendiving:` prefix. Check (1) catches it whenever the prefix is
// used, which is the house style in every one of these files.
// - a storage write spelled in some way `STORAGE_WRITE` does not list. It targets the
// three forms this app actually uses or could slip into - `setItem`, the index form,
// `document.cookie` - and it is a text match over source, not a semantic one, so it
// claims no completeness beyond those three. Check (3) covers two of the mechanisms
// §10.4 names, IndexedDB and service workers; anything beyond both is genuinely
// unseen.
// - storage set by the *server*, which is a different mechanism entirely: the
// `refresh_token` cookie arrives as a `Set-Cookie` header forwarded by
// `lib/api-proxy.ts`, is `HttpOnly` so client code could not write it anyway, and
// is disclosed by hand in §10.1.
// - the mirror of that: check (2) reads raw text, so a production file that merely
// *mentions* one of those forms in a comment counts as a writer and fails the
// equality. That is a false positive, and it is the deliberate trade - stripping
// comments correctly is parsing, which is the work this check was cut back to avoid.
// The fix, if it ever fires, is to add the file to the list or reword the comment.
// Every production module that writes browser storage. Adding an entry is the moment to
// ask what §10 now has to say; that prompt is the entire point of the list.
const STORAGE_WRITERS = [
"lib/auth-redirect.ts",
"lib/chart-period-view.ts",
"lib/chart-series-view.ts",
// Writes the objection flag, and calls through to the original `setItem` for
// everything it does not suppress - so it is a writer twice over.
"lib/device-memory.ts",
"lib/entry-units.ts",
"lib/google-oauth.ts",
"lib/passkey-nudge.ts",
];
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PRIVACY_PAGE = path.join(SRC, "app", "privacy", "page.tsx");
// A quoted `opendiving:`-prefixed literal, in any of the three JS string forms.
// The suffix is `+` rather than `*` on purpose: `*` also matches a bare
// `` `opendiving:` `` written in a comment, and since every real key starts with
// that string, the resulting assertion passes against any page at all - a test case
// that cannot fail, quietly diluting the ones that can.
const KEY_LITERAL = /(['"`])(opendiving:[^'"`]+)\1/g;
// A write to browser storage from client code. `setItem` is how every writer does
// it; the index and cookie forms are here because they are real writes that spell no
// `setItem` at all, and neither appears in production code today - they are matched so
// that reaching for one is not a way around the list.
//
// Both of those alternatives insist on an actual assignment, which is fiddlier than it
// looks: a bare `localStorage\[` also matches a
// *read* (`const x = localStorage[key]`), and a bare `=` after `document.cookie` also
// matches `document.cookie === x`, a comparison. Either would report a module that only
// reads as a writer. Hence the `\]\s*=` and the `(?!=)`.
const STORAGE_WRITE =
/\.setItem\(|(?:local|session)Storage\[[^\]]*\]\s*=(?!=)|document\.cookie\s*=(?!=)/;
// §10.4 does not merely omit these - it states outright that this app has "no IndexedDB
// database and no service worker". That is an affirmative claim on a page whose whole
// point is that its claims are true, so it gets a check rather than an assurance.
// `cookieStore` rides along: it is not named on the page, but it is a storage write that
// spells none of `STORAGE_WRITE`, and the cheapest moment to notice it is now.
//
// `sessionStorage` is deliberately absent from this pattern even though §10.4 names it
// too. Four production modules mention it in comments - `lib/auth-redirect.ts` and
// `lib/chart-period-view.ts` explaining why they chose `localStorage` over it,
// `lib/api/client.ts` explaining why the access token is in neither, and
// `lib/device-memory.ts` explaining why its interposition guards on the receiver - so
// matching raw text would fail on all four for saying nothing at all. Its *writes* are
// caught by `STORAGE_WRITE` above, which is the half that matters.
const ABSENT_MECHANISMS = /\bindexedDB\b|\bserviceWorker\b|\bcookieStore\b/i;
function isProductionSource(file: string): boolean {
return /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file);
}
function productionSources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// `src/test/` is shared test scaffolding rather than shipped code - its
// storage stub is not a key anyone stores anything under.
return entry.name === "test" && dir === SRC
? []
: productionSources(full);
}
// The privacy page is the disclosure surface, never a definition site. Sweeping
// it too would let a key satisfy this invariant by appearing *only* there, and
// would misreport every key as defined on the page, since `src/app/` is walked
// before `src/lib/`.
if (full === PRIVACY_PAGE) return [];
return isProductionSource(entry.name) ? [full] : [];
});
}
function storageKeysIn(files: string[]): Map<string, string> {
const found = new Map<string, string>();
for (const file of files) {
const source = readFileSync(file, "utf8");
for (const [, , key] of source.matchAll(KEY_LITERAL)) {
if (!found.has(key)) {
found.set(key, path.relative(SRC, file));
}
}
}
return found;
}
describe("browser storage keys", () => {
const keys = storageKeysIn(productionSources(SRC));
const privacyPage = readFileSync(PRIVACY_PAGE, "utf8");
// Without this the whole suite passes vacuously the day the walk above breaks -
// a rename of `src/`, a thrown `readdirSync`, a regex that stops matching - and
// a guard that cannot fail is worse than none, because it reads as coverage.
it("finds the keys at all", () => {
expect(keys.size).toBeGreaterThan(0);
expect(privacyPage.length).toBeGreaterThan(0);
});
it.each([...keys].map(([key, file]) => ({ key, file })))(
"$key is disclosed on the privacy page (defined in $file)",
({ key }) => {
expect(privacyPage).toContain(key);
},
);
// The other half. A key named without the `opendiving:` prefix never enters the
// sweep above at all, so the disclosure check would pass while the key shipped
// undisclosed - and nothing else in this repo requires the prefix: no lint rule,
// nothing in AGENTS.md. This does not police names; it makes the *arrival* of
// browser storage in a new module impossible to do quietly.
it("writes browser storage only from the modules already accounted for", () => {
const writers = productionSources(SRC)
.filter((file) => STORAGE_WRITE.test(readFileSync(file, "utf8")))
.map((file) => path.relative(SRC, file))
.sort();
expect(writers).toEqual(STORAGE_WRITERS);
});
// §10.4 claims these are absent, which is a stronger thing to say than "undisclosed"
// and so worth holding to. A module reaching for one of them makes that sentence
// false, and check (2) would not notice because none of them spells a `setItem`.
it("uses none of the storage mechanisms §10.4 says it does not", () => {
const users = productionSources(SRC)
.filter((file) => ABSENT_MECHANISMS.test(readFileSync(file, "utf8")))
.map((file) => path.relative(SRC, file));
expect(users).toEqual([]);
});
// §10.3 offers one switch against all of this, and a key nobody classified is
// a key that switch quietly does not reach - the same silent gap check (1)
// exists to close for disclosure, one section later. So every key the sweep
// finds has to be in one of `device-memory.ts`'s two lists, and a new one
// fails here until its author decides which.
describe("the device-memory switch classifies every key", () => {
it.each([...keys].map(([key, file]) => ({ key, file })))(
"$key is covered or excluded (defined in $file)",
({ key }) => {
expect(deviceMemoryClass(key)).not.toBeNull();
},
);
// The half the sweep cannot reach: `theme` is next-themes' own default key,
// configured in `app/layout.tsx` with no `storageKey` override, so there is
// no literal in this tree for check (1) to find and nothing above would
// notice it falling out of the covered list.
it("covers `theme`, which no literal in this tree spells", () => {
expect(deviceMemoryClass("theme")).toBe("covered");
});
// Without this the check above is only as strong as its inputs: if
// `deviceMemoryClass` ever answered for everything, every case would pass
// and nothing would be enforced. This is the case that must fail.
it("answers for nothing else", () => {
expect(deviceMemoryClass("opendiving:never-classified")).toBeNull();
});
// A key in both lists would satisfy the check above while meaning two
// contradictory things - cleared and suppressed, and deliberately kept.
it("puts no key in both lists", () => {
const covered = new Set<string>(COVERED_KEYS);
expect(EXCLUDED_KEYS.filter((key) => covered.has(key))).toEqual([]);
});
});
});