/** * @file project-memory.test.js * @description Tests for POST /api/{cc,codex}-config/project-memory — the one * write path that honours a caller-supplied directory, so it is the one that * has to prove its narrowing rules hold: absolute + existing dir only, a fixed * filename, never an overwrite, and never reachable from a foreign origin. * * Every refusal asserts the file did NOT appear — a 4xx with the write already * done is not a refusal. * @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"); const http = require("node:http"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "project-memory-test-")); const FAKE_HOME = path.join(TMP, "home", ".claude"); const NEW_PROJECT = path.join(TMP, "brand-new-project"); fs.mkdirSync(FAKE_HOME, { recursive: true }); process.env.CLAUDE_HOME = FAKE_HOME; process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard-test.db"); const { createApp } = require("../index"); let server; let BASE; function post(p, body, headers = {}) { return new Promise((resolve, reject) => { const url = new URL(p, BASE); const buf = Buffer.from(JSON.stringify(body)); const req = http.request( { hostname: url.hostname, port: url.port, path: url.pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": buf.length, ...headers, }, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); let json; try { json = JSON.parse(text); } catch { json = text; } resolve({ status: res.statusCode, body: json }); }); } ); req.on("error", reject); req.write(buf); req.end(); }); } // Both routes are the same code with a different filename, so every rule is // asserted once per tool rather than once overall. const TOOLS = [ { route: "/api/cc-config/project-memory", file: "CLAUDE.md" }, { route: "/api/codex-config/project-memory", file: "AGENTS.md" }, ]; describe("POST /project-memory", () => { before(async () => { server = http.createServer(createApp()); await new Promise((r) => server.listen(0, r)); BASE = `http://127.0.0.1:${server.address().port}`; }); after(async () => { await new Promise((r) => server.close(r)); try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best-effort temp cleanup */ } }); beforeEach(() => { fs.rmSync(NEW_PROJECT, { recursive: true, force: true }); fs.mkdirSync(NEW_PROJECT, { recursive: true }); }); for (const { route, file } of TOOLS) { it(`${file}: creates it in a project the dashboard has never run in`, async () => { const { status, body } = await post(route, { dir: NEW_PROJECT, content: "# Hello\n" }); assert.equal(status, 200); assert.equal(body.created, true); assert.equal(body.file, path.join(fs.realpathSync(NEW_PROJECT), file)); assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "# Hello\n"); }); it(`${file}: refuses to overwrite one that already exists`, async () => { fs.writeFileSync(path.join(NEW_PROJECT, file), "ORIGINAL"); const { status, body } = await post(route, { dir: NEW_PROJECT, content: "REPLACED" }); assert.equal(status, 409); assert.equal(body.error.code, "EEXISTS"); assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ORIGINAL"); }); it(`${file}: refuses a relative dir`, async () => { const { status, body } = await post(route, { dir: "some/project", content: "x" }); assert.equal(status, 400); assert.equal(body.error.code, "EBADDIR"); }); it(`${file}: refuses a dir that does not exist`, async () => { const missing = path.join(TMP, "not-there"); const { status, body } = await post(route, { dir: missing, content: "x" }); assert.equal(status, 400); assert.equal(body.error.code, "EBADDIR"); assert.equal(fs.existsSync(missing), false); }); it(`${file}: refuses a path that is a file, not a directory`, async () => { const notADir = path.join(NEW_PROJECT, "README.md"); fs.writeFileSync(notADir, "x"); const { status, body } = await post(route, { dir: notADir, content: "y" }); assert.equal(status, 400); assert.equal(body.error.code, "EBADDIR"); assert.equal(fs.readFileSync(notADir, "utf8"), "x"); }); it(`${file}: refuses a cross-origin request`, async () => { const { status, body } = await post( route, { dir: NEW_PROJECT, content: "drive-by" }, { Origin: "https://evil.example" } ); assert.equal(status, 403); assert.equal(body.error.code, "EBADORIGIN"); assert.equal(fs.existsSync(path.join(NEW_PROJECT, file)), false); }); it(`${file}: allows a loopback origin (the dashboard itself)`, async () => { const { status } = await post(route, { dir: NEW_PROJECT, content: "ok" }, { Origin: BASE }); assert.equal(status, 200); assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ok"); }); it(`${file}: refuses to follow a symlink planted under that name`, async () => { // Dangling on purpose: `existsSync`/`statSync` both say "no file here" // for a broken link, so only the lstat check refuses this — and writing // through it would create the file at the link's target instead. const outside = path.join(TMP, `outside-${file}`); fs.rmSync(outside, { force: true }); fs.symlinkSync(outside, path.join(NEW_PROJECT, file)); const { status } = await post(route, { dir: NEW_PROJECT, content: "PWNED" }); assert.equal(status, 409); assert.equal(fs.existsSync(outside), false); }); } it("writes only the tool's own filename — the caller cannot name the file", async () => { // `filename` is not part of the request body at all; proving that here // means a future refactor cannot quietly add it back as a passthrough. await post("/api/cc-config/project-memory", { dir: NEW_PROJECT, content: "x", filename: "../../escaped.md", }); assert.equal(fs.existsSync(path.join(NEW_PROJECT, "CLAUDE.md")), true); assert.equal(fs.existsSync(path.join(TMP, "escaped.md")), false); }); });