diff --git a/src/infra/db/connection-policy.ts b/src/infra/db/connection-policy.ts index fa7ec70..b9267e7 100644 --- a/src/infra/db/connection-policy.ts +++ b/src/infra/db/connection-policy.ts @@ -170,10 +170,16 @@ export type PostgresClientOptions = { idle_timeout: number; max_lifetime: number; ssl?: { rejectUnauthorized: true }; - connection?: { family: 4 }; }; -/** Opções do driver. Em ambiente implantado o certificado é verificado sempre. */ +/** + * Opções do driver. Em ambiente implantado o certificado é verificado sempre. + * + * `connection` no postgres.js é um parâmetro de startup, não uma opção do + * socket. Enviar `family=4` faz o PostgreSQL recusar a sessão com 42704 + * (`unrecognized configuration parameter "family"`) antes da primeira + * consulta. O IPv4 continua garantido pelo host do Session Pooler. + */ export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions { const deployed = isDeployedEnv(env); if (deployed) assertDeployedUrl(connectionString, purpose, env); @@ -185,10 +191,7 @@ export function buildPostgresOptions(connectionString: string, purpose: DbPurpos idle_timeout: 20, max_lifetime: 60 * 30, }; - if (deployed) { - options.ssl = { rejectUnauthorized: true }; - options.connection = { family: 4 }; - } + if (deployed) options.ssl = { rejectUnauthorized: true }; return options; } diff --git a/src/infra/db/target-guard.ts b/src/infra/db/target-guard.ts index 71a42aa..b1ce2e9 100644 --- a/src/infra/db/target-guard.ts +++ b/src/infra/db/target-guard.ts @@ -113,6 +113,13 @@ function postgresErrorCode(error: unknown): string | null { return null; } +/** Código do driver, sem a mensagem. A mensagem pode conter usuário, host ou senha. */ +function unavailableIdentityCode(error: unknown): string { + const code = postgresErrorCode(error); + if (code && /^[A-Z0-9_]{2,48}$/.test(code)) return `identidade_indisponivel:${code}`; + return "identidade_indisponivel"; +} + async function readRole(db: Db): Promise { try { const rows = await db.withService((query) => @@ -132,7 +139,7 @@ async function readRole(db: Db): Promise { return facts; } catch (error) { if (error instanceof DatabaseGuardError) throw error; - throw new DatabaseGuardError("identidade_indisponivel"); + throw new DatabaseGuardError(unavailableIdentityCode(error)); } } diff --git a/tests/unit/database-guard.test.ts b/tests/unit/database-guard.test.ts index 7185fce..710fe8d 100644 --- a/tests/unit/database-guard.test.ts +++ b/tests/unit/database-guard.test.ts @@ -5,6 +5,7 @@ import { DatabaseGuardError, assertDeployedUrl, assertLocalDatabaseUrl, + buildPostgresOptions, expectedPoolerHost, resolvePoolMax, scrubDbError, @@ -12,7 +13,8 @@ import { import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity"; -import { evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; +import { assertAdminTarget, evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; +import type { Db } from "@/infra/db/types"; const REF = "abcdefghijabcdefghij"; const REF_COM_DIGITO = "abcd1234abcd1234wxyz"; @@ -165,6 +167,16 @@ describe("barreira do banco de homologação", () => { expect(resolvePoolMax("web", env)).toBe(2); expect(resolvePoolMax("worker", env)).toBe(2); expect(resolvePoolMax("admin", env)).toBe(1); + const deployed = buildPostgresOptions(adminUrl(), "admin", env); + const production = buildPostgresOptions(adminUrl(), "admin", { ...env, ADAPTER_ENV: "production" }); + for (const options of [deployed, production]) { + expect(options.ssl).toEqual({ rejectUnauthorized: true }); + expect(options).not.toHaveProperty("connection"); + expect(JSON.stringify(options)).not.toContain("family"); + } + expect(buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", { ADAPTER_ENV: "development" })).not.toHaveProperty( + "connection", + ); }); it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => { @@ -207,6 +219,41 @@ describe("barreira do banco de homologação", () => { ); }); + it("classifica a falha de conexão pelo código do driver e omite a mensagem", async () => { + const db = { + exec: async () => undefined, + close: async () => undefined, + withUser: async () => { + throw new Error("nao"); + }, + withService: async () => { + throw Object.assign(new Error("postgres://postgres.ref:segredo@host/db family"), { code: "42704" }); + }, + } as Db; + await expect(assertAdminTarget(db, env)).rejects.toMatchObject({ + code: "identidade_indisponivel:42704", + }); + await expect(assertAdminTarget(db, env)).rejects.not.toThrow(/segredo/); + + const semCodigo = { + ...db, + withService: async () => { + throw new Error("postgres://postgres.ref:segredo@host/db"); + }, + } as Db; + await expect(assertAdminTarget(semCodigo, env)).rejects.toMatchObject({ code: "identidade_indisponivel" }); + await expect(assertAdminTarget(semCodigo, env)).rejects.not.toThrow(/segredo/); + + const codigoInseguro = { + ...db, + withService: async () => { + throw Object.assign(new Error("falha"), { code: "postgres://segredo" }); + }, + } as Db; + await expect(assertAdminTarget(codigoInseguro, env)).rejects.toMatchObject({ code: "identidade_indisponivel" }); + await expect(assertAdminTarget(codigoInseguro, env)).rejects.not.toThrow(/segredo/); + }); + it("não deixa senha nem URL no erro visível", () => { const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres")); expect(scrubbed).not.toContain("segredo");