import { expect } from "vitest"; import { test } from "@openwork/testkit"; import { cloudSessionDraftScope, createSessionDraftStore, LEGACY_SESSION_DRAFT_STORAGE_KEY, LOCAL_SESSION_DRAFT_SCOPE, resolveSessionDraftScope, SESSION_DRAFT_STORAGE_KEY, } from "../../apps/app/src/react-app/domains/session/sync/draft-store"; type StorageMutation = { key: string | null; newValue: string | null }; function sharedStorageContexts() { const values = new Map(); const listeners = new Map void>>(); const context = (id: string) => ({ storage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); for (const [listenerId, contextListeners] of listeners) { if (listenerId === id) continue; for (const listener of contextListeners) listener({ key, newValue: value }); } }, removeItem: (key: string) => { values.delete(key); for (const [listenerId, contextListeners] of listeners) { if (listenerId === id) continue; for (const listener of contextListeners) listener({ key, newValue: null }); } }, }, subscribeToStorage: (listener: (mutation: StorageMutation) => void) => { const contextListeners = listeners.get(id) ?? new Set<(mutation: StorageMutation) => void>(); contextListeners.add(listener); listeners.set(id, contextListeners); return () => { contextListeners.delete(listener); }; }, }); return { context, values }; } const aliceOps = cloudSessionDraftScope({ principalId: "usr_alice", organizationId: "org_ops" }); const aliceFinance = cloudSessionDraftScope({ principalId: "usr_alice", organizationId: "org_finance" }); const bobOps = cloudSessionDraftScope({ principalId: "usr_bob", organizationId: "org_ops" }); test("composer drafts stay private per identity and safe under concurrent tabs", ({ evidence }) => { const shared = sharedStorageContexts(); // Claim: a draft written under one verified identity survives a reload and // is readable again by exactly that identity. const firstWindow = createSessionDraftStore(shared.context("first")); expect(aliceOps).toBeTruthy(); expect(firstWindow.save(aliceOps, "workspace-a", "session-a", { text: "Alice operations draft", mode: "prompt", }).status).toBe("saved"); firstWindow.dispose(); const reloaded = createSessionDraftStore(shared.context("reload")); expect(reloaded.get(aliceOps, "workspace-a", "session-a")).toEqual({ text: "Alice operations draft", mode: "prompt", }); // Negative half 1 (identity and organization transitions): a different // account, a different organization of the same account, the unsigned // local scope, and an unverified credential (null scope) each read nothing. expect(reloaded.get(bobOps, "workspace-a", "session-a")).toBeNull(); expect(reloaded.get(aliceFinance, "workspace-a", "session-a")).toBeNull(); expect(reloaded.get(LOCAL_SESSION_DRAFT_SCOPE, "workspace-a", "session-a")).toBeNull(); expect(reloaded.get(null, "workspace-a", "session-a")).toBeNull(); // Scope resolution: a cloud credential without a verified identity resolves // to no scope at all — drafts become inaccessible the moment the verified // identity or organization changes, not after the next round trip. expect(resolveSessionDraftScope({ hasCloudCredential: true, verifiedIdentity: null })).toBeNull(); expect(resolveSessionDraftScope({ hasCloudCredential: true, verifiedIdentity: { principalId: "usr_alice", organizationId: "org_ops" }, })).toBe(aliceOps); expect(resolveSessionDraftScope({ hasCloudCredential: false, verifiedIdentity: { principalId: "usr_alice", organizationId: "org_ops" }, })).toBe(LOCAL_SESSION_DRAFT_SCOPE); // Claim: two live tabs reconcile through storage events — the second tab // observes the first tab's write without a reload. const tabA = createSessionDraftStore(shared.context("tab-a")); const tabB = createSessionDraftStore(shared.context("tab-b")); let tabBChanges = 0; tabB.subscribe(() => { tabBChanges += 1; }); expect(tabB.get(aliceOps, "workspace-a", "session-b")).toBeNull(); tabA.save(aliceOps, "workspace-a", "session-b", { text: "from tab A", mode: "prompt" }); expect(tabB.get(aliceOps, "workspace-a", "session-b")?.text).toBe("from tab A"); expect(tabBChanges).toBe(1); // Negative half 2 (render stability): persisting the same composer value is // a no-op. It retains the observable snapshot identity and does not notify // subscribers, preventing a persistence/render feedback loop. const stableSnapshot = tabA.get(aliceOps, "workspace-a", "session-b"); let tabAChanges = 0; tabA.subscribe(() => { tabAChanges += 1; }); expect(tabA.save(aliceOps, "workspace-a", "session-b", { text: "from tab A", mode: "prompt" })) .toEqual({ status: "saved", snapshot: stableSnapshot }); expect(tabA.get(aliceOps, "workspace-a", "session-b")).toBe(stableSnapshot); expect(tabAChanges).toBe(0); // Negative half 3 (stale writes): a tab holding an old revision can neither // overwrite a newer draft nor clear it; both operations surface the newer // snapshot as a conflict instead of silently losing it. After observing a // conflict the tab is resynchronized, so its next operation acts on the // newer revision intentionally rather than accidentally. const staleSaveTab = createSessionDraftStore({ storage: shared.context("stale-save").storage }); const staleClearTab = createSessionDraftStore({ storage: shared.context("stale-clear").storage }); expect(staleSaveTab.get(aliceOps, "workspace-a", "session-b")?.text).toBe("from tab A"); expect(staleClearTab.get(aliceOps, "workspace-a", "session-b")?.text).toBe("from tab A"); tabA.save(aliceOps, "workspace-a", "session-b", { text: "newer edit", mode: "prompt" }); expect(staleSaveTab.save(aliceOps, "workspace-a", "session-b", { text: "stale overwrite", mode: "prompt", })).toEqual({ status: "conflict", snapshot: { text: "newer edit", mode: "prompt" } }); expect(staleClearTab.clear(aliceOps, "workspace-a", "session-b")).toEqual({ status: "conflict", snapshot: { text: "newer edit", mode: "prompt" }, }); expect(tabA.get(aliceOps, "workspace-a", "session-b")?.text).toBe("newer edit"); // Post-conflict recovery: the resynchronized tab may now clear on purpose. expect(staleClearTab.clear(aliceOps, "workspace-a", "session-b").status).toBe("saved"); expect(tabA.get(aliceOps, "workspace-a", "session-b")).toBeNull(); // Negative half 4 (legacy storage): v1 drafts carry no identity boundary, // so they are dropped fail-closed instead of being exposed to any scope. const legacy = sharedStorageContexts(); legacy.values.set(LEGACY_SESSION_DRAFT_STORAGE_KEY, JSON.stringify({ "workspace-a:session-a": { text: "unknown former owner", mode: "prompt" }, })); const migrated = createSessionDraftStore(legacy.context("migration")); expect(migrated.get(LOCAL_SESSION_DRAFT_SCOPE, "workspace-a", "session-a")).toBeNull(); expect(migrated.get(aliceOps, "workspace-a", "session-a")).toBeNull(); expect(legacy.values.has(LEGACY_SESSION_DRAFT_STORAGE_KEY)).toBe(false); // Negative half 5 (untrusted storage): malformed documents read as empty // and repair on the next valid save; a storage that throws never crashes // the composer and reports the write as unavailable rather than saved. const corrupt = sharedStorageContexts(); corrupt.values.set(SESSION_DRAFT_STORAGE_KEY, "{not-json"); const repaired = createSessionDraftStore(corrupt.context("repair")); expect(repaired.get(aliceOps, "workspace-a", "session-a")).toBeNull(); expect(repaired.save(aliceOps, "workspace-a", "session-a", { text: "recovered", mode: "prompt", }).status).toBe("saved"); const denied = createSessionDraftStore({ storage: { getItem: () => { throw new Error("storage denied"); }, setItem: () => { throw new Error("quota exceeded"); }, removeItem: () => { throw new Error("storage denied"); }, }, }); expect(denied.get(aliceOps, "workspace-a", "session-a")).toBeNull(); expect(denied.save(aliceOps, "workspace-a", "session-a", { text: "cannot persist", mode: "prompt", }).status).toBe("unavailable"); evidence.recordAssertionEvidence( "Composer drafts are private and conflict-safe", "Drafts survive reloads within one verified identity scope; other accounts, organizations, the local scope, and unverified credentials read nothing; concurrent tabs reconcile via storage events; unchanged saves preserve snapshot identity without notifying subscribers; stale writers and clears conflict instead of overwriting; legacy v1 data is dropped fail-closed; corrupt or denied storage degrades safely.", true, ); });