Files
bitvid/tests/nostr/signatureVerifyWorkerClient.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 7e3cdb4174 fix(nostr): make feed signature verification fail-open and non-hanging
The UX harness (fake nip-07 extension) revealed that the off-thread verification
could blank the feed: if nostr-tools is slow/unable to load inside the verify
worker, the worker hangs, each batch waits the full 10s timeout, and the
main-thread fallback could return an empty set (fail-closed) — dropping every
event so no videos ever render, silently (devLogger is suppressed in prod).

Harden it so verification can never stall or blank the read feed:
- Worker timeout 10s -> 2.5s, and disable the worker for the session after one
  timeout/error so we never pay the timeout on every batch.
- Bound the main-thread nostr-tools wait (Promise.race ~2s) so a slow/failed
  load can't hang the fallback.
- Fail OPEN when the verifier is genuinely unavailable: pass events through
  unverified rather than dropping them (the pool already does not verify, so this
  does not regress the prior posture). Real verification still runs whenever
  nostr-tools is available.

Adds a regression test (hung worker -> resolves via fallback, worker disabled).
Also updates the harness to seed mode:"live" videos + a NIP-65 relay list for
mock-relay isolation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 15:52:40 -04:00

109 lines
4.3 KiB
JavaScript

// Tests the signature-verify worker client protocol: a batch of events is sent
// to the worker and the client resolves with the Set of ids the worker reports
// valid. This is the contract the feed ingestion relies on to drop forged events
// after the pool stopped verifying on the main thread.
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
// A controllable fake Worker installed as the global before importing nothing
// stateful — the client lazily constructs the worker on first use.
class FakeWorker {
constructor() {
this.listeners = { message: [], error: [] };
FakeWorker.instances.push(this);
}
addEventListener(type, fn) {
(this.listeners[type] ||= []).push(fn);
}
postMessage(msg) {
if (FakeWorker.silent) return; // model a hung worker that never responds
// Respond asynchronously, echoing as valid only the ids in FakeWorker.validIds
// (or all ids if validIds is null), to mimic real verification.
setTimeout(() => {
const ids = (msg.events || []).map((e) => e.id);
const validIds = FakeWorker.validIds === null ? ids : ids.filter((id) => FakeWorker.validIds.has(id));
for (const fn of this.listeners.message) fn({ data: { id: msg.id, ok: true, validIds } });
}, 0);
}
terminate() {
FakeWorker.terminated += 1;
}
}
FakeWorker.instances = [];
FakeWorker.validIds = null;
FakeWorker.silent = false;
FakeWorker.terminated = 0;
describe("signatureVerifyWorkerClient", () => {
let verifyEventsInWorker;
beforeEach(async () => {
globalThis.Worker = FakeWorker;
FakeWorker.instances = [];
FakeWorker.validIds = null;
FakeWorker.silent = false;
FakeWorker.terminated = 0;
// Fresh module each test so the singleton worker is rebuilt against FakeWorker.
const mod = await import(`../../js/nostr/signatureVerifyWorkerClient.js?bust=${Math.random()}`);
verifyEventsInWorker = mod.verifyEventsInWorker;
});
afterEach(() => {
delete globalThis.Worker;
});
it("a hung worker must not hang the caller — it times out and falls back", async () => {
FakeWorker.silent = true; // worker never responds
const events = [{ id: "a".repeat(64) }, { id: "b".repeat(64) }];
const started = Date.now();
// Short timeout so the test is fast; the point is it RESOLVES, not hangs,
// and disables the worker so the next batch doesn't pay the timeout again.
const result = await verifyEventsInWorker(events, { timeoutMs: 80 });
assert.ok(result instanceof Set, "must resolve to a Set, not hang");
assert.ok(Date.now() - started < 2000, "must resolve promptly via fallback");
assert.ok(FakeWorker.terminated >= 1, "hung worker should be terminated/disabled");
// Subsequent call must skip the worker entirely (no new instance created).
const before = FakeWorker.instances.length;
await verifyEventsInWorker([{ id: "c".repeat(64) }], { timeoutMs: 80 });
assert.equal(FakeWorker.instances.length, before, "worker stays disabled after a failure");
});
it("resolves with the set of ids the worker reports valid", async () => {
const events = [
{ id: "a".repeat(64), sig: "x" },
{ id: "b".repeat(64), sig: "y" },
{ id: "c".repeat(64), sig: "z" },
];
const valid = await verifyEventsInWorker(events);
assert.ok(valid instanceof Set);
assert.equal(valid.size, 3);
assert.ok(valid.has("a".repeat(64)));
});
it("drops events the worker reports invalid", async () => {
FakeWorker.validIds = new Set(["a".repeat(64)]); // only 'a' is valid
const events = [
{ id: "a".repeat(64) },
{ id: "b".repeat(64) },
];
const valid = await verifyEventsInWorker(events);
assert.equal(valid.size, 1);
assert.ok(valid.has("a".repeat(64)));
assert.ok(!valid.has("b".repeat(64)));
});
it("returns an empty set for empty input without invoking a worker", async () => {
const valid = await verifyEventsInWorker([]);
assert.equal(valid.size, 0);
assert.equal(FakeWorker.instances.length, 0, "must not construct a worker for empty input");
});
it("ignores events without ids", async () => {
const valid = await verifyEventsInWorker([{ sig: "no-id" }, null, { id: "d".repeat(64) }]);
assert.equal(valid.size, 1);
assert.ok(valid.has("d".repeat(64)));
});
});