feat: expose workspace registry API
This commit is contained in:
@@ -85,40 +85,8 @@ test("POST /sessions/:id/sql/preview returns 500 when thtRunner throws", async (
|
||||
expect(res.json()).toMatchObject({ error: /boom/ });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("GET /workspaces lists yaml files from ../harness/workspaces", async () => {
|
||||
// The real ../harness/workspaces directory contains *.yaml files.
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {} as any,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/workspaces" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { name: string; file: string }[];
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
// ../harness/workspaces has at least one yaml (tht-test.yaml / tht.example.yaml)
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
for (const w of body) {
|
||||
expect(typeof w.name).toBe("string");
|
||||
expect(w.name).not.toContain(".yaml"); // name strips extension
|
||||
expect(w.file).toMatch(/\.ya?ml$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /workspaces returns [] when harnessDir has no workspaces subdir", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/nonexistent-harness-dir" }), {
|
||||
thtRunner: {} as any,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/workspaces" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual([]);
|
||||
});
|
||||
// Workspace registry route coverage lives in routes-workspaces.test.ts. `/workspaces` no longer
|
||||
// reads legacy harness files: the Git registry is the single shared source of truth.
|
||||
|
||||
test("GET /models returns {models:[...]} from injected listModels stub", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import yazl from "yazl";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js";
|
||||
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||
import { renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
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: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector",
|
||||
database: "warehouse",
|
||||
schema: "vectors",
|
||||
collection: "clinical_documents",
|
||||
dimensions: 768,
|
||||
distance: "cosine",
|
||||
supported_transports: ["pgvector_direct"],
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_compatible",
|
||||
model: "nomic-embed-text-v2-moe",
|
||||
dimensions: 768,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const revision: WorkspaceRevision = {
|
||||
id: workspace.workspace.id,
|
||||
commit: "a".repeat(40),
|
||||
blob: "b".repeat(40),
|
||||
snapshotPath: "/registry/snapshots/psd-clinical.yaml",
|
||||
state: "operational",
|
||||
};
|
||||
|
||||
type RegistryFake = Pick<WorkspaceRegistry, "bootstrap" | "pull" | "list" | "read" | "publish">;
|
||||
|
||||
function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
||||
return {
|
||||
bootstrap: vi.fn(async () => ({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
|
||||
})),
|
||||
pull: vi.fn(async () => ({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
|
||||
})),
|
||||
list: vi.fn(async () => [revision]),
|
||||
read: vi.fn(async () => ({ workspace, revision })),
|
||||
publish: vi.fn(async () => revision),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
}), {
|
||||
thtRunner: {} as any,
|
||||
workspaceRegistry: registry as WorkspaceRegistry,
|
||||
workspaceDiagnoser: diagnose,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
async function zip(files: Record<string, string>): Promise<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<typeof appFor>, 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,
|
||||
})),
|
||||
});
|
||||
const app = appFor(registry);
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/workspace-registry/status" });
|
||||
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(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.yaml", displayName: "Policlinico San Donato",
|
||||
})]);
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json()).toMatchObject({ workspace, revision });
|
||||
});
|
||||
|
||||
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 } });
|
||||
const testResult = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} });
|
||||
|
||||
expect(validate.statusCode).toBe(200);
|
||||
expect(validate.json()).toMatchObject({ workspace });
|
||||
expect(testResult.statusCode).toBe(200);
|
||||
expect(testResult.json()).toMatchObject({ activatable: false, diagnostics: [{ code: "binding_missing" }] });
|
||||
expect(diagnose).toHaveBeenCalledWith(workspace, expect.any(Object), { writeProbe: false });
|
||||
});
|
||||
|
||||
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: ["semantic_index.embedding.model"],
|
||||
base: workspace,
|
||||
local: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local/model" } } },
|
||||
remote: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote/model" } } },
|
||||
},
|
||||
);
|
||||
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: ["semantic_index.embedding.model"],
|
||||
base: workspace,
|
||||
remote: expect.objectContaining({
|
||||
semantic_index: expect.objectContaining({
|
||||
embedding: expect.objectContaining({ model: "remote/model" }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user