diff --git a/server/__tests__/proof.test.js b/server/__tests__/proof.test.js new file mode 100644 index 0000000..23c6a9e --- /dev/null +++ b/server/__tests__/proof.test.js @@ -0,0 +1,226 @@ +/** + * @file Tests for server/lib/proof.js: proof-directory listing, containment- + * checked file resolution, delete granularities, and the clone-root symlink + * convergence (ensureProofLink). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const lanesLib = require("../lib/lanes"); +const features = require("../lib/lane-features"); +const proof = require("../lib/proof"); + +after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +function writeShot(lane, slug, group, name) { + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", slug, group); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), "fake-png"); +} + +describe("listProof", () => { + it("groups screenshots by slug then phase group", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + writeShot(lane, "one", "qc-local", "b.png"); + writeShot(lane, "one", "qc-dev", "c.png"); + const list = proof.listProof(lane.id); + const one = list.find((f) => f.slug === "one"); + assert.deepEqual(one.groups["qc-local"].sort(), ["a.png", "b.png"]); + assert.deepEqual(one.groups["qc-dev"], ["c.png"]); + }); + + it("surfaces the ticket report path when present", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + const one = proof.listProof(lane.id).find((f) => f.slug === "one"); + assert.equal(one.ticket_report, "one/ticket/REPORT.html"); + }); + + it("never lists the ticket dir as a screenshot group", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + const one = proof.listProof(lane.id).find((f) => f.slug === "one"); + assert.equal(one.groups.ticket, undefined); + }); + + it("surfaces an activated feature with zero screenshots yet, groups empty", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "brand-new"); + const list = proof.listProof(lane.id); + const f = list.find((x) => x.slug === "brand-new"); + assert.ok(f); + assert.deepEqual(f.groups, {}); + }); +}); + +describe("proofFile", () => { + it("resolves a real screenshot inside the proof root", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const p = proof.proofFile(lane.id, "one", "qc-local", "a.png"); + assert.ok(p); + assert.ok(fs.existsSync(p)); + }); + + it("refuses a traversal attempt via ..", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + assert.equal(proof.proofFile(lane.id, "one", "..", "..%2fsecret"), null); + assert.equal(proof.proofFile(lane.id, "../../etc", "x", "passwd"), null); + }); + + it("refuses a symlink that escapes the proof root", () => { + const lane = makeLane(); + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local"); + fs.mkdirSync(dir, { recursive: true }); + const outside = path.join(SUITE_ROOT, "outside-secret.png"); + fs.writeFileSync(outside, "secret"); + fs.symlinkSync(outside, path.join(dir, "escape.png")); + assert.equal(proof.proofFile(lane.id, "one", "qc-local", "escape.png"), null); + }); + + it("refuses a non-image, non-html extension", () => { + const lane = makeLane(); + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "a.txt"), "not an image"); + assert.equal(proof.proofFile(lane.id, "one", "qc-local", "a.txt"), null); + }); + + it("returns null for a missing lane", () => { + assert.equal(proof.proofFile(999999, "one", "qc-local", "a.png"), null); + }); +}); + +describe("deleteProof", () => { + it("deletes listed images and prunes the group dir if it becomes empty", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const result = proof.deleteProof(lane.id, { + slug: "one", + group: "qc-local", + images: ["a.png"], + }); + assert.equal(result.deleted, 1); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + }); + + it("deletes a whole group, leaves siblings intact", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + writeShot(lane, "one", "qc-dev", "b.png"); + proof.deleteProof(lane.id, { slug: "one", group: "qc-local" }); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + assert.ok( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-dev", "b.png")) + ); + }); + + it("refuses to delete the ticket group by name", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "one", group: "ticket" }), + (err) => err.code === "EBADPATH" + ); + }); + + it("whole-feature delete removes every group but keeps ticket/", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + proof.deleteProof(lane.id, { slug: "one" }); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + assert.ok(fs.existsSync(path.join(ticketDir, "REPORT.html"))); + }); + + it("refuses a slug containing a path separator or ..", () => { + const lane = makeLane(); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "../escape" }), + (err) => err.code === "EBADPATH" + ); + }); + + it("throws ENOFEATURE for a slug with no proof directory", () => { + const lane = makeLane(); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "never-had-proof" }), + (err) => err.code === "ENOFEATURE" + ); + }); +}); + +describe("ensureProofLink", () => { + it("creates the symlink when neither clone-root proof/ nor the canonical dir exist yet", () => { + const lane = makeLane(); + const result = proof.ensureProofLink(lanesLib.getLane(lane.id)); + assert.equal(result.linked, true); + const strayPath = path.join(lane.cwd, "proof"); + assert.equal(fs.lstatSync(strayPath).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(strayPath), path.join(".playwright-mcp", "proof")); + }); + + it("merges a pre-existing real proof/ dir into the canonical root, then links it", () => { + const lane = makeLane(); + const strayDir = path.join(lane.cwd, "proof", "stranded", "qc-local"); + fs.mkdirSync(strayDir, { recursive: true }); + fs.writeFileSync(path.join(strayDir, "old.png"), "stranded shot"); + + proof.ensureProofLink(lanesLib.getLane(lane.id)); + + const merged = path.join( + lane.cwd, + ".playwright-mcp", + "proof", + "stranded", + "qc-local", + "old.png" + ); + assert.ok(fs.existsSync(merged)); + assert.equal(fs.lstatSync(path.join(lane.cwd, "proof")).isSymbolicLink(), true); + }); + + it("is a no-op (linked: false) when the symlink already points at the right place", () => { + const lane = makeLane(); + proof.ensureProofLink(lanesLib.getLane(lane.id)); + const second = proof.ensureProofLink(lanesLib.getLane(lane.id)); + assert.equal(second.linked, false); + }); +}); diff --git a/server/lib/proof.js b/server/lib/proof.js new file mode 100644 index 0000000..a8ddb8d --- /dev/null +++ b/server/lib/proof.js @@ -0,0 +1,307 @@ +/** + * @file Proof gallery (C): the screenshots a QC agent captures live at + * `/.playwright-mcp/proof///*.png` (plus a + * `ticket/REPORT.html`), grouped by feature slug — the same slug Task B's + * `lane_features` already tracks. Ported from Shipyard's + * `dashboard/server/services/proof.js`; the "current feature" special-casing + * that file needed (it has no DB, so it parsed a branch regex) is dropped — + * CCAM's `lane_features` row already IS that source of truth. + * + * Path containment is the entire security surface here: every path this + * module touches is resolved, realpath'd, and checked to be inside the + * lane's own proof root before any read/write/delete reaches `fs`. Ported + * near-verbatim from Shipyard rather than "cleaned up" — this is the one part + * of C worth reviewing line-by-line, not abstracting. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const lanesLib = require("./lanes"); +const laneFeatures = require("./lane-features"); + +const IMAGE_RE = /\.(png|jpg|jpeg)$/i; +const SERVEABLE_RE = /\.(png|jpg|jpeg|html)$/i; + +/** Where a lane's proof lives — inside its OWN cwd, not LANES_ROOT (an + * adopted lane's cwd need not be under LANES_ROOT at all). */ +function proofRoot(lane) { + return path.join(lane.cwd, ".playwright-mcp", "proof"); +} + +const badName = (s) => + !s || typeof s !== "string" || s.includes("/") || s.includes("\\") || s.includes(".."); + +function countFiles(dir) { + let c = 0; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) c += countFiles(path.join(dir, e.name)); + else c++; + } + return c; +} + +function latestMtime(dir) { + let mt = 0; + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, e.name); + if (e.isDirectory()) walk(full); + else { + const s = fs.statSync(full); + if (s.mtimeMs > mt) mt = s.mtimeMs; + } + } + }; + try { + walk(dir); + } catch { + /* best effort */ + } + return mt / 1000; +} + +/** + * Every feature slug with proof (or an activated-but-not-yet-shot feature + * from `lane_features`), most recently touched first. + * + * @param {number} laneId + * @returns {Array<{slug: string, groups: object, ticket_report: string, mtime: number}>} + */ +function listProof(laneId) { + const lane = lanesLib.getLane(laneId); + if (!lane) return []; + const base = proofRoot(lane); + const feats = []; + + if (fs.existsSync(base) && fs.statSync(base).isDirectory()) { + for (const slug of fs.readdirSync(base).sort()) { + const d = path.join(base, slug); + try { + if (!fs.statSync(d).isDirectory()) continue; + } catch { + continue; + } + + const groups = {}; + for (const grp of fs.readdirSync(d).sort()) { + if (grp === "ticket") continue; + const g = path.join(d, grp); + try { + if (!fs.statSync(g).isDirectory()) continue; + } catch { + continue; + } + const imgs = fs + .readdirSync(g) + .filter((f) => IMAGE_RE.test(f)) + .sort(); + if (imgs.length) groups[grp] = imgs; + } + + let ticketReport = ""; + if (fs.existsSync(path.join(d, "ticket", "REPORT.html"))) { + ticketReport = `${slug}/ticket/REPORT.html`; + } + + feats.push({ slug, groups, ticket_report: ticketReport, mtime: latestMtime(d) }); + } + } + + // Surface an activated feature with no proof dir yet. + const have = new Set(feats.map((f) => f.slug)); + for (const f of laneFeatures.listFeatures(laneId)) { + if (!have.has(f.slug)) { + feats.push({ slug: f.slug, groups: {}, ticket_report: "", mtime: 0 }); + } + } + + feats.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + return feats; +} + +/** + * Resolve one proof file, containment-checked. Returns the absolute realpath + * or null on any failure — missing lane, missing file, traversal, symlink + * escape, or wrong extension. + */ +function proofFile(laneId, slug, group, file) { + const lane = lanesLib.getLane(laneId); + if (!lane) return null; + + let base; + try { + base = fs.realpathSync(proofRoot(lane)); + } catch { + return null; + } + + let p; + try { + p = fs.realpathSync(path.join(base, slug, group, file)); + } catch { + return null; + } + + if ( + (p === base || p.startsWith(base + path.sep)) && + fs.existsSync(p) && + fs.statSync(p).isFile() && + SERVEABLE_RE.test(p) + ) { + return p; + } + return null; +} + +/** + * Delete proof screenshots at one of three granularities: + * { slug, group, images: [...] } -> unlink those files, prune the group dir if now empty + * { slug, group } -> remove the whole group dir + * { slug } -> remove every group dir EXCEPT ticket/ + * Never touches `ticket/`, never reaches outside the lane's proof root. + * + * @returns {{deleted: number}} + */ +function deleteProof(laneId, { slug, group, images } = {}) { + const lane = lanesLib.getLane(laneId); + if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" }); + if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" }); + + let base; + try { + base = fs.realpathSync(proofRoot(lane)); + } catch { + throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" }); + } + const inBase = (p) => p === base || p.startsWith(base + path.sep); + + let featDir; + try { + featDir = fs.realpathSync(path.join(base, slug)); + } catch { + throw Object.assign(new Error("feature not found"), { code: "ENOFEATURE" }); + } + if (!inBase(featDir) || !fs.statSync(featDir).isDirectory()) { + throw Object.assign(new Error("invalid feature dir"), { code: "EBADPATH" }); + } + + let deleted = 0; + + if (group !== undefined) { + if (badName(group) || group === "ticket") { + throw Object.assign(new Error("invalid group"), { code: "EBADPATH" }); + } + let grpDir; + try { + grpDir = fs.realpathSync(path.join(featDir, group)); + } catch { + throw Object.assign(new Error("group not found"), { code: "ENOFEATURE" }); + } + if (!inBase(grpDir) || !fs.statSync(grpDir).isDirectory()) { + throw Object.assign(new Error("invalid group dir"), { code: "EBADPATH" }); + } + + if (Array.isArray(images) && images.length) { + for (const img of images) { + if (badName(img) || !IMAGE_RE.test(img)) { + throw Object.assign(new Error("invalid image"), { code: "EBADPATH" }); + } + let f; + try { + f = fs.realpathSync(path.join(grpDir, img)); + } catch { + continue; + } + if (inBase(f) && fs.statSync(f).isFile() && IMAGE_RE.test(f)) { + fs.unlinkSync(f); + deleted++; + } + } + try { + if (!fs.readdirSync(grpDir).length) fs.rmdirSync(grpDir); + } catch { + /* ok */ + } + } else { + deleted = countFiles(grpDir); + fs.rmSync(grpDir, { recursive: true, force: true }); + } + } else { + for (const e of fs.readdirSync(featDir, { withFileTypes: true })) { + if (!e.isDirectory() || e.name === "ticket") continue; + const g = path.join(featDir, e.name); + deleted += countFiles(g); + fs.rmSync(g, { recursive: true, force: true }); + } + } + + return { deleted }; +} + +/** + * Port of Shipyard's `ensure_proof_link`: converge the clone-root `proof/` + * onto `.playwright-mcp/proof` so every proof write lands in one place + * regardless of an MCP's `--output-dir` convention. A pre-existing REAL + * `proof/` dir is merged into the canonical root first (no clobber), then + * replaced with a relative symlink. Idempotent — a no-op when the symlink + * already points at the right place. + * + * Never auto-invoked by this codebase; exposed only as the explicit + * `ccam lanes proof-link` primitive. + * + * @param {object} lane - A hydrated lane row (must have `cwd`). + * @returns {{linked: boolean}} `linked: false` when nothing changed. + */ +function ensureProofLink(lane) { + const canon = proofRoot(lane); + const stray = path.join(lane.cwd, "proof"); + const relTarget = path.join(".playwright-mcp", "proof"); + + let strayStat = null; + try { + strayStat = fs.lstatSync(stray); + } catch { + /* doesn't exist yet */ + } + + if (strayStat && strayStat.isSymbolicLink()) { + if (fs.readlinkSync(stray) === relTarget) return { linked: false }; + fs.rmSync(stray, { force: true }); + } else if (strayStat && strayStat.isDirectory()) { + fs.mkdirSync(canon, { recursive: true }); + mergeDirInto(stray, canon); + fs.rmSync(stray, { recursive: true, force: true }); + } else if (strayStat) { + // A stray file named `proof` — refuse to clobber; leave it alone. + return { linked: false }; + } + + fs.mkdirSync(canon, { recursive: true }); + fs.symlinkSync(relTarget, stray); + return { linked: true }; +} + +/** Recursively copy `src`'s contents into `dest`, never overwriting an + * existing file at the destination (no-clobber merge). */ +function mergeDirInto(src, dest) { + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, e.name); + const d = path.join(dest, e.name); + if (e.isDirectory()) { + fs.mkdirSync(d, { recursive: true }); + mergeDirInto(s, d); + } else if (!fs.existsSync(d)) { + fs.copyFileSync(s, d); + } + } +} + +module.exports = { + proofRoot, + listProof, + proofFile, + deleteProof, + ensureProofLink, +};