Files

196 lines
6.3 KiB
JavaScript

// End-to-end tests for the standalone viewer (packages/viewer/bin/viewer.mjs).
// Spawns the real server against a fixture project and exercises the token
// gate, graph sanitisation, file-content allowlist, and .ua/legacy resolution.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execFileSync, spawn } from "node:child_process";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const VIEWER_BIN = join(
REPO_ROOT,
"understand-anything-plugin",
"packages",
"viewer",
"bin",
"viewer.mjs",
);
const VIEWER_DIST = join(REPO_ROOT, "understand-anything-plugin", "packages", "viewer", "dist");
function fixtureGraph(gitCommitHash) {
return {
version: "1.0.0",
project: {
name: "fixture", languages: ["ts"], frameworks: [], description: "d",
analyzedAt: "2026-07-17T00:00:00.000Z", gitCommitHash,
},
nodes: [
{
id: "file:src/a.ts", type: "file", name: "a.ts", filePath: "src/a.ts",
summary: "s", tags: [], complexity: "simple",
},
],
edges: [],
layers: [],
tour: [],
};
}
function git(projectRoot, ...args) {
return execFileSync("git", args, {
cwd: projectRoot,
encoding: "utf8",
}).trim();
}
function setupProject(dataDirName) {
const root = mkdtempSync(join(tmpdir(), "ua-viewer-"));
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "src", "a.ts"), "export const a = 1;\n");
writeFileSync(join(root, "secret.txt"), "not in graph\n");
git(root, "init");
git(root, "config", "user.email", "viewer-tests@example.com");
git(root, "config", "user.name", "Viewer Tests");
git(root, "add", "--all");
git(root, "commit", "-m", "baseline");
const dataDir = join(root, dataDirName);
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(dataDir, "knowledge-graph.json"),
JSON.stringify(fixtureGraph(git(root, "rev-parse", "HEAD"))),
);
return root;
}
/** Start the viewer and wait for the printed URL. Returns { proc, url, token, port }. */
function startViewer(projectRoot) {
return new Promise((resolvePromise, rejectPromise) => {
const proc = spawn(
process.execPath,
[VIEWER_BIN, projectRoot, "--no-open", "--port", "0"],
{ env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"] },
);
let out = "";
const timer = setTimeout(() => {
proc.kill();
rejectPromise(new Error(`viewer did not start.\n${out}`));
}, 10_000);
const onData = (chunk) => {
out += String(chunk);
const m = out.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([a-f0-9]+)/);
if (m) {
clearTimeout(timer);
resolvePromise({ proc, url: m[0], port: Number(m[1]), token: m[2] });
}
};
proc.stdout.on("data", onData);
proc.stderr.on("data", onData);
proc.on("exit", (code) => {
clearTimeout(timer);
rejectPromise(new Error(`viewer exited with ${code}.\n${out}`));
});
});
}
describe.skipIf(!existsSync(VIEWER_DIST))("understand-anything-viewer", () => {
let root;
let viewer;
beforeAll(async () => {
root = setupProject(".ua");
viewer = await startViewer(root);
}, 15_000);
afterAll(() => {
viewer?.proc.kill();
if (root) rmSync(root, { recursive: true, force: true });
});
const base = () => `http://127.0.0.1:${viewer.port}`;
it("serves the embedded dashboard index", async () => {
const res = await fetch(`${base()}/`);
expect(res.status).toBe(200);
expect(await res.text()).toContain("<!doctype html>");
});
it("rejects data requests without the token", async () => {
const res = await fetch(`${base()}/knowledge-graph.json`);
expect(res.status).toBe(403);
});
it("serves the graph from .ua/ with a valid token", async () => {
const res = await fetch(`${base()}/knowledge-graph.json?token=${viewer.token}`);
expect(res.status).toBe(200);
const graph = await res.json();
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].filePath).toBe("src/a.ts");
});
it("serves a protected no-store freshness report", async () => {
const denied = await fetch(`${base()}/staleness.json`);
expect(denied.status).toBe(403);
expect(denied.headers.get("cache-control")).toBe("no-store");
const res = await fetch(
`${base()}/staleness.json?token=${viewer.token}`,
);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe("no-store");
expect(await res.json()).toMatchObject({
graphs: {
knowledge: {
status: "fresh",
graphCommitHash: git(root, "rev-parse", "HEAD"),
headCommitHash: git(root, "rev-parse", "HEAD"),
},
},
});
});
it("serves file content only for files listed in the graph", async () => {
const ok = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("src/a.ts")}`,
);
expect(ok.status).toBe(200);
const body = await ok.json();
expect(body.content).toContain("export const a");
expect(body.language).toBe("typescript");
const denied = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=secret.txt`,
);
expect(denied.status).toBe(404);
});
it("rejects path traversal in file-content", async () => {
const res = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("../outside.txt")}`,
);
expect(res.status).toBe(400);
});
it("blocks static requests escaping dist/", async () => {
const res = await fetch(`${base()}/%2e%2e/package.json`);
expect([403, 404]).toContain(res.status);
});
it("falls back to legacy .understand-anything/ projects", async () => {
const legacyRoot = setupProject(".understand-anything");
const legacyViewer = await startViewer(legacyRoot);
try {
const res = await fetch(
`http://127.0.0.1:${legacyViewer.port}/knowledge-graph.json?token=${legacyViewer.token}`,
);
expect(res.status).toBe(200);
expect((await res.json()).nodes).toHaveLength(1);
} finally {
legacyViewer.proc.kill();
rmSync(legacyRoot, { recursive: true, force: true });
}
}, 15_000);
});