Files
bitvid/tests/ensure-signer-decision.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 1b79ab9d09 [auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)
A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory
signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh,
adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer
that supports encryption before managing hashtag preferences" — even though the user
looks logged in. Same class blocks subscriptions/blocks/DMs/reactions.

New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }):
checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING
locked nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing
flow until the next reload. Never unlocks a different account's key (pubkey-mismatch
guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension)
to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure,
unit-testable without the app shell).

Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences):
a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish
never runs until the signer is confirmed. Exposed to the modal as
services.ensureEncryptionCapableSigner.

This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57);
subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same
gate (tracked in TODO #57).

Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail
branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in
tests/profile-modal-controller.test.mjs. Lint + build green.

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

135 lines
4.6 KiB
JavaScript

// The account-switch / reload signer gate (app.ensureEncryptionCapableSigner)
// decides whether a signing/encryption action can proceed, must fail, or should
// prompt the user to re-unlock a persisted nsec key. decideSignerEnsure is the
// pure branching behind it (TODO #51/#54/#56/#57).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-signer-ensure-decision
// given: "current signer capabilities + whether a locked nsec key exists"
// when: "decideSignerEnsure runs"
// then: "ok when capable, prompt when recoverable, fail with a precise reason otherwise"
// observable_outcomes:
// - "capable encrypt signer -> {action:ok}"
// - "no signer + no pubkey -> fail:no-signer"
// - "incapable + no stored key -> fail:no-stored-key"
// - "incapable + stored key for a DIFFERENT pubkey -> fail:pubkey-mismatch"
// - "incapable + matching locked key -> prompt"
// - "need:sign only requires signEvent (nip44/nip04 not required)"
// determinism_controls:
// - "pure function; explicit inputs; injected normalizePubkey"
// 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 {
decideSignerEnsure,
isSignerCapable,
} from "../js/nostr/ensureSignerDecision.js";
const PUB = "a".repeat(64);
const OTHER = "b".repeat(64);
const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
test("capable encrypt-signer proceeds", () => {
const d = decideSignerEnsure({
capabilities: { sign: true, nip44: true, nip04: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "ok" });
});
test("nip04-only signer is still encrypt-capable", () => {
assert.equal(isSignerCapable({ sign: true, nip04: true }, "encrypt"), true);
});
test("a sign-only signer is NOT encrypt-capable but IS sign-capable", () => {
const caps = { sign: true, nip44: false, nip04: false };
assert.equal(isSignerCapable(caps, "encrypt"), false);
assert.equal(isSignerCapable(caps, "sign"), true);
// need:sign proceeds even without any encryption method
assert.deepEqual(
decideSignerEnsure({
capabilities: caps,
need: "sign",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
}),
{ action: "ok" },
);
});
test("no signer and no pubkey -> fail:no-signer", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: "",
storedKeyMeta: null,
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-signer" });
});
test("incapable but no stored nsec key -> fail:no-stored-key (caller surfaces its own error)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: null,
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
});
test("stored key belongs to a DIFFERENT account -> fail:pubkey-mismatch (never unlock the wrong key)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: OTHER },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "pubkey-mismatch" });
});
test("incapable + matching locked nsec key -> prompt to re-unlock", () => {
const d = decideSignerEnsure({
capabilities: { sign: true, nip44: false, nip04: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "prompt" });
});
test("stored key with no pubkey field still prompts (metadata may omit it)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "prompt" });
});
test("hasEncryptedKey must be strictly true (a falsey/omitted flag never prompts)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: "yes", pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
});