diff --git a/backend/package-lock.json b/backend/package-lock.json index c1b35046..866c39e8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,7 +7,9 @@ "name": "thothii-backend", "dependencies": { "@fastify/cors": "^11.2.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", @@ -968,12 +970,22 @@ "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.3", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.3.tgz", + "integrity": "sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "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", @@ -1634,6 +1646,95 @@ "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", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1707,6 +1808,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2039,7 +2179,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -2638,6 +2777,15 @@ "node": ">=8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/backend/package.json b/backend/package.json index 96ed8dba..e337a124 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@fastify/cors": "^11.2.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", diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index 6c652696..bf3da193 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, realpath } from "node:fs/promises"; import { createConnection } from "node:net"; import { once } from "node:events"; +import { Client } from "pg"; import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js"; import { buildInstallationContract } from "./contracts.js"; import type { RuntimeBindings } from "./runtime-renderer.js"; @@ -90,6 +91,10 @@ export interface VectorDiagnosticRequest { collection: string; dimensions?: number; distance?: "cosine" | "l2" | "inner_product"; + host?: string; + port?: number; + user?: string; + resource?: DiagnosticResource; timeoutMs: number; signal: AbortSignal; } @@ -130,6 +135,17 @@ export interface DirectProtocolFactory { probe(request: ConnectorDiagnosticRequest): Promise; } +export interface DatabaseDiagnosticClient { + query(sql: string, values: readonly unknown[]): Promise<{ rows: Array> }>; + end(): Promise; +} + +export interface DatabaseDiagnosticClientFactory { + connect(request: { + host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal; + }): Promise; +} + export interface SshProcessFactory { start(request: SshTunnelRequest, args: readonly string[]): Promise<{ tunnel: LoopbackTunnel; @@ -140,6 +156,7 @@ export interface SshProcessFactory { export interface ConcreteDiagnosticAdapterDependencies { directProtocol?: DirectProtocolFactory; sshProcess?: SshProcessFactory; + databaseClient?: DatabaseDiagnosticClientFactory; } /** @@ -176,6 +193,24 @@ async function secretPresent(file: string): Promise { return (await readFile(file, "utf8")).trim().length > 0; } +async function sameSecretFile(first: string, second: string): Promise { + try { + return await realpath(first) === await realpath(second); + } catch { + return first === second; + } +} + +async function restHeaders( + diagnostic: RestDiagnosticRequest, + credentialFile: string | undefined, +): Promise> { + if (diagnostic.auth === "none") return {}; + if (!credentialFile || !(await secretPresent(credentialFile))) throw new Error("REST probe failed"); + const secret = (await readFile(credentialFile, "utf8")).trim(); + return diagnostic.auth === "bearer" ? { authorization: `Bearer ${secret}` } : { "x-api-key": secret }; +} + /** * Concrete production adapters deliberately retain only probe metadata. Protocol failures and * response bodies are discarded at this boundary; callers receive fixed diagnostics instead. @@ -183,23 +218,57 @@ async function secretPresent(file: string): Promise { export function createConcreteDiagnosticAdapters( dependencies: ConcreteDiagnosticAdapterDependencies = {}, ): DiagnosticAdapters { + const databaseClient = dependencies.databaseClient ?? { + async connect(request: { host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal }) { + const client = new Client({ + host: request.host, port: request.port, database: request.database, user: request.user, + password: (await readFile(request.credentialFile, "utf8")).trim(), + ssl: { ca: await readFile(request.tlsCaFile, "utf8"), rejectUnauthorized: true }, + connectionTimeoutMillis: 5_000, + }); + const abort = () => { void client.end(); }; + request.signal.addEventListener("abort", abort, { once: true }); + try { + await client.connect(); + return { query: async (sql: string, values: readonly unknown[]) => await client.query(sql, [...values]), end: async () => { request.signal.removeEventListener("abort", abort); await client.end(); } }; + } catch (error) { + request.signal.removeEventListener("abort", abort); + await client.end().catch(() => undefined); + throw error; + } + }, + }; const directProtocol = dependencies.directProtocol ?? { async probe(request: ConnectorDiagnosticRequest): Promise { - if (!request.host || !request.port || !request.credentialFile || !(await secretPresent(request.credentialFile))) { + if (!request.host || !request.port || !request.user || !request.credentialFile || !request.tlsCaFile + || !(await secretPresent(request.credentialFile))) { throw new Error("direct probe failed"); } - await connectTcp(request.host, request.port, request.signal); - return { resolved: true, tlsVerified: request.tlsCaFile !== undefined, authenticated: true, resource: request.resource }; + const database = request.resource.database; + const schema = request.resource.schema; + if (!database || !schema) throw new Error("direct probe failed"); + const client = await databaseClient.connect({ + host: request.host, port: request.port, database, user: request.user, + credentialFile: request.credentialFile, tlsCaFile: request.tlsCaFile, signal: request.signal, + }); + try { + const result = await client.query("SELECT current_database() AS database, current_schema() AS schema", []); + const row = result.rows[0]; + if (row?.database !== database || row.schema !== schema) throw new Error("direct probe failed"); + return { resolved: true, tlsVerified: true, authenticated: true, resource: request.resource }; + } finally { + await client.end().catch(() => undefined); + } }, }; return { async probeConnector(request) { if (request.transport === "rest_api") { - if (!request.baseUrl || !request.diagnostic || !request.credentialFile || !(await secretPresent(request.credentialFile))) throw new Error("REST probe failed"); + if (!request.baseUrl || !request.diagnostic || request.tlsCaFile) throw new Error("REST probe failed"); const endpoint = resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path); const response = await fetch(endpoint.toString(), { method: request.diagnostic.method, - headers: { authorization: `Bearer ${(await readFile(request.credentialFile, "utf8")).trim()}` }, + headers: await restHeaders(request.diagnostic, request.credentialFile), signal: request.signal, redirect: "error", }); @@ -241,6 +310,28 @@ export function createConcreteDiagnosticAdapters( } }, async inspectVector(request) { + if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") { + const resource = request.resource; + if (!request.host || !request.port || !request.user || !request.credentialFile || !request.tlsCaFile + || !resource?.database || !resource.schema || !(await secretPresent(request.credentialFile))) { + throw new Error("vector metadata adapter is unavailable"); + } + const client = await databaseClient.connect({ + host: request.host, port: request.port, database: resource.database, user: request.user, + credentialFile: request.credentialFile, tlsCaFile: request.tlsCaFile, signal: request.signal, + }); + try { + const metadata = await client.query( + "SELECT a.atttypmod - 4 AS dimensions, CASE WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_cosine_ops%' THEN 'cosine' WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_l2_ops%' THEN 'l2' WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_ip_ops%' THEN 'inner_product' END AS distance FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_index i ON i.indrelid = c.oid WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped AND a.atttypid = (SELECT oid FROM pg_type WHERE typname = 'vector') LIMIT 1", + [resource.schema, request.collection], + ); + const row = metadata.rows[0]; + if (!row || !Number.isInteger(row.dimensions) || (row.distance !== "cosine" && row.distance !== "l2" && row.distance !== "inner_product")) throw new Error("vector metadata adapter is unavailable"); + return { collection: request.collection, dimensions: row.dimensions as number, distance: row.distance as VectorDiagnosticResult["distance"] }; + } finally { + await client.end().catch(() => undefined); + } + } if (request.transport !== "rest_api" || !request.baseUrl || !request.credentialFile || !request.diagnostic || !(await secretPresent(request.credentialFile))) throw new Error("vector metadata adapter is unavailable"); const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), { @@ -514,6 +605,7 @@ export function createWorkspaceDiagnoser( const dwhTimeout = boundedTimeout(canonical.dwh.timeout_ms, fallbackTimeout); const vectorTimeout = boundedTimeout(canonical.semantic_index.vector_store.timeout_ms, fallbackTimeout); const embeddingTimeout = boundedTimeout(canonical.semantic_index.embedding.timeout_ms, fallbackTimeout); + let tunneledVectorMetadata: VectorDiagnosticResult | undefined; for (const role of ["dwh", "vector"] as const) { const timeoutMs = role === "dwh" ? dwhTimeout : vectorTimeout; @@ -526,9 +618,22 @@ export function createWorkspaceDiagnoser( const result = "sshHost" in request ? await withTimeout(timeoutMs, (signal) => adapters.withSshTunnel( { ...request, signal }, - (tunnel) => adapters.probeConnector(tunnelProbeRequest( - canonical, role, bindings, timeoutMs, tunnel, signal, - )), + async (tunnel) => { + const tunneledRequest = tunnelProbeRequest(canonical, role, bindings, timeoutMs, tunnel, signal); + const connector = await adapters.probeConnector(tunneledRequest); + if (role === "vector") { + tunneledVectorMetadata = await adapters.inspectVector({ + transport: "ssh_tunnel", host: tunneledRequest.host, port: tunneledRequest.port, + user: tunneledRequest.user, credentialFile: tunneledRequest.credentialFile, + tlsCaFile: tunneledRequest.tlsCaFile, resource: tunneledRequest.resource, + collection: canonical.semantic_index.vector_store.collection, + dimensions: canonical.semantic_index.vector_store.dimensions, + distance: canonical.semantic_index.vector_store.distance, + timeoutMs: vectorTimeout, signal, + }); + } + return connector; + }, )) : await withTimeout(timeoutMs, (signal) => adapters.probeConnector({ ...request, signal })); const resource = role === "dwh" @@ -549,7 +654,9 @@ export function createWorkspaceDiagnoser( try { const vectorBinding = bindings.vector; const vectorRest = canonical.diagnostics?.vector_rest?.metadata; - const vector = await withTimeout(vectorTimeout, (signal) => adapters.inspectVector({ + const vectorDirect = connectorRequest(canonical, "vector", bindings, vectorTimeout); + const directVectorRequest = vectorDirect && !("sshHost" in vectorDirect) ? vectorDirect : undefined; + const vector = tunneledVectorMetadata ?? await withTimeout(vectorTimeout, (signal) => adapters.inspectVector({ transport: vectorBinding.transport === "pgvector_direct" || vectorBinding.transport === "rest_api" || vectorBinding.transport === "ssh_tunnel" ? vectorBinding.transport : undefined, baseUrl: vectorBinding.values[bindingName(canonical, "VECTOR", "BASE_URL")], @@ -559,6 +666,14 @@ export function createWorkspaceDiagnoser( collection: canonical.semantic_index.vector_store.collection, dimensions: canonical.semantic_index.vector_store.dimensions, distance: canonical.semantic_index.vector_store.distance, + ...(directVectorRequest ? { + host: directVectorRequest.host, + port: directVectorRequest.port, + user: directVectorRequest.user, + credentialFile: directVectorRequest.credentialFile, + tlsCaFile: directVectorRequest.tlsCaFile, + resource: directVectorRequest.resource, + } : {}), timeoutMs: vectorTimeout, signal, })); @@ -601,6 +716,11 @@ export function createWorkspaceDiagnoser( ) { const credentialFile = bindings.vector.values[bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")]; if (!credentialFile) return { activatable: true, diagnostics }; + const readerCredentialFile = bindings.vector.values[bindingName(canonical, "VECTOR", "API_KEY_FILE")]; + if (readerCredentialFile && await sameSecretFile(credentialFile, readerCredentialFile)) { + diagnostics.push(diagnosticError("binding_missing", bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE"))); + return { activatable: false, diagnostics }; + } const request: WriteDiagnosticRecordRequest = { collection: canonical.semantic_index.vector_store.collection, id: `diagnostic:${randomUUID()}`, @@ -611,20 +731,22 @@ export function createWorkspaceDiagnoser( baseUrl: bindings.vector.values[bindingName(canonical, "VECTOR", "BASE_URL")], diagnostic: canonical.diagnostics.vector_rest.reversible_probe, }; - let writeSucceeded = false; + let writeStarted = false; + let cleanupAttempted = false; let cleanupFailed = false; try { + writeStarted = true; await withTimeout(vectorTimeout, (signal) => adapters.writeDiagnosticRecord({ ...request, signal })); - writeSucceeded = true; + cleanupAttempted = true; await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal })); } catch { cleanupFailed = true; } finally { - if (writeSucceeded && cleanupFailed) { + if (writeStarted && (!cleanupAttempted || cleanupFailed)) { try { await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal })); } catch { - // The cleanup attempt is deliberately best-effort and remains redacted. + cleanupFailed = true; } } } diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index e4b0f5b3..ce99bb96 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -10,7 +10,7 @@ export type VectorTransport = (typeof VECTOR_TRANSPORTS)[number]; export const REST_DIAGNOSTIC_METHODS = ["GET", "POST"] as const; export type RestDiagnosticMethod = (typeof REST_DIAGNOSTIC_METHODS)[number]; -export const DIAGNOSTIC_AUTH_MODES = ["none", "bearer"] as const; +export const DIAGNOSTIC_AUTH_MODES = ["none", "bearer", "x-api-key"] as const; export type DiagnosticAuthMode = (typeof DIAGNOSTIC_AUTH_MODES)[number]; export interface RestDiagnosticRequest { diff --git a/backend/test/workspaces-diagnostics.test.ts b/backend/test/workspaces-diagnostics.test.ts index 7ca1c5dc..8750c5fb 100644 --- a/backend/test/workspaces-diagnostics.test.ts +++ b/backend/test/workspaces-diagnostics.test.ts @@ -366,6 +366,31 @@ test("does not substitute the reader credential for a declared vector writer", a expect(adapters.writeDiagnosticRecord).not.toHaveBeenCalled(); }); +test("rejects a writer credential that aliases the reader credential", async () => { + const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-")); + const readerKey = join(directory, "reader-key"); + const writerAlias = join(directory, "writer-key"); + await writeFile(readerKey, "same-secret\n", { mode: 0o600 }); + await (await import("node:fs/promises")).symlink(readerKey, writerAlias); + const adapters = successfulAdapters(); + const aliasedBindings: RuntimeBindings = { + ...writerBindings, + vector: { ...writerBindings.vector, values: { + ...writerBindings.vector.values, + THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey, + THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerAlias, + } }, + }; + + try { + const result = await diagnose(adapters)(writerWorkspace, aliasedBindings, { writeProbe: true }); + expect(result.activatable).toBe(false); + expect(adapters.writeDiagnosticRecord).not.toHaveBeenCalled(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + test("uses the declared POST DWH ping endpoint without exposing its local credential", async () => { const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-")); const credentialFile = join(directory, "dwh-api-key"); @@ -403,6 +428,50 @@ test("uses the declared POST DWH ping endpoint without exposing its local creden } }); +test("requires an authenticated TLS database query before direct diagnostics succeed", async () => { + const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-")); + const passwordFile = join(directory, "password"); + const caFile = join(directory, "ca.pem"); + await Promise.all([writeFile(passwordFile, "password\n", { mode: 0o600 }), writeFile(caFile, "test-ca\n")]); + const query = vi.fn(async () => ({ rows: [{ database: "warehouse", schema: "datawarehouse" }] })); + const end = vi.fn(async () => undefined); + const connect = vi.fn(async () => ({ query, end })); + + try { + const result = await createConcreteDiagnosticAdapters({ databaseClient: { connect } } as any).probeConnector({ + role: "dwh", transport: "postgres_direct", host: "127.0.0.1", port: 5432, user: "reader", + credentialFile: passwordFile, tlsCaFile: caFile, + resource: { database: "warehouse", schema: "datawarehouse" }, timeoutMs: 5000, + signal: new AbortController().signal, + }); + + expect(connect).toHaveBeenCalledWith(expect.objectContaining({ database: "warehouse", tlsCaFile: caFile })); + expect(query).toHaveBeenCalledWith(expect.stringContaining("current_database"), []); + expect(end).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ authenticated: true, tlsVerified: true }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("honors a declared unauthenticated REST diagnostic without reading a credential", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ database: "warehouse", schema: "datawarehouse" }), { + status: 200, headers: { "content-type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchSpy); + try { + await expect(createConcreteDiagnosticAdapters().probeConnector({ + role: "dwh", transport: "rest_api", baseUrl: "https://dwh.example.test", + resource: { database: "warehouse", schema: "datawarehouse" }, timeoutMs: 5000, + signal: new AbortController().signal, + diagnostic: { method: "POST", path: "/rpc/ping", auth: "none", response: { database: "database", schema: "schema" } } as any, + })).resolves.toMatchObject({ authenticated: true }); + expect(fetchSpy.mock.calls[0]?.[1]).not.toMatchObject({ headers: expect.objectContaining({ authorization: expect.anything() }) }); + } finally { + vi.unstubAllGlobals(); + } +}); + test("constructs the production diagnoser with the configured timeout and injected adapters", async () => { const adapters = successfulAdapters(); @@ -430,3 +499,16 @@ test("retries bounded cleanup after a write-probe removal times out", async () = expect(result).toMatchObject({ activatable: false }); expect(JSON.stringify(result)).not.toContain("timeout"); }); + +test("attempts bounded cleanup when a timed-out write may already have created the record", async () => { + const adapters = successfulAdapters({ + writeDiagnosticRecord: vi.fn(() => new Promise(() => undefined)), + }); + const diagnoseWithShortTimeout = createWorkspaceDiagnoser(adapters, { timeoutMs: 10 }); + + const result = await diagnoseWithShortTimeout(writerWorkspace, writerBindings, { writeProbe: true }); + + expect(adapters.writeDiagnosticRecord).toHaveBeenCalledOnce(); + expect(adapters.removeDiagnosticRecord).toHaveBeenCalledOnce(); + expect(result.activatable).toBe(false); +});