phase 6: web storage layer (KeyStore, Cache, session)
KeyStore + Cache TS interfaces with WebCrypto non-extractable Ed25519 keys persisted via IndexedDB (idb), plus thin api/session.ts that loads or creates the device session at app startup. Vitest unit tests under fake-indexeddb cover both adapters; Playwright e2e verifies the keypair survives reload and produces signatures still verifiable under the persisted public key (gateway round-trip moves to Phase 7's existing acceptance bullet). Browser baseline: WebCrypto Ed25519 — Chrome >=137, Firefox >=130, Safari >=17.4. No JS fallback; ui/docs/storage.md documents the matrix and the WebKit non-determinism quirk. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// IDBCache unit tests under JSDOM with `fake-indexeddb` standing in
|
||||
// for the browser's IndexedDB factory. Each case opens a freshly
|
||||
// named database so state cannot leak across tests.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
indexedDB.deleteDatabase(dbName);
|
||||
});
|
||||
|
||||
describe("IDBCache", () => {
|
||||
test("round-trips a typed object", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
const value = {
|
||||
name: "ping",
|
||||
payload: new Uint8Array([1, 2, 3]),
|
||||
nested: { count: 7 },
|
||||
};
|
||||
await cache.put("commands", "k1", value);
|
||||
const out = await cache.get<typeof value>("commands", "k1");
|
||||
expect(out?.name).toBe("ping");
|
||||
expect(out?.nested.count).toBe(7);
|
||||
expect(Array.from(out?.payload ?? [])).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("namespaces are isolated", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
await cache.put("a", "shared-key", "from-a");
|
||||
await cache.put("b", "shared-key", "from-b");
|
||||
expect(await cache.get("a", "shared-key")).toBe("from-a");
|
||||
expect(await cache.get("b", "shared-key")).toBe("from-b");
|
||||
});
|
||||
|
||||
test("delete removes a single row without touching neighbours", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
await cache.put("ns", "k1", "v1");
|
||||
await cache.put("ns", "k2", "v2");
|
||||
await cache.delete("ns", "k1");
|
||||
expect(await cache.get("ns", "k1")).toBeUndefined();
|
||||
expect(await cache.get("ns", "k2")).toBe("v2");
|
||||
});
|
||||
|
||||
test("clear(namespace) wipes only that namespace", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
await cache.put("a", "k1", "a1");
|
||||
await cache.put("a", "k2", "a2");
|
||||
await cache.put("b", "k1", "b1");
|
||||
await cache.clear("a");
|
||||
expect(await cache.get("a", "k1")).toBeUndefined();
|
||||
expect(await cache.get("a", "k2")).toBeUndefined();
|
||||
expect(await cache.get("b", "k1")).toBe("b1");
|
||||
});
|
||||
|
||||
test("clear() wipes every namespace", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
await cache.put("a", "k1", "a1");
|
||||
await cache.put("b", "k1", "b1");
|
||||
await cache.clear();
|
||||
expect(await cache.get("a", "k1")).toBeUndefined();
|
||||
expect(await cache.get("b", "k1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("get on a missing key returns undefined", async () => {
|
||||
const cache = new IDBCache(db);
|
||||
expect(await cache.get("absent", "k")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user