From d57c6e24a088c0f279f1cca4c8dabe1ac1261a75 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Tue, 8 Jul 2025 17:29:34 -0700 Subject: [PATCH] Add readSettings unit test (#602) --- src/__tests__/readSettings.test.ts | 431 +++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 src/__tests__/readSettings.test.ts diff --git a/src/__tests__/readSettings.test.ts b/src/__tests__/readSettings.test.ts new file mode 100644 index 0000000..77a9379 --- /dev/null +++ b/src/__tests__/readSettings.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { safeStorage } from "electron"; +import { readSettings, getSettingsFilePath } from "@/main/settings"; +import { getUserDataPath } from "@/paths/paths"; + +// Mock dependencies +vi.mock("node:fs"); +vi.mock("node:path"); +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(), + decryptString: vi.fn(), + }, +})); +vi.mock("@/paths/paths", () => ({ + getUserDataPath: vi.fn(), +})); + +const mockFs = vi.mocked(fs); +const mockPath = vi.mocked(path); +const mockSafeStorage = vi.mocked(safeStorage); +const mockGetUserDataPath = vi.mocked(getUserDataPath); + +describe("readSettings", () => { + const mockUserDataPath = "/mock/user/data"; + const mockSettingsPath = "/mock/user/data/user-settings.json"; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetUserDataPath.mockReturnValue(mockUserDataPath); + mockPath.join.mockReturnValue(mockSettingsPath); + mockSafeStorage.isEncryptionAvailable.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("when settings file does not exist", () => { + it("should create default settings file and return default settings", () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.writeFileSync.mockImplementation(() => {}); + + const result = readSettings(); + + expect(mockFs.existsSync).toHaveBeenCalledWith(mockSettingsPath); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + mockSettingsPath, + expect.stringContaining('"selectedModel"'), + ); + expect(result).toEqual({ + selectedModel: { + name: "auto", + provider: "auto", + }, + providerSettings: {}, + telemetryConsent: "unset", + telemetryUserId: expect.any(String), + hasRunBefore: false, + experiments: {}, + enableProLazyEditsMode: true, + enableProSmartFilesContextMode: true, + selectedChatMode: "build", + enableAutoFixProblems: false, + enableAutoUpdate: true, + releaseChannel: "stable", + }); + }); + }); + + describe("when settings file exists", () => { + it("should read and merge settings with defaults", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + provider: "openai", + }, + telemetryConsent: "opted_in", + hasRunBefore: true, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockFs.readFileSync).toHaveBeenCalledWith( + mockSettingsPath, + "utf-8", + ); + expect(result.selectedModel).toEqual({ + name: "gpt-4", + provider: "openai", + }); + expect(result.telemetryConsent).toBe("opted_in"); + expect(result.hasRunBefore).toBe(true); + // Should still have defaults for missing properties + expect(result.enableAutoUpdate).toBe(true); + expect(result.releaseChannel).toBe("stable"); + }); + + it("should decrypt encrypted provider API keys", () => { + const mockFileContent = { + providerSettings: { + openai: { + apiKey: { + value: "encrypted-api-key", + encryptionType: "electron-safe-storage", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockReturnValue("decrypted-api-key"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledWith( + Buffer.from("encrypted-api-key", "base64"), + ); + expect(result.providerSettings.openai.apiKey).toEqual({ + value: "decrypted-api-key", + encryptionType: "electron-safe-storage", + }); + }); + + it("should decrypt encrypted GitHub access token", () => { + const mockFileContent = { + githubAccessToken: { + value: "encrypted-github-token", + encryptionType: "electron-safe-storage", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockReturnValue("decrypted-github-token"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledWith( + Buffer.from("encrypted-github-token", "base64"), + ); + expect(result.githubAccessToken).toEqual({ + value: "decrypted-github-token", + encryptionType: "electron-safe-storage", + }); + }); + + it("should decrypt encrypted Supabase tokens", () => { + const mockFileContent = { + supabase: { + accessToken: { + value: "encrypted-access-token", + encryptionType: "electron-safe-storage", + }, + refreshToken: { + value: "encrypted-refresh-token", + encryptionType: "electron-safe-storage", + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString + .mockReturnValueOnce("decrypted-refresh-token") + .mockReturnValueOnce("decrypted-access-token"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledTimes(2); + expect(result.supabase?.refreshToken).toEqual({ + value: "decrypted-refresh-token", + encryptionType: "electron-safe-storage", + }); + expect(result.supabase?.accessToken).toEqual({ + value: "decrypted-access-token", + encryptionType: "electron-safe-storage", + }); + }); + + it("should handle plaintext secrets without decryption", () => { + const mockFileContent = { + githubAccessToken: { + value: "plaintext-token", + encryptionType: "plaintext", + }, + providerSettings: { + openai: { + apiKey: { + value: "plaintext-api-key", + encryptionType: "plaintext", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).not.toHaveBeenCalled(); + expect(result.githubAccessToken?.value).toBe("plaintext-token"); + expect(result.providerSettings.openai.apiKey?.value).toBe( + "plaintext-api-key", + ); + }); + + it("should handle secrets without encryptionType", () => { + const mockFileContent = { + githubAccessToken: { + value: "token-without-encryption-type", + }, + providerSettings: { + openai: { + apiKey: { + value: "api-key-without-encryption-type", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).not.toHaveBeenCalled(); + expect(result.githubAccessToken?.value).toBe( + "token-without-encryption-type", + ); + expect(result.providerSettings.openai.apiKey?.value).toBe( + "api-key-without-encryption-type", + ); + }); + + it("should strip extra fields not recognized by the schema", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + provider: "openai", + }, + telemetryConsent: "opted_in", + hasRunBefore: true, + // Extra fields that are not in the schema + unknownField: "should be removed", + deprecatedSetting: true, + extraConfig: { + someValue: 123, + anotherValue: "test", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockFs.readFileSync).toHaveBeenCalledWith( + mockSettingsPath, + "utf-8", + ); + expect(result.selectedModel).toEqual({ + name: "gpt-4", + provider: "openai", + }); + expect(result.telemetryConsent).toBe("opted_in"); + expect(result.hasRunBefore).toBe(true); + + // Extra fields should be stripped by schema validation + expect(result).not.toHaveProperty("unknownField"); + expect(result).not.toHaveProperty("deprecatedSetting"); + expect(result).not.toHaveProperty("extraConfig"); + + // Should still have defaults for missing properties + expect(result.enableAutoUpdate).toBe(true); + expect(result.releaseChannel).toBe("stable"); + }); + }); + + describe("error handling", () => { + it("should return default settings when file read fails", () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockImplementation(() => { + throw new Error("File read error"); + }); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = readSettings(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error reading settings:", + expect.any(Error), + ); + expect(result).toEqual({ + selectedModel: { + name: "auto", + provider: "auto", + }, + providerSettings: {}, + telemetryConsent: "unset", + telemetryUserId: expect.any(String), + hasRunBefore: false, + experiments: {}, + enableProLazyEditsMode: true, + enableProSmartFilesContextMode: true, + selectedChatMode: "build", + enableAutoFixProblems: false, + enableAutoUpdate: true, + releaseChannel: "stable", + }); + + consoleSpy.mockRestore(); + }); + + it("should return default settings when JSON parsing fails", () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue("invalid json"); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = readSettings(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error reading settings:", + expect.any(Error), + ); + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + + consoleSpy.mockRestore(); + }); + + it("should return default settings when schema validation fails", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + // Missing required 'provider' field + }, + releaseChannel: "invalid-channel", // Invalid enum value + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = readSettings(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error reading settings:", + expect.any(Error), + ); + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + + consoleSpy.mockRestore(); + }); + + it("should handle decryption errors gracefully", () => { + const mockFileContent = { + githubAccessToken: { + value: "corrupted-encrypted-data", + encryptionType: "electron-safe-storage", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockImplementation(() => { + throw new Error("Decryption failed"); + }); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = readSettings(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error reading settings:", + expect.any(Error), + ); + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + + consoleSpy.mockRestore(); + }); + }); + + describe("getSettingsFilePath", () => { + it("should return correct settings file path", () => { + const result = getSettingsFilePath(); + + expect(mockGetUserDataPath).toHaveBeenCalled(); + expect(mockPath.join).toHaveBeenCalledWith( + mockUserDataPath, + "user-settings.json", + ); + expect(result).toBe(mockSettingsPath); + }); + }); +});