354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
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, type WorkspaceV2 } from "../src/workspaces/schema.js";
|
|
|
|
const workspace: CanonicalWorkspace = {
|
|
workspace: {
|
|
schema_version: 3,
|
|
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: "qdrant",
|
|
collection: "psd-clinical",
|
|
dimensions: 1024,
|
|
distance: "cosine",
|
|
},
|
|
embedding: {
|
|
provider: "ollama_internal",
|
|
model: "qwen3-embedding:0.6b",
|
|
dimensions: 1024,
|
|
},
|
|
},
|
|
llm_policy: { allowed: ["zai/glm-5.2"] },
|
|
};
|
|
|
|
const workspaceV2: 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 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 } });
|
|
|
|
expect(validate.statusCode).toBe(200);
|
|
expect(validate.json()).toMatchObject({ workspace });
|
|
expect(diagnose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("runs the injected installation diagnostic for a migration-required v2 workspace when legacy bindings resolve", async () => {
|
|
const diagnose = vi.fn(async () => ({
|
|
activatable: false,
|
|
diagnostics: [{ level: "error" as const, code: "binding_missing" as const, field: "THT_WS_PSD_CLINICAL_VECTOR_BASE_URL", message: "Installation binding is missing or invalid." }],
|
|
}));
|
|
const registry = registryFake({
|
|
read: vi.fn(async () => ({ workspace: workspaceV2, revision: { ...revision, state: "migration_required" as const } })),
|
|
});
|
|
const app = appFor(registry, diagnose);
|
|
|
|
const originalEnv = { ...process.env };
|
|
process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api";
|
|
process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test";
|
|
process.env.THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT = "rest_api";
|
|
process.env.THT_WS_PSD_CLINICAL_VECTOR_BASE_URL = "https://vector.example.test";
|
|
process.env.THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL = "https://embedding.example.test";
|
|
try {
|
|
const testResult = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} });
|
|
|
|
expect(testResult.statusCode).toBe(200);
|
|
expect(testResult.json()).toMatchObject({ activatable: false, diagnostics: [{ code: "binding_missing" }] });
|
|
expect(diagnose).toHaveBeenCalledWith(workspaceV2, expect.objectContaining({
|
|
dwh: expect.objectContaining({ transport: "rest_api", missing: [] }),
|
|
vector: expect.objectContaining({ transport: "rest_api", missing: [] }),
|
|
embedding: expect.objectContaining({ transport: "rest_api", missing: [] }),
|
|
}), { writeProbe: false });
|
|
} finally {
|
|
process.env = originalEnv;
|
|
}
|
|
});
|
|
|
|
test("fails closed for /workspaces/:id/test on a schema v3 workspace before the internal runtime lands", 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: {} });
|
|
|
|
expect(testResult.statusCode).toBe(400);
|
|
expect(testResult.json()).toMatchObject({ code: "workspace_invalid" });
|
|
expect(diagnose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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"],
|
|
expected: { commit: "c".repeat(40), blob: "d".repeat(40) },
|
|
actual: { commit: revision.commit, blob: revision.blob },
|
|
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"],
|
|
expected: { commit: "c".repeat(40), blob: "d".repeat(40) },
|
|
actual: { commit: revision.commit, blob: revision.blob },
|
|
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();
|
|
});
|