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.
- package/README.md +82 -9
- package/bin/postgres-ai.ts +813 -233
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +6193 -1059
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +255 -24
- package/lib/config.ts +3 -0
- package/lib/init.ts +197 -5
- package/lib/instances.ts +245 -0
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +229 -18
- package/lib/metrics-loader.ts +6 -4
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +367 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +2 -0
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +625 -1
- package/test/mcp-server.test.ts +944 -2
- package/test/monitoring.test.ts +355 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +935 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
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.
|
|
@@ -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
|
+
});
|