diff --git a/src/lib/prisma.test.ts b/src/lib/prisma.test.ts index 62b7e6cd..215a6c91 100644 --- a/src/lib/prisma.test.ts +++ b/src/lib/prisma.test.ts @@ -89,3 +89,79 @@ describe("prisma module lazy initialization", () => { expect(() => (mod.prisma as any).repository).not.toThrow(/DATABASE_URL/); }); }); + +describe("prisma DATABASE_URL sslmode handling", () => { + let adapterCtor: ReturnType void>>; + let PrismaPgMock: any; + + beforeEach(() => { + vi.resetModules(); + adapterCtor = vi.fn((..._args: unknown[]) => undefined); + const PrismaPgCtor: any = vi.fn(function (this: any, ...args: any[]) { + adapterCtor(...args); + this.adapterName = "PrismaPg"; + this.provider = "postgres"; + }); + PrismaPgMock = PrismaPgCtor; + vi.doMock("@prisma/adapter-pg", () => ({ + PrismaPg: PrismaPgMock, + })); + }); + + afterEach(() => { + vi.doUnmock("@prisma/adapter-pg"); + }); + + it("passes ssl: { rejectUnauthorized: false } when sslmode=no-verify is set", async () => { + process.env.DATABASE_URL = + "postgresql://dispatch:secret@ai-primary.ai.svc:5432/dispatch?sslmode=no-verify"; + const mod = await import("./prisma"); + // Force the cached client to be rebuilt with the new DATABASE_URL. + mod.__resetPrismaClientForTests(); + // Touch the lazy proxy to force initClient(). + void (mod.prisma as any).repository; + expect(adapterCtor).toHaveBeenCalledTimes(1); + const config = adapterCtor.mock.calls[0][0] as { + connectionString: string; + ssl: { rejectUnauthorized: boolean }; + }; + expect(config.ssl).toEqual({ rejectUnauthorized: false }); + // The unknown sslmode must be stripped so pg-connection-string does + // not fall back to "prefer" and try a plaintext handshake. + expect(new URL(config.connectionString).searchParams.has("sslmode")).toBe(false); + expect(new URL(config.connectionString).toString()).toBe( + "postgresql://dispatch:secret@ai-primary.ai.svc:5432/dispatch", + ); + }); + + it("leaves the URL untouched for standard sslmode values like require", async () => { + process.env.DATABASE_URL = + "postgresql://dispatch:secret@ai-primary.ai.svc:5432/dispatch?sslmode=require"; + const mod = await import("./prisma"); + mod.__resetPrismaClientForTests(); + void (mod.prisma as any).repository; + expect(adapterCtor).toHaveBeenCalledTimes(1); + const arg = adapterCtor.mock.calls[0][0]; + expect(arg).toBe(process.env.DATABASE_URL); + }); + + it("does not add explicit ssl config when sslmode is absent", async () => { + process.env.DATABASE_URL = "postgresql://dispatch:secret@ai-primary.ai.svc:5432/dispatch"; + const mod = await import("./prisma"); + mod.__resetPrismaClientForTests(); + void (mod.prisma as any).repository; + expect(adapterCtor).toHaveBeenCalledTimes(1); + const arg = adapterCtor.mock.calls[0][0]; + expect(arg).toBe(process.env.DATABASE_URL); + }); + + it("falls back to passing the raw URL when it is not a parseable URL", async () => { + process.env.DATABASE_URL = "not-a-valid-url"; + const mod = await import("./prisma"); + mod.__resetPrismaClientForTests(); + void (mod.prisma as any).repository; + expect(adapterCtor).toHaveBeenCalledTimes(1); + const arg = adapterCtor.mock.calls[0][0]; + expect(arg).toBe("not-a-valid-url"); + }); +}); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 164120c2..05b0f658 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -13,6 +13,37 @@ function databaseUrl(): string | undefined { return process.env.DATABASE_URL ?? process.env.DISPATCH_DATABASE_URL; } +/** + * Build a `PrismaPg` adapter from the connection URL. + * + * `pg-connection-string` (the parser used by `@prisma/adapter-pg` and + * `pg.Pool`) only recognises libpq's standard `sslmode` values: + * `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`. + * The README documents `sslmode=no-verify` (TLS on, certificate not + * verified) but `no-verify` is not one of those, so it falls through and + * `pg.Pool` ends up with no `ssl` config — the server then rejects the + * connection with `no pg_hba.conf entry ... no encryption`. Detect the + * documented alias, strip it from the URL, and hand PrismaPg an explicit + * `ssl: { rejectUnauthorized: false }` so node-postgres requires TLS + * without validating the certificate. + */ +function buildAdapter(url: string): PrismaPg { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return new PrismaPg(url); + } + if (parsed.searchParams.get("sslmode") !== "no-verify") { + return new PrismaPg(url); + } + parsed.searchParams.delete("sslmode"); + return new PrismaPg({ + connectionString: parsed.toString(), + ssl: { rejectUnauthorized: false }, + }); +} + function initClient(): PrismaClient { if (_client) return _client; if (globalForPrisma.prisma) { @@ -27,7 +58,7 @@ function initClient(): PrismaClient { ); } - const adapter = new PrismaPg(url); + const adapter = buildAdapter(url); const client = new PrismaClient({ adapter, log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],