Ctrl K
ComunidadGuías…

src/lib/storage-keys.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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([]);
    });
  });
});