f1785b58565a
add unpair regression: storage revoke + UI refresh — message handler and Settings test
- time
- 2026-07-27 04:20 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- unpair regression
- parent
- fee1c9e1e62e
↓ peach-browser-f1785b58.tar.gzatlas at this save
files changed2 files
src/background/messages.test.ts+102 −0src/popup/views/contracts.test.tsx+17 −4src/background/messages.test.ts+102 −0
@@ -104,6 +104,12 @@104104 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105105 }),106106 },107+ sync: {108+ get: vi.fn(async (_key?: string | string[]) => ({})),109+ set: vi.fn(async (_values: Record<string, unknown>) => undefined),110+ remove: vi.fn(async (_keys: string | string[]) => undefined),111+ clear: vi.fn(async () => undefined),112+ },107113 },108114 alarms: {109115 clear: vi.fn(),@@ -1111,6 +1117,102 @@11111117 11121118 expect(await findPairedDeviceById("android-new")).toBeNull();11131119 expect(await listPendingVouchedDevices()).toHaveLength(0);1120+ });1121+1122+ // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---1123+1124+ it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1125+ const vault = await import("./vault");1126+ await vault.createVault("correct-pass-12345");1127+ const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1128+ await import("./paired-devices");1129+1130+ await upsertPairedDevice({1131+ deviceId: "phone-1",1132+ deviceName: "June's Phone",1133+ deviceType: "android",1134+ longTermPublicKeyHex: "a".repeat(64),1135+ signingPublicKeyHex: "b".repeat(64),1136+ pairedAt: 1_700_000_000_000,1137+ trustSource: "direct",1138+ });1139+1140+ const res = await dispatch({1141+ type: "UNPAIR_DEVICE",1142+ payload: { deviceId: "phone-1" },1143+ });1144+ expect(res).toMatchObject({ ok: true });1145+1146+ // The row is gone locally — re-listing must not surface it.1147+ expect(await findPairedDeviceById("phone-1")).toBeNull();1148+ // The revocation is recorded so a future manifest from a peer cannot1149+ // resurrect this device under either the same id or the same key (MV-7 + S1).1150+ expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1151+ const records = await listDeviceRevocations();1152+ expect(records).toHaveLength(1);1153+ expect(records[0]).toMatchObject({1154+ deviceId: "phone-1",1155+ longTermPublicKeyHex: "a".repeat(64),1156+ revokedByDeviceId: expect.any(String),1157+ });1158+ });1159+1160+ it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1161+ const vault = await import("./vault");1162+ await vault.createVault("correct-pass-12345");1163+ const { listDeviceRevocations } = await import("./paired-devices");1164+1165+ const res = await dispatch({1166+ type: "UNPAIR_DEVICE",1167+ payload: { deviceId: "never-paired" },1168+ });1169+ expect(res).toMatchObject({ ok: true });1170+ // A revocation with the all-zero key sentinel is still written — the1171+ // contract is that the user-intent is recorded even when the registry had1172+ // no row. This is what blocks a peer from later vouched-adopting the same1173+ // fresh id back in.1174+ const records = await listDeviceRevocations();1175+ expect(records).toHaveLength(1);1176+ expect(records[0]).toMatchObject({1177+ deviceId: "never-paired",1178+ longTermPublicKeyHex: "0".repeat(64),1179+ });1180+ });1181+1182+ it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1183+ const res = await dispatch({1184+ type: "UNPAIR_DEVICE",1185+ payload: { deviceId: "" },1186+ });1187+ expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1188+ });1189+1190+ it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1191+ // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1192+ // here we just pin the storage-level invariant the UNPAIR_DEVICE call1193+ // establishes, since the rest of the testbed's revoke coverage rides on1194+ // this state.1195+ const vault = await import("./vault");1196+ await vault.createVault("correct-pass-12345");1197+ const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1198+ "./paired-devices"1199+ );1200+1201+ await upsertPairedDevice({1202+ deviceId: "phone-1",1203+ deviceName: "June's Phone",1204+ deviceType: "android",1205+ longTermPublicKeyHex: "a".repeat(64),1206+ signingPublicKeyHex: "b".repeat(64),1207+ pairedAt: 1_700_000_000_000,1208+ trustSource: "direct",1209+ });1210+1211+ expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1212+ await revokeDeviceById("phone-1", "self");1213+ // The key check is what blocks a re-vouch under a fresh id.1214+ expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1215+ expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);11141216 });11151217 11161218 // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---src/popup/views/contracts.test.tsx+17 −4
@@ -463,9 +463,17 @@463463 pairedAt: 1,464464 trustSource: "direct" as const,465465 };466- sendMessageMock.mockImplementation(async (msg: unknown) => {467- const m = msg as { type: string };468- if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };466+ let paired: typeof device[] = [device];467+ sendMessageMock.mockImplementation(async (msg: any) => {468+ if (msg?.type === "LIST_PAIRED_DEVICES") {469+ return { ok: true, data: { devices: [...paired] } };470+ }471+ if (msg?.type === "UNPAIR_DEVICE") {472+ // Mirror the real handler: UNPAIR_DEVICE drops the row from the473+ // registry. The next LIST_PAIRED_DEVICES must not see it.474+ paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);475+ return { ok: true };476+ }469477 return { ok: true, data: null };470478 });471479 @@ -489,12 +497,17 @@489497 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),490498 ).find((b) => (b.textContent ?? "").trim() === "remove");491499 confirm!.click();492- for (let i = 0; i < 20; i++) await Promise.resolve();500+ for (let i = 0; i < 30; i++) await Promise.resolve();493501 494502 expect(sendMessageMock).toHaveBeenCalledWith({495503 type: "UNPAIR_DEVICE",496504 payload: { deviceId: "phone-1" },497505 });506+ // The list must be refreshed and the device must be gone. The regression507+ // we're guarding against: a Settings view that calls UNPAIR_DEVICE but508+ // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.509+ expect(container.textContent).not.toContain("June's Phone");510+ expect(container.textContent).not.toContain("PAIRED DEVICES");498511 });499512 500513 it("Settings requires current, new, and confirm passwords before enabling save", async () => {