Files
bitvid/tests/authService.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 abdd11b18c [auth] #48: NIP-46 switch reconnects stored session + reject wrong-account switches
Completes fluent switching across signing methods (NIP-07 / nsec / NIP-46):

- NIP-46 switching: it had the same bug — switchProfile called the provider with no
  reuseStored, so it started a fresh handshake (needs a new connect URI/QR) instead of
  reconnecting. Now handleProfileSwitchRequest sends { providerId: "nip46",
  reuseStored: true } for NIP-46 targets; authService.switchProfile forwards reuseStored;
  the nip46 provider's useStoredRemoteSigner reconnects the stored session and activates
  the account. On failure -> a clear "reconnect your remote signer" toast.

- Wrong-account guard (all methods): requestLogin now enforces expectPubkey (only set by
  switchProfile). A single-slot stored nsec/NIP-46 session resolving to a DIFFERENT
  account than requested is rejected with `pubkey-mismatch` instead of silently switching
  to the wrong profile. Also protects NIP-07 (switching to an account the extension isn't
  currently on fails clearly instead of grabbing the extension's active account).

Tests: tests/authService.test.mjs (+3 — unlockStored/passphrase + reuseStored forwarding,
expectPubkey rejection). Build + lint clean; auth suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:19:13 -04:00

244 lines
9.2 KiB
JavaScript

import "./test-helpers/setup-localstorage.mjs";
import { test, describe, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import AuthService from "../js/services/authService.js";
import {
getPubkey,
setPubkey,
getCurrentUserNpub,
setCurrentUserNpub,
} from "../js/state/appState.js";
import {
getSavedProfiles,
mutateSavedProfiles,
getActiveProfilePubkey,
setActiveProfilePubkey,
} from "../js/state/cache.js";
// Mock dependencies
const mockNostrClient = {
pubkey: null,
logout: mock.fn(),
pool: { list: mock.fn(async () => []) },
relays: [],
handleEvent: mock.fn(),
};
const mockUserBlocks = {
loadBlocks: mock.fn(async () => true),
reset: mock.fn(),
};
const mockRelayManager = {
loadRelayList: mock.fn(async () => true),
reset: mock.fn(),
};
const mockAccessControl = {
isLockdownActive: mock.fn(() => false),
isAdminEditor: mock.fn(() => false),
canAccess: mock.fn(() => true),
isBlacklisted: mock.fn(() => false),
};
const mockLogger = mock.fn();
const mockAuthProvider = {
login: mock.fn(async () => ({ pubkey: "0000000000000000000000000000000000000000000000000000000000000001", authType: "nip07" })),
};
const mockGetAuthProvider = mock.fn((id) => mockAuthProvider);
describe("AuthService Coverage", () => {
let authService;
beforeEach(() => {
// Reset global state
setPubkey(null);
setCurrentUserNpub(null);
setActiveProfilePubkey(null, { persist: false });
mutateSavedProfiles(() => [], { persist: false, persistActive: false });
// Reset mocks
mockNostrClient.logout.mock.resetCalls();
mockUserBlocks.loadBlocks.mock.resetCalls();
mockUserBlocks.reset.mock.resetCalls();
mockRelayManager.loadRelayList.mock.resetCalls();
mockRelayManager.reset.mock.resetCalls();
mockAccessControl.isLockdownActive.mock.resetCalls();
mockAuthProvider.login.mock.resetCalls();
authService = new AuthService({
nostrClient: mockNostrClient,
userBlocks: mockUserBlocks,
relayManager: mockRelayManager,
logger: mockLogger,
accessControl: mockAccessControl,
getAuthProvider: mockGetAuthProvider,
});
});
describe("login", () => {
test("successfully logs in with valid pubkey", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const result = await authService.login(pubkey);
assert.equal(getPubkey(), pubkey);
assert.equal(getActiveProfilePubkey(), pubkey);
assert.equal(result.pubkey, pubkey);
const saved = getSavedProfiles();
assert.equal(saved.length, 1);
assert.equal(saved[0].pubkey, pubkey);
});
test("updates global state and calls post-login hooks", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000002";
await authService.login(pubkey);
assert.strictEqual(mockRelayManager.loadRelayList.mock.callCount(), 1);
// Blocks are deferred by default in login flow, so loadBlocks should not be called immediately
assert.strictEqual(mockUserBlocks.loadBlocks.mock.callCount(), 0);
});
test("throws if lockdown is active and user is not admin", async () => {
mockAccessControl.isLockdownActive.mock.mockImplementation(() => true);
mockAccessControl.isAdminEditor.mock.mockImplementation(() => false);
await assert.rejects(
async () => await authService.login("0000000000000000000000000000000000000000000000000000000000000003"),
{ code: "site-lockdown" }
);
});
test("allows login if lockdown is active but user is admin", async () => {
mockAccessControl.isLockdownActive.mock.mockImplementation(() => true);
mockAccessControl.isAdminEditor.mock.mockImplementation(() => true);
const pubkey = "0000000000000000000000000000000000000000000000000000000000000004";
await authService.login(pubkey);
assert.equal(getPubkey(), pubkey);
});
});
describe("logout", () => {
test("clears state and calls reset on dependencies", async () => {
// Setup initial state
const pubkey = "0000000000000000000000000000000000000000000000000000000000000005";
await authService.login(pubkey);
await authService.logout();
assert.equal(getPubkey(), null);
assert.equal(getActiveProfilePubkey(), null);
assert.strictEqual(mockNostrClient.logout.mock.callCount(), 1);
assert.strictEqual(mockUserBlocks.reset.mock.callCount(), 1);
assert.strictEqual(mockRelayManager.reset.mock.callCount(), 1);
});
});
describe("requestLogin", () => {
test("uses provider to login", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey, authType: "nip07" }));
const result = await authService.requestLogin({ providerId: "nip07" });
assert.equal(result.pubkey, pubkey);
assert.strictEqual(mockAuthProvider.login.mock.callCount(), 1);
// requestLogin calls login() internally if autoApply is true (default)
assert.equal(getPubkey(), pubkey);
});
test("does not call login if autoApply is false", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey, authType: "nip07" }));
await authService.requestLogin({ providerId: "nip07", autoApply: false });
assert.equal(getPubkey(), null);
});
});
describe("switchProfile", () => {
test("switches to an existing profile", async () => {
const pubkey1 = "0000000000000000000000000000000000000000000000000000000000000001";
const pubkey2 = "0000000000000000000000000000000000000000000000000000000000000002";
await authService.login(pubkey1);
// Reset mocks to clear login calls
mockRelayManager.loadRelayList.mock.resetCalls();
// Mock login for switch
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: pubkey2, authType: "nip07" }));
await authService.switchProfile(pubkey2, { providerId: "nip07" });
assert.equal(getPubkey(), pubkey2);
assert.equal(getActiveProfilePubkey(), pubkey2);
});
test("reorders saved profiles on switch", async () => {
const pubkey1 = "0000000000000000000000000000000000000000000000000000000000000001";
const pubkey2 = "0000000000000000000000000000000000000000000000000000000000000002";
await authService.login(pubkey1);
await authService.login(pubkey2);
// At this point pubkey2 is active and likely last or first in savedProfiles depending on implementation.
// mutateSavedProfiles pushes to end if new.
// So [pubkey1, pubkey2].
let saved = getSavedProfiles();
assert.equal(saved.length, 2);
assert.equal(saved[1].pubkey, pubkey2);
// Switch back to pubkey1
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: pubkey1, authType: "nip07" }));
await authService.switchProfile(pubkey1);
saved = getSavedProfiles();
// Should be moved to front?
// Code says: if (index > 0) { const [moved] = draft.splice(index, 1); draft.unshift(moved); }
// So yes, moved to front.
assert.equal(saved[0].pubkey, pubkey1);
});
// #48: fluent switching across signing methods — switchProfile must forward the
// per-method restore options to the provider.
test("forwards unlockStored + passphrase to the provider (nsec switch)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000a1";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nsec" }));
await authService.switchProfile(target, {
providerId: "nsec",
unlockStored: true,
passphrase: "pin1234",
});
const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
assert.equal(opts.unlockStored, true);
assert.equal(opts.passphrase, "pin1234");
});
test("forwards reuseStored to the provider (NIP-46 switch)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000b2";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nip46" }));
await authService.switchProfile(target, { providerId: "nip46", reuseStored: true });
const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
assert.equal(opts.reuseStored, true);
});
test("rejects and does NOT activate a DIFFERENT account than requested (expectPubkey)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000c3";
const other = "00000000000000000000000000000000000000000000000000000000000000d4";
// A single-slot stored session that resolves to another account:
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: other, authType: "nip46" }));
await assert.rejects(
() => authService.switchProfile(target, { providerId: "nip46", reuseStored: true }),
{ code: "pubkey-mismatch" },
);
assert.notEqual(getPubkey(), other, "must not silently switch to the wrong account");
});
});
});