← back to Kickbacks Ai Upstream
test/backup-safety.test.ts
239 lines
/** Backup/restore safety regressions (audit 2026-06-09 findings #12, #13, #18).
*
* Prime-directive territory: these pin the protocol that protects the two
* files Kickbacks does not own — Claude Code's webview/index.js and the
* user's ~/.claude/settings.json — against the cross-window interleave
* (tainted-backup recapture) and the stale-snapshot whole-file rollback. */
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync,
mkdirSync, statSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code/adapter";
import { ClaudeCliStatuslineAdapter } from "../src/adapters/claude-cli/adapter";
import { upsertStatusLine, upsertSpinnerVerbs, removeTopLevel, parseable }
from "../src/adapters/claude-cli/settingsEdit";
const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
function tmpTarget(): string {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-bsafe-"));
const p = join(d, "index.js");
writeFileSync(p, FIX, "utf8");
return p;
}
const params = {
tier: 3 as const, adText: "Ramp corporate cards & expense mgmt",
iconRef: "icon.r", iconUrl: "", clickToken: "ck",
clickUrl: "https://ramp.example/lp", corr: "ad1.tst",
loopbackPort: 5555, loopbackToken: "lt",
loopbackBase: "http://127.0.0.1:5555",
};
// ---------------------------------------------------------------------------
// Finding #12/#18 — tainted-backup recapture (ClaudeCodeAdapter)
// ---------------------------------------------------------------------------
describe("ClaudeCodeAdapter taint guard (audit #12/#18)", () => {
let target: string;
let bak: string;
beforeEach(() => { target = tmpTarget(); bak = target + ".kickbacks-backup"; });
it("ensureBackup REFUSES to capture a patched live file: applyPatch is"
+ " success-no-write, no tainted backup is minted", () => {
const a = new ClaudeCodeAdapter(target);
expect(a.applyPatch(params).ok).toBe(true); // normal first apply
// Cross-window interleave: window B's restore() deleted the backup while
// the live file stayed patched (window A re-patched after the restore).
rmSync(bak);
const patched = readFileSync(target, "utf8");
const r = a.applyPatch({ ...params, adText: "Different ad text" });
expect(r.ok).toBe(true); // apply-success-no-write
expect(r.reason).toMatch(/already patched/i);
expect(existsSync(bak)).toBe(false); // refused the capture
expect(readFileSync(target, "utf8")).toBe(patched); // byte-identical
expect(readFileSync(target, "utf8")).not.toContain("Different ad text");
});
it("after a refused capture, restore() is honest (no backup) and never"
+ " touches the live file", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
rmSync(bak);
const patched = readFileSync(target, "utf8");
a.applyPatch(params); // refused, no capture
const r = a.restore();
expect(r.ok).toBe(true);
expect(r.restored).toBe(false);
expect(r.reason).toMatch(/no backup/i);
expect(readFileSync(target, "utf8")).toBe(patched); // untouched
});
it("stale-backup recapture also refuses when the live file is patched"
+ " (never re-mints a poisoned backup from a poisoned live file)", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params); // live now patched
writeFileSync(bak, "garbage without the anchor", "utf8"); // stale backup
const live = readFileSync(target, "utf8");
const r = a.applyPatch(params);
expect(r.ok).toBe(true);
expect(existsSync(bak)).toBe(false); // stale deleted, NOT replaced
expect(readFileSync(target, "utf8")).toBe(live); // no write
});
it("restore() STRIPS our block from a tainted backup instead of reinstating"
+ " it (pre-guard field backups)", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
const patchedBytes = readFileSync(target);
// Simulate a pre-guard tainted capture: backup holds the PATCHED bytes.
writeFileSync(bak, patchedBytes);
const r = a.restore();
expect(r.ok).toBe(true);
expect(r.restored).toBe(true);
const after = readFileSync(target, "utf8");
expect(after).not.toContain("VIBE-ADS-START"); // ad block NOT reinstated
expect(after).toContain('"Discombobulating"'); // verb array intact
expect(a.isPatched()).toBe(false);
expect(existsSync(bak)).toBe(false); // consumed after verify
});
it("normal restore stays byte-exact (taint strip never fires on a clean"
+ " backup)", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
expect(a.restore().restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX);
});
});
// ---------------------------------------------------------------------------
// Finding #13 — key-scoped settings.json restore (ClaudeCliStatuslineAdapter)
// ---------------------------------------------------------------------------
const P = { tier: 0 as const, adText: "Acme", iconRef: "i", iconUrl: "",
clickToken: "", clickUrl: "https://acme/x", corr: "cli.abc", loopbackPort: 0,
loopbackToken: "", loopbackBase: "" };
function homeWithClaude(): { home: string; settings: string } {
const home = mkdtempSync(join(tmpdir(), "vibe-cli-bsafe-"));
mkdirSync(join(home, ".claude"), { recursive: true });
return { home, settings: join(home, ".claude", "settings.json") };
}
describe("ClaudeCliStatuslineAdapter key-scoped restore (audit #13)", () => {
it("restore preserves user edits made AFTER first apply (no stale-snapshot"
+ " rollback)", () => {
const { settings } = homeWithClaude();
writeFileSync(settings, '{\n "model": "opus"\n}\n', "utf8");
const a = new ClaudeCliStatuslineAdapter(settings);
expect(a.applyPatch(P).ok).toBe(true);
// Days later: the user adds a hooks config while the patch is applied.
const cur = readFileSync(settings, "utf8");
writeFileSync(settings, cur.replace("{\n",
'{\n "hooks": { "PostToolUse": [{ "command": "fmt" }] },\n'), "utf8");
const r = a.restore();
expect(r.restored).toBe(true);
const out = readFileSync(settings, "utf8");
expect(parseable(out)).toBe(true);
const parsed = JSON.parse(out);
expect(parsed.hooks).toBeDefined(); // user edit SURVIVES
expect(parsed.model).toBe("opus");
expect(parsed.statusLine).toBeUndefined(); // our keys removed
expect(parsed.spinnerVerbs).toBeUndefined();
});
it("restore on the ABSENT sentinel keeps the file when the user added"
+ " content since (only our keys removed)", () => {
const { settings } = homeWithClaude(); // no settings.json yet
const a = new ClaudeCliStatuslineAdapter(settings);
expect(a.applyPatch(P).ok).toBe(true); // we created the file
const cur = readFileSync(settings, "utf8");
writeFileSync(settings,
cur.replace("{\n", '{\n "model": "sonnet",\n'), "utf8");
const r = a.restore();
expect(r.restored).toBe(true);
expect(existsSync(settings)).toBe(true); // NOT deleted
const parsed = JSON.parse(readFileSync(settings, "utf8"));
expect(parsed.model).toBe("sonnet"); // user content survives
expect(parsed.statusLine).toBeUndefined();
expect(parsed.spinnerVerbs).toBeUndefined();
});
it("restore on the ABSENT sentinel deletes the untouched empty shell we"
+ " created", () => {
const { settings } = homeWithClaude();
const a = new ClaudeCliStatuslineAdapter(settings);
expect(a.applyPatch(P).ok).toBe(true);
const r = a.restore();
expect(r.restored).toBe(true);
expect(existsSync(settings)).toBe(false); // shell removed
expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
});
it("restore round-trips a never-edited settings.json byte-exact", () => {
const { settings } = homeWithClaude();
const pristine = '{\n "model": "opus",\n "theme": "dark"\n}\n';
writeFileSync(settings, pristine, "utf8");
const a = new ClaudeCliStatuslineAdapter(settings);
a.applyPatch(P);
expect(a.restore().restored).toBe(true);
expect(readFileSync(settings, "utf8")).toBe(pristine);
});
it("restore refuses (and keeps the backup) when the current settings.json"
+ " is unparseable — never clobbers a user-broken file", () => {
const { settings } = homeWithClaude();
writeFileSync(settings, '{\n "model": "opus"\n}\n', "utf8");
const a = new ClaudeCliStatuslineAdapter(settings);
a.applyPatch(P);
writeFileSync(settings, "{ broken ", "utf8"); // user breaks the file
const r = a.restore();
expect(r.ok).toBe(false);
expect(r.restored).toBe(false);
expect(r.reason).toMatch(/not parseable/);
expect(readFileSync(settings, "utf8")).toBe("{ broken "); // untouched
expect(existsSync(settings + ".vibe-ads-backup")).toBe(true); // retryable
});
it("applyPatch script write is idempotent (no per-tick rewrite) but heals"
+ " a corrupted script", () => {
const { home, settings } = homeWithClaude();
writeFileSync(settings, "{}\n", "utf8");
const a = new ClaudeCliStatuslineAdapter(settings);
a.applyPatch(P);
const script = join(home, ".vibe-ads", "vibe-ads-statusline.mjs");
const old = (Date.now() - 3_600_000) / 1000;
utimesSync(script, old, old);
const before = statSync(script).mtimeMs;
a.applyPatch(P); // 60s cliSync tick
expect(statSync(script).mtimeMs).toBe(before); // byte-identical → no write
writeFileSync(script, "corrupted", "utf8");
a.applyPatch(P);
expect(readFileSync(script, "utf8")).not.toBe("corrupted"); // healed
});
});
// removeTopLevel must round-trip upsertTopLevel byte-exact (whitespace-aware
// trailing-comma removal) — the property the key-scoped restore stands on.
describe("settingsEdit.removeTopLevel round-trip (audit #13)", () => {
it("upsert x2 then remove x2 returns the original text byte-exact", () => {
const src = '{\n "model": "opus"\n}\n';
const VAL = '{ "type": "command", "command": "node x", "padding": 0 }';
const SV = '{"mode":"replace","verbs":["Acme"]}';
let out = upsertStatusLine(src, VAL);
out = upsertSpinnerVerbs(out, SV);
out = removeTopLevel(out, "statusLine");
out = removeTopLevel(out, "spinnerVerbs");
expect(out).toBe(src);
});
it("never consumes a comment between the previous token and the key", () => {
const src = '{\n // user comment\n "spinnerVerbs": { "mode": "replace",'
+ ' "verbs": ["Old"] },\n "model": "opus"\n}\n';
const out = removeTopLevel(src, "spinnerVerbs");
expect(out).toContain("// user comment");
expect(out).toContain('"model": "opus"');
expect(out).not.toContain('"verbs"');
expect(parseable(out)).toBe(true);
});
});