postgresai 0.15.0-rc.2 → 0.15.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postgres-ai.ts +88 -124
- package/dist/bin/postgres-ai.js +3260 -540
- package/lib/instances.ts +245 -0
- package/package.json +1 -1
- package/test/monitoring.test.ts +277 -0
package/lib/instances.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for managing instances.yml (the pgwatch monitoring target list)
|
|
3
|
+
* and for opening pg connections that honor libpq sslmode semantics.
|
|
4
|
+
*
|
|
5
|
+
* These helpers exist as a single source of truth so that `mon targets
|
|
6
|
+
* add/remove/test` and `mon local-install` (which has its own inline copies
|
|
7
|
+
* of the same logic) stay consistent — and so unit tests exercise the same
|
|
8
|
+
* code path the CLI actually runs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as yaml from "js-yaml";
|
|
13
|
+
import { parse as parseConnString } from "pg-connection-string";
|
|
14
|
+
import type { ClientConfig } from "pg";
|
|
15
|
+
|
|
16
|
+
export interface Instance {
|
|
17
|
+
name: string;
|
|
18
|
+
conn_str?: string;
|
|
19
|
+
preset_metrics?: string;
|
|
20
|
+
custom_metrics?: any;
|
|
21
|
+
is_enabled?: boolean;
|
|
22
|
+
group?: string;
|
|
23
|
+
custom_tags?: Record<string, any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class InstancesParseError extends Error {
|
|
27
|
+
constructor(file: string, cause: unknown) {
|
|
28
|
+
const causeMsg = cause instanceof Error ? cause.message : String(cause);
|
|
29
|
+
super(`Failed to parse ${file}: ${causeMsg}`);
|
|
30
|
+
this.name = "InstancesParseError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read instances.yml as an array of Instance.
|
|
36
|
+
*
|
|
37
|
+
* Returns `[]` for a missing/empty file (this is normal — fresh installs and
|
|
38
|
+
* just-after-`remove` states). Throws InstancesParseError on a corrupted file
|
|
39
|
+
* so callers can surface the corruption to the user instead of silently
|
|
40
|
+
* overwriting it (the previous append-text behavior could erase several
|
|
41
|
+
* targets — including their conn_strs with credentials — if the file had a
|
|
42
|
+
* partial write or hand-edit problem).
|
|
43
|
+
*/
|
|
44
|
+
export function loadInstances(file: string): Instance[] {
|
|
45
|
+
if (!fs.existsSync(file)) return [];
|
|
46
|
+
if (fs.lstatSync(file).isDirectory()) return [];
|
|
47
|
+
const text = fs.readFileSync(file, "utf8");
|
|
48
|
+
if (text.trim() === "") return [];
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = yaml.load(text);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new InstancesParseError(file, err);
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || parsed === undefined) return [];
|
|
56
|
+
if (!Array.isArray(parsed)) {
|
|
57
|
+
throw new InstancesParseError(file, "expected a YAML list at the document root");
|
|
58
|
+
}
|
|
59
|
+
return parsed as Instance[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildInstance(name: string, connStr: string): Instance {
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
conn_str: connStr,
|
|
66
|
+
preset_metrics: "full",
|
|
67
|
+
custom_metrics: null,
|
|
68
|
+
is_enabled: true,
|
|
69
|
+
group: "default",
|
|
70
|
+
custom_tags: {
|
|
71
|
+
env: "production",
|
|
72
|
+
cluster: "default",
|
|
73
|
+
node_name: name,
|
|
74
|
+
// Sed-substituted placeholder by config/scripts/generate-pgwatch-sources.sh.
|
|
75
|
+
// js-yaml emits this unquoted on dump (~ is only special at the start of a
|
|
76
|
+
// scalar in the right context); sed s/~sink_type~/.../g still hits it as
|
|
77
|
+
// raw text regardless.
|
|
78
|
+
sink_type: "~sink_type~",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse → mutate → serialize: load existing list, append, dump back.
|
|
85
|
+
*
|
|
86
|
+
* Replaces the previous text-append code path which corrupted instances.yml
|
|
87
|
+
* after `remove` had left the empty marker `[]` in the file (the append
|
|
88
|
+
* produced two YAML documents in one file → parse error on every subsequent
|
|
89
|
+
* read).
|
|
90
|
+
*
|
|
91
|
+
* Replaces files where the previous code path treated the directory created
|
|
92
|
+
* by Docker's bind-mount-into-missing-path as a target.
|
|
93
|
+
*/
|
|
94
|
+
export function addInstanceToFile(file: string, instance: Instance): void {
|
|
95
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
|
|
96
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
const existing = loadInstances(file);
|
|
99
|
+
if (existing.some((i) => i.name === instance.name)) {
|
|
100
|
+
throw new Error(`Monitoring target '${instance.name}' already exists`);
|
|
101
|
+
}
|
|
102
|
+
existing.push(instance);
|
|
103
|
+
fs.writeFileSync(file, yaml.dump(existing), "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a named instance from the file. Returns true if removed.
|
|
108
|
+
*/
|
|
109
|
+
export function removeInstanceFromFile(file: string, name: string): boolean {
|
|
110
|
+
const instances = loadInstances(file);
|
|
111
|
+
const filtered = instances.filter((i) => i.name !== name);
|
|
112
|
+
if (filtered.length === instances.length) return false;
|
|
113
|
+
fs.writeFileSync(file, yaml.dump(filtered), "utf8");
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extract `sslmode` (lowercased) from a postgresql:// URL. Returns `""` for
|
|
119
|
+
* unset or unparseable.
|
|
120
|
+
*/
|
|
121
|
+
export function extractSslmode(connStr: string): string {
|
|
122
|
+
try {
|
|
123
|
+
return (new URL(connStr).searchParams.get("sslmode") || "").toLowerCase();
|
|
124
|
+
} catch {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map libpq sslmode values to node-postgres' `ssl` option.
|
|
131
|
+
*
|
|
132
|
+
* libpq: node-postgres ssl:
|
|
133
|
+
* disable false
|
|
134
|
+
* allow / prefer / require { rejectUnauthorized: false } (encrypt, no chain check)
|
|
135
|
+
* verify-ca { rejectUnauthorized: true, checkServerIdentity: () => undefined }
|
|
136
|
+
* verify-full { rejectUnauthorized: true }
|
|
137
|
+
* no-verify (pg extension) { rejectUnauthorized: false }
|
|
138
|
+
*
|
|
139
|
+
* Default for unset: prefer-like → no chain verification, matches what
|
|
140
|
+
* pgwatch (Go pgx) does and what users pass to psql every day.
|
|
141
|
+
*/
|
|
142
|
+
export type SslOption = false | { rejectUnauthorized: boolean; checkServerIdentity?: () => undefined };
|
|
143
|
+
|
|
144
|
+
export function sslOptionFromConnString(connStr: string): SslOption {
|
|
145
|
+
return sslOptionFromSslmode(extractSslmode(connStr));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sslOptionFromSslmode(sslmode: string): SslOption {
|
|
149
|
+
switch (sslmode) {
|
|
150
|
+
case "disable":
|
|
151
|
+
return false;
|
|
152
|
+
case "verify-ca":
|
|
153
|
+
return { rejectUnauthorized: true, checkServerIdentity: () => undefined };
|
|
154
|
+
case "verify-full":
|
|
155
|
+
return { rejectUnauthorized: true };
|
|
156
|
+
case "allow":
|
|
157
|
+
case "prefer":
|
|
158
|
+
case "require":
|
|
159
|
+
case "no-verify":
|
|
160
|
+
case "":
|
|
161
|
+
default:
|
|
162
|
+
return { rejectUnauthorized: false };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The set of sslmode values for which we DO NOT verify the certificate chain.
|
|
168
|
+
* Exposed so callers can warn users about the lax security posture.
|
|
169
|
+
*/
|
|
170
|
+
export const LAX_SSLMODES = new Set(["", "allow", "prefer", "require"]);
|
|
171
|
+
|
|
172
|
+
export function isLaxSslmode(sslmode: string): boolean {
|
|
173
|
+
return LAX_SSLMODES.has(sslmode);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Print a stderr warning when the connection string uses an sslmode that
|
|
178
|
+
* skips certificate-chain verification. Centralises the message so the
|
|
179
|
+
* three Client-construction sites stay consistent.
|
|
180
|
+
*/
|
|
181
|
+
export function warnIfLaxSslmode(connStr: string): void {
|
|
182
|
+
const sslmode = extractSslmode(connStr);
|
|
183
|
+
if (!isLaxSslmode(sslmode)) return;
|
|
184
|
+
const shown = sslmode || "(unset)";
|
|
185
|
+
console.error(
|
|
186
|
+
`⚠ sslmode=${shown}: TLS chain is NOT verified (matches libpq/psql semantics). ` +
|
|
187
|
+
`Use sslmode=verify-full for full chain+hostname verification.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build a `pg.Client` config from a connection string that ACTUALLY honors
|
|
193
|
+
* libpq sslmode semantics.
|
|
194
|
+
*
|
|
195
|
+
* This is non-trivial because node-postgres' `Client` constructor, given a
|
|
196
|
+
* `connectionString`, runs `Object.assign({}, config, parse(connectionString))`
|
|
197
|
+
* — meaning the parsed sslmode-derived `ssl` value REPLACES any explicit
|
|
198
|
+
* `ssl` you passed alongside `connectionString`. So setting
|
|
199
|
+
* `{ connectionString, ssl: { rejectUnauthorized: false } }` does not work
|
|
200
|
+
* when the URL contains `sslmode=require` (the parsed value `{}` wins, and
|
|
201
|
+
* `{}` defaults to chain verification → "self-signed certificate in
|
|
202
|
+
* certificate chain" against managed Postgres).
|
|
203
|
+
*
|
|
204
|
+
* The fix: parse the URL ourselves, pass discrete host/port/user/etc., and
|
|
205
|
+
* include our explicit `ssl` — never pass `connectionString` so nothing
|
|
206
|
+
* overrides us.
|
|
207
|
+
*
|
|
208
|
+
* We strip `sslmode` from the URL before handing it to `pg-connection-string`'s
|
|
209
|
+
* `parse()` so that:
|
|
210
|
+
* 1. its `process.emitWarning("SECURITY WARNING: SSL modes 'prefer'/'require'/
|
|
211
|
+
* 'verify-ca' are treated as aliases for 'verify-full'…")` doesn't fire
|
|
212
|
+
* on every CLI invocation against a Supabase-shaped URL, and
|
|
213
|
+
* 2. its `verify-ca` compatibility branch doesn't *throw* on us (requires
|
|
214
|
+
* sslrootcert which we don't have).
|
|
215
|
+
*
|
|
216
|
+
* We don't use the parser's `ssl` output anyway — we compute our own from
|
|
217
|
+
* `sslOptionFromSslmode(extractSslmode(originalConnStr))` — so removing the
|
|
218
|
+
* sslmode parameter before parsing is a no-op for the fields we actually use.
|
|
219
|
+
*/
|
|
220
|
+
export function buildClientConfig(
|
|
221
|
+
connStr: string,
|
|
222
|
+
extra: { connectionTimeoutMillis?: number } = {},
|
|
223
|
+
): ClientConfig {
|
|
224
|
+
const sslmode = extractSslmode(connStr);
|
|
225
|
+
const parsed = parseConnString(withoutSslmode(connStr));
|
|
226
|
+
return {
|
|
227
|
+
host: parsed.host || undefined,
|
|
228
|
+
port: parsed.port ? Number(parsed.port) : undefined,
|
|
229
|
+
user: parsed.user,
|
|
230
|
+
password: parsed.password,
|
|
231
|
+
database: parsed.database || undefined,
|
|
232
|
+
ssl: sslOptionFromSslmode(sslmode),
|
|
233
|
+
...extra,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function withoutSslmode(connStr: string): string {
|
|
238
|
+
try {
|
|
239
|
+
const u = new URL(connStr);
|
|
240
|
+
u.searchParams.delete("sslmode");
|
|
241
|
+
return u.toString();
|
|
242
|
+
} catch {
|
|
243
|
+
return connStr;
|
|
244
|
+
}
|
|
245
|
+
}
|
package/package.json
CHANGED
package/test/monitoring.test.ts
CHANGED
|
@@ -2,6 +2,20 @@ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as os from "os";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
|
+
import { Client } from "pg";
|
|
7
|
+
import {
|
|
8
|
+
addInstanceToFile,
|
|
9
|
+
removeInstanceFromFile,
|
|
10
|
+
loadInstances,
|
|
11
|
+
buildInstance,
|
|
12
|
+
buildClientConfig,
|
|
13
|
+
sslOptionFromConnString,
|
|
14
|
+
warnIfLaxSslmode,
|
|
15
|
+
isLaxSslmode,
|
|
16
|
+
extractSslmode,
|
|
17
|
+
InstancesParseError,
|
|
18
|
+
} from "../lib/instances";
|
|
5
19
|
|
|
6
20
|
/**
|
|
7
21
|
* Test updatePgwatchConfig function behavior.
|
|
@@ -337,3 +351,266 @@ describe("demo mode instances.demo.yml", () => {
|
|
|
337
351
|
}
|
|
338
352
|
});
|
|
339
353
|
});
|
|
354
|
+
|
|
355
|
+
describe("docker-compose: default network has IPv6 enabled", () => {
|
|
356
|
+
const repoRoot = path.resolve(import.meta.dir, "..", "..");
|
|
357
|
+
|
|
358
|
+
test("root docker-compose.yml declares networks.default with enable_ipv6", () => {
|
|
359
|
+
const composePath = path.join(repoRoot, "docker-compose.yml");
|
|
360
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
361
|
+
|
|
362
|
+
// Use string match so we also catch the env-overridable form
|
|
363
|
+
// `enable_ipv6: ${PGAI_ENABLE_IPV6:-true}`, which Compose interpolates
|
|
364
|
+
// before the YAML strict-bool check.
|
|
365
|
+
expect(content).toMatch(/^networks:/m);
|
|
366
|
+
expect(content).toMatch(/^\s*default:/m);
|
|
367
|
+
expect(content).toMatch(/enable_ipv6:\s*(true|\$\{PGAI_ENABLE_IPV6:-true\})/);
|
|
368
|
+
|
|
369
|
+
// Also assert the YAML is parseable (catches indentation regressions).
|
|
370
|
+
const parsed = yaml.load(content) as any;
|
|
371
|
+
expect(parsed?.networks?.default).toBeDefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("env override default resolves to 'true' when PGAI_ENABLE_IPV6 is unset", () => {
|
|
375
|
+
// Mirror Compose's `${VAR:-default}` interpolation rule. Verifies that the
|
|
376
|
+
// template's default value matches the documented one.
|
|
377
|
+
const composePath = path.join(repoRoot, "docker-compose.yml");
|
|
378
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
379
|
+
const m = content.match(/enable_ipv6:\s*\$\{PGAI_ENABLE_IPV6:-(\w+)\}/);
|
|
380
|
+
expect(m).not.toBeNull();
|
|
381
|
+
expect(m![1]).toBe("true");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("addInstanceToFile / removeInstanceFromFile round-trip", () => {
|
|
386
|
+
let tempDir: string;
|
|
387
|
+
let instancesFile: string;
|
|
388
|
+
|
|
389
|
+
beforeEach(() => {
|
|
390
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "instances-test-"));
|
|
391
|
+
instancesFile = path.join(tempDir, "instances.yml");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
afterEach(() => {
|
|
395
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
396
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("add to empty file produces a valid YAML list", () => {
|
|
401
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
402
|
+
const list = loadInstances(instancesFile);
|
|
403
|
+
expect(list.length).toBe(1);
|
|
404
|
+
expect(list[0].name).toBe("t1");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("add → remove → add cycle keeps file parseable (regression)", () => {
|
|
408
|
+
// The previous bug: after `remove` left `[]` in the file, `add` appended a
|
|
409
|
+
// list-item next to it, producing two YAML documents in one file.
|
|
410
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
411
|
+
removeInstanceFromFile(instancesFile, "t1");
|
|
412
|
+
expect(loadInstances(instancesFile)).toEqual([]);
|
|
413
|
+
|
|
414
|
+
addInstanceToFile(instancesFile, buildInstance("t2", "postgresql://u:p@h:5432/db2"));
|
|
415
|
+
|
|
416
|
+
// Must NOT throw "end of the stream or a document separator is expected".
|
|
417
|
+
const list = loadInstances(instancesFile);
|
|
418
|
+
expect(list.length).toBe(1);
|
|
419
|
+
expect(list[0].name).toBe("t2");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("sink_type placeholder survives the round-trip", () => {
|
|
423
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
424
|
+
const content = fs.readFileSync(instancesFile, "utf8");
|
|
425
|
+
// js-yaml emits ~sink_type~ unquoted (it's only special when standalone);
|
|
426
|
+
// sed s/~sink_type~/.../g still hits it as raw text regardless.
|
|
427
|
+
expect(content).toContain("~sink_type~");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("add throws InstancesParseError on a corrupted file (no silent overwrite)", () => {
|
|
431
|
+
// Silent overwrite would discard credentials in conn_str values. Refuse.
|
|
432
|
+
fs.writeFileSync(instancesFile, "key: [unclosed\nfoo: bar\n", "utf8");
|
|
433
|
+
|
|
434
|
+
expect(() =>
|
|
435
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
|
|
436
|
+
).toThrow(InstancesParseError);
|
|
437
|
+
|
|
438
|
+
// File contents are unchanged.
|
|
439
|
+
expect(fs.readFileSync(instancesFile, "utf8")).toBe("key: [unclosed\nfoo: bar\n");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("add rejects duplicate name", () => {
|
|
443
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
444
|
+
expect(() =>
|
|
445
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
|
|
446
|
+
).toThrow(/already exists/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("add replaces a directory at the target path (Docker bind-mount artifact)", () => {
|
|
450
|
+
fs.mkdirSync(instancesFile);
|
|
451
|
+
expect(fs.statSync(instancesFile).isDirectory()).toBe(true);
|
|
452
|
+
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
453
|
+
expect(fs.statSync(instancesFile).isFile()).toBe(true);
|
|
454
|
+
expect(loadInstances(instancesFile).length).toBe(1);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("sslOptionFromConnString — libpq semantics", () => {
|
|
459
|
+
test("sslmode=require → SSL without chain verification", () => {
|
|
460
|
+
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=require"))
|
|
461
|
+
.toEqual({ rejectUnauthorized: false });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("sslmode unset → SSL without chain verification", () => {
|
|
465
|
+
expect(sslOptionFromConnString("postgresql://u:p@h/db"))
|
|
466
|
+
.toEqual({ rejectUnauthorized: false });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("sslmode=disable → no SSL", () => {
|
|
470
|
+
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=disable")).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("sslmode=verify-ca → chain verification, no hostname check", () => {
|
|
474
|
+
const opt = sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-ca");
|
|
475
|
+
expect(opt).toMatchObject({ rejectUnauthorized: true });
|
|
476
|
+
expect(typeof (opt as any).checkServerIdentity).toBe("function");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("sslmode=verify-full → chain + hostname verification", () => {
|
|
480
|
+
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-full"))
|
|
481
|
+
.toEqual({ rejectUnauthorized: true });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("sslmode=prefer → SSL without chain verification", () => {
|
|
485
|
+
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=prefer"))
|
|
486
|
+
.toEqual({ rejectUnauthorized: false });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("malformed connection string → safe default (no chain verification)", () => {
|
|
490
|
+
expect(sslOptionFromConnString("not-a-url")).toEqual({ rejectUnauthorized: false });
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("buildClientConfig — actual node-postgres Client gets the intended ssl (regression)", () => {
|
|
495
|
+
// The previous code passed `{ connectionString, ssl }` to `new Client(...)`.
|
|
496
|
+
// node-postgres' ConnectionParameters internally does
|
|
497
|
+
// `Object.assign({}, config, parse(connectionString))`, so the parsed
|
|
498
|
+
// `connectionString.ssl` REPLACES the explicit `ssl`. Net effect:
|
|
499
|
+
// `?sslmode=require` + `ssl: { rejectUnauthorized: false }` → `ssl: {}`
|
|
500
|
+
// (chain verified)
|
|
501
|
+
// — exactly the bug the MR claims to fix. This integration test asserts
|
|
502
|
+
// against `client.connectionParameters.ssl`, so the bug cannot return.
|
|
503
|
+
|
|
504
|
+
test("require: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
|
|
505
|
+
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require"));
|
|
506
|
+
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("verify-full: actual Client.connectionParameters.ssl has rejectUnauthorized:true", () => {
|
|
510
|
+
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=verify-full"));
|
|
511
|
+
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: true });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("disable: actual Client.connectionParameters.ssl is false", () => {
|
|
515
|
+
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=disable"));
|
|
516
|
+
expect(c.connectionParameters.ssl).toBe(false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("unset: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
|
|
520
|
+
const c = new Client(buildClientConfig("postgresql://u:p@h/db"));
|
|
521
|
+
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("connectionTimeoutMillis is forwarded (exact value, not just truthy)", () => {
|
|
525
|
+
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require", { connectionTimeoutMillis: 5000 }));
|
|
526
|
+
// node-postgres v8 stores it on `_connectionTimeoutMillis`. Asserting the
|
|
527
|
+
// exact value catches regressions that would silently swap in the default.
|
|
528
|
+
expect((c as any)._connectionTimeoutMillis).toBe(5000);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("warnIfLaxSslmode — UX warning for lax sslmode", () => {
|
|
533
|
+
let stderrSpy: ReturnType<typeof spyOn>;
|
|
534
|
+
|
|
535
|
+
beforeEach(() => {
|
|
536
|
+
stderrSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
afterEach(() => {
|
|
540
|
+
stderrSpy.mockRestore();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
for (const sslmode of ["require", "prefer", "allow"]) {
|
|
544
|
+
test(`warns when sslmode=${sslmode}`, () => {
|
|
545
|
+
warnIfLaxSslmode(`postgresql://u:p@h/db?sslmode=${sslmode}`);
|
|
546
|
+
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
|
547
|
+
const msg = String(stderrSpy.mock.calls[0][0]);
|
|
548
|
+
expect(msg).toContain(`sslmode=${sslmode}`);
|
|
549
|
+
expect(msg).toContain("NOT verified");
|
|
550
|
+
expect(msg).toContain("verify-full");
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
test("warns when sslmode is unset (uses '(unset)' label)", () => {
|
|
555
|
+
warnIfLaxSslmode("postgresql://u:p@h/db");
|
|
556
|
+
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
|
557
|
+
expect(String(stderrSpy.mock.calls[0][0])).toContain("sslmode=(unset)");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("does NOT warn when sslmode=verify-full or verify-ca or disable", () => {
|
|
561
|
+
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-full");
|
|
562
|
+
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-ca");
|
|
563
|
+
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=disable");
|
|
564
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe("buildClientConfig — silences pg-connection-string deprecation warning", () => {
|
|
569
|
+
// pg-connection-string v2.x prints `process.emitWarning("SECURITY WARNING:
|
|
570
|
+
// ... 'prefer'/'require'/'verify-ca' ...")` whenever a recognised lax
|
|
571
|
+
// sslmode appears. Without our `uselibpqcompat=true` shim, this would fire
|
|
572
|
+
// on every CLI invocation against a Supabase-shaped URL. Assert it doesn't.
|
|
573
|
+
|
|
574
|
+
let warnings: string[];
|
|
575
|
+
let origEmitWarning: typeof process.emitWarning;
|
|
576
|
+
|
|
577
|
+
beforeEach(() => {
|
|
578
|
+
warnings = [];
|
|
579
|
+
origEmitWarning = process.emitWarning;
|
|
580
|
+
(process as any).emitWarning = (warning: any) => {
|
|
581
|
+
warnings.push(typeof warning === "string" ? warning : String(warning));
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
afterEach(() => {
|
|
586
|
+
(process as any).emitWarning = origEmitWarning;
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
for (const sslmode of ["require", "prefer", "verify-ca"]) {
|
|
590
|
+
test(`no SECURITY WARNING for sslmode=${sslmode}`, () => {
|
|
591
|
+
buildClientConfig(`postgresql://u:p@h/db?sslmode=${sslmode}`);
|
|
592
|
+
const security = warnings.filter((w) => w.includes("SECURITY"));
|
|
593
|
+
expect(security).toEqual([]);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe("extractSslmode / isLaxSslmode", () => {
|
|
599
|
+
test("extractSslmode returns lowercase", () => {
|
|
600
|
+
expect(extractSslmode("postgresql://u:p@h/db?sslmode=REQUIRE")).toBe("require");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("extractSslmode returns '' for unparseable URLs", () => {
|
|
604
|
+
expect(extractSslmode("not-a-url")).toBe("");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("isLaxSslmode covers the full set", () => {
|
|
608
|
+
expect(isLaxSslmode("")).toBe(true);
|
|
609
|
+
expect(isLaxSslmode("require")).toBe(true);
|
|
610
|
+
expect(isLaxSslmode("prefer")).toBe(true);
|
|
611
|
+
expect(isLaxSslmode("allow")).toBe(true);
|
|
612
|
+
expect(isLaxSslmode("verify-ca")).toBe(false);
|
|
613
|
+
expect(isLaxSslmode("verify-full")).toBe(false);
|
|
614
|
+
expect(isLaxSslmode("disable")).toBe(false);
|
|
615
|
+
});
|
|
616
|
+
});
|