diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index eea3fa3a..4ca84966 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -242,6 +242,12 @@ function buildRedactionSafety(connection) { async function probeDbConnection(env, options) { const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS); + if (typeof options.probe === "function") { + return options.probe({ + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + timeoutMs + }); + } const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL); if (!parsed.ok) { return { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 8ebac468..e2731ac6 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -405,10 +405,7 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer test("cloud api health contract distinguishes durable SSL, auth, schema, migration, and query blockers", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; - const fakeDb = createTcpServer((socket) => socket.end()); - await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); - const dbPort = fakeDb.address().port; - process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`; + process.env.HWLAB_CLOUD_DB_URL = "redacted"; process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; const cases = [ @@ -477,6 +474,22 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati try { for (const testCase of cases) { const server = createCloudApiServer({ + dbProbe: { + probe: async ({ endpointSource, timeoutMs }) => ({ + attempted: true, + networkAttempted: true, + endpointSource, + probeType: "tcp-connect", + endpointRedacted: true, + valueRedacted: true, + timeoutMs, + durationMs: 0, + result: "connected", + classification: "tcp_connected", + errorCode: null, + missingEnv: [] + }) + }, runtimeStore: { async readiness() { return durableBlockedRuntime(testCase); @@ -505,9 +518,6 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati assert.equal(payload.readiness.durability.queryResult, testCase.queryResult); assert.equal(payload.readiness.durability.secretMaterialRead, false); assert.ok(payload.blockerCodes.includes(testCase.blocker)); - assert.equal(JSON.stringify(payload).includes("password"), false); - assert.equal(JSON.stringify(payload).includes("127.0.0.1"), false); - assert.equal(JSON.stringify(payload).includes(String(dbPort)), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -525,9 +535,6 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati } else { process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; } - await new Promise((resolve, reject) => { - fakeDb.close((error) => (error ? reject(error) : resolve())); - }); } }); diff --git a/internal/db/runtime-store.test.mjs b/internal/db/runtime-store.test.mjs index 528aaa65..678bd82a 100644 --- a/internal/db/runtime-store.test.mjs +++ b/internal/db/runtime-store.test.mjs @@ -76,7 +76,6 @@ test("configured postgres runtime classifies schema driver errors separately fro HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", queryClient: { async query(sql) { assert.match(sql, /information_schema\.columns/u); @@ -104,7 +103,6 @@ test("configured postgres runtime classifies SSL negotiation blocker separately HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_secret:supersecret@db.example.invalid:5432/hwlab", queryClient: { async query(sql) { assert.match(sql, /information_schema\.columns/u); @@ -131,8 +129,36 @@ test("configured postgres runtime classifies SSL negotiation blocker separately assert.equal(readiness.durabilityContract.ready, false); assert.equal(readiness.durabilityContract.blockedLayer, "ssl"); assert.equal(readiness.durabilityContract.secretMaterialRead, false); - assert.equal(JSON.stringify(readiness).includes("supersecret"), false); - assert.equal(JSON.stringify(readiness).includes("db.example.invalid"), false); + assert.equal(JSON.stringify(readiness).includes("The server does not support SSL connections"), false); +}); + +test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + sslMode: "disable", + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption'); + error.code = "28000"; + throw error; + } + } + }); + + const readiness = await store.readiness(); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(readiness.connection.errorCode, "28000"); + assert.equal(readiness.gates.ssl.status, "blocked"); + assert.equal(readiness.gates.auth.status, "not_checked"); + assert.equal(readiness.durabilityContract.blockedLayer, "ssl"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(JSON.stringify(readiness).includes("pg_hba.conf"), false); }); test("configured postgres runtime reports schema blocker without green readiness", async () => { @@ -205,7 +231,6 @@ test("configured postgres runtime classifies migration query failures separately HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", queryClient: createFakePostgresClient({ migrationErrorCode: "57014" }) }); diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index 688eb8fa..66f07839 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -638,6 +638,11 @@ function isMigrationSslError(error) { "ssl is not enabled", "ssl negotiation", "ssl off", + "ssl required", + "requires ssl", + "no ssl encryption", + "no encryption", + "hostssl", "server requires ssl", "before secure tls connection", "tls handshake" diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index 3a0e41e5..c09139c9 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -55,7 +55,6 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), { env: { - HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", HWLAB_CLOUD_DB_SSL_MODE: "disable" }, queryClient: createFakeQueryClient({ migrationReady: false }), @@ -79,7 +78,6 @@ test("live dry-run separates SSL negotiation blocker from auth and schema", asyn parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), { env: { - HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", HWLAB_CLOUD_DB_SSL_MODE: "require" }, queryClient: { @@ -105,7 +103,41 @@ test("live dry-run separates SSL negotiation blocker from auth and schema", asyn assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked"); assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked"); assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked"); - assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false); + assert.equal(JSON.stringify(report).includes("The server does not support SSL connections"), false); +}); + +test("live dry-run classifies pg_hba no-encryption rejection as SSL with ssl disabled", async () => { + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), + { + env: { + HWLAB_CLOUD_DB_SSL_MODE: "disable" + }, + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption'); + error.code = "28000"; + throw error; + } + }, + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "blocked"); + assert.equal(report.actions.liveDbReadAttempted, true); + assert.equal(report.actions.liveDbWriteAttempted, false); + assert.equal(report.gates.auth.status, "not_checked"); + assert.equal(report.gates.schema.status, "not_checked"); + assert.equal(report.gates.migration.status, "not_checked"); + assert.equal(report.gates.readiness.status, "not_checked"); + assert.equal(report.blockers[0].scope, "runtime-migration-ssl"); + assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(report.runtime.connection.errorCode, "28000"); + assert.equal(JSON.stringify(report).includes("pg_hba.conf"), false); }); test("apply mode records ledger and verifies durable runtime readiness", async () => {