Files
bitvid/tests/reaction-signer-gate.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 1622e85d10 [auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)
Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.

- Reactions (#54): ReactionController.handleReaction gates before the optimistic
  update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
  "like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
  (need:"encrypt", NIP-17/NIP-04).

Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.

Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:23:04 -04:00

86 lines
3.2 KiB
JavaScript

// Like/dislike (kind 7) must re-unlock a reloaded nsec signer before publishing,
// instead of the reaction silently failing / the button appearing dead (TODO #54).
// The ReactionController runs its ensureSigner gate before publishing.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-reaction-signer-gate
// given: "a logged-in user reacting with a mocked ensureSigner + publish"
// when: "handleReaction runs"
// then: "publish is gated on the signer being ensured (sign-capable)"
// observable_outcomes:
// - "cancelled/bad-passphrase gate -> publish never called, button reset"
// - "ok gate -> publish called once with the reaction content"
// determinism_controls:
// - "fully mocked services/ui/state/callbacks; no DOM/network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import ReactionController from "../js/ui/reactionController.js";
function makeController({ ensureSigner } = {}) {
const publishCalls = [];
const reactionsSet = [];
const modal = {
setUserReaction: (r) => reactionsSet.push(r),
};
const controller = new ReactionController({
services: {
reactionCounter: {
publish: async (pointer, payload) => {
publishCalls.push({ pointer, payload });
return { ok: true };
},
},
},
ui: { getVideoModal: () => modal, showError: () => {} },
state: {
getCurrentVideo: () => ({ pubkey: "b".repeat(64) }),
getCurrentVideoPointer: () => ["a", 30078, "id"],
getCurrentVideoPointerKey: () => "pointer-key",
},
callbacks: {
isUserLoggedIn: () => true,
getPubkey: () => "b".repeat(64),
ensureSigner: ensureSigner || (async () => ({ ok: true })),
},
});
return { controller, publishCalls, reactionsSet };
}
test("reaction: a cancelled signer gate blocks publish and resets the button", async () => {
let ensureCalls = 0;
const { controller, publishCalls } = makeController({
ensureSigner: async (opts) => {
ensureCalls += 1;
assert.equal(opts.need, "sign", "reactions need signing, not encryption");
return { ok: false, reason: "cancelled" };
},
});
await controller.handleReaction({ reaction: "+" });
assert.equal(ensureCalls, 1, "the signer gate ran");
assert.equal(publishCalls.length, 0, "publish was NOT attempted after a cancelled gate");
});
test("reaction: a bad-passphrase gate blocks publish (toast already shown by the gate)", async () => {
const { controller, publishCalls } = makeController({
ensureSigner: async () => ({ ok: false, reason: "bad-passphrase" }),
});
await controller.handleReaction({ reaction: "-" });
assert.equal(publishCalls.length, 0);
});
test("reaction: an ok signer gate lets publish proceed once", async () => {
const { controller, publishCalls } = makeController({
ensureSigner: async () => ({ ok: true, unlocked: true }),
});
await controller.handleReaction({ reaction: "+" });
assert.equal(publishCalls.length, 1, "publish ran after the gate passed");
assert.equal(publishCalls[0].payload.content, "+");
});