|
1 | 1 | import { describe, it, expect } from 'vitest'; |
| 2 | +import { DatasourceSchema } from '../datasource.zod'; |
| 3 | +import { MongoConfigSchema } from './mongo.zod'; |
2 | 4 | import { PostgresConfigSchema } from './postgres.zod'; |
3 | 5 |
|
4 | 6 | describe('PostgresConfigSchema', () => { |
@@ -192,3 +194,159 @@ describe('PostgresConfigSchema', () => { |
192 | 194 | .toThrow(); |
193 | 195 | }); |
194 | 196 | }); |
| 197 | + |
| 198 | +/** |
| 199 | + * #9091 — a `url` that `pg` itself cannot parse is refused at publish. |
| 200 | + * |
| 201 | + * The describe text always documented the postgres URL grammar; until #9091 |
| 202 | + * the value was only string-scanned (credentials #8082/#8337, placeholders |
| 203 | + * #8336) because the SHARED helper's refusal to parse is load-bearing for |
| 204 | + * mongo's multi-host/`+srv` forms (#8696). The parse question is asked |
| 205 | + * per-driver, of `pg`'s own parser (`pg-connection-string`). |
| 206 | + * |
| 207 | + * Envelope note (the standing minimum for rejection pins): the zod issue's |
| 208 | + * `code` and its (re-pathed) location are the whole envelope at this layer — |
| 209 | + * `status` does not exist here; the publish door wraps every schema refusal |
| 210 | + * uniformly (metadata-protocol's `422 INVALID_METADATA`, whose `issues[]` |
| 211 | + * carry these zod codes verbatim). |
| 212 | + */ |
| 213 | +describe('PostgresConfigSchema.url pg-grammar enforcement (#9091)', () => { |
| 214 | + it("refuses libpq's multi-host DSN — the form `pg` measurably cannot open", () => { |
| 215 | + // Measured on pg@8.22.0 / pg-connection-string@2.14.0: both `parse` and |
| 216 | + // `ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on this exact |
| 217 | + // value. It parsed green here until #9091. |
| 218 | + const result = PostgresConfigSchema.safeParse({ |
| 219 | + url: 'postgresql://app@h1:5432,h2:5433/app', |
| 220 | + }); |
| 221 | + |
| 222 | + expect(result.success).toBe(false); |
| 223 | + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); |
| 224 | + expect(issue, 'refusal must land at `url`').toBeDefined(); |
| 225 | + expect(issue!.code).toBe('custom'); |
| 226 | + expect(issue!.message).toContain('not a connection URL `pg` can open'); |
| 227 | + // The message names the common cause and its working replacements. |
| 228 | + expect(issue!.message).toContain('multi-host'); |
| 229 | + // The runtime-DSN carve-out, stated rather than implied (family convention). |
| 230 | + expect(issue!.message).toContain('OS_DATABASE_URL'); |
| 231 | + }); |
| 232 | + |
| 233 | + it('re-paths the refusal at `config.url` through the datasource door', () => { |
| 234 | + const result = DatasourceSchema.safeParse({ |
| 235 | + name: 'warehouse', |
| 236 | + driver: 'postgres', |
| 237 | + config: { url: 'postgresql://app@h1:5432,h2:5433/app' }, |
| 238 | + }); |
| 239 | + |
| 240 | + expect(result.success).toBe(false); |
| 241 | + const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.url'); |
| 242 | + expect(issue, 'refusal must be re-pathed at `config.url`').toBeDefined(); |
| 243 | + expect(issue!.code).toBe('custom'); |
| 244 | + expect(issue!.message).toContain('not a connection URL `pg` can open'); |
| 245 | + }); |
| 246 | + |
| 247 | + it('refuses a non-numeric port — `pg` throws ERR_INVALID_URL on it', () => { |
| 248 | + const result = PostgresConfigSchema.safeParse({ |
| 249 | + url: 'postgresql://db.example.com:notaport/app', |
| 250 | + }); |
| 251 | + |
| 252 | + expect(result.success).toBe(false); |
| 253 | + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); |
| 254 | + expect(issue).toBeDefined(); |
| 255 | + expect(issue!.code).toBe('custom'); |
| 256 | + expect(issue!.message).toContain('not a connection URL `pg` can open'); |
| 257 | + }); |
| 258 | + |
| 259 | + it('refuses a scheme-less non-URL — `pg` would resolve it against a placeholder host', () => { |
| 260 | + // `pg-connection-string` parses these via `new URL(str, 'postgres://base')`, |
| 261 | + // so they do NOT throw: pg would connect to the literal host `base` with |
| 262 | + // the authored text as the database name. Structurally unusable, refused. |
| 263 | + for (const url of ['not a url at all', 'host=localhost dbname=app']) { |
| 264 | + const result = PostgresConfigSchema.safeParse({ url }); |
| 265 | + |
| 266 | + expect(result.success, url).toBe(false); |
| 267 | + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); |
| 268 | + expect(issue, `refusal for ${url} must land at \`url\``).toBeDefined(); |
| 269 | + expect(issue!.code).toBe('custom'); |
| 270 | + expect(issue!.message).toContain('no scheme'); |
| 271 | + expect(issue!.message).toContain('`base`'); |
| 272 | + } |
| 273 | + }); |
| 274 | + |
| 275 | + it('refuses the fs-reading query parameters, pointing at the datasource-level `ssl` block', () => { |
| 276 | + // `?sslcert=`/`?sslkey=`/`?sslrootcert=` make `parse` itself call |
| 277 | + // `fs.readFileSync` — a publish verdict must not depend on the validating |
| 278 | + // host's filesystem, and certificate material already has its declared |
| 279 | + // home (the same prescription the config-level `ca`/`cert`/`key` keys |
| 280 | + // carry). |
| 281 | + const result = PostgresConfigSchema.safeParse({ |
| 282 | + url: 'postgresql://db.example.com/app?sslcert=/etc/ssl/client.pem', |
| 283 | + }); |
| 284 | + |
| 285 | + expect(result.success).toBe(false); |
| 286 | + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); |
| 287 | + expect(issue).toBeDefined(); |
| 288 | + expect(issue!.code).toBe('custom'); |
| 289 | + expect(issue!.message).toContain('?sslcert='); |
| 290 | + expect(issue!.message).toContain('datasource-level `ssl` block'); |
| 291 | + }); |
| 292 | + |
| 293 | + it('mirrors `pg` exactly on the fs-param boundary: exact-case, non-empty value', () => { |
| 294 | + // Measured: `?SSLCERT=` is copied into the parsed config and read by |
| 295 | + // nothing (no fs touch), and an empty `?sslcert=` is falsy at the |
| 296 | + // parser's guard (no fs touch) — refusing either would narrow past what |
| 297 | + // `pg` does. Both stay accepted. |
| 298 | + for (const url of [ |
| 299 | + 'postgresql://db.example.com/app?SSLCERT=/etc/ssl/client.pem', |
| 300 | + 'postgresql://db.example.com/app?sslcert=', |
| 301 | + ]) { |
| 302 | + const result = PostgresConfigSchema.safeParse({ url }); |
| 303 | + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); |
| 304 | + } |
| 305 | + }); |
| 306 | + |
| 307 | + it('reports the parse refusal ALONGSIDE the credential refusal on a value violating both', () => { |
| 308 | + // Composition pin: independent superRefines judge one value, each |
| 309 | + // reporting its own finding (#8082 userinfo + #9091 grammar here). |
| 310 | + const result = PostgresConfigSchema.safeParse({ |
| 311 | + url: 'postgresql://user:pass@h1:5432,h2:5433/app', |
| 312 | + }); |
| 313 | + |
| 314 | + expect(result.success).toBe(false); |
| 315 | + const messages = result.error!.issues |
| 316 | + .filter((i) => i.path.join('.') === 'url') |
| 317 | + .map((i) => i.message); |
| 318 | + expect(messages.some((m) => m.includes('embeds a password'))).toBe(true); |
| 319 | + expect(messages.some((m) => m.includes('not a connection URL `pg` can open'))).toBe(true); |
| 320 | + }); |
| 321 | + |
| 322 | + it('accepts every measured shape `pg` genuinely opens', () => { |
| 323 | + for (const url of [ |
| 324 | + // The documented single-host forms, credential-free ones included. |
| 325 | + 'postgresql://db.example.com/app', |
| 326 | + 'postgresql://user@db.example.com:5432/production', |
| 327 | + 'postgres://host/db', |
| 328 | + // Empty-host libpq forms (default socket/localhost). |
| 329 | + 'postgresql:///dbname', |
| 330 | + 'postgresql://user@/mydb', |
| 331 | + // Unix-socket spellings: leading-`/` path, `socket:`, encoded host. |
| 332 | + '/var/run/postgresql', |
| 333 | + 'socket:/var/run/postgresql?db=app', |
| 334 | + 'postgresql://%2Fvar%2Frun%2Fpostgresql/mydb', |
| 335 | + // IPv6 host and non-credential, non-fs query parameters. |
| 336 | + 'postgresql://user@[2001:db8::1]:5432/db', |
| 337 | + 'postgresql://db.example.com/app?application_name=objectstack', |
| 338 | + ]) { |
| 339 | + const result = PostgresConfigSchema.safeParse({ url, database: 'app' }); |
| 340 | + expect(result.success, `${url}: ${JSON.stringify(result.error?.issues)}`).toBe(true); |
| 341 | + } |
| 342 | + }); |
| 343 | + |
| 344 | + it("leaves mongo's multi-host form untouched — the shared helper's leniency it must keep (#8696)", () => { |
| 345 | + // The #9091 parse check is per-driver BY DESIGN: for mongo the multi-host |
| 346 | + // DSN is a real, working, documented shape. Pin that it still parses. |
| 347 | + const result = MongoConfigSchema.safeParse({ |
| 348 | + url: 'mongodb://app@h1:27017,h2:27017/app', |
| 349 | + }); |
| 350 | + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); |
| 351 | + }); |
| 352 | +}); |
0 commit comments