diff --git a/src/__tests__/MCPPairingPanel.test.tsx b/src/__tests__/MCPPairingPanel.test.tsx index 9e181ff..44be119 100644 --- a/src/__tests__/MCPPairingPanel.test.tsx +++ b/src/__tests__/MCPPairingPanel.test.tsx @@ -1,54 +1,92 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { validatePairingParams } from "../panel/components/MCPPairingPanel"; const VALID_TOKEN = "a".repeat(64); const VALID_HASH = `#token=${VALID_TOKEN}&port=65000`; +const mockSessionStorage: Record = {}; +const mockLocalStorage: Record = {}; + +vi.stubGlobal("chrome", { + storage: { + session: { + set: vi.fn(async (data: Record) => { + Object.assign(mockSessionStorage, data); + }), + get: vi.fn(async (keys: string[]) => { + return Object.fromEntries(keys.map((k) => [k, mockSessionStorage[k]])); + }), + }, + local: { + set: vi.fn(async (data: Record) => { + Object.assign(mockLocalStorage, data); + }), + get: vi.fn(async (keys: string[]) => { + return Object.fromEntries(keys.map((k) => [k, mockLocalStorage[k]])); + }), + remove: vi.fn(async (keys: string[]) => { + keys.forEach((k) => delete mockLocalStorage[k]); + }), + }, + }, +}); + +beforeEach(() => { + Object.keys(mockSessionStorage).forEach((k) => delete mockSessionStorage[k]); + Object.keys(mockLocalStorage).forEach((k) => delete mockLocalStorage[k]); +}); + describe("validatePairingParams", () => { it("returns no errors for a valid deep-link", () => { - const errors = validatePairingParams(VALID_HASH); - expect(errors).toHaveLength(0); + expect(validatePairingParams(VALID_HASH)).toHaveLength(0); }); it("returns token-malformed when token is too short", () => { - const hash = `#token=short&port=65000`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=short&port=65000`).map((e) => e.code); expect(codes).toContain("token-malformed"); }); it("returns port-out-of-range when port is 99999", () => { - const hash = `#token=${VALID_TOKEN}&port=99999`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=99999`).map((e) => e.code); expect(codes).toContain("port-out-of-range"); }); it("returns both token-malformed AND port-out-of-range", () => { - const hash = `#token=short&port=99999`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=short&port=99999`).map((e) => e.code); expect(codes).toContain("token-malformed"); expect(codes).toContain("port-out-of-range"); }); it("returns missing-params when both are absent", () => { - const errors = validatePairingParams("#"); - expect(errors[0].code).toBe("missing-params"); + expect(validatePairingParams("#")[0].code).toBe("missing-params"); }); it("rejects decimal ports like 1024.5", () => { - const hash = `#token=${VALID_TOKEN}&port=1024.5`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=1024.5`).map((e) => e.code); expect(codes).toContain("port-out-of-range"); }); it("rejects hex ports like 0x2000", () => { - const hash = `#token=${VALID_TOKEN}&port=0x2000`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=0x2000`).map((e) => e.code); expect(codes).toContain("port-out-of-range"); }); it("rejects ports with leading spaces", () => { - const hash = `#token=${VALID_TOKEN}&port= 8080`; - const codes = validatePairingParams(hash).map((e) => e.code); + const codes = validatePairingParams(`#token=${VALID_TOKEN}&port= 8080`).map((e) => e.code); expect(codes).toContain("port-out-of-range"); }); -}); \ No newline at end of file +}); + +describe("storage migration", () => { + it("token does not appear in chrome.storage.local after migration", async () => { + mockLocalStorage["mcp_pairing_token_v1"] = VALID_TOKEN; + mockLocalStorage["mcp_pairing_port_v1"] = "65000"; + + const { migrateLegacyStorage } = await import("../panel/components/MCPPairingPanel"); + await migrateLegacyStorage(); + + expect(mockLocalStorage["mcp_pairing_token_v1"]).toBeUndefined(); + expect(mockLocalStorage["mcp_pairing_port_v1"]).toBeUndefined(); + expect(mockSessionStorage["mcp_pairing_token_v1"]).toBe(VALID_TOKEN); + }); +}); diff --git a/src/panel/components/MCPPairingPanel.tsx b/src/panel/components/MCPPairingPanel.tsx index dbbd9c7..d0cfd22 100644 --- a/src/panel/components/MCPPairingPanel.tsx +++ b/src/panel/components/MCPPairingPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -type ErrorCode = "token-malformed" | "port-out-of-range" | "missing-params"; +type ErrorCode = "token-malformed" | "port-out-of-range" | "missing-params" | "storage-failed"; interface PairingError { code: ErrorCode; @@ -57,21 +57,52 @@ export function validatePairingParams(hash: string): PairingError[] { return errors; } +const TOKEN_KEY = "mcp_pairing_token_v1"; +const PORT_KEY = "mcp_pairing_port_v1"; + +export async function migrateLegacyStorage() { + if (typeof chrome === "undefined" || !chrome.storage) return; + const local = await chrome.storage.local.get([TOKEN_KEY, PORT_KEY]); + if (local[TOKEN_KEY] || local[PORT_KEY]) { + await chrome.storage.session.set({ + [TOKEN_KEY]: local[TOKEN_KEY], + [PORT_KEY]: local[PORT_KEY], + }); + await chrome.storage.local.remove([TOKEN_KEY, PORT_KEY]); + } +} + +async function savePairing(token: string, port: string) { + await chrome.storage.session.set({ + [TOKEN_KEY]: token, + [PORT_KEY]: port, + }); +} + export function MCPPairingPanel() { const [errors, setErrors] = useState([]); const [paired, setPaired] = useState(false); - const performPairing = () => { + const performPairing = async () => { const hash = window.location.hash; const found = validatePairingParams(hash); if (found.length === 0) { const { token, port } = parseHash(hash); - if (typeof chrome !== "undefined" && chrome.storage?.local) { - chrome.storage.local.set({ mcpToken: token, mcpPort: port }); + try { + if (typeof chrome !== "undefined" && chrome.storage?.session) { + await savePairing(token!, port!); + } + window.history.replaceState(null, "", window.location.pathname); + setPaired(true); + setErrors([]); + } catch (err) { + setErrors([{ + code: "storage-failed", + message: "Could not save pairing.", + fix: (err as Error).message, + }]); } - setPaired(true); - setErrors([]); } else { setErrors(found); setPaired(false); @@ -79,7 +110,11 @@ export function MCPPairingPanel() { }; useEffect(() => { - performPairing(); + if (typeof chrome !== "undefined" && chrome.storage) { + migrateLegacyStorage().then(() => performPairing()); + } else { + performPairing(); + } }, []); const handleRetry = () => { @@ -110,4 +145,4 @@ export function MCPPairingPanel() { ); -} \ No newline at end of file +}