From 9db4463a8393c32ac137facbc6c401c745127ed8 Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 16:28:01 +0200 Subject: [PATCH] refactor: remove workspace publishing and bundles --- backend/package-lock.json | 106 --- backend/package.json | 5 - backend/src/config.ts | 12 +- backend/src/routes/workspaces.ts | 273 +------- backend/src/workspaces/git-repository.ts | 3 - backend/src/workspaces/types.ts | 7 +- backend/test/routes-workspaces.test.ts | 782 ++++------------------- backend/test/workspaces-config.test.ts | 19 +- 8 files changed, 147 insertions(+), 1060 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index dbc16ac6..a261691e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,19 +7,14 @@ "name": "thothii-backend", "dependencies": { "@fastify/cors": "^11.2.0", - "@fastify/multipart": "^9.4.0", "@types/pg": "^8.20.3", "fastify": "^5.0.0", "pg": "^8.22.0", "yaml": "^2.9.0", - "yauzl": "^3.4.0", - "yazl": "^3.3.1", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.0.0", - "@types/yauzl": "^3.4.0", - "@types/yazl": "^3.3.1", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -488,12 +483,6 @@ "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "license": "MIT" - }, "node_modules/@fastify/cors": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", @@ -514,22 +503,6 @@ "toad-cache": "^3.7.0" } }, - "node_modules/@fastify/deepmerge": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", - "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -600,29 +573,6 @@ "dequal": "^2.0.3" } }, - "node_modules/@fastify/multipart": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", - "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^3.0.0", - "@fastify/deepmerge": "^3.0.0", - "@fastify/error": "^4.0.0", - "fastify-plugin": "^5.0.0", - "secure-json-parse": "^4.0.0" - } - }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", @@ -1033,26 +983,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/yauzl": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", - "integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yazl": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz", - "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1244,15 +1174,6 @@ "fastq": "^1.17.1" } }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1697,12 +1618,6 @@ "node": ">= 14.16" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -2858,27 +2773,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yauzl": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", - "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yazl": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", - "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "^1.0.0" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/backend/package.json b/backend/package.json index edfd9c81..476bf99f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,19 +12,14 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", - "@fastify/multipart": "^9.4.0", "@types/pg": "^8.20.3", "fastify": "^5.0.0", "pg": "^8.22.0", "yaml": "^2.9.0", - "yauzl": "^3.4.0", - "yazl": "^3.3.1", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.0.0", - "@types/yauzl": "^3.4.0", - "@types/yazl": "^3.3.1", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/backend/src/config.ts b/backend/src/config.ts index 597d521b..5866d11f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -84,7 +84,7 @@ function safeInstallationId(value: string): string { function positiveImportLimit(value: string | undefined, fallback: number): number { const limit = Number(value ?? fallback); if (!Number.isSafeInteger(limit) || limit <= 0) { - throw new Error("workspace import limit configuration is invalid"); + throw new Error("workspace limit configuration is invalid"); } return limit; } @@ -239,18 +239,8 @@ export function loadConfig(env: Record): AppConfig { root: registryRoot, remoteUrl, branch: registryBranch, - gitAuthorName: requiredRegistryValue( - env.THT_WORKSPACE_GIT_AUTHOR_NAME ?? "Thoth Workspace Registry", - "Git author name", - ), - gitAuthorEmail: requiredRegistryValue( - env.THT_WORKSPACE_GIT_AUTHOR_EMAIL ?? "thoth-workspace-registry@localhost", - "Git author email", - ), installationId, secretRoots, - maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024), - maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32), dataRoot: env.THT_DATA_ROOT, maxEvidenceEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_ENTRIES, 4096), maxEvidenceBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_BYTES, 64 * 1024 * 1024), diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index c3386d56..424922c5 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -1,22 +1,11 @@ -import { createHash } from "node:crypto"; -import { Buffer } from "node:buffer"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import multipart from "@fastify/multipart"; -import yauzl from "yauzl"; -import yazl from "yazl"; +import type { FastifyInstance, FastifyReply } from "fastify"; import { z } from "zod"; import type { WorkspaceRegistryConfig } from "../workspaces/types.js"; import { WorkspaceRegistryError } from "../workspaces/git-repository.js"; -import { - WorkspaceConflictError, - type PublishWorkspaceRequest, - type WorkspaceRegistry, -} from "../workspaces/registry.js"; +import type { WorkspaceRegistry } from "../workspaces/registry.js"; import { resolveRuntimeBindings } from "../workspaces/bindings.js"; -import { buildInstallationContract, renderWorkspaceDocs } from "../workspaces/contracts.js"; +import { buildInstallationContract } from "../workspaces/contracts.js"; import { - parseWorkspaceYaml, - serializeWorkspaceYaml, validateOperationalWorkspace, validateWorkspaceDescriptor, type CanonicalWorkspace, @@ -38,248 +27,36 @@ interface WorkspaceRoutesDeps { } const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/); -const commit = z.string().regex(/^[0-9a-f]{40}$/); const workspacePayload = z.object({ workspace: z.unknown() }).strict(); -const publishPayload = z.discriminatedUnion("action", [ - z.object({ action: z.literal("create"), workspace: z.unknown(), baseCommit: commit }).strict(), - z.object({ action: z.literal("update"), workspace: z.unknown(), baseCommit: commit, baseBlob: commit }).strict(), - z.object({ action: z.literal("delete"), id: workspaceId, baseCommit: commit, baseBlob: commit }).strict(), -]); -const bundleManifest = z.object({ - schema_version: z.literal(1), - workspace_id: workspaceId, - files: z.object({ - "workspace.yaml": z.string().regex(/^[0-9a-f]{64}$/), - "contract.env.example": z.string().regex(/^[0-9a-f]{64}$/), - "README.md": z.string().regex(/^[0-9a-f]{64}$/), - }).strict(), -}).strict(); - -// Public P1 bundles contain only the descriptor and derived docs. Evidence file bytes remain -// revision-owned Git content for the later P6 materialization boundary. -const BUNDLE_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"] as const; -type BundleFile = (typeof BUNDLE_FILES)[number]; const SAFE_MESSAGES = { - workspace_invalid: "Workspace request or bundle is invalid.", + workspace_invalid: "Workspace request is invalid.", binding_missing: "Installation binding is missing or invalid.", workspace_not_activatable: "Workspace cannot be activated on this installation.", - workspace_stale: "Workspace revision is stale.", - workspace_conflict: "Workspace changed in the registry.", - workspace_curator_owned: "Workspace descriptor is owned by the curator and must be changed through Git.", + workspace_stale: "Workspace repository state is stale.", git_unavailable: "Workspace Git service is unavailable.", git_auth_failed: "Workspace Git authentication failed.", git_non_fast_forward: "Workspace Git branch has changed.", - git_push_rejected: "Workspace Git publication was rejected.", connector_unavailable: "Workspace connector is unavailable.", semantic_index_incompatible: "Semantic index is incompatible with this workspace.", } as const; -function sha256(value: string | Buffer): string { - return createHash("sha256").update(value).digest("hex"); -} - -function invalidBundle(): WorkspaceRegistryError { - return new WorkspaceRegistryError("workspace_invalid", "Workspace bundle is invalid"); -} - -function isBundleFile(value: string): value is BundleFile { - return (BUNDLE_FILES as readonly string[]).includes(value); -} - -function unsafeArchiveEntry(entry: yauzl.Entry): boolean { - const name = entry.fileName; - const unixType = (entry.externalFileAttributes >>> 16) & 0o170000; - return name.length === 0 - || name.startsWith("/") - || name.startsWith("\\") - || name.includes("\\") - || name.split("/").includes("..") - || name.endsWith("/") - || unixType === 0o120000 - || !isBundleFile(name); -} - -async function readZipBundle(source: Buffer, config: WorkspaceRegistryConfig): Promise> { - if (source.length === 0 || source.length > config.maxImportBytes) throw invalidBundle(); - return await new Promise>((resolve, reject) => { - yauzl.fromBuffer(source, { - lazyEntries: true, - strictFileNames: true, - validateEntrySizes: true, - decodeStrings: true, - }, (error, archive) => { - if (error || !archive) return reject(invalidBundle()); - const files = new Map(); - let entries = 0; - let settled = false; - const fail = () => { - if (settled) return; - settled = true; - archive.close(); - reject(invalidBundle()); - }; - archive.on("error", fail); - archive.on("entry", (entry) => { - entries += 1; - if (entries > config.maxImportEntries || unsafeArchiveEntry(entry) || files.has(entry.fileName as BundleFile)) { - fail(); - return; - } - if (entry.uncompressedSize > config.maxImportBytes) { - fail(); - return; - } - archive.openReadStream(entry, (streamError, stream) => { - if (streamError || !stream) return fail(); - const chunks: Buffer[] = []; - let size = 0; - stream.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > config.maxImportBytes) return fail(); - chunks.push(chunk); - }); - stream.on("error", fail); - stream.on("end", () => { - if (settled || size !== entry.uncompressedSize) return fail(); - files.set(entry.fileName as BundleFile, Buffer.concat(chunks)); - archive.readEntry(); - }); - }); - }); - archive.on("end", () => { - if (settled) return; - settled = true; - if (entries !== BUNDLE_FILES.length || BUNDLE_FILES.some((name) => !files.has(name))) return reject(invalidBundle()); - resolve(Object.fromEntries(files) as Record); - }); - archive.readEntry(); - }); - }); -} - -function utf8(buffer: Buffer): string { - const text = buffer.toString("utf8"); - if (!Buffer.from(text, "utf8").equals(buffer) || text.includes("\0")) throw invalidBundle(); - return text; -} - -async function importDraft(source: Buffer, config: WorkspaceRegistryConfig): Promise { - const files = await readZipBundle(source, config); - let manifest: z.infer; - try { - manifest = bundleManifest.parse(JSON.parse(utf8(files["manifest.json"]))); - } catch { - throw invalidBundle(); - } - for (const name of ["workspace.yaml", "contract.env.example", "README.md"] as const) { - if (sha256(files[name]) !== manifest.files[name]) throw invalidBundle(); - } - try { - const descriptor = parseWorkspaceYaml(utf8(files["workspace.yaml"])); - const workspace = validateWorkspaceDescriptor(descriptor); - const docs = renderWorkspaceDocs(workspace); - if ( - workspace.workspace.id !== manifest.workspace_id - || serializeWorkspaceYaml(workspace) !== utf8(files["workspace.yaml"]) - || docs.envExample !== utf8(files["contract.env.example"]) - || docs.markdown !== utf8(files["README.md"]) - ) throw invalidBundle(); - return workspace; - } catch (error) { - if (error instanceof WorkspaceRegistryError) throw error; - throw invalidBundle(); - } -} - -async function exportBundle(workspace: CanonicalWorkspace): Promise { - const yaml = serializeWorkspaceYaml(workspace); - const docs = renderWorkspaceDocs(workspace); - const files: Record = { - "manifest.json": JSON.stringify({ - schema_version: 1, - workspace_id: workspace.workspace.id, - files: { - "workspace.yaml": sha256(yaml), - "contract.env.example": sha256(docs.envExample), - "README.md": sha256(docs.markdown), - }, - }), - "workspace.yaml": yaml, - "contract.env.example": docs.envExample, - "README.md": docs.markdown, - }; - const archive = new yazl.ZipFile(); - const chunks: Buffer[] = []; - archive.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk)); - for (const name of BUNDLE_FILES) archive.addBuffer(Buffer.from(files[name]), name); - archive.end(); - await new Promise((resolve, reject) => { - archive.outputStream.once("end", resolve); - archive.outputStream.once("error", reject); - }); - return Buffer.concat(chunks); -} - function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES { return error instanceof WorkspaceRegistryError ? error.code : "workspace_invalid"; } function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number { - if (code === "workspace_conflict" || code === "workspace_curator_owned" || code === "workspace_stale" || code === "git_non_fast_forward") return 409; - if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503; + if (code === "workspace_stale" || code === "git_non_fast_forward") return 409; + if (code === "git_unavailable" || code === "git_auth_failed") return 503; return 400; } -function validatedWorkspace(value: unknown): WorkspaceDescriptor | undefined { - try { - return validateWorkspaceDescriptor(value); - } catch { - return undefined; - } -} - function errorReply(reply: FastifyReply, error: unknown) { const code = workspaceErrorCode(error); - const body: Record = { code, message: SAFE_MESSAGES[code] }; - if (error instanceof WorkspaceConflictError) { - body.fields = error.fields; - body.expected = error.expected; - body.actual = error.actual; - if (error.base) body.base = error.base; - if (error.local) body.local = error.local; - if (error.remote) body.remote = error.remote; - } else if (code === "workspace_conflict" && error && typeof error === "object") { - const conflict = error as Partial; - if (Array.isArray(conflict.fields) && conflict.fields.every((field) => typeof field === "string")) body.fields = conflict.fields; - for (const key of ["expected", "actual"] as const) { - const revision = conflict[key]; - if ( - revision - && typeof revision.commit === "string" && /^[0-9a-f]{40}$/.test(revision.commit) - && (revision.blob === undefined || (typeof revision.blob === "string" && /^[0-9a-f]{40}$/.test(revision.blob))) - ) body[key] = revision; - } - for (const key of ["base", "local", "remote"] as const) { - const workspace = validatedWorkspace(conflict[key]); - if (workspace) body[key] = workspace; - } - } - return reply.code(workspaceErrorStatus(code)).send(body); -} - -function publishRequest(value: unknown): PublishWorkspaceRequest { - const parsed = publishPayload.parse(value); - if (parsed.action === "delete") return parsed; - return { ...parsed, workspace: validateWorkspaceDescriptor(parsed.workspace) }; + return reply.code(workspaceErrorStatus(code)).send({ code, message: SAFE_MESSAGES[code] }); } export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void { - app.register(multipart, { - limits: { fileSize: deps.config.maxImportBytes, files: 1, fields: 0, parts: 1 }, - throwFileSizeLimit: true, - }); - app.get("/workspace-registry/status", async (_request, reply) => { try { return await deps.registry.bootstrap(); @@ -352,38 +129,4 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) } }); - app.post("/workspaces/publish", async (request, reply) => { - try { - const result = await deps.registry.publish(publishRequest(request.body)); - return result ? { revision: result } : reply.code(204).send(); - } catch (error) { - return errorReply(reply, error); - } - }); - - app.get("/workspaces/:id/export", async (request, reply) => { - try { - const { id } = z.object({ id: workspaceId }).parse(request.params); - const { workspace } = await deps.registry.read(id); - const canonical = validateWorkspaceDescriptor(workspace); - const bundle = await exportBundle(canonical); - return reply - .type("application/zip") - .header("content-disposition", `attachment; filename=\"${id}.zip\"`) - .send(bundle); - } catch (error) { - return errorReply(reply, error); - } - }); - - app.post("/workspaces/import", async (request: FastifyRequest, reply) => { - try { - const file = await request.file(); - if (!file || file.fieldname !== "bundle" || file.mimetype !== "application/zip") throw invalidBundle(); - const draft = await importDraft(await file.toBuffer(), deps.config); - return { draft: { workspace: draft, contract: buildInstallationContract(draft) } }; - } catch (error) { - return errorReply(reply, error); - } - }); } diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index c273902a..b23a3ad1 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -67,9 +67,6 @@ function gitErrorCode(error: unknown): WorkspaceErrorCode { if (/non-fast-forward|not possible to fast-forward|fast-forward/.test(detail)) { return "git_non_fast_forward"; } - if (/remote rejected|pre-receive hook declined|push.*rejected/.test(detail)) { - return "git_push_rejected"; - } return "git_unavailable"; } diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index 0e82d83b..dd40c1fd 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -2,12 +2,8 @@ export interface WorkspaceRegistryConfig { root: string; remoteUrl?: string; branch: string; - gitAuthorName: string; - gitAuthorEmail: string; installationId: string; secretRoots: readonly string[]; - maxImportBytes: number; - maxImportEntries: number; /** Absolute runtime data root; when set, activation also syncs curated annotations per revision. */ dataRoot?: string; /** P6 Evidence materialization bounds; defaults are applied by the materializer. */ @@ -20,8 +16,7 @@ export interface WorkspaceRegistryConfig { export type WorkspaceErrorCode = | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" - | "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable" - | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" + | "workspace_stale" | "git_unavailable" | "git_auth_failed" | "git_non_fast_forward" | "connector_unavailable" | "semantic_index_incompatible"; export type { WorkspaceV3 } from "./schema.js"; diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 5bd4ab59..048890ef 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -1,23 +1,14 @@ import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { once } from "node:events"; -import { Buffer } from "node:buffer"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, expect, test, vi } from "vitest"; -import yauzl from "yauzl"; -import yazl from "yazl"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js"; -import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js"; import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; -import { - parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateWorkspaceDescriptor, - type CanonicalWorkspace, -} from "../src/workspaces/schema.js"; +import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; const workspace: CanonicalWorkspace = { workspace: { @@ -49,66 +40,6 @@ const workspace: CanonicalWorkspace = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; -const workspaceV2 = { - workspace: { - schema_version: 2, - id: "psd-clinical", - name: "Policlinico San Donato", - description: "Clinical analytics workspace", - language: "it", - }, - dwh: { - engine: "postgres", - database: "warehouse", - schema: "datawarehouse", - supported_transports: ["rest_api"], - }, - semantic_index: { - vector_store: { - engine: "pgvector", - database: "warehouse", - schema: "vectors", - collection: "clinical_documents", - dimensions: 768, - distance: "cosine", - supported_transports: ["rest_api"], - }, - embedding: { - provider: "ollama_compatible", - model: "nomic-embed-text-v2-moe", - dimensions: 768, - }, - }, - diagnostics: { - dwh_rest: { - method: "GET", - path: "/health", - auth: "none", - response: { database: "database", schema: "schema" }, - }, - vector_rest: { - metadata: { - method: "GET", - path: "/metadata", - auth: "none", - response: { collection: "collection", dimensions: "dimensions", distance: "distance" }, - }, - }, - embedding: { - method: "GET", - path: "/models", - auth: "none", - response: { model: "model", dimensions: "dimensions" }, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, -}; - -const workspaceV1 = { - ...workspaceV2, - workspace: { ...workspaceV2.workspace, schema_version: 1 as const }, -}; - const revision: WorkspaceRevision = { id: workspace.workspace.id, commit: "a".repeat(40), @@ -116,7 +47,7 @@ const revision: WorkspaceRevision = { snapshotPath: "/registry/snapshots/psd-clinical.yaml", }; -type RegistryFake = Pick; +type RegistryFake = Pick; function registryFake(overrides: Partial = {}): RegistryFake { return { @@ -128,15 +59,20 @@ function registryFake(overrides: Partial = {}): RegistryFake { })), list: vi.fn(async () => [revision]), listCatalog: vi.fn(async () => [{ - id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision, + id: "psd-clinical", + name: "Policlinico San Donato", + configurationState: "ready" as const, + revision, }]), read: vi.fn(async () => ({ workspace, revision })), - publish: vi.fn(async () => revision), ...overrides, }; } -function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] }))) { +function appFor( + registry: RegistryFake, + diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })), +) { return buildApp(loadConfig({ THT_HARNESS_DIR: "/missing-harness", THT_WORKSPACE_REGISTRY_ROOT: "/tmp/thoth-route-test-registry", @@ -147,71 +83,15 @@ function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activata } as any); } -function sha256(value: string | Buffer): string { - return createHash("sha256").update(value).digest("hex"); -} - -async function zip(files: Record): Promise { - const archive = new yazl.ZipFile(); - const chunks: Buffer[] = []; - archive.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk)); - for (const [name, contents] of Object.entries(files)) archive.addBuffer(Buffer.from(contents), name); - archive.end(); - await once(archive.outputStream, "end"); - return Buffer.concat(chunks); -} - -async function validBundle(): Promise { - const workspaceYaml = serializeWorkspaceYaml(workspace); - const docs = renderWorkspaceDocs(workspace); - const contractEnv = docs.envExample; - const readme = docs.markdown; - return await zip({ - "manifest.json": JSON.stringify({ - schema_version: 1, - workspace_id: workspace.workspace.id, - files: { - "workspace.yaml": sha256(workspaceYaml), - "contract.env.example": sha256(contractEnv), - "README.md": sha256(readme), - }, - }), - "workspace.yaml": workspaceYaml, - "contract.env.example": contractEnv, - "README.md": readme, - }); -} - -function zipWithZipSlipEntry(): Promise { - return zip({ "aa/escape.yaml": "bad" }).then((archive) => { - const safeName = Buffer.from("aa/escape.yaml"); - const unsafeName = Buffer.from("../escape.yaml"); - for (let offset = archive.indexOf(safeName); offset !== -1; offset = archive.indexOf(safeName, offset + safeName.length)) { - unsafeName.copy(archive, offset); - } - return archive; - }); -} - -async function importBundle(app: ReturnType, archive: Buffer) { - const boundary = "----thoth-workspace-test-boundary"; - const payload = Buffer.concat([ - Buffer.from(`--${boundary}\r\ncontent-disposition: form-data; name="bundle"; filename="workspace.zip"\r\ncontent-type: application/zip\r\n\r\n`), - archive, - Buffer.from(`\r\n--${boundary}--\r\n`), - ]); - return await app.inject({ - method: "POST", - url: "/workspaces/import", - headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, - payload, - }); -} - test("returns a redacted registry status and pulls without Git credential details", async () => { const registry = registryFake({ bootstrap: vi.fn(async () => ({ - branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: true, lastError: "git_auth_failed" as const, + branch: "main", + head: revision.commit, + ahead: 0, + behind: 0, + degraded: true, + lastError: "git_auth_failed" as const, })), }); const app = appFor(registry); @@ -220,83 +100,81 @@ test("returns a redacted registry status and pulls without Git credential detail const pull = await app.inject({ method: "POST", url: "/workspace-registry/pull" }); expect(status.statusCode).toBe(200); - expect(status.json()).toEqual({ - branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: true, lastError: "git_auth_failed", - }); + expect(status.json()).toMatchObject({ branch: "main", degraded: true, lastError: "git_auth_failed" }); + expect(status.body).not.toMatch(/token|credential|private.?key/i); expect(pull.statusCode).toBe(200); - expect(JSON.stringify([status.json(), pull.json()])).not.toMatch(/token|password|ssh:\/\//i); -}); - -test("lists compatible workspace summaries and reads a validated workspace", async () => { - const app = appFor(registryFake()); - - const list = await app.inject({ method: "GET", url: "/workspaces" }); - const detail = await app.inject({ method: "GET", url: "/workspaces/psd-clinical" }); - - expect(list.statusCode).toBe(200); - expect(list.json()).toEqual([expect.objectContaining({ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical/workspace.yaml", displayName: "Policlinico San Donato", configurationState: "ready", - })]); - expect(detail.statusCode).toBe(200); - expect(detail.json()).toMatchObject({ workspace, revision }); - expect(list.json()[0].revision).not.toHaveProperty("state"); - expect(detail.json().revision).not.toHaveProperty("state"); -}); - -test("validates a canonical workspace and runs the injected installation diagnostic", async () => { - const diagnose = vi.fn(async () => ({ - activatable: false, - diagnostics: [{ level: "error" as const, code: "binding_missing" as const, field: "THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE", message: "Installation binding is missing or invalid." }], - })); - const app = appFor(registryFake(), diagnose); - - const validate = await app.inject({ method: "POST", url: "/workspaces/validate", payload: { workspace } }); - - expect(validate.statusCode).toBe(200); - expect(validate.json()).toMatchObject({ workspace }); - expect(diagnose).not.toHaveBeenCalled(); + expect(registry.pull).toHaveBeenCalledTimes(1); }); test.each([ - ["v1", workspaceV1], - ["v2", workspaceV2], -])("rejects schema %s at validate and publish boundaries with a sanitized error", async (_version, legacy) => { - const registry = registryFake(); - const app = appFor(registry); + ["POST", "/workspaces/publish"], + ["GET", "/workspaces/psd-clinical/export"], + ["POST", "/workspaces/import"], +] as const)("does not register the removed %s %s mutation or bundle route", async (method, url) => { + const response = await appFor(registryFake()).inject({ method, url }); - for (const request of [ - { url: "/workspaces/validate", payload: { workspace: legacy } }, - { - url: "/workspaces/publish", - payload: { action: "create", workspace: legacy, baseCommit: revision.commit }, - }, - ]) { - const response = await app.inject({ method: "POST", ...request }); - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ - code: "workspace_invalid", - message: "Workspace request or bundle is invalid.", - }); - expect(response.body).not.toMatch(/migration_required|schema version/i); - } - expect(registry.publish).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(404); }); -test("runs diagnostics for a schema v3 workspace without external semantic bindings", async () => { +test("lists workspace summaries and reads a validated immutable workspace", async () => { + const app = appFor(registryFake()); + + const list = await app.inject({ method: "GET", url: "/workspaces" }); + const read = await app.inject({ method: "GET", url: "/workspaces/psd-clinical" }); + + expect(list.statusCode).toBe(200); + expect(list.json()).toEqual([expect.objectContaining({ + id: "psd-clinical", + displayName: "Policlinico San Donato", + configurationState: "ready", + revision, + })]); + expect(read.statusCode).toBe(200); + expect(read.json()).toEqual({ workspace, revision }); +}); + +test("validates a schema v3 workspace without mutating the repository", async () => { + const app = appFor(registryFake()); + + const response = await app.inject({ + method: "POST", url: "/workspaces/validate", payload: { workspace }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ workspace }); +}); + +test.each([1, 2])("rejects schema v%s at the validation boundary with a sanitized error", async (version) => { + const legacy = { + ...workspace, + workspace: { ...workspace.workspace, schema_version: version }, + }; + const response = await appFor(registryFake()).inject({ + method: "POST", url: "/workspaces/validate", payload: { workspace: legacy }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request is invalid." }); + expect(response.body).not.toMatch(/migration_required|schema version/i); +}); + +test("runs diagnostics for a schema v3 workspace", async () => { const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })); const app = appFor(registryFake(), diagnose); - const testResult = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} }); + const response = await app.inject({ + method: "POST", url: "/workspaces/psd-clinical/test", payload: {}, + }); - expect(testResult.statusCode).toBe(200); - expect(testResult.json()).toMatchObject({ activatable: true, diagnostics: [] }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ activatable: true, diagnostics: [] }); expect(diagnose).toHaveBeenCalledWith(workspace, { dwh: expect.objectContaining({ transport: "postgres_direct" }), evidence: { missing: [], values: {} }, }, { writeProbe: false }); }); -test("reports missing Evidence binding through the real test route without changing registry revision", async () => { +test("reports a missing Evidence credential without changing the registry revision", async () => { const evidenceWorkspace: CanonicalWorkspace = { ...workspace, evidence: { @@ -315,203 +193,83 @@ test("reports missing Evidence binding through the real test route without chang }, }; const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision })); - const registry = registryFake({ read }); - const app = appFor(registry, createProductionWorkspaceDiagnoser(100)); + const app = appFor(registryFake({ read }), createProductionWorkspaceDiagnoser(100)); const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"; const previous = process.env[variable]; delete process.env[variable]; try { - const res = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} }); + const response = await app.inject({ + method: "POST", url: "/workspaces/psd-clinical/test", payload: {}, + }); - expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body.activatable).toBe(false); - expect(body.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ - code: "binding_missing", - field: "evidence.source.authentication", - variable, - })])); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + activatable: false, + diagnostics: expect.arrayContaining([expect.objectContaining({ + code: "binding_missing", + field: "evidence.source.authentication", + variable, + })]), + }); expect(read).toHaveBeenCalledTimes(1); - expect(registry.publish).not.toHaveBeenCalled(); - expect(revision).toMatchObject({ commit: "a".repeat(40), blob: "b".repeat(40) }); } finally { if (previous === undefined) delete process.env[variable]; else process.env[variable] = previous; } }); -test("returns a 409 field conflict instead of overwriting a changed workspace", async () => { - const conflict = Object.assign( - new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"), - { - fields: ["workspace.description"], - expected: { commit: "c".repeat(40), blob: "d".repeat(40) }, - actual: { commit: revision.commit, blob: revision.blob }, - base: workspace, - local: { ...workspace, workspace: { ...workspace.workspace, description: "Local description" } }, - remote: { ...workspace, workspace: { ...workspace.workspace, description: "Remote description" } }, - }, - ); - const registry = registryFake({ publish: vi.fn(async () => { throw conflict; }) }); - const app = appFor(registry); - const staleUpdate = { - action: "update", - workspace, - baseCommit: "c".repeat(40), - baseBlob: "d".repeat(40), - }; - - const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: staleUpdate }); - - expect(res.statusCode).toBe(409); - expect(res.json()).toMatchObject({ - code: "workspace_conflict", - fields: ["workspace.description"], - expected: { commit: "c".repeat(40), blob: "d".repeat(40) }, - actual: { commit: revision.commit, blob: revision.blob }, - base: workspace, - remote: expect.objectContaining({ - workspace: expect.objectContaining({ description: "Remote description" }), - }), - }); -}); - -test("maps a stale registry commit to HTTP 409 without conflict payloads", async () => { - const registry = registryFake({ - publish: vi.fn(async () => { - throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale"); - }), - }); - const app = appFor(registry); - - const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { - action: "update", - workspace, - baseCommit: "c".repeat(40), - baseBlob: "d".repeat(40), - } }); - - expect(res.statusCode).toBe(409); - expect(res.json()).toEqual({ code: "workspace_stale", message: "Workspace revision is stale." }); -}); - -test("exports generated public artifacts without secret values", async () => { - const app = appFor(registryFake()); - - const res = await app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" }); - - expect(res.statusCode).toBe(200); - expect(res.headers["content-disposition"]).toMatch(/attachment; filename="psd-clinical\.zip"/); - expect(res.headers["content-type"]).toMatch(/application\/zip/); - expect(res.rawPayload.toString("utf8")).toContain("contract.env.example"); - expect(res.rawPayload.toString("utf8")).not.toContain("secret-value"); -}); - -test("rejects a zip-slip import without publishing or writing a checkout file", async () => { - const registry = registryFake(); - const app = appFor(registry); - - const res = await importBundle(app, await zipWithZipSlipEntry()); - - expect(res.statusCode).toBe(400); - expect(res.json()).toMatchObject({ code: "workspace_invalid" }); - expect(registry.publish).not.toHaveBeenCalled(); -}); - -test("imports an exact generated bundle only as a browser draft", async () => { - const registry = registryFake(); - const app = appFor(registry); - - const res = await importBundle(app, await validBundle()); - - expect(res.statusCode).toBe(200); - expect(res.json()).toMatchObject({ draft: { workspace } }); - expect(registry.publish).not.toHaveBeenCalled(); -}); - - const runFile = promisify(execFile); const realRouteRoots: string[] = []; -interface RealRouteFixture { - root: string; - remote: string; - author: string; - registryRoot: string; - initialCommit: string; - app: ReturnType; - registry: WorkspaceRegistry; +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await runFile("git", args, { cwd }); + return stdout.trim(); } -const EVIDENCE_FILE_BYTES = "PUBLIC-EVIDENCE-FILE-BYTES-NOT-FOR-ZIP\n"; -const SECRET_CANARY = "CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"; - -function withEvidence( - source: Partial & { type: "filesystem" | "http" | "s3" }, - changes: Partial = {}, -): CanonicalWorkspace { - return validateWorkspaceDescriptor({ - ...workspace, - evidence: { source, policy: changes }, - }); -} - -const filesystemEvidenceWorkspace = withEvidence({ - type: "filesystem", uri: "psd-clinical/evidence", -}); -const httpEvidenceWorkspace = withEvidence({ - type: "http", - uris: ["https://evidence.example.test/guide.md"], - authentication: "signed_urls_file", -}); - -async function realGit(cwd: string, args: string[]): Promise { - return (await runFile("git", args, { cwd })).stdout.trim(); -} - -async function createRealRouteFixture( - initialWorkspace: CanonicalWorkspace = filesystemEvidenceWorkspace, -): Promise { - const root = mkdtempSync(join(tmpdir(), "thoth-real-workspace-route-")); +async function createRealRouteFixture() { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-route-")); realRouteRoots.push(root); const remote = join(root, "remote.git"); const author = join(root, "author"); const registryRoot = join(root, "registry"); - await realGit(root, ["init", "--bare", "--initial-branch=main", remote]); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); mkdirSync(author); - await realGit(author, ["init", "--initial-branch=main"]); - await realGit(author, ["config", "user.name", "Workspace Route Test"]); - await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]); - const catalogName = initialWorkspace.workspace.name; - const catalogDescription = initialWorkspace.workspace.description; + await git(author, ["init", "--initial-branch=main"]); + await git(author, ["config", "user.name", "Workspace Route Test"]); + await git(author, ["config", "user.email", "workspace-route@example.invalid"]); + const descriptor: CanonicalWorkspace = { + ...workspace, + evidence: { + source: { + type: "filesystem", + uri: "psd-clinical/evidence", + patterns: ["**/*.md"], + max_bytes: 1024 * 1024, + }, + policy: { max_chunk_chars: 4_000, retain_published_generations: 3 }, + }, + }; writeFileSync(join(author, "thoth-workspaces.yaml"), [ "schema_version: 1", "workspaces:", - ` - id: psd-clinical\n name: ${catalogName}${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, - ` - id: research-clinical\n name: Research Clinical${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, - ` - id: missing-evidence\n name: Missing Evidence${catalogDescription ? `\n description: ${catalogDescription}` : ""}`, - ].join("\n") + "\n"); - mkdirSync(join(author, "psd-clinical"), { recursive: true }); - writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(initialWorkspace)); - if (initialWorkspace.evidence?.source.type === "filesystem") { - mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true }); - writeFileSync( - join(author, "psd-clinical", "evidence", "guide.md"), - EVIDENCE_FILE_BYTES, - ); - } - await realGit(author, ["add", "."]); - await realGit(author, ["commit", "-m", "Initial Evidence workspace"]); - await realGit(author, ["remote", "add", "origin", remote]); - await realGit(author, ["push", "origin", "main"]); - const initialCommit = await realGit(author, ["rev-parse", "HEAD"]); + " - id: psd-clinical", + " name: Policlinico San Donato", + " description: Clinical analytics workspace", + "", + ].join("\n")); + mkdirSync(join(author, "psd-clinical", "evidence"), { recursive: true }); + writeFileSync(join(author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(descriptor)); + writeFileSync(join(author, "psd-clinical", "evidence", "guide.md"), "Evidence bytes\n"); + await git(author, ["add", "."]); + await git(author, ["commit", "-m", "Initial workspace"]); + await git(author, ["remote", "add", "origin", remote]); + await git(author, ["push", "origin", "main"]); + const initialCommit = await git(author, ["rev-parse", "HEAD"]); const config = loadConfig({ THT_HARNESS_DIR: "/missing-harness", THT_WORKSPACE_REGISTRY_ROOT: registryRoot, THT_WORKSPACE_GIT_REMOTE: remote, - THT_WORKSPACE_GIT_AUTHOR_NAME: "Workspace Route Publisher", - THT_WORKSPACE_GIT_AUTHOR_EMAIL: "workspace-route-publisher@example.invalid", }); const registry = new WorkspaceRegistry(config.workspaceRegistry); const app = buildApp(config, { @@ -519,306 +277,26 @@ async function createRealRouteFixture( workspaceRegistry: registry, workspaceDiagnoser: vi.fn(async () => ({ activatable: true, diagnostics: [] })), }); - return { root, remote, author, registryRoot, initialCommit, app, registry }; -} - -async function extractZip(source: Buffer): Promise> { - return await new Promise((resolve, reject) => { - yauzl.fromBuffer(source, { lazyEntries: true, strictFileNames: true }, (error, archive) => { - if (error || !archive) return reject(error ?? new Error("archive unavailable")); - const files: Record = {}; - archive.on("error", reject); - archive.on("entry", (entry) => { - if (entry.fileName.startsWith("/") || entry.fileName.includes("..") || entry.fileName.includes("\\")) { - archive.close(); - reject(new Error("unsafe exported path")); - return; - } - archive.openReadStream(entry, (streamError, stream) => { - if (streamError || !stream) return reject(streamError ?? new Error("entry unavailable")); - const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => chunks.push(chunk)); - stream.on("error", reject); - stream.on("end", () => { - files[entry.fileName] = Buffer.concat(chunks); - archive.readEntry(); - }); - }); - }); - archive.on("end", () => resolve(files)); - archive.readEntry(); - }); - }); + return { author, initialCommit, app, registry }; } afterEach(() => { realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); }); -test.each([ - { - source: { type: "filesystem", uri: "psd-clinical/evidence" }, - expectedVariables: [], - }, - { - source: { - type: "http", uris: ["https://evidence.example.test/guide.md"], - authentication: "signed_urls_file", - }, - expectedVariables: ["THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"], - }, - { - source: { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files" }, - expectedVariables: [ - "THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE", - "THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE", - "THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE", - ], - }, -])("real validate route canonicalizes $source.type Evidence and returns only its file contract", async ({ - source, expectedVariables, -}) => { - const fixture = await createRealRouteFixture(); - const response = await fixture.app.inject({ - method: "POST", url: "/workspaces/validate", - payload: { workspace: { ...workspace, evidence: { source } } }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.workspace.evidence.policy).toEqual({ - max_chunk_chars: 4_000, retain_published_generations: 3, - }); - expect(body.workspace.evidence.source.max_bytes).toBe(10 * 1024 * 1024); - expect(body.contract.variables.filter(({ role }: { role: string }) => role === "EVIDENCE") - .map(({ name }: { name: string }) => name)).toEqual(expectedVariables); -}); - -test("real bootstrap create, curator push/pull, list, and read preserve a complete Evidence descriptor", async () => { - const fixture = await createRealRouteFixture(httpEvidenceWorkspace); - const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" }); - const created = validateWorkspaceDescriptor({ - ...httpEvidenceWorkspace, - workspace: { ...httpEvidenceWorkspace.workspace, id: "research-clinical", name: "Research Clinical" }, - semantic_index: { - ...httpEvidenceWorkspace.semantic_index, - vector_store: { ...httpEvidenceWorkspace.semantic_index.vector_store, collection: "research-clinical" }, - }, - }); - const create = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { action: "create", workspace: created, baseCommit: status.json().head }, - }); - expect(create.statusCode).toBe(200); - const createdRevision = create.json().revision as WorkspaceRevision; - - await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]); - const remotelyEdited = validateWorkspaceDescriptor({ - ...created, - evidence: { - ...created.evidence, - policy: { max_chunk_chars: 9_001, retain_published_generations: 9 }, - }, - }); - writeFileSync( - join(fixture.author, "research-clinical", "workspace.yaml"), - serializeWorkspaceYaml(remotelyEdited), - ); - await realGit(fixture.author, ["add", "research-clinical/workspace.yaml"]); - await realGit(fixture.author, ["commit", "-m", "Remote Evidence-only descriptor edit"]); - await realGit(fixture.author, ["push", "origin", "main"]); - const remoteCommit = await realGit(fixture.author, ["rev-parse", "HEAD"]); - - const update = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { - action: "update", workspace: remotelyEdited, - baseCommit: createdRevision.commit, baseBlob: createdRevision.blob, - }, - }); - expect(update.statusCode).toBe(409); - expect(update.json()).toMatchObject({ code: "workspace_curator_owned" }); - - const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); - const list = await fixture.app.inject({ method: "GET", url: "/workspaces" }); - const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" }); - - expect(status.statusCode).toBe(200); - expect(create.statusCode).toBe(200); - expect(pull.statusCode).toBe(200); - // Explicit pull may produce a deterministic docs-only follow-up commit on top of the curator - // commit; the active descriptor must always be the curator's bytes. - const pulledHead = pull.json().head; - const pulledDiff = pulledHead === remoteCommit ? [] : (await realGit(join(fixture.registryRoot, "repo"), ["diff", "--name-only", `${remoteCommit}..${pulledHead}`])).split(/\s+/).filter(Boolean); - expect(pulledHead).toMatch(/^[0-9a-f]{40}$/); - expect(pulledDiff.every((path) => path.startsWith("workspace-docs/"))).toBe(true); - expect(list.statusCode).toBe(200); - const summary = list.json().find(({ id }: { id: string }) => id === "research-clinical"); - expect(summary.configurationState).toBe("ready"); - expect(summary.revision.commit).toBe(pulledHead); - expect(read.statusCode).toBe(200); - expect(read.json().workspace).toEqual(remotelyEdited); -}); -test("real route refuses curator-owned updates with a safe 409 after an Evidence-only concurrent edit", async () => { - const fixture = await createRealRouteFixture(httpEvidenceWorkspace); - await fixture.registry.bootstrap(); - const base = await fixture.registry.read("psd-clinical"); - const remote = withEvidence( - { ...httpEvidenceWorkspace.evidence!.source }, - { max_chunk_chars: 9_000, retain_published_generations: 3 }, - ); - writeFileSync(join(fixture.author, "psd-clinical", "workspace.yaml"), serializeWorkspaceYaml(remote)); - await realGit(fixture.author, ["add", "psd-clinical/workspace.yaml"]); - await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]); - await realGit(fixture.author, ["push", "origin", "main"]); - const local = withEvidence( - { ...httpEvidenceWorkspace.evidence!.source }, - { max_chunk_chars: 4_000, retain_published_generations: 8 }, - ); - - const response = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { - action: "update", workspace: local, - baseCommit: base.revision.commit, baseBlob: base.revision.blob, - }, - }); - - expect(response.statusCode).toBe(409); - expect(response.json()).toMatchObject({ code: "workspace_curator_owned" }); - expect(response.body).not.toContain(SECRET_CANARY); -}); -test.each([ - ["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], - ["traversal", "psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], - ["cross-workspace", "research/evidence"], -])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => { +test("a failed candidate pull keeps the last valid active workspace", async () => { const fixture = await createRealRouteFixture(); await fixture.registry.bootstrap(); - const base = await fixture.registry.read("psd-clinical"); - const invalid = structuredClone(filesystemEvidenceWorkspace) as any; - invalid.evidence.source.uri = uri; - - const response = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { action: "update", workspace: invalid, baseCommit: base.revision.commit, baseBlob: base.revision.blob }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); - expect(response.body).not.toContain(SECRET_CANARY); - expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) - .toBe(fixture.initialCommit); -}); - -test.each([ - { - label: "credential-bearing HTTP URI", - source: { type: "http", uris: [`https://user:${SECRET_CANARY}@evidence.example.test/guide.md`] }, - }, - { - label: "unsupported HTTP protocol", - source: { type: "http", uris: [`ftp://evidence.example.test/${SECRET_CANARY}`] }, - }, - { - label: "inline S3 credential field", - source: { type: "s3", uri: "s3://clinical-evidence/published/", access_key: SECRET_CANARY }, - }, -])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => { - const fixture = await createRealRouteFixture(); - await fixture.registry.bootstrap(); - const base = await fixture.registry.read("psd-clinical"); - const invalid = structuredClone(base.workspace) as any; - invalid.evidence = { source }; - const response = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { - action: "update", workspace: invalid, - baseCommit: base.revision.commit, baseBlob: base.revision.blob, - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); - expect(response.body).not.toContain(SECRET_CANARY); - expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) - .toBe(fixture.initialCommit); -}); - -test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => { - const fixture = await createRealRouteFixture(); - await fixture.registry.bootstrap(); - const current = await fixture.registry.read("psd-clinical"); - const missing = validateWorkspaceDescriptor({ - ...workspace, - workspace: { ...workspace.workspace, id: "missing-evidence", name: "Missing Evidence" }, - semantic_index: { - ...workspace.semantic_index, - vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" }, - }, - evidence: { source: { type: "filesystem", uri: "missing-evidence/evidence" } }, - }); - const publish = await fixture.app.inject({ - method: "POST", url: "/workspaces/publish", - payload: { action: "create", workspace: missing, baseCommit: current.revision.commit }, - }); - expect(publish.statusCode).toBe(400); - expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); - expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); - expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) - .toBe(fixture.initialCommit); - rmSync(join(fixture.author, "psd-clinical", "evidence"), { recursive: true }); - await realGit(fixture.author, ["add", "-A"]); - await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]); - await realGit(fixture.author, ["push", "origin", "main"]); + await git(fixture.author, ["add", "-A"]); + await git(fixture.author, ["commit", "-m", "Remove required Evidence tree"]); + await git(fixture.author, ["push", "origin", "main"]); + const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); + expect(pull.statusCode).toBe(400); - expect(pull.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); - expect(pull.body).not.toContain(SECRET_CANARY); + expect(pull.json()).toEqual({ code: "workspace_invalid", message: "Workspace request is invalid." }); await expect(fixture.registry.read("psd-clinical")).resolves.toMatchObject({ revision: { commit: fixture.initialCommit }, }); }); - -test("real export and import preserve stable public Evidence artifacts without Evidence or secret bytes", async () => { - const fixture = await createRealRouteFixture(); - const secretDirectory = join(fixture.root, "fixture-secrets"); - mkdirSync(secretDirectory); - writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY); - await fixture.registry.bootstrap(); - - const firstResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" }); - const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" }); - expect(firstResponse.statusCode).toBe(200); - expect(secondResponse.statusCode).toBe(200); - const first = await extractZip(firstResponse.rawPayload); - const second = await extractZip(secondResponse.rawPayload); - const names = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"]; - expect(Object.keys(first).sort()).toEqual([...names].sort()); - expect(Object.keys(second).sort()).toEqual([...names].sort()); - for (const name of names) expect(second[name]).toEqual(first[name]); - - const descriptor = parseWorkspaceYaml(first["workspace.yaml"].toString("utf8")); - const docs = renderWorkspaceDocs(descriptor); - const manifest = JSON.parse(first["manifest.json"].toString("utf8")); - expect(descriptor).toEqual(filesystemEvidenceWorkspace); - expect(first["contract.env.example"].toString("utf8")).toBe(docs.envExample); - expect(first["README.md"].toString("utf8")).toBe(docs.markdown); - expect(manifest.files).toEqual({ - "workspace.yaml": sha256(first["workspace.yaml"]), - "contract.env.example": sha256(first["contract.env.example"]), - "README.md": sha256(first["README.md"]), - }); - const publicBytes = Buffer.concat(Object.values(first)).toString("utf8"); - expect(publicBytes).not.toContain(EVIDENCE_FILE_BYTES.trim()); - expect(publicBytes).not.toContain(SECRET_CANARY); - - const imported = await importBundle(fixture.app, firstResponse.rawPayload); - expect(imported.statusCode).toBe(200); - expect(imported.json().draft.workspace).toEqual(filesystemEvidenceWorkspace); - expect(imported.json().draft.contract.variables.some(({ role }: { role: string }) => role === "EVIDENCE")) - .toBe(false); - expect(imported.body).not.toContain(EVIDENCE_FILE_BYTES.trim()); - expect(imported.body).not.toContain(SECRET_CANARY); -}); diff --git a/backend/test/workspaces-config.test.ts b/backend/test/workspaces-config.test.ts index de1e7a0a..65a455e6 100644 --- a/backend/test/workspaces-config.test.ts +++ b/backend/test/workspaces-config.test.ts @@ -20,24 +20,19 @@ test("loads a safe Git workspace registry configuration", () => { }); test("uses safe workspace registry defaults", () => { - expect(loadConfig({}).workspaceRegistry).toMatchObject({ + const registry = loadConfig({}).workspaceRegistry; + expect(registry).toMatchObject({ root: "/data/workspace-registry", branch: "main", - maxImportBytes: 10 * 1024 * 1024, - maxImportEntries: 32, }); + expect(registry).not.toHaveProperty("gitAuthorName"); + expect(registry).not.toHaveProperty("gitAuthorEmail"); + expect(registry).not.toHaveProperty("maxImportBytes"); + expect(registry).not.toHaveProperty("maxImportEntries"); }); -test("rejects a relative registry root and invalid import limits", () => { +test("rejects a relative registry root", () => { expect(() => loadConfig({ THT_WORKSPACE_REGISTRY_ROOT: "registry" })).toThrow(/registry/i); - expect(() => loadConfig({ - THT_WORKSPACE_REGISTRY_ROOT: "/data/registry", - THT_WORKSPACE_MAX_IMPORT_BYTES: "0", - })).toThrow(/import/i); - expect(() => loadConfig({ - THT_WORKSPACE_REGISTRY_ROOT: "/data/registry", - THT_WORKSPACE_MAX_IMPORT_ENTRIES: "1.5", - })).toThrow(/import/i); }); test("rejects unsafe registry branch, installation ID, and secret roots", () => {