/** * @file cc-mutate-settings.test.js * @description Comprehensive tests for cc-mutate.js patchSettings — the per-key * merge for settings.json layers. Tests deep merge, unset, prototype pollution * guards, redaction marker handling (including nested), backup atomicity, * large files, invalid JSON, array/null rejection, and key path validation. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); // Build fixture BEFORE requiring modules that read CLAUDE_HOME const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-settings-")); const FAKE_HOME = path.join(TMP, "home", ".claude"); const FAKE_PROJECT = path.join(TMP, "project"); const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude"); fs.mkdirSync(FAKE_HOME, { recursive: true }); fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true }); // Now set env and require process.env.CLAUDE_HOME = FAKE_HOME; const ccMutate = require("../lib/cc-mutate"); after(() => { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 }); }); describe("patchSettings — happy path", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true }); }); it("creates a new settings.json with a single key", () => { const result = ccMutate.patchSettings({ scope: "user", set: { model: "opus" }, }); assert(result.ok === undefined); // Returns { path, backupPath, created, changed } assert.equal(result.created, true); assert.equal(result.backupPath, null); assert.deepEqual(result.changed, ["model"]); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.model, "opus"); }); it("merges into an existing settings.json without clobbering untouched keys", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ model: "opus", persist: "keep-me" })); const result = ccMutate.patchSettings({ scope: "user", set: { apiKey: "secret" }, }); assert.equal(result.created, false); assert.notEqual(result.backupPath, null); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.model, "opus"); assert.equal(content.persist, "keep-me"); assert.equal(content.apiKey, "secret"); }); it("unsets a top-level key", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ model: "opus", temp: "remove" })); const result = ccMutate.patchSettings({ scope: "user", unset: ["temp"], }); assert.deepEqual(result.changed, ["temp"]); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(content.model, "opus"); assert.equal(content.temp, undefined); }); it("unsets a key that does not exist (no error, not listed)", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ model: "opus" })); const result = ccMutate.patchSettings({ scope: "user", unset: ["nonexistent"], }); assert.deepEqual(result.changed, []); }); }); describe("patchSettings — deep merge", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("merges nested objects without clobbering siblings", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync( file, JSON.stringify({ permissions: { allow: ["Bash"], deny: ["network"] }, other: "data", }) ); const result = ccMutate.patchSettings({ scope: "user", set: { "permissions.allow": ["Bash", "Python"] }, }); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.deepEqual(content.permissions.allow, ["Bash", "Python"]); assert.deepEqual(content.permissions.deny, ["network"]); assert.equal(content.other, "data"); }); it("creates deeply nested paths in a fresh file", () => { const result = ccMutate.patchSettings({ scope: "user", set: { "a.b.c.d": 42 }, }); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.a.b.c.d, 42); }); it("overwrites a non-object intermediate value during nested set", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ config: "string-value" })); ccMutate.patchSettings({ scope: "user", set: { "config.nested.key": "new-value" }, }); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(content.config.nested.key, "new-value"); }); it("unsets a nested key without touching siblings", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync( file, JSON.stringify({ hooks: { PreToolUse: [{ command: "x" }], PostToolUse: [{ command: "y" }], }, }) ); const result = ccMutate.patchSettings({ scope: "user", unset: ["hooks.PreToolUse"], }); assert.deepEqual(result.changed, ["hooks.PreToolUse"]); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(content.hooks.PreToolUse, undefined); assert.deepEqual(content.hooks.PostToolUse, [{ command: "y" }]); }); it("fails to unset a nested key whose parent is not an object", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ config: "string" })); const result = ccMutate.patchSettings({ scope: "user", unset: ["config.nested.key"], }); // unsetDeep returns false, so the key is not listed in changed assert.deepEqual(result.changed, []); }); }); describe("patchSettings — redaction marker", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("rejects a value containing at top level", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { apiKey: "" }, }), (err) => { assert.equal(err.code, "EREDACTED"); assert.match(err.message, /placeholder/); return true; } ); }); it("rejects a value containing at nested position", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { "auth.token": "" }, }), (err) => { assert.equal(err.code, "EREDACTED"); return true; } ); }); it("rejects inside an object", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { config: { secret: "", other: "ok" } }, }), (err) => { assert.equal(err.code, "EREDACTED"); return true; } ); }); it("rejects inside an array", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { items: ["a", "", "b"] }, }), (err) => { assert.equal(err.code, "EREDACTED"); return true; } ); }); it("accepts a value that merely contains the string redacted (no angle brackets)", () => { const result = ccMutate.patchSettings({ scope: "user", set: { notes: "this value is redacted in the UI" }, }); assert.deepEqual(result.changed, ["notes"]); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.notes, "this value is redacted in the UI"); }); }); describe("patchSettings — key path validation", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("accepts alphanumeric keys with dots for nesting", () => { const result = ccMutate.patchSettings({ scope: "user", set: { "my_key.sub-key": "value" }, }); assert.deepEqual(result.changed, ["my_key.sub-key"]); }); it("accepts $ in key paths (for $schema, $docs)", () => { const result = ccMutate.patchSettings({ scope: "user", set: { $schema: "http://example.com" }, }); assert.deepEqual(result.changed, ["$schema"]); }); it("rejects __proto__", () => { // Built via JSON.parse, not an object literal: a literal `__proto__:` key // sets the prototype instead of an own property, so it would never reach // the validator. An HTTP body arrives parsed, i.e. as an own key. const set = JSON.parse('{"__proto__": {"polluted": true}}'); assert.throws( () => ccMutate.patchSettings({ scope: "user", set, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects prototype", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { prototype: "bad" }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects constructor", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { constructor: {} }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects __proto__ in a nested path", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { "config.__proto__.polluted": true }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects empty key paths", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { "": "bad" }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects keys with invalid characters", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { "bad@key": "value" }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); it("rejects empty segments in dotted paths", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { "config..key": "bad" }, }), (err) => { assert.equal(err.code, "EBADNAME"); return true; } ); }); }); describe("patchSettings — scopes", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true }); }); it("writes to user scope (~/.claude/settings.json)", () => { const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" }, }); assert(result.path.includes(".claude/settings.json")); assert(fs.existsSync(result.path)); }); it("writes to project scope (./.claude/settings.json)", () => { const result = ccMutate.patchSettings({ scope: "project", cwd: FAKE_PROJECT, set: { model: "haiku" }, }); assert(result.path.includes(".claude/settings.json")); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.model, "haiku"); }); it("writes to project-local scope (./.claude/settings.local.json)", () => { const result = ccMutate.patchSettings({ scope: "project-local", cwd: FAKE_PROJECT, set: { permissions: { allow: ["Bash"] } }, }); assert(result.path.includes("settings.local.json")); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.deepEqual(content.permissions.allow, ["Bash"]); }); it("rejects unknown scope", () => { assert.throws( () => ccMutate.patchSettings({ scope: "unknown", set: { key: "value" }, }), (err) => { assert.equal(err.code, "EBADSCOPE"); return true; } ); }); }); describe("patchSettings — file format validation", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("rejects a settings file that is not valid JSON", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, "not json at all {{{"); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }), (err) => { assert.equal(err.code, "EBADJSON"); assert.match(err.message, /not valid JSON/); return true; } ); }); it("rejects a settings file that parses to an array", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify(["a", "b", "c"])); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }), (err) => { assert.equal(err.code, "EBADJSON"); assert.match(err.message, /does not contain a JSON object/); return true; } ); }); it("rejects a settings file that parses to null", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, "null"); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }), (err) => { assert.equal(err.code, "EBADJSON"); return true; } ); }); it("rejects a settings file that parses to a plain string", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, '"not-an-object"'); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }), (err) => { assert.equal(err.code, "EBADJSON"); return true; } ); }); it("allows empty files to be treated as {}", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, ""); const result = ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(content.key, "value"); }); it("allows files with only whitespace to be treated as {}", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, " \n \t "); const result = ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }); const content = JSON.parse(fs.readFileSync(file, "utf8")); assert.equal(content.key, "value"); }); }); describe("patchSettings — size limits", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("rejects a settings file larger than MAX_FILE_BYTES", () => { const file = path.join(FAKE_HOME, "settings.json"); // The MAX_FILE_BYTES is typically 1MB; create a string close to that // We don't have direct access to MAX_FILE_BYTES here, so read it from the discovery module const discovery = require("../lib/cc-discovery"); const largeString = "x".repeat(discovery.MAX_FILE_BYTES + 100); fs.writeFileSync(file, JSON.stringify({ data: largeString })); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }), (err) => { assert.equal(err.code, "ETOOLARGE"); return true; } ); }); it("rejects a write that would produce output exceeding MAX_FILE_BYTES", () => { const discovery = require("../lib/cc-discovery"); const nearLimit = Math.floor(discovery.MAX_FILE_BYTES / 2); const largeValue = "y".repeat(nearLimit); assert.throws( () => ccMutate.patchSettings({ scope: "user", set: { a: largeValue, b: largeValue }, }), (err) => { assert.equal(err.code, "ETOOLARGE"); return true; } ); }); }); describe("patchSettings — backup and atomicity", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("creates a backup of an existing file before writing", () => { const file = path.join(FAKE_HOME, "settings.json"); const original = { model: "opus", data: "original" }; fs.writeFileSync(file, JSON.stringify(original)); const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" }, }); assert.notEqual(result.backupPath, null); assert(fs.existsSync(result.backupPath)); const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8")); assert.deepEqual(backup, original); }); it("writes and backs up nothing when the patch changes nothing", () => { // Re-attaching the same AI Proxy profile sends the same eight env keys // every time. With an unconditional backup that left a `.bak` per apply // piling up inside the user's own project — a dozen untracked files in a // minute of switching lanes. const file = path.join(FAKE_HOME, "settings.json"); ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } }); const before = fs.readFileSync(file, "utf8"); const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } }); assert.equal(result.backupPath, null); assert.deepEqual(result.changed, ["model"], "the request was still satisfied"); assert.equal(fs.readFileSync(file, "utf8"), before); const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings"); assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []); }); it("returns null backupPath for a fresh file", () => { const result = ccMutate.patchSettings({ scope: "user", set: { key: "value" }, }); assert.equal(result.backupPath, null); }); it("backup path includes the settings file name and a timestamp", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ existing: true })); const result = ccMutate.patchSettings({ scope: "user", set: { new: "key" }, }); assert(result.backupPath.includes("settings.json")); assert(result.backupPath.includes(".bak")); }); it("backup is stored under .cc-config-backups/settings/", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ existing: true })); const result = ccMutate.patchSettings({ scope: "user", set: { new: "key" }, }); assert(result.backupPath.includes(".cc-config-backups/settings/")); }); }); describe("patchSettings — edge cases", () => { beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); }); it("rejects when both set and unset are empty", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: {}, unset: [], }), (err) => { assert.equal(err.code, "EBADCONTENT"); assert.match(err.message, /nothing to change/); return true; } ); }); it("rejects when set is not an object", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: "not an object", }), (err) => { assert.equal(err.code, "EBADCONTENT"); return true; } ); }); it("rejects when set is an array", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", set: ["a", "b"], }), (err) => { assert.equal(err.code, "EBADCONTENT"); return true; } ); }); it("rejects when unset is not an array", () => { assert.throws( () => ccMutate.patchSettings({ scope: "user", unset: "not an array", }), (err) => { assert.equal(err.code, "EBADCONTENT"); return true; } ); }); it("accepts various JSON types as values", () => { const result = ccMutate.patchSettings({ scope: "user", set: { string: "value", number: 42, boolean: true, null_value: null, array: [1, 2, 3], object: { nested: "data" }, }, }); const content = JSON.parse(fs.readFileSync(result.path, "utf8")); assert.equal(content.string, "value"); assert.equal(content.number, 42); assert.equal(content.boolean, true); assert.equal(content.null_value, null); assert.deepEqual(content.array, [1, 2, 3]); assert.deepEqual(content.object, { nested: "data" }); }); it("lists only keys that were actually changed in the response", () => { const file = path.join(FAKE_HOME, "settings.json"); fs.writeFileSync(file, JSON.stringify({ unchanged: "kept", changed: "old" })); const result = ccMutate.patchSettings({ scope: "user", set: { changed: "new" }, }); assert.deepEqual(result.changed, ["changed"]); assert(!result.changed.includes("unchanged")); }); });