chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user