logancyang_obsidian-copilot/src/encryptionService.test.ts
Zero Liu 6a71cf8586
chore(react): centralize React root creation via createPluginRoot helper (#2467)
* chore(react): centralize React root creation via createPluginRoot helper

Every standalone React root in the plugin must wrap its tree in
AppContext.Provider so descendants can rely on useApp() — PR #2466 fixed
one missing wrap (QuickAskOverlay) but the contract was implicit and any
new createRoot callsite could silently re-introduce the same crash.
Introduce a createPluginRoot(container, app) helper that injects the
provider automatically, migrate all 17 createRoot callsites to use it,
and add a Jest guardrail that fails if any non-helper file imports
createRoot from react-dom/client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): replace createRoot Jest guardrail with ESLint rule

Switch the createPluginRoot enforcement from a Jest test (walks src/
and greps imports) to an ESLint no-restricted-syntax rule. Same
invariant, faster feedback (in-editor instead of on test run), and one
fewer test file to maintain. The helper file is exempted via the
config block's `ignores`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:49:03 -07:00

290 lines
10 KiB
TypeScript

import { TextDecoder, TextEncoder } from "util";
// Mock electron module with proper types
const mockElectron = {
remote: {
safeStorage: {
encryptString: jest.fn().mockImplementation((text) => Buffer.from(`${text}_encrypted`)),
decryptString: jest
.fn()
.mockImplementation((buffer: Buffer) => buffer.toString().replace("_encrypted", "")),
isEncryptionAvailable: jest.fn().mockReturnValue(true),
},
},
};
jest.mock("electron", () => mockElectron);
window.TextEncoder = TextEncoder;
window.TextDecoder = TextDecoder as unknown as typeof window.TextDecoder;
// Now we can import our modules
import { getDecryptedKey, isEncryptedValue, isSensitiveKey } from "@/encryptionService";
import { Buffer } from "buffer";
// Mock btoa/atob for base64 encoding/decoding (binary-safe). jsdom's defaults
// are not binary-safe and break the legacy encrypted-payload round-trips below.
window.btoa = jest
.fn()
.mockImplementation((str: string) => Buffer.from(str, "latin1").toString("base64"));
window.atob = jest
.fn()
.mockImplementation((str: string) => Buffer.from(str, "base64").toString("latin1"));
/**
* Ensure `window.localStorage` is usable for tests.
*/
function ensureTestLocalStorage(): { clear: () => void } {
try {
const storage = window.localStorage;
storage.setItem("__copilot_test__", "1");
storage.removeItem("__copilot_test__");
return { clear: () => window.localStorage.clear() };
} catch {
const data = new Map<string, string>();
const shim = {
getItem: jest.fn((key: string) => data.get(key) ?? null),
setItem: jest.fn((key: string, value: string) => {
data.set(key, value);
}),
removeItem: jest.fn((key: string) => {
data.delete(key);
}),
clear: jest.fn(() => data.clear()),
};
Object.defineProperty(window, "localStorage", { value: shim, configurable: true });
return { clear: () => data.clear() };
}
}
const testLocalStorage = ensureTestLocalStorage();
let randomCounter = 1;
Object.defineProperty(window.crypto, "getRandomValues", {
value: jest.fn((arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = randomCounter++ & 0xff;
return arr;
}),
configurable: true,
});
const mockSubtle = {
digest: jest.fn().mockImplementation(() => Promise.resolve(new Uint8Array(32).buffer)),
importKey: jest.fn().mockResolvedValue("mockCryptoKey"),
encrypt: jest.fn().mockImplementation((algorithm, key, data: ArrayBuffer) => {
const originalText = new TextDecoder().decode(data);
const iv = (algorithm as { iv?: Uint8Array })?.iv;
const ivTag = iv ? Array.from(iv.slice(0, 4)).join(",") : "noiv";
const encryptedText = `${originalText}_encrypted_${ivTag}`;
return Promise.resolve(new TextEncoder().encode(encryptedText).buffer);
}),
decrypt: jest.fn().mockImplementation((algorithm, key, data) => {
const encryptedText = new TextDecoder().decode(new Uint8Array(data));
const originalText = encryptedText.replace(/_encrypted_.+$/, "");
return Promise.resolve(new TextEncoder().encode(originalText).buffer);
}),
};
Object.defineProperty(window.crypto, "subtle", {
value: mockSubtle,
configurable: true,
});
describe("EncryptionService", () => {
beforeEach(() => {
jest.clearAllMocks();
randomCounter = 1;
testLocalStorage.clear();
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(true);
});
describe("getDecryptedKey", () => {
it("should return the original key if it is in plain text", async () => {
const apiKey = "testApiKey";
const decryptedKey = await getDecryptedKey(apiKey);
expect(decryptedKey).toBe(apiKey);
});
it("should decrypt CP00 portable payloads", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
const plaintext = "legacyPortableSecret";
const iv = new Uint8Array(12);
iv[0] = 1;
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
"mockCryptoKey" as unknown as CryptoKey,
new TextEncoder().encode(plaintext)
)
);
const cp00 = new Uint8Array([0x43, 0x50, 0x30, 0x30]); // "CP00"
const payload = new Uint8Array(cp00.length + iv.length + ciphertext.length);
payload.set(cp00, 0);
payload.set(iv, cp00.length);
payload.set(ciphertext, cp00.length + iv.length);
const base64 = Buffer.from(payload).toString("base64");
const decrypted = await getDecryptedKey(`enc_web_${base64}`);
expect(decrypted).toBe(plaintext);
});
it("should decrypt legacy fixed-IV enc_web_ payloads", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
const plaintext = "legacyFixedIvSecret";
const legacyIv = new Uint8Array(12);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: legacyIv },
"mockCryptoKey" as unknown as CryptoKey,
new TextEncoder().encode(plaintext)
)
);
const base64 = Buffer.from(ciphertext).toString("base64");
const decrypted = await getDecryptedKey(`enc_web_${base64}`);
expect(decrypted).toBe(plaintext);
});
it("should decrypt CP01 per-device payloads when localStorage key exists", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
// Reason: CP01 uses a per-device AES key stored in localStorage.
// Seed it so getExistingWebCryptoKey() returns a usable key.
const keyBytes = new Uint8Array(32);
window.localStorage.setItem(
"obsidian-copilot:webcrypto-key:v1",
Buffer.from(keyBytes).toString("base64")
);
const plaintext = "cp01DeviceSecret";
const iv = new Uint8Array(12);
iv[0] = 2;
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
"mockCryptoKey" as unknown as CryptoKey,
new TextEncoder().encode(plaintext)
)
);
const cp01 = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01"
const payload = new Uint8Array(cp01.length + iv.length + ciphertext.length);
payload.set(cp01, 0);
payload.set(iv, cp01.length);
payload.set(ciphertext, cp01.length + iv.length);
const base64 = Buffer.from(payload).toString("base64");
const decrypted = await getDecryptedKey(`enc_web_${base64}`);
expect(decrypted).toBe(plaintext);
});
it("should return empty string for CP01 when localStorage key is missing", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
// Reason: no key in localStorage — CP01 decryption should fail gracefully.
const cp01 = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01"
const iv = new Uint8Array(12);
const fakeCiphertext = new Uint8Array([1, 2, 3, 4]);
const payload = new Uint8Array(cp01.length + iv.length + fakeCiphertext.length);
payload.set(cp01, 0);
payload.set(iv, cp01.length);
payload.set(fakeCiphertext, cp01.length + iv.length);
const base64 = Buffer.from(payload).toString("base64");
const result = await getDecryptedKey(`enc_web_${base64}`);
expect(result).toBe("");
});
it("should decrypt legacy enc_ prefix payloads via WebCrypto fallback", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
const plaintext = "legacyEncPrefixSecret";
const legacyIv = new Uint8Array(12);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: legacyIv },
"mockCryptoKey" as unknown as CryptoKey,
new TextEncoder().encode(plaintext)
)
);
const base64 = Buffer.from(ciphertext).toString("base64");
const decrypted = await getDecryptedKey(`enc_${base64}`);
expect(decrypted).toBe(plaintext);
});
});
describe("getDecryptedKey — failure returns empty string", () => {
it("should return empty string when safeStorage is unavailable for desktop key", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
const result = await getDecryptedKey("enc_desk_" + Buffer.from("test").toString("base64"));
expect(result).toBe("");
});
it("should return empty string when WebCrypto decryption throws", async () => {
mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false);
const originalDecrypt = mockSubtle.decrypt.getMockImplementation();
mockSubtle.decrypt.mockImplementation(() => {
throw new Error("decrypt boom");
});
const result = await getDecryptedKey("enc_web_" + Buffer.from("garbage").toString("base64"));
expect(result).toBe("");
mockSubtle.decrypt.mockImplementation(originalDecrypt);
});
});
describe("isEncryptedValue", () => {
it.each(["enc_desk_abc123", "enc_web_abc123", "enc_abc123"])(
'should return true for encrypted value "%s"',
(val) => {
expect(isEncryptedValue(val)).toBe(true);
}
);
it.each(["sk-abc123", "plaintext-key", ""])(
'should return false for non-encrypted value "%s"',
(val) => {
expect(isEncryptedValue(val)).toBe(false);
}
);
});
});
// ---------------------------------------------------------------------------
// isSensitiveKey (pure function — no mocks needed)
// ---------------------------------------------------------------------------
describe("isSensitiveKey", () => {
it.each([
"openAIApiKey",
"cohereApiKey",
"anthropicApiKey",
"plusLicenseKey",
"githubCopilotAccessToken",
"githubCopilotToken",
"clientSecret",
"refreshToken",
"apiPassword",
])('should return true for sensitive key "%s"', (key) => {
expect(isSensitiveKey(key)).toBe(true);
});
it.each([
"temperature",
"userSystemPrompt",
"defaultModelKey",
"activeModels",
"maxTokens",
"githubCopilotTokenExpiresAt",
"openAIOrgId",
])('should return false for non-sensitive key "%s"', (key) => {
expect(isSensitiveKey(key)).toBe(false);
});
});