postgresai 0.15.0-dev.1 → 0.15.0-dev.11

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.
@@ -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.
@@ -259,3 +273,344 @@ describe("registerMonitoringInstance", () => {
259
273
  expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
260
274
  });
261
275
  });
276
+
277
+ describe("demo mode instances.demo.yml", () => {
278
+ const repoRoot = path.resolve(import.meta.dir, "..", "..");
279
+
280
+ test("instances.demo.yml exists in repo root", () => {
281
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
282
+ expect(fs.existsSync(demoFile)).toBe(true);
283
+ });
284
+
285
+ test("instances.demo.yml contains demo target connection", () => {
286
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
287
+ const content = fs.readFileSync(demoFile, "utf8");
288
+ expect(content).toContain("name: target_database");
289
+ expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
290
+ expect(content).toContain("is_enabled: true");
291
+ expect(content).toContain("preset_metrics: full");
292
+ });
293
+
294
+ test("instances.demo.yml has required YAML structure", () => {
295
+ const demoFile = path.join(repoRoot, "instances.demo.yml");
296
+ const content = fs.readFileSync(demoFile, "utf8");
297
+ // Verify it's a YAML list (starts with "- name:")
298
+ expect(content).toMatch(/^- name: target_database/m);
299
+ // Verify required fields are present with correct indentation
300
+ expect(content).toMatch(/^\s+conn_str:/m);
301
+ expect(content).toMatch(/^\s+preset_metrics: full/m);
302
+ expect(content).toMatch(/^\s+is_enabled: true/m);
303
+ // ~sink_type~ is a sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
304
+ expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
305
+ });
306
+
307
+ test("instances.yml is gitignored (not tracked)", () => {
308
+ const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
309
+ expect(gitignore).toMatch(/^instances\.yml$/m);
310
+ });
311
+
312
+ test("demo config can be copied to instances.yml in temp dir", () => {
313
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-install-test-"));
314
+ try {
315
+ const demoSrc = path.join(repoRoot, "instances.demo.yml");
316
+ const instancesDest = path.join(tempDir, "instances.yml");
317
+
318
+ fs.copyFileSync(demoSrc, instancesDest);
319
+
320
+ expect(fs.existsSync(instancesDest)).toBe(true);
321
+ const content = fs.readFileSync(instancesDest, "utf8");
322
+ expect(content).toContain("name: target_database");
323
+ expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
324
+ } finally {
325
+ fs.rmSync(tempDir, { recursive: true, force: true });
326
+ }
327
+ });
328
+
329
+ test("demo config copy overwrites directory at instances.yml path", () => {
330
+ // Docker bind-mounts create missing paths as directories; the copy must handle this
331
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-eisdir-test-"));
332
+ try {
333
+ const demoSrc = path.join(repoRoot, "instances.demo.yml");
334
+ const instancesDest = path.join(tempDir, "instances.yml");
335
+
336
+ // Simulate Docker creating a directory at instances.yml path
337
+ fs.mkdirSync(instancesDest);
338
+ expect(fs.statSync(instancesDest).isDirectory()).toBe(true);
339
+
340
+ // The fix: remove directory then copy
341
+ if (fs.statSync(instancesDest).isDirectory()) {
342
+ fs.rmSync(instancesDest, { recursive: true, force: true });
343
+ }
344
+ fs.copyFileSync(demoSrc, instancesDest);
345
+
346
+ expect(fs.statSync(instancesDest).isFile()).toBe(true);
347
+ const content = fs.readFileSync(instancesDest, "utf8");
348
+ expect(content).toContain("name: target_database");
349
+ } finally {
350
+ fs.rmSync(tempDir, { recursive: true, force: true });
351
+ }
352
+ });
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
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Test the SQL logic for checking postgres_ai.pg_statistic view existence
3
+ * across different permission scenarios.
4
+ */
5
+ import { describe, test, expect } from "bun:test";
6
+
7
+ describe("postgres_ai.pg_statistic permission check SQL", () => {
8
+ test("to_regclass() returns NULL when schema doesn't exist", () => {
9
+ // Simulate the SQL check behavior
10
+ const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when schema doesn't exist
11
+ const granted = viewExists !== null;
12
+
13
+ expect(granted).toBe(false);
14
+ });
15
+
16
+ test("to_regclass() returns NULL when user lacks USAGE on schema", () => {
17
+ // When user lacks USAGE on postgres_ai schema, to_regclass() returns NULL
18
+ // even if the schema and view exist
19
+ const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when no USAGE
20
+ const granted = viewExists !== null;
21
+
22
+ expect(granted).toBe(false);
23
+ });
24
+
25
+ test("to_regclass() returns oid when view exists and user has access", () => {
26
+ // When user has USAGE on schema and view exists
27
+ const viewExists = 12345; // to_regclass('postgres_ai.pg_statistic') returns oid
28
+ const granted = viewExists !== null;
29
+
30
+ expect(granted).toBe(true);
31
+ });
32
+
33
+ test("has_table_privilege is skipped (returns null) when view doesn't exist", () => {
34
+ const viewExists = null;
35
+ const selectGranted = viewExists === null ? null : true; // skipped
36
+
37
+ expect(selectGranted).toBeNull();
38
+ });
39
+
40
+ test("has_table_privilege is checked when view exists", () => {
41
+ const viewExists = 12345;
42
+ const userHasSelect = true;
43
+ const selectGranted = viewExists === null ? null : userHasSelect;
44
+
45
+ expect(selectGranted).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("Expected behavior per scenario", () => {
50
+ test("Scenario 1: Superuser with postgres_ai.pg_statistic", () => {
51
+ // to_regclass returns oid, has_table_privilege returns true
52
+ const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
53
+ const checkSelectPrivilege = true; // has_table_privilege returns true
54
+
55
+ const missingOptional: string[] = [];
56
+ if (!checkViewExists) {
57
+ missingOptional.push("postgres_ai.pg_statistic view exists");
58
+ }
59
+ if (checkSelectPrivilege === false) {
60
+ missingOptional.push("select on postgres_ai.pg_statistic");
61
+ }
62
+
63
+ expect(missingOptional).toHaveLength(0);
64
+ });
65
+
66
+ test("Scenario 2: pg_monitor, no postgres_ai schema access (before prepare-db)", () => {
67
+ // to_regclass returns NULL because user lacks USAGE on postgres_ai schema
68
+ const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
69
+ const checkSelectPrivilege = null; // skipped because view doesn't exist
70
+
71
+ const missingOptional: string[] = [];
72
+ if (!checkViewExists) {
73
+ missingOptional.push("postgres_ai.pg_statistic view exists");
74
+ }
75
+ if (checkSelectPrivilege === false) {
76
+ missingOptional.push("select on postgres_ai.pg_statistic");
77
+ }
78
+
79
+ // Should show warning about missing view but NOT crash
80
+ expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
81
+ });
82
+
83
+ test("Scenario 3: No pg_monitor (before prepare-db)", () => {
84
+ // to_regclass returns NULL because schema doesn't exist yet
85
+ const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
86
+ const checkSelectPrivilege = null; // skipped
87
+
88
+ const missingOptional: string[] = [];
89
+ if (!checkViewExists) {
90
+ missingOptional.push("postgres_ai.pg_statistic view exists");
91
+ }
92
+ if (checkSelectPrivilege === false) {
93
+ missingOptional.push("select on postgres_ai.pg_statistic");
94
+ }
95
+
96
+ // Should show warning but NOT crash
97
+ expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
98
+ });
99
+
100
+ test("Scenario 8: After prepare-db with schema grants", () => {
101
+ // to_regclass returns oid, has_table_privilege returns true
102
+ const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
103
+ const checkSelectPrivilege = true; // has_table_privilege returns true
104
+
105
+ const missingOptional: string[] = [];
106
+ if (!checkViewExists) {
107
+ missingOptional.push("postgres_ai.pg_statistic view exists");
108
+ }
109
+ if (checkSelectPrivilege === false) {
110
+ missingOptional.push("select on postgres_ai.pg_statistic");
111
+ }
112
+
113
+ // Should be clean, no warnings
114
+ expect(missingOptional).toHaveLength(0);
115
+ });
116
+ });