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
2 files src/background/messages.test.ts+102 −0src/popup/views/contracts.test.tsx+17 −4
src/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 ---

view file

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 () => {

view file