509 lines
15 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|