Files
tech-log-frontend/tests/unit/storage-registry.test.ts

509 lines
15 KiB
TypeScript

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<Record<string, string>>;
writeFailure?: StorageFailureMode;
readFailure?: StorageFailureMode;
removeFailure?: StorageFailureMode;
} = {}) {
const values = new Map<string, string>(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<Record<string, StorageDefinition>>,
) {
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,
);
});
});