|
|
@@ -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");
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|