vault.consolidate-passkeys.test.ts

8.0 KB · raw

1// T-023: vault.consolidatePasskeyEntries — passkey merge with lastUsed ranking2//3// Why this test exists: `consolidatePasskeyEntries()` (exposed via the4// CONSOLIDATE_PASSKEYS message) calls `mergePasskeyOnlyIntoLogins()`, which:5//   1. Groups all passkey-bearing entries by rpId.6//   2. Within each group, sorts by lastUsedAt (then createdAt, then modifiedAt).7//   3. The "winner" (most-recently-used) passkey is merged into the existing8//      non-passkey login for the same rpId domain (if one exists).9//   4. If no login exists but multiple passkey entries exist for the same rpId,10//      the loser entries are soft-deleted (winner kept).11//12// This function is called on every passkey registration (via REGISTER_PASSKEY)13// and has ZERO direct test coverage. A regression in the sort order would14// incorrectly merge an older passkey, losing a newer one — a silent security15// regression.1617import { 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 chrome;49  return { storage, chrome };50}5152// Minimal passkey object for test entries53function 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("../../background/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("../../background/vault");88    await vault.createVault("consolidate-test-pass");89  });9091  // T-023a: passkey entry for a domain + login entry for the same domain →92  // consolidatePasskeyEntries() merges the passkey onto the login entry and93  // soft-deletes the passkey-only entry.94  //95  // Bug this catches: if the rpId → URL domain match is broken (e.g., wrong96  // extractDomain call), the passkey would never merge and the user would see97  // duplicate entries.98  it("T-023a: passkey-only entry merges into existing login with matching domain", async () => {99    // Add a login entry for example.com100    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    // Add a passkey-only entry for the same rpId109    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    // The login must now have the passkey merged in125    expect(loginAfter?.passkey).toBeDefined();126    expect(loginAfter?.passkey?.credentialId).toBe("cred-001");127128    // The passkey-only entry must be soft-deleted (not in active entries)129    expect(passkeyAfter).toBeUndefined();130  });131132  // T-023b: two passkey entries for the same rpId, no login → winner is kept133  // (by lastUsedAt), loser is soft-deleted.134  //135  // Bug this catches: an incorrect sort that picks the OLDER passkey as the136  // winner would retain a stale credential and soft-delete the current one.137  it("T-023b: two passkeys for same rpId → most recently used is kept, other soft-deleted", async () => {138    const olderPasskey = await vault.addEntry({139      type: "login",140      title: "Old Passkey",141      username: "user",142      password: "",143      url: "https://github.com",144      passkey: makePasskey({145        rpId: "github.com",146        credentialId: "cred-old",147        lastUsedAt: 1000,148        createdAt: 1000,149      }),150    });151152    const newerPasskey = await vault.addEntry({153      type: "login",154      title: "New Passkey",155      username: "user",156      password: "",157      url: "https://github.com",158      passkey: makePasskey({159        rpId: "github.com",160        credentialId: "cred-new",161        lastUsedAt: 9999,162        createdAt: 9999,163      }),164    });165166    await vault.consolidatePasskeyEntries();167168    const entries = vault.getEntries();169170    // The newer passkey must still be in active entries171    const keptEntry = entries.find((e) =>172      e.passkey?.credentialId === "cred-new" && !e.deletedAt,173    );174    expect(keptEntry).toBeDefined();175176    // The older passkey must be soft-deleted177    const deletedEntry = vault.getEntryById(olderPasskey.id);178    expect(deletedEntry).toBeUndefined();179180    // Void reference to suppress unused var warning181    void newerPasskey;182  });183184  // T-023c: passkey entry for a domain with no matching login → it stays185  // (is not merged or deleted). consolidatePasskeyEntries must not touch186  // single-passkey, no-login groups.187  it("T-023c: single passkey with no matching login → entry is not deleted", async () => {188    const passkey = await vault.addEntry({189      type: "login",190      title: "Solo Passkey",191      username: "user",192      password: "",193      url: "https://uniquesite.example.com",194      passkey: makePasskey({ rpId: "uniquesite.example.com", credentialId: "cred-solo" }),195    });196197    await vault.consolidatePasskeyEntries();198199    const after = vault.getEntryById(passkey.id);200    expect(after).toBeDefined();201    expect(after?.passkey?.credentialId).toBe("cred-solo");202  });203204  // T-023d: passkey on a different domain than any login → no merge.205  // The domain match must use exact domain extraction, not substring matching.206  // A passkey for "signin.google.com" must not absorb a login for "google.com".207  it("T-023d: passkey domain mismatch → no merge (exact domain match, not substring)", async () => {208    await vault.addEntry({209      type: "login",210      title: "Google Login",211      username: "user",212      password: "pw",213      url: "https://google.com",214    });215216    const signinPasskey = await vault.addEntry({217      type: "login",218      title: "Google Signin Passkey",219      username: "user",220      password: "",221      url: "https://signin.google.com",222      passkey: makePasskey({ rpId: "signin.google.com", credentialId: "cred-signin" }),223    });224225    await vault.consolidatePasskeyEntries();226227    // The passkey-only entry must remain (no login for "signin.google.com")228    const after = vault.getEntryById(signinPasskey.id);229    expect(after).toBeDefined();230  });231});