import { describe, expect, it, vi } from "vitest"; import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.ts"; import { STORAGE_REGISTRY, buildPhysicalKey, defineStorageKey, getStorageDefinition, isStorageValueAllowed, type StorageDefinition, type StorageKeyInput, } from "../../src/contracts/storage-keys.ts"; type StorageFailureMode = "none" | "quota" | "security"; function createStorage(options: { initial?: Readonly>; writeFailure?: StorageFailureMode; readFailure?: StorageFailureMode; removeFailure?: StorageFailureMode; } = {}) { const values = new Map(Object.entries(options.initial ?? {})); const state = { writeFailure: options.writeFailure ?? "none", readFailure: options.readFailure ?? "none", removeFailure: options.removeFailure ?? "none", }; const getItem = vi.fn((key: string) => { throwFor(state.readFailure); return values.get(key) ?? null; }); const setItem = vi.fn((key: string, value: string) => { throwFor(state.writeFailure); values.set(key, value); }); const removeItem = vi.fn((key: string) => { throwFor(state.removeFailure); values.delete(key); }); const storage: Storage = { getItem, setItem, removeItem, clear: () => values.clear(), key: (index) => [...values.keys()][index] ?? null, get length() { return values.size; }, }; return { storage, values, state, getItem, setItem, removeItem }; } function throwFor(mode: StorageFailureMode): void { if (mode === "quota") { throw new DOMException("private quota detail", "QuotaExceededError"); } if (mode === "security") { throw new DOMException("private security detail", "SecurityError"); } } function serializedEnvelope( value: unknown, overrides: Readonly<{ schemaVersion?: number; expiresAt?: number | null; }> = {}, ): string { return JSON.stringify({ schemaVersion: overrides.schemaVersion ?? 1, expiresAt: overrides.expiresAt ?? null, value, }); } function definitionResolver( extra: Readonly>, ) { return (logicalName: string): StorageDefinition => extra[logicalName] ?? getStorageDefinition(logicalName); } describe("storage registry", () => { it("builds namespace and schema-versioned physical keys", () => { expect(buildPhysicalKey("preference", 2, "theme")).toBe( "ca-frontend:preference:v2:theme", ); expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:"); }); it("rejects token or secret persistence registrations", () => { expect(() => defineStorageKey({ logicalName: "TOKEN", scope: "auth", name: "token", backend: "localStorage", classification: "sensitive-forbidden", schemaVersion: 1, valueCodec: "none", ttl: null, migration: "discard", quotaFallback: "feature-disable", }), ).toThrow("Sensitive client storage registration is forbidden"); }); it("enforces the registry-owned typed value codec contract", () => { const colorScheme = STORAGE_REGISTRY.COLOR_SCHEME; expect( ["system", "light", "dark"].every((value) => isStorageValueAllowed(colorScheme, value), ), ).toBe(true); expect( [ "", "LIGHT", "private-custom-theme", 1, null, { mode: "dark" }, ].some((value) => isStorageValueAllowed(colorScheme, value)), ).toBe(false); const opaqueString = STORAGE_REGISTRY.CHUNK_RELOAD_GUARD; expect(isStorageValueAllowed(opaqueString, "a")).toBe(true); expect(isStorageValueAllowed(opaqueString, "x".repeat(2_048))).toBe(true); expect(isStorageValueAllowed(opaqueString, "")).toBe(false); expect(isStorageValueAllowed(opaqueString, "x".repeat(2_049))).toBe(false); expect(isStorageValueAllowed(opaqueString, { value: "opaque" })).toBe(false); expect( isStorageValueAllowed( STORAGE_REGISTRY.QUERY_PERSISTENCE, "must-never-persist", ), ).toBe(false); }); it("rejects unregistered codecs and executable migration policies", () => { const definition = { logicalName: "UNSAFE_POLICY", scope: "preference", name: "unsafe-policy", backend: "localStorage", classification: "public-preference", schemaVersion: 1, valueCodec: "unregistered-codec", ttl: null, migration: "discard", quotaFallback: "no-persist", } as unknown as StorageKeyInput; expect(() => defineStorageKey(definition)).toThrow( "Unknown client storage value codec", ); expect(() => defineStorageKey({ ...definition, valueCodec: "opaque-string-v1", migration: (() => "unsafe") as unknown as "discard", }), ).toThrow("Unsupported client storage migration policy"); }); it("round-trips public preferences through the adapter", () => { const { storage: localStorage } = createStorage(); const adapter = createBrowserStorageAdapter({ localStorage }); expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" }); }); it("keeps local and session registry entries on their declared backends", () => { const local = createStorage(); const session = createStorage(); const adapter = createBrowserStorageAdapter({ localStorage: local.storage, sessionStorage: session.storage, }); expect(adapter.write("COLOR_SCHEME", "light")).toEqual({ ok: true }); expect(adapter.write("CHUNK_RELOAD_GUARD", "release-a->release-b")).toEqual({ ok: true, }); expect( local.values.has(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey), ).toBe(true); expect( local.values.has(STORAGE_REGISTRY.CHUNK_RELOAD_GUARD.physicalKey), ).toBe(false); expect( session.values.has(STORAGE_REGISTRY.CHUNK_RELOAD_GUARD.physicalKey), ).toBe(true); expect(adapter.read("CHUNK_RELOAD_GUARD")).toEqual({ ok: true, value: "release-a->release-b", }); }); it("falls back to memory when preference storage quota is exceeded", () => { const { storage: localStorage } = createStorage({ writeFailure: "quota", }); const record = vi.fn(); const adapter = createBrowserStorageAdapter({ localStorage, diagnostics: { record }, }); expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({ ok: false, fallback: "memory", error: { kind: "STORAGE_QUOTA_EXCEEDED" }, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" }); expect(record).toHaveBeenCalledOnce(); expect(record).toHaveBeenCalledWith({ level: "warn", eventId: "storage.operation.failed", context: { operation: "write:COLOR_SCHEME", error_kind: "STORAGE_QUOTA_EXCEEDED", }, }); expect(JSON.stringify(record.mock.calls)).not.toContain("dark"); expect(JSON.stringify(record.mock.calls)).not.toContain( "private quota detail", ); }); it("discards data from a previous schema version", () => { const { storage: localStorage, values, removeItem } = createStorage({ initial: { [STORAGE_REGISTRY.COLOR_SCHEME.physicalKey]: serializedEnvelope( "dark", { schemaVersion: 2 }, ), }, }); const adapter = createBrowserStorageAdapter({ localStorage }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined }); expect(removeItem).toHaveBeenCalledOnce(); expect(values.has(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey)).toBe(false); }); it("rejects values outside the selected key codec before persistence", () => { const { storage: localStorage, setItem } = createStorage(); const adapter = createBrowserStorageAdapter({ localStorage }); expect(adapter.write("COLOR_SCHEME", { mode: "dark" })).toMatchObject({ ok: false, error: { kind: "STORAGE_UNAVAILABLE", code: "COLOR_SCHEME_WRITE_VALUE_REJECTED", }, }); expect(setItem).not.toHaveBeenCalled(); }); it.each([ ["malformed JSON", "{private malformed value"], [ "invalid envelope", JSON.stringify({ schemaVersion: 1, expiresAt: null, value: "dark", unexpected: "private", }), ], ["oversized record", "x".repeat(512)], ])( "removes a %s once and suppresses it when native cleanup fails", (_label, raw) => { const physicalKey = STORAGE_REGISTRY.COLOR_SCHEME.physicalKey; const { storage, getItem, removeItem } = createStorage({ initial: { [physicalKey]: raw }, removeFailure: "security", }); const record = vi.fn(); const adapter = createBrowserStorageAdapter({ localStorage: storage, diagnostics: { record }, maxSerializedBytes: 128, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined, }); expect(getItem).toHaveBeenCalledOnce(); expect(removeItem).toHaveBeenCalledOnce(); expect(JSON.stringify(record.mock.calls)).not.toMatch( /private|malformed|unexpected/, ); }, ); it("removes expired persistent records and does not return their value", () => { const expiring = defineStorageKey({ logicalName: "EXPIRING_PREFERENCE", scope: "preference", name: "expiring", backend: "localStorage", classification: "public-preference", schemaVersion: 1, valueCodec: "opaque-string-v1", ttl: 10, migration: "discard", quotaFallback: "memory", }); const { storage, removeItem } = createStorage({ initial: { [expiring.physicalKey]: serializedEnvelope("private-expired", { expiresAt: 99, }), }, }); const adapter = createBrowserStorageAdapter({ localStorage: storage, now: () => 100, resolveDefinition: definitionResolver({ EXPIRING_PREFERENCE: expiring, }), }); expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({ ok: true, value: undefined, }); expect(removeItem).toHaveBeenCalledWith(expiring.physicalKey); }); it("prioritizes a failed-write memory envelope over stale persistent data and expires it", () => { let timestamp = 100; const expiring = defineStorageKey({ logicalName: "EXPIRING_PREFERENCE", scope: "preference", name: "expiring", backend: "localStorage", classification: "public-preference", schemaVersion: 1, valueCodec: "opaque-string-v1", ttl: 10, migration: "discard", quotaFallback: "memory", }); const { storage, state } = createStorage({ initial: { [expiring.physicalKey]: serializedEnvelope("stale", { expiresAt: 1_000, }), }, writeFailure: "quota", }); const adapter = createBrowserStorageAdapter({ localStorage: storage, now: () => timestamp, resolveDefinition: definitionResolver({ EXPIRING_PREFERENCE: expiring, }), }); expect(adapter.write("EXPIRING_PREFERENCE", "fresh")).toMatchObject({ ok: false, fallback: "memory", }); expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({ ok: true, value: "fresh", }); state.writeFailure = "none"; timestamp = 111; expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({ ok: true, value: undefined, }); }); it("clears a failed-write overlay after a later persistent write succeeds", () => { const physicalKey = STORAGE_REGISTRY.COLOR_SCHEME.physicalKey; const { storage, state, values } = createStorage({ writeFailure: "security", }); const adapter = createBrowserStorageAdapter({ localStorage: storage }); expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({ ok: false, fallback: "memory", error: { kind: "STORAGE_UNAVAILABLE" }, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark", }); state.writeFailure = "none"; expect(adapter.write("COLOR_SCHEME", "light")).toEqual({ ok: true }); values.set(physicalKey, serializedEnvelope("system")); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "system", }); }); it("purges the memory overlay even when persistent remove throws", () => { const { storage, state } = createStorage({ writeFailure: "quota", removeFailure: "security", }); const adapter = createBrowserStorageAdapter({ localStorage: storage }); expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({ fallback: "memory", }); state.writeFailure = "none"; expect(adapter.remove("COLOR_SCHEME")).toMatchObject({ ok: false, error: { kind: "STORAGE_UNAVAILABLE" }, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined, }); }); it("normalizes SecurityError reads and never exposes native details", () => { const { storage } = createStorage({ readFailure: "security" }); const record = vi.fn(); const adapter = createBrowserStorageAdapter({ localStorage: storage, diagnostics: { record }, }); const result = adapter.read("COLOR_SCHEME"); expect(result).toMatchObject({ ok: false, error: { kind: "STORAGE_UNAVAILABLE", code: "COLOR_SCHEME_READ_UNAVAILABLE", }, }); expect(JSON.stringify({ result, calls: record.mock.calls })).not.toContain( "private security detail", ); }); it.each([ ["cyclic", (() => { const value: { self?: unknown; secret: string } = { secret: "private-cyclic", }; value.self = value; return value; })()], ["BigInt", { secret: "private-bigint", value: 1n }], ["exotic", new Date(0)], ])("rejects %s values before calling the backend", (_label, value) => { const { storage, setItem } = createStorage(); const record = vi.fn(); const adapter = createBrowserStorageAdapter({ localStorage: storage, diagnostics: { record }, }); const result = adapter.write("COLOR_SCHEME", value); expect(result).toMatchObject({ ok: false, error: { kind: "STORAGE_UNAVAILABLE", code: "COLOR_SCHEME_WRITE_VALUE_REJECTED", }, }); expect(setItem).not.toHaveBeenCalled(); expect(JSON.stringify({ result, calls: record.mock.calls })).not.toMatch( /private-cyclic|private-bigint/, ); }); it("rejects oversized serialized values before persistence and redacts them", () => { const secret = `private-oversize-${"x".repeat(512)}`; const { storage, setItem } = createStorage(); const record = vi.fn(); const adapter = createBrowserStorageAdapter({ sessionStorage: storage, diagnostics: { record }, maxSerializedBytes: 128, }); const result = adapter.write("CHUNK_RELOAD_GUARD", secret); expect(result).toMatchObject({ ok: false, error: { kind: "STORAGE_UNAVAILABLE", code: "CHUNK_RELOAD_GUARD_WRITE_SIZE_LIMIT_EXCEEDED", }, }); expect(setItem).not.toHaveBeenCalled(); expect(JSON.stringify({ result, calls: record.mock.calls })).not.toContain( secret, ); }); });