Files
AgentHub/server/__tests__/cc-mutate-settings.test.js
T
nntrivi2001 008ebf1c5c feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard
The Claude Config page was read-only for these four surfaces. It can now:

- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
  absolute path, and install / enable / disable / update / uninstall plugins,
  all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
  layers.

Server:

- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
  argv array — never a shell string. Every argument is validated before the
  spawn (shape allowlist per kind, no control characters, no leading `-`, no
  `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
  closed so a prompt errors instead of hanging, and mutating calls are
  serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
  layer. It re-reads the file unredacted, so keys the UI never displayed —
  including secret-valued ones — survive byte-for-byte. A payload carrying the
  `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
  `prototype` / `constructor` segments; a settings file that does not parse is
  reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
  the CLI's own message, a missing binary a 503.

Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.

Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
2026-08-25 15:58:03 +07:00

742 lines
21 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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 <redacted> at top level", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { apiKey: "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
assert.match(err.message, /placeholder/);
return true;
}
);
});
it("rejects a value containing <redacted> at nested position", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "auth.token": "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an object", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { config: { secret: "<redacted>", other: "ok" } },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { items: ["a", "<redacted>", "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("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"));
});
});