5ba9e07d78
Closes #19, #20, #21, #27. #19 — a saved auth profile now reports how much of its account's quota is already spent, which is the only number that decides which one is worth switching to. Nothing under CLAUDE_HOME records it, so it comes from the same endpoint the CLI's own /usage reads, answered for whichever account's OAuth token is presented. It is a POST and a button, never a poll: every profile that is not the active one is normally expired, so answering means refreshing it first, and a refresh ROTATES the stored refresh token. The rotated pair is written straight back — dropping it would leave the profile holding a credential Anthropic has already retired, i.e. an account needing a fresh login. The token is read, put in an Authorization header, and never returned, logged or echoed; what leaves the server is two percentages and two reset times. #20 — a settings patch that changes nothing wrote nothing but still took a timestamped backup, so opening the config panel quietly grew a backup directory inside every lane. patchSettings now compares the rendered output with what is already on disk and returns early, fixed once where all callers route through. A lane's git exclude also picks up .claude/.cc-config-backups/ beside settings.local.json. #21 — the router apply route was the only mutating lane route that never broadcast its lane. The write reached disk and the column, but the client's cached row kept the old router_profile forever: the picker looked right until the user switched lanes and back, at which point the effect re-read the stale row and flipped the selection to the previous profile. One broadcastLane, plus a lane-switch guard so an apply that resolves after the user has moved on no longer paints its result onto whichever lane is now on screen. #27 — Add-lane in worktree mode left the dialog up after a clean setup with a dead confirm button and no obvious way out. It closes itself when nothing failed, and when something did, the summary stays and the confirm becomes a real Close.
760 lines
22 KiB
JavaScript
760 lines
22 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("writes and backs up nothing when the patch changes nothing", () => {
|
|
// Re-attaching the same AI Router 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"));
|
|
});
|
|
});
|