Implement safe storage abstraction with Zod validation and namespace prefix
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./storage.js";
|
||||
|
||||
const schema = z.object({ name: z.string(), age: z.number() });
|
||||
type Person = z.infer<typeof schema>;
|
||||
|
||||
describe("storage", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
const mockStorage = {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: vi.fn((index: number) => [...store.keys()][index] ?? null),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
} satisfies Storage;
|
||||
|
||||
vi.stubGlobal("localStorage", mockStorage);
|
||||
});
|
||||
|
||||
it("round-trips get/set with a valid schema", () => {
|
||||
const value: Person = { name: "Alice", age: 30 };
|
||||
storage.set("user", value, schema);
|
||||
expect(storage.get("user", schema)).toEqual(value);
|
||||
});
|
||||
|
||||
it("returns null for a missing key", () => {
|
||||
expect(storage.get("nonexistent", schema)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when stored value fails schema validation", () => {
|
||||
// Store invalid data directly
|
||||
localStorage.setItem("afl_bad", JSON.stringify({ name: 123, age: "wrong" }));
|
||||
expect(storage.get("bad", schema)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when stored value is not valid JSON", () => {
|
||||
localStorage.setItem("afl_corrupt", "not-json{{{");
|
||||
expect(storage.get("corrupt", schema)).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when set value does not match schema", () => {
|
||||
const invalid = { name: 123, age: "wrong" } as unknown as Person;
|
||||
expect(() => storage.set("user", invalid, schema)).toThrow();
|
||||
});
|
||||
|
||||
it("deletes a key", () => {
|
||||
storage.set("user", { name: "Alice", age: 30 }, schema);
|
||||
storage.delete("user");
|
||||
expect(storage.get("user", schema)).toBeNull();
|
||||
});
|
||||
|
||||
it("clear removes only afl_-prefixed keys", () => {
|
||||
storage.set("user", { name: "Alice", age: 30 }, schema);
|
||||
localStorage.setItem("other_key", "keep me");
|
||||
storage.clear();
|
||||
expect(storage.get("user", schema)).toBeNull();
|
||||
expect(localStorage.getItem("other_key")).toBe("keep me");
|
||||
});
|
||||
|
||||
it("stores keys with afl_ prefix", () => {
|
||||
storage.set("user", { name: "Alice", age: 30 }, schema);
|
||||
expect(localStorage.getItem("afl_user")).not.toBeNull();
|
||||
expect(localStorage.getItem("user")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
const PREFIX = "afl_";
|
||||
|
||||
function prefixed(key: string): string {
|
||||
return `${PREFIX}${key}`;
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string, schema: ZodSchema<T>): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(prefixed(key));
|
||||
if (raw === null) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const result = schema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T, schema: ZodSchema<T>): void {
|
||||
schema.parse(value);
|
||||
localStorage.setItem(prefixed(key), JSON.stringify(value));
|
||||
},
|
||||
|
||||
delete(key: string): void {
|
||||
localStorage.removeItem(prefixed(key));
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith(PREFIX)) {
|
||||
keysToRemove.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of keysToRemove) {
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user