Files
bitvid/tests/nwc-client.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 d6ac9429c1 tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter
Closes the #11b un-quarantine work: the three target files now pass and run in
CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing
hang that had never been in the map (dm-block-filter) — quarantined with a triage
note rather than left to hang CI.

user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs
a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the
run (manager.reset() races it). Added the repo's established bare-assert
`setTimeout(() => process.exit(0), 50)` exit after the final assertion.

nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it
had been silently hanging local `npm run test:unit`, which has no per-file timeout
unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app
stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same
`process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were
already in the file.)

nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's
frozen canonical toolkit over window.NostrTools, so per-section mocks never took
effect and real @noble rejected the fake keys. Remove the scope canonical so the
resolver falls through to each mock. (2) spec correction: one section expected a
rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills
the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The
wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the
same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md.

dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass,
then it hangs: manager.loadBlocks() opens a live block-list subscription through
nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP
sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix
(inject a mock pool so no real socket opens) — a force process.exit in after() is
UNSAFE (verified: process.exitCode isn't set yet, so it masks failures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 10:45:55 -04:00

590 lines
16 KiB
JavaScript

import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const nwcModule = await import("../js/payments/nwcClient.js");
const { __TESTING__, resetWalletClient } = nwcModule;
const { buildPayInvoiceParams, ensureActiveState, parseNwcUri, getActiveState } = __TESTING__;
// The nostr-tools bootstrap installs a frozen canonical toolkit on the global
// scope, and nwcClient's readToolkitFromScope() prefers it over
// window.NostrTools. That shadows the per-section mocks below, so the real
// @noble crypto runs and rejects the fake test keys ("bad point: is not on
// curve"). These sections exercise the NWC client's encryption-SELECTION
// wiring — which scheme it negotiates and how it threads
// getConversationKey/encrypt — NOT @noble's crypto correctness (that's real
// elsewhere, and the hex-vs-bytes production bug parseNwcUri once had is
// guarded by tests/nwc-parse-uri.test.mjs). Remove the canonical so the
// resolver falls through to each section's mock. Nothing re-installs it during
// the run, so one removal holds for every section.
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
if (globalThis.window && typeof globalThis.window === "object") {
delete globalThis.window.__BITVID_CANONICAL_NOSTR_TOOLS__;
}
await (async () => {
const params = buildPayInvoiceParams({
invoice: "bolt11-invoice",
amountSats: 123,
zapRequest: "zap-request-json",
lnurl: " https://pay.example/lnurlp/test ",
});
assert.equal(params.invoice, "bolt11-invoice");
assert.equal(params.amount, 123_000);
assert.equal(params.zap_request, "zap-request-json");
assert.equal(params.lnurl, "https://pay.example/lnurlp/test");
})();
await (async () => {
const params = buildPayInvoiceParams({
invoice: "bolt11-invoice",
amountSats: 0,
zapRequest: null,
});
assert.equal(params.invoice, "bolt11-invoice");
assert.ok(!("amount" in params));
assert.ok(!("zap_request" in params));
})();
await (async () => {
const params = buildPayInvoiceParams({
invoice: "bolt11-invoice",
amountSats: 42.7,
});
assert.equal(params.amount, 43_000);
})();
await (async () => {
resetWalletClient();
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
nip44: {
v2: {
async encrypt(plaintext, key) {
return `nip44:${key}:${plaintext}`;
},
async decrypt(ciphertext, key) {
return `nip44:${key}:${ciphertext}`;
},
utils: {
getConversationKey(secretBytes, pubkey) {
return `key-${pubkey}-${secretBytes.length}`;
},
},
},
},
nip04: {
async encrypt() {
return "nip04";
},
async decrypt() {
return "nip04";
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relays = ["wss://relay.one.example", "wss://relay.two.example"];
const lud16 = "[email protected]";
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relays[0])}` +
`&relay=${encodeURIComponent(relays[1])}` +
`&secret=${secretKey}` +
`&lud16=${encodeURIComponent(lud16)}`;
const context = ensureActiveState({ nwcUri: uri });
assert.deepEqual(context.relayUrls, relays);
assert.equal(context.relayUrl, relays[0]);
assert.equal(context.uri.split("?")[0], `nostr+walletconnect://${walletPubkey}`);
const state = getActiveState();
assert.ok(state?.settings?.nwcUri);
const reparsed = parseNwcUri(state.settings.nwcUri);
assert.deepEqual(reparsed.relays, relays);
assert.equal(reparsed.secretKey, secretKey);
assert.equal(reparsed.queryParams.lud16, lud16);
context.infoEvent = {
kind: 13194,
tags: [["encryption", "nip44_v2"]],
};
const encryption = await __TESTING__.ensureEncryptionForContext(context);
assert.equal(encryption.scheme, "nip44_v2");
const encrypted = await encryption.encrypt("payload");
assert.match(encrypted, /^nip44:key-b{64}-/);
resetWalletClient();
})();
await (async () => {
resetWalletClient();
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
nip44: {
v2: {
async encrypt(plaintext, key) {
return `nip44:${key}:${plaintext}`;
},
async decrypt(ciphertext, key) {
return `nip44:${key}:${ciphertext}`;
},
utils: {
getConversationKey(secretBytes, pubkey) {
return `key-${pubkey}-${secretBytes.length}`;
},
},
},
},
nip04: {
async encrypt() {
return "nip04";
},
async decrypt() {
return "nip04";
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relays = ["wss://relay.one.example"];
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relays[0])}` +
`&secret=${secretKey}`;
const context = ensureActiveState({ nwcUri: uri });
context.infoEvent = { kind: 13194, tags: [] };
const encryption = await __TESTING__.ensureEncryptionForContext(context);
assert.equal(encryption.scheme, "nip04");
resetWalletClient();
})();
await (async () => {
resetWalletClient();
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
nip44: {
v2: {
async encrypt(plaintext, key) {
return `nip44:${key}:${plaintext}`;
},
async decrypt(ciphertext, key) {
return `nip44:${key}:${ciphertext}`;
},
utils: {
getConversationKey(secretBytes, pubkey) {
return `key-${pubkey}-${secretBytes.length}`;
},
},
},
},
nip04: {
async encrypt() {
return "nip04";
},
async decrypt() {
return "nip04";
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relays = ["wss://relay.one.example"];
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relays[0])}` +
`&secret=${secretKey}`;
const context = ensureActiveState({ nwcUri: uri });
context.infoEvent = {
kind: 13194,
tags: [["encryption", "nip04"]],
};
const encryption = await __TESTING__.ensureEncryptionForContext(context);
assert.equal(encryption.scheme, "nip04");
resetWalletClient();
})();
await (async () => {
resetWalletClient();
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
nip04: {
async encrypt() {
return "nip04";
},
async decrypt() {
return "nip04";
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relays = ["wss://relay.one.example"];
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relays[0])}` +
`&secret=${secretKey}`;
const context = ensureActiveState({ nwcUri: uri });
// Spec correction: the original scenario advertised "nip44_v2" and relied on
// the mock toolkit LACKING nip44 to force a rejection. That precondition is
// architecturally unreachable — the nostr-tools bootstrap's mergeWithCanonical
// always backfills the canonical nip44 (even an explicit `nip44: null` is
// overwritten), so nip44_v2 is ALWAYS a supported scheme and the wallet's
// advertisement would be honored, not rejected. The intended behavior under
// test is: when a wallet advertises ONLY encryption schemes bitvid cannot
// provide, ensureEncryption rejects with UNSUPPORTED_ENCRYPTION. bitvid only
// implements nip04 + nip44_v2, so advertising an unknown scheme is the
// reachable, faithful way to exercise that exact branch.
context.infoEvent = {
kind: 13194,
tags: [["encryption", "nip44_v3"]],
};
await assert.rejects(
() => __TESTING__.ensureEncryptionForContext(context),
(error) => {
assert.match(
error?.message || "",
/Wallet advertises unsupported encryption schemes: nip44_v3\./
);
assert.equal(
error?.code,
"UNSUPPORTED_ENCRYPTION",
"unsupported-scheme rejection must carry the UNSUPPORTED_ENCRYPTION code"
);
return true;
}
);
resetWalletClient();
})();
await (async () => {
resetWalletClient();
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
nip44: {
v2: {
async encrypt(plaintext, key) {
return `nip44:${key}:${plaintext}`;
},
async decrypt(ciphertext, key) {
return `nip44:${key}:${ciphertext}`;
},
utils: {
getConversationKey(secretBytes, pubkey) {
return `key-${pubkey}-${secretBytes.length}`;
},
},
},
},
nip04: {
async encrypt() {
return "nip04";
},
async decrypt() {
return "nip04";
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relays = ["wss://relay.one.example"];
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relays[0])}` +
`&secret=${secretKey}`;
const context = ensureActiveState({ nwcUri: uri });
context.infoEvent = {
kind: 13194,
tags: [["encryption", "nip44_v2 nip04"]],
};
context.encryptionState.unsupported.add("nip44_v2");
const encryption = await __TESTING__.ensureEncryptionForContext(context);
assert.equal(encryption.scheme, "nip04");
resetWalletClient();
})();
await (async () => {
resetWalletClient();
const originalWebSocket = globalThis.WebSocket;
const RESPONSE_KIND = 23195;
class FakeWebSocket {
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
this._listeners = new Map();
this._responseSubscriptionId = null;
setTimeout(() => {
this.readyState = FakeWebSocket.OPEN;
this._emit("open");
}, 0);
}
addEventListener(type, handler) {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
this._listeners.get(type).add(handler);
}
removeEventListener(type, handler) {
const listeners = this._listeners.get(type);
if (listeners) {
listeners.delete(handler);
}
}
send(payload) {
let parsed;
try {
parsed = JSON.parse(payload);
} catch (error) {
return;
}
const [type] = parsed;
if (type === "REQ") {
const subscription = parsed[1];
const filters = parsed[2];
if (filters?.kinds && filters.kinds.includes(RESPONSE_KIND)) {
this._responseSubscriptionId = subscription;
}
return;
}
if (type === "EVENT") {
const event = parsed[1];
const state = __TESTING__.getActiveState();
const context = state?.context || null;
const encryption = context?.encryption || null;
if (!context || !encryption || !this._responseSubscriptionId) {
return;
}
Promise.resolve()
.then(() => encryption.decrypt(event.content))
.then((plaintext) => {
const requestPayload = JSON.parse(plaintext);
const responsePayload = {
id: requestPayload.id,
result_type: "pay_invoice",
result: { preimage: "test-preimage" },
};
return encryption.encrypt(JSON.stringify(responsePayload));
})
.then((ciphertext) => {
if (!ciphertext) {
return;
}
const tools = window.NostrTools || {};
const responseEvent = {
pubkey: context.walletPubkey,
kind: RESPONSE_KIND,
content: ciphertext,
tags: [
["p", context.clientPubkey],
["e", event.id],
],
created_at: Math.floor(Date.now() / 1000),
};
if (typeof tools.getEventHash === "function") {
responseEvent.id = tools.getEventHash(responseEvent);
} else {
responseEvent.id = `resp-${Date.now()}`;
}
if (typeof tools.signEvent === "function") {
responseEvent.sig = tools.signEvent(responseEvent, context.secretKey);
}
const message = JSON.stringify([
"EVENT",
this._responseSubscriptionId,
responseEvent,
]);
setTimeout(() => {
this._emit("message", { data: message });
}, 0);
})
.catch(() => {});
}
}
close() {
this.readyState = FakeWebSocket.CLOSED;
this._emit("close", {});
}
_emit(type, event = {}) {
const listeners = this._listeners.get(type);
if (!listeners) {
return;
}
for (const handler of Array.from(listeners)) {
try {
handler(event);
} catch (error) {
// ignore
}
}
}
}
FakeWebSocket.CONNECTING = 0;
FakeWebSocket.OPEN = 1;
FakeWebSocket.CLOSED = 3;
globalThis.WebSocket = FakeWebSocket;
let eventCounter = 0;
const encode = (value) => Buffer.from(value, "utf8").toString("base64");
const decode = (value) => Buffer.from(value, "base64").toString("utf8");
window.NostrTools = {
getPublicKey() {
return "c".repeat(64);
},
getEventHash(event) {
eventCounter += 1;
return `event-${eventCounter}`;
},
signEvent(event) {
return `sig-${event.id || "unknown"}`;
},
nip04: {
async encrypt(secretKey, pubkey, plaintext) {
return `nip04:${encode(plaintext)}`;
},
async decrypt(secretKey, pubkey, ciphertext) {
const payload =
typeof ciphertext === "string" && ciphertext.startsWith("nip04:")
? ciphertext.slice("nip04:".length)
: ciphertext;
return decode(payload);
},
},
nip44: {
v2: {
async encrypt(plaintext, key) {
return `nip44:${key}:${encode(plaintext)}`;
},
async decrypt(ciphertext, key) {
if (typeof ciphertext !== "string") {
return "";
}
const parts = ciphertext.split(":");
const encoded = parts.slice(2).join(":");
return decode(encoded);
},
utils: {
getConversationKey(secretBytes, pubkey) {
return `key-${pubkey}-${secretBytes.length}`;
},
},
},
},
};
const walletPubkey = "b".repeat(64);
const secretKey = "a".repeat(64);
const relay = "wss://relay.budget.example";
const budget = 1000n;
const uri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relay)}` +
`&secret=${secretKey}` +
`&budget=${budget.toString()}`;
const context = ensureActiveState({ nwcUri: uri });
context.infoEvent = { kind: 13194, tags: [["encryption", "nip04"]] };
try {
const first = await nwcModule.sendPayment("lnbc1budgettest", {
settings: { nwcUri: uri },
amountSats: 1,
});
assert.ok(first);
assert.ok(context.budgetTracker);
assert.equal(context.budgetTracker.totalMsats, budget);
assert.equal(context.budgetTracker.spentMsats, 1000n);
await assert.rejects(
() =>
nwcModule.sendPayment("lnbc1budgettest", {
settings: { nwcUri: uri },
amountSats: 1,
}),
(error) => {
assert.equal(error.code, "NWC_BUDGET_EXHAUSTED");
assert.match(error.message || "", /Budget exceeded/i);
return true;
}
);
assert.equal(context.budgetTracker.exhausted, true);
const higherBudget = 5000n;
const upgradedUri =
`nostr+walletconnect://${walletPubkey}` +
`?relay=${encodeURIComponent(relay)}` +
`&secret=${secretKey}` +
`&budget=${higherBudget.toString()}`;
const nextContext = ensureActiveState({ nwcUri: upgradedUri });
nextContext.infoEvent = { kind: 13194, tags: [["encryption", "nip04"]] };
assert.ok(nextContext.budgetTracker);
assert.equal(nextContext.budgetTracker.totalMsats, higherBudget);
assert.equal(nextContext.budgetTracker.spentMsats, 0n);
assert.equal(nextContext.budgetTracker.exhausted, false);
} finally {
if (originalWebSocket) {
globalThis.WebSocket = originalWebSocket;
} else {
delete globalThis.WebSocket;
}
resetWalletClient();
}
})();
process.exit(0);