feat: add read-only workspace and secret management UI

This commit is contained in:
2026-08-14 17:38:24 +02:00
parent e902f758b1
commit 3978008aed
8 changed files with 586 additions and 1181 deletions
+68 -175
View File
@@ -3,205 +3,98 @@ import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import {
asWorkspaceApiError,
forgetWorkspaceSecret,
getWorkspace,
importWorkspace,
getWorkspaceRuntimeConfiguration,
listWorkspaces,
publishWorkspace,
validateWorkspace,
type CanonicalWorkspace,
saveWorkspaceSecrets,
} from "./workspaces";
const workspace = canonicalWorkspaceFixture("psd-clinical");
const revision = workspaceRevisionFixture("psd-clinical");
const readySummary = workspaceSummaryFixture("psd-clinical", {
displayName: "PSD Clinical",
description: "Clinical workspace",
const runtimeConfiguration = {
workspaceId: "psd-clinical",
revision,
});
const evidenceWorkspace = {
...workspace,
evidence: {
source: {
type: "filesystem",
uri: "psd-clinical/evidence",
patterns: ["**/*.md"],
max_bytes: 10 * 1024 * 1024,
},
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
},
} satisfies CanonicalWorkspace;
test("decodes catalog-driven workspace summaries with exact root descriptor paths", async () => {
server.use(http.get("/api/workspaces", () => HttpResponse.json([
readySummary,
workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
configurationState: "configuration_required",
}),
])));
await expect(listWorkspaces()).resolves.toEqual([
readySummary,
workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
configurationState: "configuration_required",
}),
]);
});
test.each([
["a summary with the removed language field", { ...readySummary, language: "en" }],
["a non-canonical descriptor path", { ...readySummary, file: "psd-clinical.yaml" }],
["a ready summary without a revision", { ...readySummary, revision: undefined }],
["a configuration_required summary with a revision", {
...workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
configurationState: "configuration_required",
}),
revision,
configurationState: "configuration_required",
requirements: [{
id: "dwh.password",
connector: "dwh",
label: "Data warehouse password",
description: "Password used by the selected data warehouse connection.",
input: "password",
required: true,
configured: false,
}],
])("rejects %s", async (_case, malformedSummary) => {
server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary])));
} as const;
await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary");
test("decodes read-only workspace summaries with a revision in every readiness state", async () => {
const ready = workspaceSummaryFixture("psd-clinical", {
displayName: "PSD Clinical",
revision,
});
const needsSecrets = {
...ready,
configurationState: "configuration_required" as const,
};
server.use(http.get("/api/workspaces", () => HttpResponse.json([ready, needsSecrets])));
await expect(listWorkspaces()).resolves.toEqual([ready, needsSecrets]);
});
test("uploads a workspace bundle without JSON content type", async () => {
let contentType: string | null = null;
server.use(http.post("/api/workspaces/import", ({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ draft: { workspace } });
}));
await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" }));
expect(contentType ?? "").not.toMatch(/application\/json/i);
});
test("sanitizes imported filesystem Evidence only when it uses the workspace directory root", async () => {
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
draft: { workspace: evidenceWorkspace, contract: { variables: [] } },
})));
const result = await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"));
expect(result.draft.workspace.evidence).toEqual(evidenceWorkspace.evidence);
expect(result.draft.workspace).not.toBe(evidenceWorkspace);
});
test("rejects imported filesystem Evidence that still points at workspace-content", async () => {
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
draft: {
workspace: {
...evidenceWorkspace,
evidence: {
...evidenceWorkspace.evidence,
source: {
...evidenceWorkspace.evidence.source,
uri: "workspace-content/psd-clinical/evidence",
},
},
},
contract: { variables: [] },
},
})));
await expect(importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip")))
.rejects.toThrow("invalid imported workspace draft");
});
test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => {
test("accepts the immutable workspace revision contract", async () => {
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })));
await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision });
});
test("accepts a Qdrant collection using the canonical hyphenated workspace name", async () => {
const hyphenatedCollection = {
...workspace,
semantic_index: {
...workspace.semantic_index,
vector_store: { ...workspace.semantic_index.vector_store, collection: "psd-clinical" },
},
} satisfies CanonicalWorkspace;
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: hyphenatedCollection,
revision,
})));
test("decodes runtime requirements but rejects any secret value returned by the server", async () => {
server.use(http.get(
"/api/workspaces/psd-clinical/runtime-configuration",
() => HttpResponse.json(runtimeConfiguration),
));
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
.resolves.toEqual(runtimeConfiguration);
await expect(getWorkspace("psd-clinical")).resolves.toEqual({
workspace: hyphenatedCollection,
revision,
});
server.use(http.get(
"/api/workspaces/psd-clinical/runtime-configuration",
() => HttpResponse.json({
...runtimeConfiguration,
requirements: [{ ...runtimeConfiguration.requirements[0], value: "leaked-secret" }],
}),
));
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
.rejects.toThrow("invalid runtime configuration");
});
test("sanitizes read and validate responses while preserving directory-based Evidence", async () => {
server.use(
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })),
);
const read = await getWorkspace("psd-clinical");
const validated = await validateWorkspace(evidenceWorkspace);
expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence);
expect(read.workspace).not.toBe(evidenceWorkspace);
expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence);
});
test("publishes only bootstrap create requests", async () => {
let sent: unknown;
server.use(http.post("/api/workspaces/publish", async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ revision });
test("blind secret replacement sends values once and returns status only", async () => {
let requestBody: unknown;
server.use(http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
...runtimeConfiguration,
configurationState: "ready",
requirements: [{ ...runtimeConfiguration.requirements[0], configured: true }],
});
}));
await expect(publishWorkspace({
action: "create",
workspace: evidenceWorkspace,
baseCommit: revision.commit,
})).resolves.toEqual({ revision });
expect(sent).toEqual({
action: "create",
workspace: { ...evidenceWorkspace },
baseCommit: revision.commit,
const response = await saveWorkspaceSecrets("psd-clinical", {
"dwh.password": "one-time-value",
});
expect(sent).not.toHaveProperty("baseBlob");
expect(requestBody).toEqual({ values: { "dwh.password": "one-time-value" } });
expect(response.configurationState).toBe("ready");
expect(JSON.stringify(response)).not.toContain("one-time-value");
});
test("rejects a publish response with a malformed revision", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
revision: { ...revision, commit: "not-a-commit" },
})));
test("forget targets one declared requirement", async () => {
let called = false;
server.use(http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => {
called = true;
return HttpResponse.json(runtimeConfiguration);
}));
await expect(publishWorkspace({
action: "create",
workspace: evidenceWorkspace,
baseCommit: revision.commit,
})).rejects.toThrow("invalid workspace revision");
});
test("decodes workspace_curator_owned safely without conflict fields", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_curator_owned",
message: "Existing descriptors are curator-owned.",
}, { status: 409 })));
const error = await publishWorkspace({
action: "create",
workspace,
baseCommit: revision.commit,
}).catch((cause: unknown) => cause);
expect(asWorkspaceApiError(error)).toEqual({
status: 409,
code: "workspace_curator_owned",
message: "Existing descriptors are curator-owned.",
});
await expect(forgetWorkspaceSecret("psd-clinical", "dwh.password"))
.resolves.toEqual(runtimeConfiguration);
expect(called).toBe(true);
});