1234567891011121314151617import { beforeEach, describe, expect, it, vi } from "vitest";1819type StorageShape = Record<string, unknown>;2021function makeChromeStorageMock() {22 const storage: StorageShape = {};23 const chrome = {24 storage: {25 local: {26 get: vi.fn(async (key?: string | string[]) => {27 if (!key) return { ...storage };28 if (typeof key === "string") return { [key]: storage[key] };29 return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(30 (acc, k) => { acc[k] = storage[k]; return acc; },31 {},32 );33 }),34 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),35 remove: vi.fn(async (keys: string | string[]) => {36 const ks = Array.isArray(keys) ? keys : [keys];37 for (const k of ks) delete storage[k];38 }),39 },40 session: {41 get: vi.fn(async () => ({})),42 set: vi.fn(async () => {}),43 remove: vi.fn(async () => {}),44 },45 },46 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },47 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },48 } as unknown as typeof globalThis.chrome;49 return { storage, chrome };50}515253function makePasskey(opts: {54 rpId: string;55 credentialId: string;56 lastUsedAt?: number;57 createdAt?: number;58}) {59 return {60 rpId: opts.rpId,61 rpName: opts.rpId,62 credentialId: opts.credentialId,63 publicKeyCose: "a401020326",64 privateKeyJwk: { kty: "EC", crv: "P-256", d: "test", x: "test", y: "test" },65 userHandle: "uh",66 userName: opts.rpId + "-user",67 userDisplayName: opts.rpId + " User",68 algorithm: -7 as const,69 signCount: 0,70 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",71 backupEligible: false,72 backupState: false,73 createdAt: opts.createdAt ?? 1000,74 lastUsedAt: opts.lastUsedAt,75 };76}7778describe("T-023: vault.consolidatePasskeyEntries", () => {79 let vault: typeof import("./vault");8081 beforeEach(async () => {82 vi.resetModules();83 const mock = makeChromeStorageMock();84 (globalThis as Record<string, unknown>).chrome = mock.chrome;85 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));8687 vault = await import("./vault");88 await vault.createVault("consolidate-test-pass");89 });9091 92 93 94 95 96 97 98 it("T-023a: passkey-only entry merges into existing login with matching domain", async () => {99 100 const login = await vault.addEntry({101 type: "login",102 title: "Example Login",103 username: "[email protected]",104 password: "pw123",105 url: "https://example.com/login",106 });107108 109 const passkeyEntry = await vault.addEntry({110 type: "login",111 title: "Example Passkey",112 username: "[email protected]",113 password: "",114 url: "https://example.com",115 passkey: makePasskey({ rpId: "example.com", credentialId: "cred-001" }),116 });117118 await vault.consolidatePasskeyEntries();119120 const entries = vault.getEntries();121 const loginAfter = entries.find((e) => e.id === login.id);122 const passkeyAfter = vault.getEntryById(passkeyEntry.id);123124 125 expect(loginAfter?.passkey).toBeDefined();126 expect(loginAfter?.passkey?.credentialId).toBe("cred-001");127128 129 expect(passkeyAfter).toBeUndefined();130 });131132 it("preserves TOTP when the passkey entry is consolidated into a login", async () => {133 const login = await vault.addEntry({134 type: "login",135 title: "Example Login",136 username: "[email protected]",137 password: "pw123",138 url: "https://example.com/login",139 });140 const passkeyEntry = await vault.addEntry({141 type: "login",142 title: "Example Passkey",143 username: "[email protected]",144 password: "",145 url: "https://example.com",146 totp: "JBSWY3DPEHPK3PXP",147 totpAlgorithm: "SHA256",148 totpDigits: 8,149 totpPeriod: 60,150 passkey: makePasskey({ rpId: "example.com", credentialId: "cred-totp" }),151 });152153 await vault.consolidatePasskeyEntries();154155 expect(vault.getEntryById(login.id)).toMatchObject({156 totp: "JBSWY3DPEHPK3PXP",157 totpAlgorithm: "SHA256",158 totpDigits: 8,159 totpPeriod: 60,160 });161 expect(vault.getEntryById(passkeyEntry.id)).toBeUndefined();162 });163164 165 166 167 168 169 it("T-023b: two passkeys for same rpId → most recently used is kept, other soft-deleted", async () => {170 const olderPasskey = await vault.addEntry({171 type: "login",172 title: "Old Passkey",173 username: "user",174 password: "",175 url: "https://github.com",176 passkey: makePasskey({177 rpId: "github.com",178 credentialId: "cred-old",179 lastUsedAt: 1000,180 createdAt: 1000,181 }),182 });183184 const newerPasskey = await vault.addEntry({185 type: "login",186 title: "New Passkey",187 username: "user",188 password: "",189 url: "https://github.com",190 passkey: makePasskey({191 rpId: "github.com",192 credentialId: "cred-new",193 lastUsedAt: 9999,194 createdAt: 9999,195 }),196 });197198 await vault.consolidatePasskeyEntries();199200 const entries = vault.getEntries();201202 203 const keptEntry = entries.find((e) =>204 e.passkey?.credentialId === "cred-new" && !e.deletedAt,205 );206 expect(keptEntry).toBeDefined();207208 209 const deletedEntry = vault.getEntryById(olderPasskey.id);210 expect(deletedEntry).toBeUndefined();211212 213 void newerPasskey;214 });215216 217 218 219 it("T-023c: single passkey with no matching login → entry is not deleted", async () => {220 const passkey = await vault.addEntry({221 type: "login",222 title: "Solo Passkey",223 username: "user",224 password: "",225 url: "https://uniquesite.example.com",226 passkey: makePasskey({ rpId: "uniquesite.example.com", credentialId: "cred-solo" }),227 });228229 await vault.consolidatePasskeyEntries();230231 const after = vault.getEntryById(passkey.id);232 expect(after).toBeDefined();233 expect(after?.passkey?.credentialId).toBe("cred-solo");234 });235236 237 238 239 it("T-023d: passkey domain mismatch → no merge (exact domain match, not substring)", async () => {240 await vault.addEntry({241 type: "login",242 title: "Google Login",243 username: "user",244 password: "pw",245 url: "https://google.com",246 });247248 const signinPasskey = await vault.addEntry({249 type: "login",250 title: "Google Signin Passkey",251 username: "user",252 password: "",253 url: "https://signin.google.com",254 passkey: makePasskey({ rpId: "signin.google.com", credentialId: "cred-signin" }),255 });256257 await vault.consolidatePasskeyEntries();258259 260 const after = vault.getEntryById(signinPasskey.id);261 expect(after).toBeDefined();262 });263});