mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
test(desktop): cover section workspace authority boundaries
Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user