diff --git a/src/utils/settings/__tests__/config.test.ts b/src/utils/settings/__tests__/config.test.ts new file mode 100644 index 0000000..f8bf1b6 --- /dev/null +++ b/src/utils/settings/__tests__/config.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, test } from "bun:test"; +import { + SettingsSchema, + EnvironmentVariablesSchema, + PermissionsSchema, + AllowedMcpServerEntrySchema, + DeniedMcpServerEntrySchema, + isMcpServerNameEntry, + isMcpServerCommandEntry, + isMcpServerUrlEntry, + CUSTOMIZATION_SURFACES, +} from "../types"; +import { + SETTING_SOURCES, + SOURCES, + CLAUDE_CODE_SETTINGS_SCHEMA_URL, + getSettingSourceName, + getSourceDisplayName, + getSettingSourceDisplayNameLowercase, + getSettingSourceDisplayNameCapitalized, + parseSettingSourcesFlag, +} from "../constants"; +import { + formatZodError, + filterInvalidPermissionRules, + validateSettingsFileContent, +} from "../validation"; + +// ─── Settings Schema Validation ────────────────────────────────────────── + +describe("SettingsSchema", () => { + test("accepts empty object", () => { + const result = SettingsSchema().safeParse({}); + expect(result.success).toBe(true); + }); + + test("accepts model string", () => { + const result = SettingsSchema().safeParse({ model: "sonnet" }); + expect(result.success).toBe(true); + }); + + test("accepts permissions block with allow rules", () => { + const result = SettingsSchema().safeParse({ + permissions: { allow: ["Bash(npm install)"] }, + }); + expect(result.success).toBe(true); + }); + + test("accepts permissions block with deny rules", () => { + const result = SettingsSchema().safeParse({ + permissions: { deny: ["Bash(rm -rf *)"] }, + }); + expect(result.success).toBe(true); + }); + + test("accepts env variables", () => { + const result = SettingsSchema().safeParse({ + env: { FOO: "bar", DEBUG: "1" }, + }); + expect(result.success).toBe(true); + }); + + test("accepts hooks configuration", () => { + const result = SettingsSchema().safeParse({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts attribution settings", () => { + const result = SettingsSchema().safeParse({ + attribution: { + commit: "Generated by AI", + pr: "AI-generated PR", + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts worktree settings", () => { + const result = SettingsSchema().safeParse({ + worktree: { + symlinkDirectories: ["node_modules", ".cache"], + sparsePaths: ["src/"], + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts $schema field", () => { + const result = SettingsSchema().safeParse({ + $schema: CLAUDE_CODE_SETTINGS_SCHEMA_URL, + }); + expect(result.success).toBe(true); + }); + + test("passes through unknown keys (passthrough mode)", () => { + const result = SettingsSchema().safeParse({ unknownKey: "value" }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as any).unknownKey).toBe("value"); + } + }); + + test("coerces env var numbers to strings", () => { + const result = EnvironmentVariablesSchema().safeParse({ PORT: 3000 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.PORT).toBe("3000"); + } + }); + + test("accepts boolean settings", () => { + const result = SettingsSchema().safeParse({ + includeCoAuthoredBy: true, + respectGitignore: false, + disableAllHooks: true, + }); + expect(result.success).toBe(true); + }); + + test("accepts cleanupPeriodDays", () => { + const result = SettingsSchema().safeParse({ cleanupPeriodDays: 30 }); + expect(result.success).toBe(true); + }); + + test("rejects negative cleanupPeriodDays", () => { + const result = SettingsSchema().safeParse({ cleanupPeriodDays: -1 }); + expect(result.success).toBe(false); + }); + + test("accepts statusLine configuration", () => { + const result = SettingsSchema().safeParse({ + statusLine: { type: "command", command: "echo status" }, + }); + expect(result.success).toBe(true); + }); + + test("accepts sshConfigs", () => { + const result = SettingsSchema().safeParse({ + sshConfigs: [ + { + id: "dev-server", + name: "Development Server", + sshHost: "dev.example.com", + sshPort: 22, + }, + ], + }); + expect(result.success).toBe(true); + }); +}); + +// ─── Permissions Schema ───────────────────────────────────────────────── + +describe("PermissionsSchema", () => { + test("accepts defaultMode", () => { + const result = PermissionsSchema().safeParse({ + defaultMode: "acceptEdits", + }); + expect(result.success).toBe(true); + }); + + test("accepts additionalDirectories", () => { + const result = PermissionsSchema().safeParse({ + additionalDirectories: ["/tmp/extra"], + }); + expect(result.success).toBe(true); + }); + + test("accepts disableBypassPermissionsMode", () => { + const result = PermissionsSchema().safeParse({ + disableBypassPermissionsMode: "disable", + }); + expect(result.success).toBe(true); + }); +}); + +// ─── AllowedMcpServerEntrySchema ──────────────────────────────────────── + +describe("AllowedMcpServerEntrySchema", () => { + test("accepts serverName entry", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverName: "my-server", + }); + expect(result.success).toBe(true); + }); + + test("accepts serverCommand entry", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverCommand: ["npx", "mcp-server"], + }); + expect(result.success).toBe(true); + }); + + test("accepts serverUrl entry", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverUrl: "https://*.example.com/*", + }); + expect(result.success).toBe(true); + }); + + test("rejects entry with no fields", () => { + const result = AllowedMcpServerEntrySchema().safeParse({}); + expect(result.success).toBe(false); + }); + + test("rejects entry with multiple fields", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverName: "my-server", + serverUrl: "https://example.com", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid serverName characters", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverName: "my server with spaces", + }); + expect(result.success).toBe(false); + }); + + test("rejects empty serverCommand array", () => { + const result = AllowedMcpServerEntrySchema().safeParse({ + serverCommand: [], + }); + expect(result.success).toBe(false); + }); +}); + +// ─── Type guards ───────────────────────────────────────────────────────── + +describe("MCP server entry type guards", () => { + test("isMcpServerNameEntry identifies name entry", () => { + expect(isMcpServerNameEntry({ serverName: "test" })).toBe(true); + }); + + test("isMcpServerNameEntry rejects non-name entry", () => { + expect(isMcpServerNameEntry({ serverUrl: "https://example.com" })).toBe( + false + ); + }); + + test("isMcpServerCommandEntry identifies command entry", () => { + expect(isMcpServerCommandEntry({ serverCommand: ["npx", "srv"] })).toBe( + true + ); + }); + + test("isMcpServerCommandEntry rejects non-command entry", () => { + expect(isMcpServerCommandEntry({ serverName: "test" })).toBe(false); + }); + + test("isMcpServerUrlEntry identifies url entry", () => { + expect( + isMcpServerUrlEntry({ serverUrl: "https://example.com" }) + ).toBe(true); + }); + + test("isMcpServerUrlEntry rejects non-url entry", () => { + expect(isMcpServerUrlEntry({ serverName: "test" })).toBe(false); + }); +}); + +// ─── Constants ────────────────────────────────────────────────────────── + +describe("SETTING_SOURCES", () => { + test("contains all five sources in order", () => { + expect(SETTING_SOURCES).toEqual([ + "userSettings", + "projectSettings", + "localSettings", + "flagSettings", + "policySettings", + ]); + }); +}); + +describe("SOURCES (editable)", () => { + test("contains three editable sources", () => { + expect(SOURCES).toEqual([ + "localSettings", + "projectSettings", + "userSettings", + ]); + }); +}); + +describe("CUSTOMIZATION_SURFACES", () => { + test("contains expected surfaces", () => { + expect(CUSTOMIZATION_SURFACES).toEqual([ + "skills", + "agents", + "hooks", + "mcp", + ]); + }); +}); + +describe("getSettingSourceName", () => { + test("maps userSettings to user", () => { + expect(getSettingSourceName("userSettings")).toBe("user"); + }); + + test("maps projectSettings to project", () => { + expect(getSettingSourceName("projectSettings")).toBe("project"); + }); + + test("maps localSettings to project, gitignored", () => { + expect(getSettingSourceName("localSettings")).toBe("project, gitignored"); + }); + + test("maps flagSettings to cli flag", () => { + expect(getSettingSourceName("flagSettings")).toBe("cli flag"); + }); + + test("maps policySettings to managed", () => { + expect(getSettingSourceName("policySettings")).toBe("managed"); + }); +}); + +describe("getSourceDisplayName", () => { + test("maps userSettings to User", () => { + expect(getSourceDisplayName("userSettings")).toBe("User"); + }); + + test("maps plugin to Plugin", () => { + expect(getSourceDisplayName("plugin")).toBe("Plugin"); + }); + + test("maps built-in to Built-in", () => { + expect(getSourceDisplayName("built-in")).toBe("Built-in"); + }); +}); + +describe("getSettingSourceDisplayNameLowercase", () => { + test("maps policySettings correctly", () => { + expect(getSettingSourceDisplayNameLowercase("policySettings")).toBe( + "enterprise managed settings" + ); + }); + + test("maps cliArg correctly", () => { + expect(getSettingSourceDisplayNameLowercase("cliArg")).toBe("CLI argument"); + }); + + test("maps session correctly", () => { + expect(getSettingSourceDisplayNameLowercase("session")).toBe( + "current session" + ); + }); +}); + +describe("getSettingSourceDisplayNameCapitalized", () => { + test("maps userSettings correctly", () => { + expect(getSettingSourceDisplayNameCapitalized("userSettings")).toBe( + "User settings" + ); + }); + + test("maps command correctly", () => { + expect(getSettingSourceDisplayNameCapitalized("command")).toBe( + "Command configuration" + ); + }); +}); + +describe("parseSettingSourcesFlag", () => { + test("parses comma-separated sources", () => { + expect(parseSettingSourcesFlag("user,project,local")).toEqual([ + "userSettings", + "projectSettings", + "localSettings", + ]); + }); + + test("parses single source", () => { + expect(parseSettingSourcesFlag("user")).toEqual(["userSettings"]); + }); + + test("returns empty array for empty string", () => { + expect(parseSettingSourcesFlag("")).toEqual([]); + }); + + test("trims whitespace", () => { + expect(parseSettingSourcesFlag("user , project")).toEqual([ + "userSettings", + "projectSettings", + ]); + }); + + test("throws for invalid source name", () => { + expect(() => parseSettingSourcesFlag("invalid")).toThrow( + "Invalid setting source" + ); + }); +}); + +// ─── Validation ───────────────────────────────────────────────────────── + +describe("filterInvalidPermissionRules", () => { + test("returns empty for non-object input", () => { + expect(filterInvalidPermissionRules(null, "test.json")).toEqual([]); + expect(filterInvalidPermissionRules("string", "test.json")).toEqual([]); + }); + + test("returns empty when no permissions", () => { + expect(filterInvalidPermissionRules({}, "test.json")).toEqual([]); + }); + + test("filters non-string rules and returns warnings", () => { + const data = { permissions: { allow: ["Bash", 123, "Read"] } }; + const warnings = filterInvalidPermissionRules(data, "test.json"); + expect(warnings.length).toBe(1); + expect(warnings[0]!.path).toBe("permissions.allow"); + expect((data.permissions as any).allow).toEqual(["Bash", "Read"]); + }); + + test("preserves valid rules", () => { + const data = { + permissions: { allow: ["Bash(npm install)", "Read", "Write"] }, + }; + const warnings = filterInvalidPermissionRules(data, "test.json"); + expect(warnings).toEqual([]); + expect((data.permissions as any).allow).toEqual([ + "Bash(npm install)", + "Read", + "Write", + ]); + }); +}); + +describe("validateSettingsFileContent", () => { + test("accepts valid JSON settings", () => { + const result = validateSettingsFileContent('{"model": "sonnet"}'); + expect(result.isValid).toBe(true); + }); + + test("accepts empty object", () => { + const result = validateSettingsFileContent("{}"); + expect(result.isValid).toBe(true); + }); + + test("rejects invalid JSON", () => { + const result = validateSettingsFileContent("not json"); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.error).toContain("Invalid JSON"); + } + }); + + test("rejects unknown keys in strict mode", () => { + const result = validateSettingsFileContent('{"unknownField": true}'); + expect(result.isValid).toBe(false); + }); +}); + +describe("formatZodError", () => { + test("formats invalid type error", () => { + const result = SettingsSchema().safeParse({ model: 123 }); + expect(result.success).toBe(false); + if (!result.success) { + const errors = formatZodError(result.error, "settings.json"); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.file).toBe("settings.json"); + expect(errors[0]!.path).toContain("model"); + } + }); +});