123456789101112131415161718import { beforeEach, describe, expect, it, vi } from "vitest";1920type StorageShape = Record<string, unknown>;212223242526function makeChromeStorageMock(): {27 storage: StorageShape;28 chrome: typeof globalThis.chrome;29} {30 const storage: StorageShape = {};31 const sessionStorage: StorageShape = {};32 const chrome = {33 storage: {34 local: {35 get: vi.fn(async (key?: string | string[]) => {36 if (!key) return { ...storage };37 if (typeof key === "string") return { [key]: storage[key] };38 return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(39 (acc, k) => { acc[k] = storage[k]; return acc; },40 {},41 );42 }),43 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),44 remove: vi.fn(async (keys: string | string[]) => {45 const ks = Array.isArray(keys) ? keys : [keys];46 for (const k of ks) delete storage[k];47 }),48 clear: vi.fn(async () => {49 for (const k of Object.keys(storage)) delete storage[k];50 }),51 },52 session: {53 get: vi.fn(async (key?: string | string[]) => {54 if (!key) return { ...sessionStorage };55 if (typeof key === "string") return { [key]: sessionStorage[key] };56 return key.reduce<StorageShape>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});57 }),58 set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59 remove: vi.fn(async (keys: string | string[]) => {60 for (const key of (Array.isArray(keys) ? keys : [keys])) delete sessionStorage[key];61 }),62 },63 },64 alarms: {65 clear: vi.fn(),66 create: vi.fn(),67 onAlarm: { addListener: vi.fn() },68 },69 idle: {70 setDetectionInterval: vi.fn(),71 onStateChanged: { addListener: vi.fn() },72 },73 } as unknown as typeof globalThis.chrome;74 return { storage, chrome };75}767778798081describe("T-016: rotateSyncIdentity — MV-8 rotation invariants", () => {82 let storage: StorageShape;83 let vault: typeof import("./vault");84 const VAULT_PASSWORD = "rotate-test-pass-007";8586 beforeEach(async () => {87 vi.resetModules();88 const mock = makeChromeStorageMock();89 storage = mock.storage;90 (globalThis as Record<string, unknown>).chrome = mock.chrome;91 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9293 vault = await import("./vault");94 await vault.createVault(VAULT_PASSWORD);95 });9697 98 99 100 101 it("T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked", async () => {102 const result = await vault.rotateSyncIdentity();103 expect(result.ok).toBe(true);104 expect(result.error).toBeUndefined();105 });106107 108 109 110 111 112 113 114 115 it("T-016b: getRootKeyHex differs before and after rotation (new key generated)", async () => {116 const rootKeyBefore = vault.getRootKeyHex();117 expect(rootKeyBefore).not.toBeNull();118119 await vault.rotateSyncIdentity();120121 const rootKeyAfter = vault.getRootKeyHex();122 expect(rootKeyAfter).not.toBeNull();123 expect(rootKeyAfter).not.toBe(rootKeyBefore);124 });125126 127 128 129 130 131 132 133 134 it("T-016c: paired devices cleared from storage after rotation", async () => {135 136 const PAIRED_KEY = "peach_paired_devices_v3";137 storage[PAIRED_KEY] = [138 {139 deviceId: "test-device-1",140 publicKey: "deadbeef".repeat(8),141 label: "My Laptop",142 pairedAt: 0,143 },144 ];145146 await vault.rotateSyncIdentity();147148 149 const devicesAfter = storage[PAIRED_KEY];150 const isEmpty =151 devicesAfter === undefined ||152 devicesAfter === null ||153 (Array.isArray(devicesAfter) && (devicesAfter as unknown[]).length === 0);154 expect(isEmpty).toBe(true);155 });156157 158 159 160 161 162 163 164 165 it("T-016d: session recovery-pending state is set after rotation", async () => {166 await vault.rotateSyncIdentity();167168 const { isRecoveryConfirmPending } = await import("./recovery-pending");169 await expect(isRecoveryConfirmPending()).resolves.toBe(true);170 expect(storage["peach_recovery_pending"]).toBeUndefined();171 });172173 174 175 176 177 178 179 180 181 182 it("T-016e: vault still unlocks with the same password after rotation", async () => {183 await vault.rotateSyncIdentity();184185 186 vault.lock();187 expect(vault.isUnlocked()).toBe(false);188189 190 const unlocked = await vault.unlock(VAULT_PASSWORD);191 expect(unlocked).toBe(true);192 expect(vault.isUnlocked()).toBe(true);193 });194195 196 197 198 199 it("T-016f: entries encrypted before rotation are still accessible after", async () => {200 201 const added = await vault.addEntry({202 type: "login",203 title: "Pre-Rotation Entry",204 username: "alice",205 password: "s3cret",206 url: "https://example.com",207 tags: [],208 });209 expect(added.id).toBeTruthy();210211 await vault.rotateSyncIdentity();212213 214 vault.lock();215 await vault.unlock(VAULT_PASSWORD);216217 218 const entries = vault.getEntries();219 const found = entries.find((e) => e.id === added.id);220 expect(found).toBeDefined();221 expect(found?.title).toBe("Pre-Rotation Entry");222 });223224 225 226 it("T-016g: rotateSyncIdentity fails gracefully when vault is locked", async () => {227 vault.lock();228 const result = await vault.rotateSyncIdentity();229 expect(result.ok).toBe(false);230 expect(result.error).toBe("vault_locked");231 });232});