008ebf1c5c
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).
742 lines
21 KiB
JavaScript
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"));
|
|
});
|
|
});
|