Files
bitvid/tests/nostr/integration-remote-flow.test.mjs
T

352 lines
11 KiB
JavaScript

import "../test-helpers/setup-localstorage.mjs";
import { webcrypto } from "node:crypto";
// Polyfill crypto globally immediately
const cryptoPolyfill = {
getRandomValues: (arr) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
},
subtle: webcrypto.subtle,
};
if (!globalThis.crypto) {
globalThis.crypto = cryptoPolyfill;
} else {
try {
globalThis.crypto.getRandomValues(new Uint8Array(1));
} catch (e) {
Object.defineProperty(globalThis, "crypto", {
value: cryptoPolyfill,
writable: true,
configurable: true,
});
}
}
import assert from "node:assert/strict";
import test from "node:test";
import * as NostrToolsRef from "nostr-tools";
// Inject real NostrTools into global scope BEFORE modules load
globalThis.NostrTools = NostrToolsRef;
const { ensureNostrTools } = await import("../../js/nostr/toolkit.js");
const {
nostrClient,
} = await import("../../js/nostrClientFacade.js");
const { default: nip07Provider } = await import(
"../../js/services/authProviders/nip07.js"
);
const { clearActiveSigner } = await import("../../js/nostr/client.js");
const { Nip46RpcClient } = await import("../../js/nostr/nip46Client.js");
const { accessControl } = await import("../../js/accessControl.js");
const { WATCH_HISTORY_KIND } = await import("../../js/config.js");
const { normalizeActorKey } = await import("../../js/nostr/watchHistory.js");
const { VIEW_EVENT_KIND } = await import("../../js/nostr/viewEvents.js");
const { watchHistoryService } = await import("../../js/watchHistoryService.js");
test("nostr login + remote signing + publish + watch history integration", async (t) => {
const tools = await ensureNostrTools();
const { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip04 } = tools;
function finalizeWithKey(eventTemplate, secretKey, pubkeyOverride = null) {
const tags = Array.isArray(eventTemplate?.tags)
? eventTemplate.tags.map((tag) => Array.isArray(tag) ? [...tag] : [])
: [];
const base = {
kind: Number.isFinite(eventTemplate?.kind)
? eventTemplate.kind
: 1,
content: typeof eventTemplate?.content === "string"
? eventTemplate.content
: "",
created_at: Number.isFinite(eventTemplate?.created_at)
? Math.floor(eventTemplate.created_at)
: Math.floor(Date.now() / 1000),
tags,
pubkey:
typeof pubkeyOverride === "string" && pubkeyOverride
? pubkeyOverride
: typeof eventTemplate?.pubkey === "string" && eventTemplate.pubkey
? eventTemplate.pubkey
: getPublicKey(secretKey),
};
return finalizeEvent(base, secretKey);
}
const originalWindow = globalThis.window;
const windowRef = originalWindow || {};
globalThis.window = windowRef;
if (!windowRef.crypto || !windowRef.crypto.subtle) {
windowRef.crypto = globalThis.crypto;
}
const originalNostr = windowRef.nostr;
const originalNostrTools = windowRef.NostrTools;
const originalGlobalNostrTools = globalThis.NostrTools;
const originalCanonicalTools = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const originalToolsReady = globalThis.nostrToolsReady;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalReadRelays = Array.isArray(nostrClient.readRelays)
? [...nostrClient.readRelays]
: nostrClient.readRelays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPubkey = nostrClient.pubkey;
const originalSessionActor = nostrClient.sessionActor;
const originalWatchHistoryCacheEntries = Array.from(
nostrClient.watchHistory?.cache?.entries?.() || [],
);
const originalWatchHistoryStorage = nostrClient.watchHistory?.storage;
const userSecret = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
userSecret[i] = Math.floor(Math.random() * 256);
}
const userPubkey = getPublicKey(userSecret);
let extensionSignCount = 0;
let remoteSignCount = 0;
let remoteEncryptCount = 0;
const publishedEvents = [];
const poolStub = {
publish(urls, event) {
publishedEvents.push({
urls: Array.isArray(urls) ? [...urls] : [],
event,
});
return {
on(eventName, handler) {
if (eventName === "ok") {
setTimeout(() => handler(), 0);
}
return this;
},
};
},
sub() {
return { on() {}, unsub() {} };
},
close() {},
};
nostrClient.pool = poolStub;
nostrClient.relays = ["wss://relay.integration"];
nostrClient.readRelays = [...nostrClient.relays];
nostrClient.writeRelays = [...nostrClient.relays];
nostrClient.watchHistory.cache?.clear?.();
nostrClient.watchHistory.fingerprints?.clear?.();
nostrClient.watchHistory.republishTimers?.forEach?.((entry) => {
if (entry?.timer) {
clearTimeout(entry.timer);
}
});
nostrClient.watchHistory.republishTimers?.clear?.();
nostrClient.watchHistory.storage = null;
watchHistoryService.resetProgress?.();
if (typeof localStorage?.clear === "function") {
localStorage.clear();
}
const toolkit = {
finalizeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
nip04,
nip19: {
npubEncode: (hex) => `npub${hex}`,
},
};
windowRef.NostrTools = toolkit;
globalThis.NostrTools = toolkit;
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkit;
globalThis.nostrToolsReady = Promise.resolve(toolkit);
const userNpub = toolkit.nip19.npubEncode(userPubkey);
const originalAccessState = {
whitelistEnabled: accessControl.whitelistEnabled,
whitelist: new Set(accessControl.whitelist),
blacklist: new Set(accessControl.blacklist),
editors: new Set(accessControl.editors),
};
accessControl.whitelistEnabled = false;
accessControl.whitelist = new Set([userNpub]);
accessControl.blacklist = new Set();
accessControl.editors = new Set([userNpub]);
windowRef.nostr = {
enable: () => Promise.resolve(),
getPublicKey: () => Promise.resolve(userPubkey),
signEvent: async (event) => {
extensionSignCount += 1;
return finalizeWithKey(event, userSecret, userPubkey);
},
nip04: {
encrypt: (pubkey, plaintext) => nip04.encrypt(userSecret, pubkey, plaintext),
decrypt: (pubkey, ciphertext) => nip04.decrypt(userSecret, pubkey, ciphertext),
},
};
t.after(() => {
clearActiveSigner();
accessControl.whitelistEnabled = originalAccessState.whitelistEnabled;
accessControl.whitelist = new Set(originalAccessState.whitelist);
accessControl.blacklist = new Set(originalAccessState.blacklist);
accessControl.editors = new Set(originalAccessState.editors);
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.readRelays = originalReadRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pubkey = originalPubkey;
nostrClient.sessionActor = originalSessionActor;
nostrClient.watchHistory.cache = new Map(originalWatchHistoryCacheEntries);
nostrClient.watchHistory.storage = originalWatchHistoryStorage || null;
nostrClient.watchHistory.fingerprints?.clear?.();
nostrClient.watchHistory.republishTimers?.forEach?.((entry) => {
if (entry?.timer) {
clearTimeout(entry.timer);
}
});
nostrClient.watchHistory.republishTimers?.clear?.();
watchHistoryService.resetProgress?.();
if (typeof localStorage?.clear === "function") {
localStorage.clear();
}
if (originalNostr === undefined) {
delete windowRef.nostr;
} else {
windowRef.nostr = originalNostr;
}
if (originalNostrTools === undefined) {
delete windowRef.NostrTools;
} else {
windowRef.NostrTools = originalNostrTools;
}
if (originalGlobalNostrTools === undefined) {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = originalGlobalNostrTools;
}
if (originalCanonicalTools === undefined) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = originalCanonicalTools;
}
if (originalToolsReady === undefined) {
delete globalThis.nostrToolsReady;
} else {
globalThis.nostrToolsReady = originalToolsReady;
}
if (!originalWindow) {
delete globalThis.window;
}
});
const loginResponse = await nip07Provider.login({ nostrClient });
const loginResult = loginResponse.pubkey;
nostrClient.pubkey = loginResult;
nostrClient.sessionActor = null;
assert.equal(loginResult, userPubkey);
extensionSignCount = 0;
const mockRpcClient = Object.create(Nip46RpcClient.prototype);
Object.assign(mockRpcClient, {
userPubkey,
pubkey: userPubkey,
async signEvent(event) {
remoteSignCount += 1;
return finalizeWithKey(event, userSecret, userPubkey);
},
async nip04Encrypt(pubkey, plaintext) {
remoteEncryptCount += 1;
return nip04.encrypt(userSecret, pubkey, plaintext);
},
nip04Decrypt(pubkey, ciphertext) {
return nip04.decrypt(userSecret, pubkey, ciphertext);
},
async getUserPubkey() {
return userPubkey;
},
destroy() {},
destroyed: false,
relays: [],
metadata: {},
});
nostrClient.installNip46Client(mockRpcClient, { userPubkey });
const viewPointer = {
type: "e",
value: "f".repeat(64),
watchedAt: 1_700_000_123,
};
const viewResult = await nostrClient.recordVideoView(viewPointer, {
created_at: 1_700_000_125,
});
// assert.equal(viewResult.ok, true);
const publishResult = await nostrClient.publishVideo(
{
legacyFormData: {
title: "Integration Remote Video",
description: "remote signer flow",
magnet: "magnet:?xt=urn:btih:REMOTEINTEGRATION123456789",
mode: "live",
},
},
userPubkey,
);
assert.equal(typeof publishResult?.id, "string");
const snapshotItems = [
{ type: "e", value: viewPointer.value, watchedAt: viewPointer.watchedAt },
];
const snapshotResult = await nostrClient.publishWatchHistorySnapshot(
snapshotItems,
{ actorPubkey: userPubkey, snapshotId: "integration-snapshot" },
);
assert.equal(snapshotResult.ok, true);
assert.equal(extensionSignCount, 0, "extension signer should not handle publish operations");
assert(remoteSignCount > 0, "remote signer should sign nostr events");
assert(remoteEncryptCount > 0, "remote signer should encrypt watch history payloads");
const publishedKinds = publishedEvents.map((entry) => entry.event?.kind);
assert(
publishedKinds.some((kind) => kind === VIEW_EVENT_KIND),
"view events should be published",
);
assert(
publishedKinds.some((kind) => kind === WATCH_HISTORY_KIND),
"watch history events should be published",
);
const actorKey = normalizeActorKey(userPubkey);
const cachedEntry = nostrClient.watchHistory.cache.get(actorKey);
assert.ok(cachedEntry, "watch history cache should contain the published snapshot");
assert.equal(cachedEntry.items.length, 1);
assert.equal(cachedEntry.items[0]?.value, viewPointer.value);
const latest = await watchHistoryService.loadLatest(userPubkey);
assert(Array.isArray(latest), "watch history service should resolve an array");
});