// @vitest-environment jsdom // // PB-26-114: the popup generated TOTP codes from the RFC defaults (SHA-1 / 6 // digits / 30 s) regardless of what the entry actually stored. The background // fill path already honoured the entry's parameters under an explicit comment, // and the import layer deliberately preserves non-default parameters from // otpauth:// URIs — so an entry imported from Bitwarden or Proton with // algorithm=SHA256, digits=8, or a 60 s period displayed and copied the WRONG // code from the popup while autofill generated the right one. // // Both popup call sites (the entry view's live code, and the copy_totp keyboard // action) must derive their code from the same stored parameters. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() })); vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage })); import { generateTOTP } from "../../crypto/totp"; import type { VaultEntry } from "../../shared/types"; import { EntryView } from "./Entry"; import { flushView, renderView } from "./view-test-utils"; let mounted: Array<{ dispose: () => void }> = []; /** The view groups the digits for readability ("824 492"), so compare squashed. */ function squashed(view: { container: HTMLElement }): string { return (view.container.textContent ?? "").replace(/\s+/g, ""); } // A seed that is stable across the default and non-default parameter sets, so // the two calls actually differ rather than coincidentally agreeing. const SEED = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; function totpEntry(overrides: Partial = {}): VaultEntry { return { id: "entry-1", type: "login", title: "Vault", username: "june", password: "pw", url: "https://example.com", totp: SEED, totpAlgorithm: "SHA256", totpDigits: 8, totpPeriod: 60, createdAt: 1, modifiedAt: 1, ...overrides, } as VaultEntry; } beforeEach(() => { mounted = []; mockSendMessage.mockReset(); mockSendMessage.mockImplementation(() => Promise.resolve({ ok: true, data: null })); (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { onMessage: { addListener: () => undefined, removeListener: () => undefined }, getURL: (path: string) => `chrome-extension://test/${path}`, }, storage: { local: { get: () => Promise.resolve({}), set: () => Promise.resolve() }, session: { get: () => Promise.resolve({}), set: () => Promise.resolve() }, }, }; }); afterEach(() => { for (const view of mounted) view.dispose(); mounted = []; vi.restoreAllMocks(); }); describe("PB-26-114: popup TOTP honours the entry's stored parameters", () => { it("the entry view shows the code for SHA-256 / 8 digits / 60 s", async () => { const view = renderView(() => ( undefined} onFieldSaved={() => undefined} /> )); mounted.push(view); await flushView(); const expected = generateTOTP(SEED, "SHA256", 8, 60); const withDefaults = generateTOTP(SEED); // Guard the premise: the two calls must not agree, or the test proves // nothing about which one the UI used. expect(expected.code).not.toBe(withDefaults.code); expect(expected.code).toHaveLength(8); expect(squashed(view)).toContain(expected.code); expect(squashed(view)).not.toContain(withDefaults.code); }); it("an entry with RFC-default parameters still shows the default code", async () => { const view = renderView(() => ( undefined} onFieldSaved={() => undefined} /> )); mounted.push(view); await flushView(); const expected = generateTOTP(SEED); expect(squashed(view)).toContain(expected.code); }); it("a 6-digit SHA-512 entry is generated with its own parameters", async () => { const view = renderView(() => ( undefined} onFieldSaved={() => undefined} /> )); mounted.push(view); await flushView(); const expected = generateTOTP(SEED, "SHA512", 6, 30); expect(expected.code).not.toBe(generateTOTP(SEED).code); expect(squashed(view)).toContain(expected.code); }); });