mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
590 lines
16 KiB
JavaScript
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);
|