diff --git a/tests/nip46-signer-lifecycle.test.mjs b/tests/nip46-signer-lifecycle.test.mjs new file mode 100644 index 00000000..1d287700 --- /dev/null +++ b/tests/nip46-signer-lifecycle.test.mjs @@ -0,0 +1,212 @@ +// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33). +// +// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared +// nip46Client being torn down by any scoped logout. Since then the layers under +// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted +// restore) — but nothing pinned the MANAGER-level contract, so any of those +// fixes could regress silently. These tests exercise the real SignerManager +// against the real signer registry and real (polyfilled) storage. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip46-logout-scope +// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer" +// when: "SignerManager.logout() logs out A" +// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives" +// - id: SCN-nip46-restore-failure-scope +// given: "stored sessions for A and B" +// when: "a targeted restore of A fails (locked session / missing session)" +// then: "the failure is typed and NO stored session is deleted" +// observable_outcomes: +// - "fake client's destroy() call count" +// - "readStoredNip46Session(pubkey) after the operation" +// - "resolveActiveSigner(pubkey) for the OTHER account" +// determinism_controls: +// - "in-memory localStorage polyfill; fake in-process client object; no network" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { SignerManager } from "../js/nostr/managers/SignerManager.js"; +import { + writeStoredNip46SessionSync, + readStoredNip46Session, +} from "../js/nostr/nip46Client.js"; +import { + registerSigner, + setActiveSigner, + clearActiveSigner, + resolveActiveSigner, +} from "../js/nostrClientRegistry.js"; + +const USER_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const USER_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; + +function storedSession(userPubkey, remotePubkey) { + return { + version: 1, + clientPublicKey: + "3333333333333333333333333333333333333333333333333333333333333333", + remotePubkey, + relays: ["wss://relay.example"], + encryption: "nip44.v2", + userPubkey, + lastConnectedAt: 1700000000000, + // Sessions at rest carry encrypted secrets; restoring without the + // passphrase must fail typed, not fall through to a broken connect. + encryptedSecrets: "cipher", + keyEncryption: { + salt: "c2FsdA==", + iv: "aXY=", + iterations: 1000, + hash: "SHA-256", + }, + }; +} + +function makeManager() { + // SignerManager only needs the app client for pool access on connect paths; + // the scoping contracts under test never reach the network. + return new SignerManager({ + relays: ["wss://relay.example"], + ensurePool: async () => { + throw new Error("network must not be touched by these tests"); + }, + }); +} + +beforeEach(() => { + localStorage.clear(); + clearActiveSigner(); +}); + +test("logging out the active NIP-46 account keeps every other account intact", () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + // B is a signed-in account in the registry (e.g. logged in earlier this + // session); A is the ACTIVE account with the live remote-signer client. + const signerB = { pubkey: USER_B, signEvent: async (e) => e }; + registerSigner(USER_B, signerB); + + let destroyCalls = 0; + const liveClient = { + remotePubkey: "1".repeat(64), + userPubkey: USER_A, + destroy() { + destroyCalls += 1; + }, + }; + + const manager = makeManager(); + manager.pubkey = USER_A; + manager.nip46Client = liveClient; + + manager.logout(); + + // The departing account's LIVE connection is torn down exactly once… + assert.equal(destroyCalls, 1); + assert.equal(manager.nip46Client, null); + assert.equal(manager.pubkey, null); + + // …but its stored session survives (logout keeps sessions reconnectable — + // that is what makes "switch back later" possible)… + const storedA = readStoredNip46Session(USER_A); + assert.ok(storedA, "A's stored session must survive A's own logout"); + assert.equal(storedA.remotePubkey, "1".repeat(64)); + + // …and account B is completely untouched: stored session AND registry signer. + const storedB = readStoredNip46Session(USER_B); + assert.ok(storedB, "B's stored session must survive A's logout"); + assert.equal(storedB.remotePubkey, "2".repeat(64)); + assert.equal( + resolveActiveSigner(USER_B), + signerB, + "B's registered signer must survive A's logout" + ); +}); + +test("logout is safe when no remote client is live (nsec/nip07 active)", () => { + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + manager.pubkey = USER_A; // active via some non-NIP-46 method + + assert.doesNotThrow(() => manager.logout()); + assert.ok( + readStoredNip46Session(USER_B), + "an unrelated account's NIP-46 session must survive a non-NIP-46 logout" + ); +}); + +test("a failed targeted restore deletes nothing", async () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + + // A's session is passphrase-locked and none was provided: typed failure. + await assert.rejects( + manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }), + (error) => error?.code === "passphrase-required" + ); + + assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore"); + assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore"); +}); + +test("restoring an account with no stored session fails typed and touches nothing", async () => { + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + + await assert.rejects( + manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }), + (error) => error?.code === "no-stored-session" + ); + + assert.ok( + readStoredNip46Session(USER_B), + "an unrelated stored session must survive a missing-session restore attempt" + ); +}); + +test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => { + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + + const manager = makeManager(); + manager.nip46Client = { destroy() {} }; + + await manager.disconnectRemoteSigner({ keepStored: true }); + + assert.ok(readStoredNip46Session(USER_A)); +}); + +test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => { + // The no-arg clear historically wiped the whole v2 map — "after switching + // away you could no longer switch back". Pin the scoped behavior: the v1 + // default slot goes, the per-account map survives. + writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64))); + writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64))); + + const manager = makeManager(); + manager.nip46Client = { destroy() {} }; + + await manager.disconnectRemoteSigner(); + + assert.ok( + readStoredNip46Session(USER_A), + "per-account session A must survive a bare disconnect" + ); + assert.ok( + readStoredNip46Session(USER_B), + "per-account session B must survive a bare disconnect" + ); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index c4fd57d0..c92b85fd 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -103,13 +103,31 @@ Likely a shared root cause in session/identity persistence + multi-account handl `SignerManager.logout` (previousPubkey/hadRemoteClient), and `authService.switchProfile` (from/to + whether requestLogin resolved the expected pubkey). **Remove these once the fix lands.** +- [x] **CODE-LEVEL SUSPECTS RE-AUDITED + PINNED (2026-07-30).** Every root cause the + 2026-06-25 audit named has since been fixed by later work, verified end-to-end + by reading the current code: + - Per-account NIP-46 session storage exists (`bitvid:nip46:sessions:v2` map, + v1 slot = last-connected default, migration on read). + - `logout()` → `disconnectRemoteSigner({ keepStored: true })` — stored + sessions survive; the no-arg `clearStoredNip46Session()` clears ONLY the + v1 default slot, never the map (the wipe-all was fixed earlier). + - Switch is fully wired: coordinator sets `reuseStored: true` for nip46 + targets → `switchProfile` passes `expectPubkey` → provider forwards it → + `useStoredRemoteSigner({ pubkey })` restores the TARGET account's session, + and forgets only the attempted account on access-denial. + - Boot restore (`scheduleStoredRemoteSignerRestore`) enforces the + access-control validator and fails scoped (nothing else cleared). + NEW: `tests/nip46-signer-lifecycle.test.mjs` pins the manager-level contract + (logout scoping, restore-failure scoping, bare-disconnect scoping) — mutation- + checked: reinjecting the historical keepStored/wipe-all bugs fails 2/6. - [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout, and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED, switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack. -- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed - by pubkey; don't tear the shared client on a scoped logout; reconnect on - switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics. + If all three behave, close this item and REMOVE the `[nip46-diag]` diagnostics; + if not, the trace now points at whatever the unit contract does NOT cover + (likely UI/state reconciliation, e.g. restore-failure leaving "unknown user" + rendered instead of a reconnect prompt). ### 1. Delete is not fully working — tombstoned videos still show in the UI - [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED