import Fastify from "fastify"; import { afterEach, expect, test, vi } from "vitest"; import { evidenceRoutes } from "../src/routes/evidence.js"; import type { ThtRunner } from "../src/tht/tht-runner.js"; import type { PrincipalContext } from "../src/auth/principal.js"; const apps: ReturnType[] = []; afterEach(async () => { await Promise.all(apps.splice(0).map(app => app.close())); }); function setup(admin = true) { const app = Fastify(); apps.push(app); const principal: PrincipalContext = { issuer: "test", subject: "curator", roles: [admin ? "admin" : "user"], permissions: admin ? ["evidence.manage"] : ["session.use"], isAdmin: admin }; app.addHook("preHandler", async request => { request.principal = principal; }); const run = vi.fn(async () => ({ code: 0, stdout: JSON.stringify({ items: [], total: 0 }), stderr: "" })); const withPrincipal = vi.fn(() => ({ runWithRuntimeSnapshot: run }) as unknown as ThtRunner); const consolidateEvidence = vi.fn(); const evidenceSources = vi.fn().mockResolvedValue({status: "succeeded"}); evidenceRoutes(app, { runner: { withPrincipal }, registryRoot: "/data/registry", hostRegistryRoot: "/srv/Thoth workspaces", registry: { list: async () => [{ id: "sales", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/missing" }] }, service: { consolidateEvidence, evidenceSources } }); return { app, run, withPrincipal, principal, consolidateEvidence, evidenceSources }; } test("browses complete local files with host paths, without a database runtime", async () => { const { app, run, withPrincipal, principal } = setup(); const response = await app.inject("/workspaces/sales/evidence?kind=domain&concept=orders&page=2"); expect(response.statusCode).toBe(200); expect(withPrincipal).toHaveBeenCalledWith(principal); const [argv, snapshot] = run.mock.calls[0] as unknown as [string[], string]; expect(argv).toEqual(["evidence", "admin", "--workspace", "sales"]); expect(JSON.parse(snapshot)).toEqual({ root: "/data/registry/repo/sales", query: { kind: "domain", concept: "orders", page: 2 } }); expect(response.json().location.workspace).toBe("/srv/Thoth workspaces/repo/sales"); expect(response.json().location.git_commands[0]).toContain("git -C '/srv/Thoth workspaces/repo'"); }); test("source actions require admin, bind the actor, and reject cross-workspace or arbitrary inputs", async () => { const denied = setup(false); expect((await denied.app.inject({method: "POST", url: "/workspaces/sales/evidence/sources", payload: {action: "refresh"}})).statusCode).toBe(403); expect(denied.evidenceSources).not.toHaveBeenCalled(); const allowed = setup(); expect((await allowed.app.inject({method: "POST", url: "/workspaces/sales/evidence/sources", payload: {action: "refresh"}})).statusCode).toBe(200); expect(allowed.evidenceSources).toHaveBeenCalledWith({workspaceId: "sales", action: "refresh", actor: "curator"}); const choice = {action: "decide", sourceId: "a".repeat(64), revision: "b".repeat(64), decision: "keep"}; expect((await allowed.app.inject({method: "POST", url: "/workspaces/sales/evidence/sources", payload: choice})).statusCode).toBe(200); expect(allowed.evidenceSources).toHaveBeenLastCalledWith({...choice, workspaceId: "sales", actor: "curator"}); for (const payload of [{action: "refresh", url: "http://localhost"}, {...choice, actor: "forged"}, {...choice, revision: "../other"}]) { expect((await allowed.app.inject({method: "POST", url: "/workspaces/sales/evidence/sources", payload})).statusCode).toBe(400); } expect((await allowed.app.inject({method: "POST", url: "/workspaces/other/evidence/sources", payload: choice})).statusCode).toBe(404); expect(allowed.evidenceSources).toHaveBeenCalledTimes(2); }); test.each(["/", "/evidence:rule", "/consolidate"])("refuses non-admin access %s", async suffix => { const { app, run, consolidateEvidence } = setup(false); const response = await app.inject({ method: suffix === "/consolidate" ? "POST" : "GET", url: `/workspaces/sales/evidence${suffix === "/" ? "" : suffix}` }); expect(response.statusCode).toBe(403); expect(run).not.toHaveBeenCalled(); expect(consolidateEvidence).not.toHaveBeenCalled(); }); test("rejects workspace escape, unknown workspace and unsupported filters", async () => { const { app, run } = setup(); expect((await app.inject("/workspaces/foreign/evidence")).statusCode).toBe(404); expect((await app.inject("/workspaces/sales/evidence?root=/private")).statusCode).toBe(400); expect((await app.inject("/workspaces/sales/evidence?page_size=101")).statusCode).toBe(400); expect(run).not.toHaveBeenCalled(); }); test("a missing unit is 404 and consolidation preserves the partial outcome", async () => { const { app, consolidateEvidence } = setup(); expect((await app.inject("/workspaces/sales/evidence/evidence:missing")).statusCode).toBe(404); consolidateEvidence.mockResolvedValue({ status: "failed", warnings: ["Saved; retry indexing."] }); const response = await app.inject({ method: "POST", url: "/workspaces/sales/evidence/consolidate" }); expect(response.json()).toEqual({ status: "failed", warnings: ["Saved; retry indexing."] }); });