499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, 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 { buildApp } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
|
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
|
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
|
import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js";
|
|
|
|
const workspace: CanonicalWorkspace = {
|
|
workspace: {
|
|
schema_version: 4,
|
|
id: "psd-clinical",
|
|
name: "Policlinico San Donato",
|
|
description: "Clinical analytics workspace",
|
|
language: "it",
|
|
},
|
|
dwh: {
|
|
engine: "postgres",
|
|
database: "warehouse",
|
|
schema: "datawarehouse",
|
|
supported_transports: ["postgres_direct"],
|
|
},
|
|
};
|
|
|
|
const revision: WorkspaceRevision = {
|
|
id: workspace.workspace.id,
|
|
commit: "a".repeat(40),
|
|
blob: "b".repeat(40),
|
|
snapshotPath: "/registry/snapshots/psd-clinical.yaml",
|
|
};
|
|
|
|
type RegistryFake = Pick<WorkspaceRegistry, "bootstrap" | "pull" | "list" | "listCatalog" | "read">;
|
|
|
|
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]),
|
|
listCatalog: vi.fn(async () => [{
|
|
id: "psd-clinical",
|
|
name: "Policlinico San Donato",
|
|
configurationState: "ready" as const,
|
|
revision,
|
|
}]),
|
|
read: vi.fn(async () => ({ workspace, revision })),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const readyAuthentication: AuthDiagnostics = {
|
|
ready: true,
|
|
mode: "none",
|
|
checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }],
|
|
};
|
|
|
|
function appFor(
|
|
registry: RegistryFake,
|
|
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
|
secretStore = testSecretStore(),
|
|
env: Record<string, string> = {},
|
|
authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => readyAuthentication) },
|
|
) {
|
|
return buildApp(loadConfig({
|
|
THT_HARNESS_DIR: "/missing-harness",
|
|
THT_WORKSPACE_REGISTRY_ROOT: "/tmp/thoth-route-test-registry",
|
|
...env,
|
|
}), {
|
|
thtRunner: {} as any,
|
|
workspaceRegistry: registry as WorkspaceRegistry,
|
|
workspaceDiagnoser: diagnose,
|
|
workspaceSecretStore: secretStore,
|
|
authDiagnoser,
|
|
} as any);
|
|
}
|
|
|
|
const userHeaders = {
|
|
"x-thoth-principal-issuer": "portal",
|
|
"x-thoth-principal-subject": "alice",
|
|
"x-thoth-is-admin": "0",
|
|
};
|
|
const adminHeaders = { ...userHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
|
|
|
const secretStoreRoots: string[] = [];
|
|
|
|
function testSecretStore(): WorkspaceSecretStore {
|
|
const root = mkdtempSync(join(tmpdir(), "thoth-route-secret-store-"));
|
|
const runtimeRoot = mkdtempSync(join(tmpdir(), "thoth-route-secret-runtime-"));
|
|
secretStoreRoots.push(root, runtimeRoot);
|
|
return new WorkspaceSecretStore({ root, runtimeRoot, installationId: "route-test" });
|
|
}
|
|
|
|
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()).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(registry.pull).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("workspace mutations and secret writes require their catalog permissions", async () => {
|
|
const registry = registryFake();
|
|
const app = appFor(registry, undefined, testSecretStore(), { AUTH_MODE: "upstream" });
|
|
try {
|
|
const deniedPull = await app.inject({ method: "POST", url: "/workspace-registry/pull", headers: userHeaders });
|
|
const allowedPull = await app.inject({ method: "POST", url: "/workspace-registry/pull", headers: adminHeaders });
|
|
const deniedBootstrap = await app.inject({ method: "GET", url: "/workspace-registry/status", headers: userHeaders });
|
|
const allowedBootstrap = await app.inject({ method: "GET", url: "/workspace-registry/status", headers: adminHeaders });
|
|
const deniedSecret = await app.inject({
|
|
method: "PUT", url: "/workspaces/psd-clinical/secrets", headers: userHeaders,
|
|
payload: { values: { "dwh.password": "secret" } },
|
|
});
|
|
const allowedSecret = await app.inject({
|
|
method: "PUT", url: "/workspaces/psd-clinical/secrets", headers: adminHeaders,
|
|
payload: { values: { "dwh.password": "secret" } },
|
|
});
|
|
|
|
expect(deniedPull.statusCode).toBe(403);
|
|
expect(deniedPull.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
|
expect(allowedPull.statusCode).toBe(200);
|
|
expect(deniedBootstrap.statusCode).toBe(403);
|
|
expect(deniedBootstrap.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
|
expect(allowedBootstrap.statusCode).toBe(200);
|
|
expect(deniedSecret.statusCode).toBe(403);
|
|
expect(deniedSecret.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
|
expect(allowedSecret.statusCode).toBe(200);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
["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 });
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
});
|
|
|
|
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: "configuration_required",
|
|
revision,
|
|
})]);
|
|
expect(read.statusCode).toBe(200);
|
|
expect(read.json()).toEqual({ workspace, revision });
|
|
});
|
|
|
|
test("validates a schema v4 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("aggregates one static and one live authentication report without reordering connector diagnostics", async () => {
|
|
const connectorDiagnostics = [{
|
|
level: "info" as const,
|
|
code: "binding_ok" as const,
|
|
message: "Installation bindings and diagnostics succeeded.",
|
|
}];
|
|
const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: connectorDiagnostics }));
|
|
const authentication: AuthDiagnostics = {
|
|
ready: false,
|
|
mode: "oidc",
|
|
checks: [{
|
|
level: "error",
|
|
code: "oidc_mapped_group_missing",
|
|
field: "Thoth Administrators",
|
|
message: "A configured authorization group does not exist.",
|
|
}],
|
|
};
|
|
const authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => authentication) };
|
|
const app = appFor(registryFake(), diagnose, testSecretStore(), {}, authDiagnoser);
|
|
|
|
const validation = await app.inject({
|
|
method: "POST", url: "/workspaces/validate", payload: { workspace },
|
|
});
|
|
const connection = await app.inject({
|
|
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
|
});
|
|
|
|
expect(validation.statusCode).toBe(200);
|
|
expect(validation.json()).toMatchObject({
|
|
workspace,
|
|
activatable: false,
|
|
diagnostics: [],
|
|
authentication,
|
|
});
|
|
expect(connection.statusCode).toBe(200);
|
|
expect(connection.json()).toEqual({
|
|
activatable: false,
|
|
diagnostics: connectorDiagnostics,
|
|
authentication,
|
|
});
|
|
expect(diagnose).toHaveBeenCalledTimes(1);
|
|
expect(authDiagnoser.inspect).toHaveBeenNthCalledWith(1, { live: false });
|
|
expect(authDiagnoser.inspect).toHaveBeenNthCalledWith(2, { live: true });
|
|
});
|
|
|
|
test("fails closed without reflecting a hostile authentication report", async () => {
|
|
const attacker = "attacker-field-SENTINEL";
|
|
const authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => ({
|
|
ready: false,
|
|
mode: "oidc",
|
|
checks: [{
|
|
level: "error", code: "oidc_secret_missing", message: "failure", field: attacker,
|
|
}],
|
|
} as AuthDiagnostics)) };
|
|
const app = appFor(registryFake(), undefined, testSecretStore(), {}, authDiagnoser);
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/workspaces/validate", payload: { workspace },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request is invalid." });
|
|
expect(response.body).not.toContain(attacker);
|
|
});
|
|
|
|
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 v4 workspace", async () => {
|
|
const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] }));
|
|
const app = appFor(registryFake(), diagnose);
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ activatable: true, diagnostics: [], authentication: readyAuthentication });
|
|
expect(diagnose).toHaveBeenCalledWith(workspace, {
|
|
dwh: expect.objectContaining({ transport: "postgres_direct" }),
|
|
evidence: { missing: [], values: {} },
|
|
}, { writeProbe: false });
|
|
});
|
|
|
|
test("reports runtime secret requirements without returning stored values", async () => {
|
|
const secretStore = testSecretStore();
|
|
const app = appFor(registryFake(), undefined, secretStore);
|
|
|
|
const missing = await app.inject({
|
|
method: "GET", url: "/workspaces/psd-clinical/runtime-configuration",
|
|
});
|
|
expect(missing.statusCode).toBe(200);
|
|
expect(missing.json()).toMatchObject({
|
|
workspaceId: "psd-clinical",
|
|
revision,
|
|
configurationState: "configuration_required",
|
|
requirements: [{
|
|
id: "dwh.password",
|
|
connector: "dwh",
|
|
label: "Data warehouse password",
|
|
required: true,
|
|
configured: false,
|
|
}],
|
|
});
|
|
|
|
const secret = "never-return-this-password";
|
|
const save = await app.inject({
|
|
method: "PUT",
|
|
url: "/workspaces/psd-clinical/secrets",
|
|
payload: { values: { "dwh.password": secret } },
|
|
});
|
|
expect(save.statusCode).toBe(200);
|
|
expect(save.body).not.toContain(secret);
|
|
expect(save.json()).toMatchObject({
|
|
configurationState: "ready",
|
|
requirements: [{ id: "dwh.password", configured: true }],
|
|
});
|
|
|
|
const configured = await app.inject({
|
|
method: "GET", url: "/workspaces/psd-clinical/runtime-configuration",
|
|
});
|
|
expect(configured.body).not.toContain(secret);
|
|
expect(configured.json()).toMatchObject({ configurationState: "ready" });
|
|
});
|
|
|
|
test("rejects undeclared secret identifiers and supports forgetting a configured secret", async () => {
|
|
const secretStore = testSecretStore();
|
|
const app = appFor(registryFake(), undefined, secretStore);
|
|
|
|
const unknown = await app.inject({
|
|
method: "PUT",
|
|
url: "/workspaces/psd-clinical/secrets",
|
|
payload: { values: { "evidence.secret_key": "not-applicable" } },
|
|
});
|
|
expect(unknown.statusCode).toBe(400);
|
|
expect(secretStore.configured("psd-clinical")).toEqual([]);
|
|
|
|
secretStore.put("psd-clinical", "dwh.password", "temporary-password");
|
|
const forget = await app.inject({
|
|
method: "DELETE",
|
|
url: "/workspaces/psd-clinical/secrets/dwh.password",
|
|
});
|
|
expect(forget.statusCode).toBe(200);
|
|
expect(forget.json()).toMatchObject({ configurationState: "configuration_required" });
|
|
expect(secretStore.has("psd-clinical", "dwh.password")).toBe(false);
|
|
});
|
|
|
|
test("materializes stored secrets only for the diagnostic lease", async () => {
|
|
const secretStore = testSecretStore();
|
|
secretStore.put("psd-clinical", "dwh.password", "diagnostic-password");
|
|
let materializedPath = "";
|
|
const diagnose = vi.fn(async (_workspace, bindings) => {
|
|
materializedPath = bindings.dwh.values.THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE;
|
|
expect(readFileSync(materializedPath, "utf8")).toBe("diagnostic-password");
|
|
return { activatable: true, diagnostics: [] };
|
|
});
|
|
const app = appFor(registryFake(), diagnose, secretStore);
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(materializedPath).not.toBe("");
|
|
expect(existsSync(materializedPath)).toBe(false);
|
|
});
|
|
|
|
test("reports a missing Evidence credential without changing the registry revision", async () => {
|
|
const evidenceWorkspace: CanonicalWorkspace = {
|
|
...workspace,
|
|
evidence: {
|
|
source: {
|
|
type: "http",
|
|
uris: ["https://evidence.example.test/guide.md"],
|
|
authentication: "signed_urls_file",
|
|
connect_timeout_ms: 5_000,
|
|
read_timeout_ms: 30_000,
|
|
max_bytes: 10 * 1024 * 1024,
|
|
max_redirects: 5,
|
|
allow_private_hosts: false,
|
|
max_cache_bytes: 64 * 1024 * 1024,
|
|
},
|
|
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
|
},
|
|
};
|
|
const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision }));
|
|
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 response = await app.inject({
|
|
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
|
});
|
|
|
|
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);
|
|
} finally {
|
|
if (previous === undefined) delete process.env[variable];
|
|
else process.env[variable] = previous;
|
|
}
|
|
});
|
|
|
|
const runFile = promisify(execFile);
|
|
const realRouteRoots: string[] = [];
|
|
|
|
async function git(cwd: string, args: string[]): Promise<string> {
|
|
const { stdout } = await runFile("git", args, { cwd });
|
|
return stdout.trim();
|
|
}
|
|
|
|
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 git(root, ["init", "--bare", "--initial-branch=main", remote]);
|
|
mkdirSync(author);
|
|
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",
|
|
" 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,
|
|
});
|
|
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
|
const app = buildApp(config, {
|
|
thtRunner: {} as any,
|
|
workspaceRegistry: registry,
|
|
workspaceDiagnoser: vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
|
});
|
|
return { author, initialCommit, app, registry };
|
|
}
|
|
|
|
afterEach(() => {
|
|
realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
|
secretStoreRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
|
|
});
|
|
|
|
test("a failed candidate pull keeps the last valid active workspace", async () => {
|
|
const fixture = await createRealRouteFixture();
|
|
await fixture.registry.bootstrap();
|
|
rmSync(join(fixture.author, "psd-clinical", "evidence"), { recursive: true });
|
|
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 is invalid." });
|
|
await expect(fixture.registry.read("psd-clinical")).resolves.toMatchObject({
|
|
revision: { commit: fixture.initialCommit },
|
|
});
|
|
});
|