From d3a2abaf06bb2340f9742478d3bab98ee2a6056d Mon Sep 17 00:00:00 2001 From: Other Brother Darryl Date: Thu, 13 Aug 2026 18:37:08 -0400 Subject: [PATCH] test(desktop): cover section workspace authority boundaries Signed-off-by: Other Brother Darryl --- .../sidebar/lib/sectionWorkspace.test.mjs | 164 ++++++++ .../features/sidebar/lib/sectionWorkspace.ts | 42 +- .../sidebar/lib/useChannelSections.test.mjs | 395 ++++++++++++++++++ 3 files changed, 579 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs index 592a67b3a4..f2d99c93e0 100644 --- a/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs @@ -8,6 +8,8 @@ import { finalizeEvent } from "nostr-tools/pure"; import { relayClient } from "@/shared/api/relayClient"; import { canonicalJson, + canonicalMetadataAad, + canonicalRelayAuthority, parseSectionWorkspaceProjection, parseStrictJson, projectionRevisionAction, @@ -113,6 +115,38 @@ test("section workspace consumes every shared projection fixture revision case", } }); +test("canonical relay authority and metadata AAD match the frozen fixture", () => { + const aadCanonical = fixture.metadata_crypto.aad_canonical; + const owner = OWNER; + const sectionId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + assert.equal( + canonicalRelayAuthority("wss://Relay.Example/"), + "relay.example", + ); + assert.equal( + canonicalRelayAuthority("https://RELAY.EXAMPLE"), + "relay.example", + ); + assert.equal( + canonicalRelayAuthority("wss://Relay.Example:8443/"), + "relay.example:8443", + ); + assert.equal( + canonicalMetadataAad("wss://Relay.Example/", owner, sectionId, 1, "label"), + aadCanonical, + ); + assert.notEqual( + canonicalMetadataAad( + "wss://Relay.Example:8443/", + owner, + sectionId, + 1, + "label", + ), + aadCanonical, + ); +}); + test("section workspace rejects unknown fields, unsupported versions, duplicate IDs, and malformed JSON", () => { const valid = structuredClone(fixture.projection_cases[0].projection); assert.throws(() => @@ -137,6 +171,46 @@ test("section workspace canonicalization matches the frozen legacy hash vector", ); }); +test("valid projection is rejected for wrong, null, or failed relay self without cache or canonical cutover", async () => { + const storage = memoryStorage(); + const event = makeProjectionEvent(); + const relaySelfValues = [ + "2222222222222222222222222222222222222222222222222222222222222222", + null, + new Error("NIP-11 unavailable"), + ]; + for (const relaySelf of relaySelfValues) { + const restoreRelay = installRelayStubs({ + fetchEvents: async () => [event], + publishEvent: async () => {}, + subscribeLive: async () => async () => {}, + }); + const restoreWindow = installWindow(storage, async (command) => { + if (command === "get_relay_self") { + if (relaySelf instanceof Error) throw relaySelf; + return relaySelf; + } + throw new Error(`unexpected command ${command}`); + }); + try { + const manager = new SectionWorkspaceSyncManager(OWNER, RELAY); + const result = await manager.bootstrap(async () => null); + assert.equal(result, null); + assert.equal(manager.isCanonical(), false); + assert.equal(manager.getCachedStore(), null); + assert.equal( + storage.getItem( + `buzz-section-workspace.v1:${OWNER}:wss%3A%2F%2Frelay.example`, + ), + null, + ); + } finally { + restoreWindow(); + restoreRelay(); + } + } +}); + test("projection is decrypted, cached by normalized relay, and rendered during outage", async () => { const storage = memoryStorage(); let fetchCount = 0; @@ -277,3 +351,93 @@ test("migration stores one canonical command and replays exact bytes after a fai restoreRelay(); } }); + +test("failed import persistence never publishes and restart replays only the durable command", async () => { + const values = new Map(); + let failActionWrite = true; + const storage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + if (failActionWrite && key.includes(":import-action:")) { + throw new Error("quota"); + } + values.set(key, String(value)); + }, + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + }; + const legacyStore = { + version: 1, + sections: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + name: "Alpha", + order: 0, + }, + ], + assignments: {}, + }; + const legacyPlaintext = JSON.stringify(legacyStore); + const legacyEvent = { + id: "2222222222222222222222222222222222222222222222222222222222222222", + pubkey: OWNER, + created_at: 1, + kind: 30078, + content: "legacy-ciphertext", + tags: [["d", "channel-sections"]], + sig: "legacy-signature", + }; + const published = []; + const restoreRelay = installRelayStubs({ + fetchEvents: async () => [], + publishEvent: async (event) => { + published.push(event); + return event; + }, + subscribeLive: async () => async () => {}, + }); + let signCount = 0; + const restoreWindow = installWindow(storage, async (command, args) => { + if (command === "generate_workspace_key") + return "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + if (command === "encrypt_workspace_metadata") + return `ciphertext:${args.aad}`; + if (command === "nip44_encrypt_to_self") return "owner-envelope"; + if (command === "sign_event") { + signCount += 1; + return JSON.stringify({ + ...legacyEvent, + id: `event-${signCount}`, + kind: args.kind, + content: args.content, + tags: args.tags, + }); + } + throw new Error(`unexpected command ${command}`); + }); + try { + const first = new SectionWorkspaceSyncManager(OWNER, RELAY); + await first.bootstrap(async () => ({ + event: legacyEvent, + plaintext: legacyPlaintext, + store: legacyStore, + })); + assert.equal(published.length, 0); + assert.equal(values.size, 1); + const durableCommand = [...values.values()][0]; + assert.match(durableCommand, /"action_id"/); + + failActionWrite = false; + const restarted = new SectionWorkspaceSyncManager(OWNER, RELAY); + await restarted.bootstrap(async () => { + throw new Error( + "restart must replay durable command without legacy fetch", + ); + }); + assert.equal(published.length, 1); + assert.equal(published[0].content, durableCommand); + } finally { + restoreWindow(); + restoreRelay(); + } +}); diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.ts b/desktop/src/features/sidebar/lib/sectionWorkspace.ts index 42c9199a51..cb48a422a5 100644 --- a/desktop/src/features/sidebar/lib/sectionWorkspace.ts +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.ts @@ -44,6 +44,12 @@ const MAX_ASSIGNMENTS = 1_000; const MAX_ENCRYPTED_METADATA_BYTES = 65_535; const MAX_KEY_ENVELOPE_BYTES = 4_096; const CACHE_PREFIX = "buzz-section-workspace.v1"; +class UntrustedProjectionError extends Error { + constructor(message: string) { + super(message); + this.name = "UntrustedProjectionError"; + } +} type ProjectionSection = { id: string; rank: number; @@ -533,7 +539,7 @@ function writeCache( ); } catch {} } -function aad( +export function canonicalMetadataAad( community: string, owner: string, sectionId: string, @@ -542,7 +548,7 @@ function aad( ): string { return canonicalJson({ version: 1, - community, + community: canonicalRelayAuthority(community), owner_pubkey: owner, section_id: sectionId, key_epoch: epoch, @@ -571,13 +577,7 @@ async function encryptMetadata( await encryptWorkspaceMetadata({ keyHex: key, plaintext: value, - aad: aad( - canonicalRelayAuthority(community), - owner, - section.id, - 1, - purpose, - ), + aad: canonicalMetadataAad(community, owner, section.id, 1, purpose), }), "encrypted_metadata", MAX_ENCRYPTED_METADATA_BYTES, @@ -595,13 +595,7 @@ async function decryptMetadata( return decryptWorkspaceMetadata({ keyHex: key, envelope: value, - aad: aad( - canonicalRelayAuthority(community), - owner, - sectionId, - epoch, - purpose, - ), + aad: canonicalMetadataAad(community, owner, sectionId, epoch, purpose), }); } async function projectionStore( @@ -690,10 +684,10 @@ async function projectionFromEvent( try { relaySelf = await getRelaySelf(); } catch { - throw new Error("relay signer is untrusted"); + throw new UntrustedProjectionError("relay signer is untrusted"); } if (!relaySelf || relaySelf !== event.pubkey) - throw new Error("invalid projection relay signer"); + throw new UntrustedProjectionError("invalid projection relay signer"); const projection = parseSectionWorkspaceProjection( parseStrictJson(event.content), ); @@ -937,8 +931,10 @@ export class SectionWorkspaceSyncManager { ); } } - } catch { - this.workspaceProbeBlocked = true; + } catch (error) { + if (!(error instanceof UntrustedProjectionError)) { + this.workspaceProbeBlocked = true; + } } return this.cached ? this.getCachedStore() : null; } @@ -974,8 +970,10 @@ export class SectionWorkspaceSyncManager { if (action !== "accept") return; const store = await this.acceptProjection(remote); onStore(store); - } catch { - this.workspaceProbeBlocked = true; + } catch (error) { + if (!(error instanceof UntrustedProjectionError)) { + this.workspaceProbeBlocked = true; + } } })(); }, diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index 88ddcb7c09..f955663669 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -218,3 +218,398 @@ test("legacy subscription resolving after workspace cutover is disposed and cann relayClient.subscribeToReconnects = originalSubscribeToReconnects; } }); + +test("canonical cutover ignores storage/reconnect/live ingress and all legacy mutations", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { finalizeEvent } = await import("nostr-tools/pure"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { storageKey } = await import("./channelSectionsStorage.ts"); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const owner = + "1111111111111111111111111111111111111111111111111111111111111111"; + const relaySelf = + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; + const ownerBytes = new Uint8Array(32).fill(1); + const projection = { + version: 1, + owner_pubkey: owner, + revision: 4, + layout_revision: 1, + key_epoch: 2, + migration: { + source_event_id: + "2222222222222222222222222222222222222222222222222222222222222222", + source_hash: + "3333333333333333333333333333333333333333333333333333333333333333", + }, + reader_key_envelope: "workspace-key-envelope", + sections: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + rank: 0, + encrypted_label: "ciphertext-alpha", + encrypted_icon: null, + }, + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + rank: 1, + encrypted_label: "ciphertext-beta", + encrypted_icon: "ciphertext-icon", + }, + ], + assignments: [ + { + channel_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + section_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + revision: 1, + }, + ], + }; + const projectionEvent = finalizeEvent( + { + pubkey: owner, + created_at: 1, + kind: 30623, + tags: [ + ["d", owner], + ["p", owner], + ], + content: JSON.stringify(projection), + }, + ownerBytes, + ); + const legacyEvent = { + id: "4444444444444444444444444444444444444444444444444444444444444444", + pubkey: owner, + created_at: 2, + kind: 30078, + tags: [["d", "channel-sections"]], + content: "legacy-ciphertext", + sig: "legacy-signature", + }; + const legacyStore = { + version: 1, + sections: [{ id: "legacy", name: "Legacy", order: 0 }], + assignments: {}, + }; + let legacyLiveCallback; + let reconnectCallback; + let legacyFetchCount = 0; + let publishCount = 0; + relayClient.fetchEvents = async (filter) => { + if (filter.kinds.includes(30623)) return [projectionEvent]; + legacyFetchCount += 1; + return []; + }; + relayClient.subscribeLive = async (filter, onEvent) => { + if (filter.kinds.includes(30078)) legacyLiveCallback = onEvent; + return async () => {}; + }; + relayClient.publishEvent = async () => { + publishCount += 1; + }; + relayClient.subscribeToReconnects = (listener) => { + reconnectCallback = listener; + return () => {}; + }; + const previousInternals = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "get_relay_self") return relaySelf; + if (command === "nip44_decrypt_from_self") { + return args.ciphertext === "legacy-ciphertext" + ? JSON.stringify(legacyStore) + : "workspace-key"; + } + if (command === "decrypt_workspace_metadata") { + if (args.envelope === "ciphertext-alpha") return "Alpha"; + if (args.envelope === "ciphertext-beta") return "Beta"; + if (args.envelope === "ciphertext-icon") return "folder"; + } + throw new Error(`unexpected command ${command}`); + }, + }; + + try { + const { result, unmount } = renderHook(() => + useChannelSections(owner, "wss://relay.example"), + ); + const flush = async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + await act(flush); + const canonical = result.current.sections; + const legacyKey = storageKey(owner, "wss://relay.example"); + const beforeStorage = window.localStorage.getItem(legacyKey); + const beforeFetches = legacyFetchCount; + + window.dispatchEvent( + new window.StorageEvent("storage", { + key: legacyKey, + newValue: JSON.stringify(legacyStore), + }), + ); + reconnectCallback(); + legacyLiveCallback(legacyEvent); + await act(flush); + assert.deepEqual(result.current.sections, canonical); + assert.equal(window.localStorage.getItem(legacyKey), beforeStorage); + assert.equal(legacyFetchCount, beforeFetches); + assert.equal(publishCount, 0); + + const mutationResults = [ + result.current.createSection("blocked"), + result.current.renameSection( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "blocked", + ), + result.current.deleteSection("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + result.current.moveSectionUp("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + result.current.moveSectionDown("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + result.current.reorderSections([ + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + ]), + result.current.assignChannel( + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ), + result.current.unassignChannel("cccccccc-cccc-4ccc-8ccc-cccccccccccc"), + ]; + assert.equal(mutationResults[0], null); + await act(flush); + assert.deepEqual(result.current.sections, canonical); + assert.deepEqual(result.current.assignments, { + "cccccccc-cccc-4ccc-8ccc-cccccccccccc": + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }); + assert.equal(window.localStorage.getItem(legacyKey), beforeStorage); + assert.equal(publishCount, 0); + unmount(); + } finally { + cleanup(); + window.__TAURI_INTERNALS__ = previousInternals; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); + +test("legacy live, reconnect, and edit remain compatible before migration", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const owner = "legacy-compatible-owner"; + const relayUrl = "wss://relay.compatible"; + const liveEvent = { + id: "1111111111111111111111111111111111111111111111111111111111111111", + pubkey: owner, + created_at: 1, + kind: 30078, + tags: [["d", "channel-sections"]], + content: "legacy-live", + sig: "signature", + }; + const reconnectEvent = { + ...liveEvent, + id: "2222222222222222222222222222222222222222222222222222222222222222", + created_at: 2, + content: "legacy-reconnect", + }; + const liveStore = { + version: 1, + sections: [{ id: "live", name: "Live", order: 0 }], + assignments: {}, + }; + const reconnectStore = { + version: 1, + sections: [{ id: "reconnect", name: "Reconnect", order: 0 }], + assignments: {}, + }; + let liveCallback; + let reconnectCallback; + let reconnectFetches = 0; + relayClient.fetchEvents = async (filter) => { + if (filter.kinds.includes(30078)) { + reconnectFetches += 1; + return reconnectFetches > 2 ? [reconnectEvent] : []; + } + return []; + }; + relayClient.subscribeLive = async (filter, onEvent) => { + if (filter.kinds.includes(30078)) liveCallback = onEvent; + return async () => {}; + }; + relayClient.subscribeToReconnects = (listener) => { + reconnectCallback = listener; + return () => {}; + }; + const previousInternals = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "nip44_decrypt_from_self") { + if (args.ciphertext === "legacy-live") return JSON.stringify(liveStore); + if (args.ciphertext === "legacy-reconnect") { + return JSON.stringify(reconnectStore); + } + } + throw new Error(`unexpected command ${command}`); + }, + }; + try { + const { result, unmount } = renderHook(() => + useChannelSections(owner, relayUrl), + ); + const flush = async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + await act(flush); + assert.equal(result.current.sections.length, 0); + liveCallback(liveEvent); + await act(flush); + assert.deepEqual(result.current.sections, [ + { id: "live", name: "Live", order: 0 }, + ]); + reconnectCallback(); + await act(flush); + assert.deepEqual(result.current.sections, [ + { id: "reconnect", name: "Reconnect", order: 0 }, + ]); + act(() => result.current.renameSection("reconnect", "Edited")); + assert.deepEqual(result.current.sections, [ + { id: "reconnect", name: "Edited", order: 0 }, + ]); + unmount(); + } finally { + cleanup(); + window.__TAURI_INTERNALS__ = previousInternals; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); + +test("canonical cutover cancels a pending legacy publish", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { finalizeEvent } = await import("nostr-tools/pure"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const originalSetTimeout = window.setTimeout; + const originalClearTimeout = window.clearTimeout; + const owner = + "3333333333333333333333333333333333333333333333333333333333333333"; + const relaySelf = + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; + const ownerBytes = new Uint8Array(32).fill(1); + const projection = { + version: 1, + owner_pubkey: owner, + revision: 1, + layout_revision: 1, + key_epoch: 1, + migration: { + source_event_id: + "4444444444444444444444444444444444444444444444444444444444444444", + source_hash: + "5555555555555555555555555555555555555555555555555555555555555555", + }, + reader_key_envelope: "workspace-key-envelope", + sections: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + rank: 0, + encrypted_label: "canonical-label", + encrypted_icon: null, + }, + ], + assignments: [], + }; + const projectionEvent = finalizeEvent( + { + pubkey: owner, + created_at: 1, + kind: 30623, + tags: [ + ["d", owner], + ["p", owner], + ], + content: JSON.stringify(projection), + }, + ownerBytes, + ); + let workspaceCallback; + let pendingTimer; + let pendingTimerCleared = false; + let publishCount = 0; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (filter, onEvent) => { + if (filter.kinds.includes(30623)) workspaceCallback = onEvent; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.publishEvent = async () => { + publishCount += 1; + }; + window.setTimeout = (callback, delay) => { + if (delay === 2000) { + pendingTimer = callback; + return 42; + } + return originalSetTimeout(callback, delay); + }; + window.clearTimeout = (timer) => { + if (timer === 42) pendingTimerCleared = true; + else originalClearTimeout(timer); + }; + const previousInternals = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: async (command) => { + if (command === "get_relay_self") return relaySelf; + if (command === "nip44_decrypt_from_self") return "workspace-key"; + if (command === "decrypt_workspace_metadata") return "canonical-name"; + throw new Error(`unexpected command ${command}`); + }, + }; + try { + const { result, unmount } = renderHook(() => + useChannelSections(owner, "wss://relay.pending"), + ); + const flush = async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + await act(flush); + act(() => result.current.createSection("pending")); + assert.equal(typeof pendingTimer, "function"); + workspaceCallback(projectionEvent); + await act(flush); + assert.equal(pendingTimerCleared, true); + assert.equal(publishCount, 0); + unmount(); + } finally { + cleanup(); + window.setTimeout = originalSetTimeout; + window.clearTimeout = originalClearTimeout; + window.__TAURI_INTERNALS__ = previousInternals; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +});