postgresai 0.15.0-dev.6 → 0.15.0-dev.9
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 +3 -1
- package/bin/postgres-ai.ts +119 -71
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +867 -232
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +225 -0
- package/lib/init.ts +195 -3
- package/lib/metrics-loader.ts +3 -1
- package/lib/supabase.ts +8 -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 +1288 -2
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +528 -8
- package/test/monitoring.test.ts +2 -2
- package/test/permission-check-sql.test.ts +116 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/test/init.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
-
import { resolve } from "path";
|
|
2
|
+
import path, { resolve } from "path";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as os from "os";
|
|
5
5
|
|
|
@@ -1073,9 +1073,64 @@ describe("CLI commands", () => {
|
|
|
1073
1073
|
test("cli: mon local-install --demo configures demo monitoring target", () => {
|
|
1074
1074
|
// --demo should copy instances.demo.yml to instances.yml and print confirmation.
|
|
1075
1075
|
// The command will fail later (no Docker), but we verify the demo target step succeeded.
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1076
|
+
// resolvePaths() walks cwd() up to find docker-compose.yml, so instances.yml
|
|
1077
|
+
// is written next to docker-compose.yml in the repo root.
|
|
1078
|
+
const repoRoot = resolve(import.meta.dir, "..", "..");
|
|
1079
|
+
const instancesPath = path.join(repoRoot, "instances.yml");
|
|
1080
|
+
// Remove instances.yml if it exists — use rmSync to handle both files and
|
|
1081
|
+
// directories (the EISDIR test may have left a directory here if it failed).
|
|
1082
|
+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
1083
|
+
try {
|
|
1084
|
+
const r = runCli(["mon", "local-install", "--demo"]);
|
|
1085
|
+
expect(r.stdout).toMatch(/Demo mode enabled/);
|
|
1086
|
+
expect(r.stdout).toMatch(/Demo monitoring target configured/);
|
|
1087
|
+
// Verify instances.yml was actually written with the demo target
|
|
1088
|
+
expect(fs.existsSync(instancesPath)).toBe(true);
|
|
1089
|
+
const content = fs.readFileSync(instancesPath, "utf8");
|
|
1090
|
+
expect(content).toContain("name: target_database");
|
|
1091
|
+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
1092
|
+
} finally {
|
|
1093
|
+
// Clean up — instances.yml is gitignored so safe to remove
|
|
1094
|
+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("cli: mon local-install --demo exits with code 1 when instances.demo.yml is missing", () => {
|
|
1099
|
+
// Regression: if instances.demo.yml cannot be found in any candidate path, the CLI
|
|
1100
|
+
// must exit with a non-zero code and a descriptive error (not silently create empty dashboards).
|
|
1101
|
+
const repoRoot = resolve(import.meta.dir, "..", "..");
|
|
1102
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
1103
|
+
const tempBackup = path.join(os.tmpdir(), `instances.demo.yml.test-backup-${Date.now()}`);
|
|
1104
|
+
// Temporarily move instances.demo.yml so neither candidate path resolves
|
|
1105
|
+
fs.copyFileSync(demoFile, tempBackup);
|
|
1106
|
+
fs.unlinkSync(demoFile);
|
|
1107
|
+
try {
|
|
1108
|
+
const r = runCli(["mon", "local-install", "--demo"]);
|
|
1109
|
+
expect(r.status).not.toBe(0);
|
|
1110
|
+
expect(r.stderr).toContain("instances.demo.yml not found");
|
|
1111
|
+
} finally {
|
|
1112
|
+
// Restore the file — critical to do before any assertion can throw
|
|
1113
|
+
if (!fs.existsSync(demoFile)) fs.copyFileSync(tempBackup, demoFile);
|
|
1114
|
+
fs.rmSync(tempBackup, { force: true });
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("cli: mon local-install --demo with EISDIR recovers instances.yml", () => {
|
|
1119
|
+
// Docker bind-mounts create missing paths as directories; the CLI must handle this.
|
|
1120
|
+
const repoRoot = resolve(import.meta.dir, "..", "..");
|
|
1121
|
+
const instancesPath = path.join(repoRoot, "instances.yml");
|
|
1122
|
+
// Create instances.yml as a directory (simulating Docker artifact)
|
|
1123
|
+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
1124
|
+
fs.mkdirSync(instancesPath);
|
|
1125
|
+
try {
|
|
1126
|
+
const r = runCli(["mon", "local-install", "--demo"]);
|
|
1127
|
+
expect(r.stdout).toMatch(/Demo monitoring target configured/);
|
|
1128
|
+
expect(fs.statSync(instancesPath).isFile()).toBe(true);
|
|
1129
|
+
const content = fs.readFileSync(instancesPath, "utf8");
|
|
1130
|
+
expect(content).toContain("name: target_database");
|
|
1131
|
+
} finally {
|
|
1132
|
+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
1133
|
+
}
|
|
1079
1134
|
});
|
|
1080
1135
|
|
|
1081
1136
|
test("cli: mon local-install --demo with global --api-key shows error", () => {
|
|
@@ -1233,12 +1288,12 @@ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
|
|
|
1233
1288
|
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
1234
1289
|
}, 60000);
|
|
1235
1290
|
|
|
1236
|
-
test("existing registry and
|
|
1291
|
+
test("existing registry and passwords are preserved while tag is updated", () => {
|
|
1237
1292
|
const testDir = resolve(tempDir, "preserve-test");
|
|
1238
1293
|
fs.mkdirSync(testDir, { recursive: true });
|
|
1239
|
-
// Create .env with stale tag but valid registry and
|
|
1294
|
+
// Create .env with stale tag but valid registry and passwords
|
|
1240
1295
|
fs.writeFileSync(resolve(testDir, ".env"),
|
|
1241
|
-
"PGAI_TAG=stale-tag\nPGAI_REGISTRY=my.registry.com\nGF_SECURITY_ADMIN_PASSWORD=secret123\n");
|
|
1296
|
+
"PGAI_TAG=stale-tag\nPGAI_REGISTRY=my.registry.com\nGF_SECURITY_ADMIN_PASSWORD=secret123\nREPLICATOR_PASSWORD=repl-secret\nVM_AUTH_USERNAME=existing-vm-user\nVM_AUTH_PASSWORD=existing-vm-pass\n");
|
|
1242
1297
|
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
1243
1298
|
|
|
1244
1299
|
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
@@ -1254,8 +1309,473 @@ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
|
|
|
1254
1309
|
// Tag should be updated (not stale-tag)
|
|
1255
1310
|
expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
|
|
1256
1311
|
|
|
1257
|
-
// But registry and
|
|
1312
|
+
// But registry and passwords should be preserved
|
|
1258
1313
|
expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
|
|
1259
1314
|
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=secret123/);
|
|
1315
|
+
expect(envContent).toMatch(/REPLICATOR_PASSWORD=repl-secret/);
|
|
1316
|
+
expect(envContent).toMatch(/VM_AUTH_USERNAME=existing-vm-user/);
|
|
1317
|
+
expect(envContent).toMatch(/VM_AUTH_PASSWORD=existing-vm-pass/);
|
|
1260
1318
|
}, 60000);
|
|
1261
1319
|
});
|
|
1320
|
+
|
|
1321
|
+
// ---------------------------------------------------------------------------
|
|
1322
|
+
// connectWithSslFallback — connectionTimeoutMillis and statement_timeout
|
|
1323
|
+
// Issues 9 & 10
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
describe("connectWithSslFallback", () => {
|
|
1326
|
+
// Issue 9: Verify that connectionTimeoutMillis is forwarded to the pg Client
|
|
1327
|
+
// constructor so slow-responding servers don't hang the CLI indefinitely.
|
|
1328
|
+
// Direct integration testing against a real TCP timeout would be flaky in CI,
|
|
1329
|
+
// so we use a mock ClientClass and assert the config passed to its constructor.
|
|
1330
|
+
test("passes connectionTimeoutMillis: 10_000 to the pg Client constructor", async () => {
|
|
1331
|
+
const receivedConfigs: any[] = [];
|
|
1332
|
+
|
|
1333
|
+
class MockClient {
|
|
1334
|
+
constructor(config: any) {
|
|
1335
|
+
receivedConfigs.push(config);
|
|
1336
|
+
}
|
|
1337
|
+
async connect() {}
|
|
1338
|
+
async query() { return {}; }
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const adminConn = init.resolveAdminConnection({ conn: "postgresql://u:p@localhost:5432/d" });
|
|
1342
|
+
// Disable SSL fallback so we exercise the simple (non-retry) path.
|
|
1343
|
+
(adminConn as any).sslFallbackEnabled = false;
|
|
1344
|
+
|
|
1345
|
+
await init.connectWithSslFallback(MockClient as any, adminConn);
|
|
1346
|
+
|
|
1347
|
+
expect(receivedConfigs.length).toBeGreaterThanOrEqual(1);
|
|
1348
|
+
expect(receivedConfigs[0].connectionTimeoutMillis).toBe(10_000);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
// Issue 10: Verify that SET statement_timeout is issued after every successful
|
|
1352
|
+
// connection to prevent runaway queries from blocking the CLI.
|
|
1353
|
+
test("issues SET statement_timeout = '30s' after connecting", async () => {
|
|
1354
|
+
const queriesSent: string[] = [];
|
|
1355
|
+
|
|
1356
|
+
class MockClient {
|
|
1357
|
+
constructor(_config: any) {}
|
|
1358
|
+
async connect() {}
|
|
1359
|
+
async query(sql: string) {
|
|
1360
|
+
queriesSent.push(sql);
|
|
1361
|
+
return {};
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const adminConn = init.resolveAdminConnection({ conn: "postgresql://u:p@localhost:5432/d" });
|
|
1366
|
+
(adminConn as any).sslFallbackEnabled = false;
|
|
1367
|
+
|
|
1368
|
+
await init.connectWithSslFallback(MockClient as any, adminConn);
|
|
1369
|
+
|
|
1370
|
+
expect(queriesSent.some((q) => /SET\s+statement_timeout/i.test(q))).toBe(true);
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
describe("checkCurrentUserPermissions", () => {
|
|
1375
|
+
function makeMockClient(rows: init.PermissionCheckRow[]) {
|
|
1376
|
+
return {
|
|
1377
|
+
query: async () => ({ rows }),
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function makeFailingClient(error: Error) {
|
|
1382
|
+
return {
|
|
1383
|
+
query: async () => { throw error; },
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
test("returns ok when all required permissions are granted", async () => {
|
|
1388
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1389
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1390
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1391
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1392
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1393
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1394
|
+
];
|
|
1395
|
+
|
|
1396
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1397
|
+
expect(result.ok).toBe(true);
|
|
1398
|
+
expect(result.missingRequired).toHaveLength(0);
|
|
1399
|
+
expect(result.missingOptional).toHaveLength(0);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test("reports missing required permissions with fix commands", async () => {
|
|
1403
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1404
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1405
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1406
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1407
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1408
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1409
|
+
];
|
|
1410
|
+
|
|
1411
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1412
|
+
expect(result.ok).toBe(false);
|
|
1413
|
+
expect(result.missingRequired).toHaveLength(1);
|
|
1414
|
+
expect(result.missingRequired[0].permission_name).toBe("pg_monitor role membership");
|
|
1415
|
+
expect(result.missingRequired[0].fix_command).toBe("grant pg_monitor to testuser;");
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test("reports missing optional permissions without failing", async () => {
|
|
1419
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1420
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1421
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1422
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1423
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
|
|
1424
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: false, fix_command: "grant select on postgres_ai.pg_statistic to testuser;" },
|
|
1425
|
+
];
|
|
1426
|
+
|
|
1427
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1428
|
+
expect(result.ok).toBe(true);
|
|
1429
|
+
expect(result.missingRequired).toHaveLength(0);
|
|
1430
|
+
expect(result.missingOptional).toHaveLength(2);
|
|
1431
|
+
expect(result.missingOptional[0].permission_name).toBe("postgres_ai.pg_statistic view exists");
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
test("reports multiple missing required permissions", async () => {
|
|
1435
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1436
|
+
{ permission_name: "connect on database postgres", status: "required", granted: false, fix_command: "grant connect on database postgres to testuser;" },
|
|
1437
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1438
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: false, fix_command: "grant select on pg_catalog.pg_index to testuser;" },
|
|
1439
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
|
|
1440
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
|
|
1441
|
+
];
|
|
1442
|
+
|
|
1443
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1444
|
+
expect(result.ok).toBe(false);
|
|
1445
|
+
expect(result.missingRequired).toHaveLength(3);
|
|
1446
|
+
expect(result.missingOptional).toHaveLength(1);
|
|
1447
|
+
// null granted for optional (view doesn't exist) should NOT count as missing optional
|
|
1448
|
+
expect(result.rows[4].granted).toBeNull();
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("treats null granted as missing for required permissions (fail-safe)", async () => {
|
|
1452
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1453
|
+
{ permission_name: "connect on database postgres", status: "required", granted: null, fix_command: null },
|
|
1454
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1455
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1456
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1457
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1458
|
+
];
|
|
1459
|
+
|
|
1460
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1461
|
+
// null on a required check should be treated as not-granted
|
|
1462
|
+
expect(result.ok).toBe(false);
|
|
1463
|
+
expect(result.missingRequired).toHaveLength(1);
|
|
1464
|
+
expect(result.missingRequired[0].permission_name).toBe("connect on database postgres");
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test("null granted on optional permission is not treated as missing", async () => {
|
|
1468
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1469
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1470
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1471
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1472
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
|
|
1473
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
|
|
1474
|
+
];
|
|
1475
|
+
|
|
1476
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1477
|
+
expect(result.ok).toBe(true);
|
|
1478
|
+
// null granted on optional should NOT be treated as missing — check was skipped
|
|
1479
|
+
expect(result.missingOptional).toHaveLength(1);
|
|
1480
|
+
expect(result.missingOptional[0].permission_name).toBe("postgres_ai.pg_statistic view exists");
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
test("propagates query errors to caller", async () => {
|
|
1484
|
+
const dbError = new Error("permission denied for relation pg_roles");
|
|
1485
|
+
const client = makeFailingClient(dbError);
|
|
1486
|
+
|
|
1487
|
+
await expect(
|
|
1488
|
+
init.checkCurrentUserPermissions(client as any)
|
|
1489
|
+
).rejects.toThrow("permission denied for relation pg_roles");
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test("returns all rows for inspection", async () => {
|
|
1493
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1494
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1495
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1496
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1497
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1498
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1499
|
+
];
|
|
1500
|
+
|
|
1501
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
|
|
1502
|
+
expect(result.rows).toHaveLength(5);
|
|
1503
|
+
expect(result.rows).toEqual(rows);
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
test("handles empty rows (no permission checks returned)", async () => {
|
|
1507
|
+
const result = await init.checkCurrentUserPermissions(makeMockClient([]) as any);
|
|
1508
|
+
expect(result.ok).toBe(true);
|
|
1509
|
+
expect(result.rows).toHaveLength(0);
|
|
1510
|
+
expect(result.missingRequired).toHaveLength(0);
|
|
1511
|
+
expect(result.missingOptional).toHaveLength(0);
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
describe("formatPermissionCheckMessages", () => {
|
|
1516
|
+
test("returns no warnings or errors when all permissions granted", () => {
|
|
1517
|
+
const result: init.PreflightPermissionResult = {
|
|
1518
|
+
ok: true,
|
|
1519
|
+
rows: [],
|
|
1520
|
+
missingRequired: [],
|
|
1521
|
+
missingOptional: [],
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1525
|
+
expect(messages.failed).toBe(false);
|
|
1526
|
+
expect(messages.warnings).toHaveLength(0);
|
|
1527
|
+
expect(messages.errors).toHaveLength(0);
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
test("returns warnings for missing optional permissions", () => {
|
|
1531
|
+
const result: init.PreflightPermissionResult = {
|
|
1532
|
+
ok: true,
|
|
1533
|
+
rows: [],
|
|
1534
|
+
missingRequired: [],
|
|
1535
|
+
missingOptional: [
|
|
1536
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create view" },
|
|
1537
|
+
],
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1541
|
+
expect(messages.failed).toBe(false);
|
|
1542
|
+
expect(messages.warnings).toHaveLength(1);
|
|
1543
|
+
expect(messages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
|
|
1544
|
+
expect(messages.warnings[0]).toContain("Fix: -- create view");
|
|
1545
|
+
expect(messages.errors).toHaveLength(0);
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
test("returns errors with fix commands for missing required permissions", () => {
|
|
1549
|
+
const result: init.PreflightPermissionResult = {
|
|
1550
|
+
ok: false,
|
|
1551
|
+
rows: [],
|
|
1552
|
+
missingRequired: [
|
|
1553
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1554
|
+
],
|
|
1555
|
+
missingOptional: [],
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1559
|
+
expect(messages.failed).toBe(true);
|
|
1560
|
+
expect(messages.errors.some((e) => e.includes("pg_monitor role membership"))).toBe(true);
|
|
1561
|
+
expect(messages.errors.some((e) => e.includes("grant pg_monitor to testuser;"))).toBe(true);
|
|
1562
|
+
expect(messages.errors.some((e) => e.includes("postgresai prepare-db"))).toBe(true);
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test("omits fix section when all fix_commands are null", () => {
|
|
1566
|
+
const result: init.PreflightPermissionResult = {
|
|
1567
|
+
ok: false,
|
|
1568
|
+
rows: [],
|
|
1569
|
+
missingRequired: [
|
|
1570
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: null, fix_command: null },
|
|
1571
|
+
],
|
|
1572
|
+
missingOptional: [],
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1576
|
+
expect(messages.failed).toBe(true);
|
|
1577
|
+
expect(messages.errors.some((e) => e.includes("pg_monitor role membership"))).toBe(true);
|
|
1578
|
+
// Should NOT have "To fix" section when no fix commands
|
|
1579
|
+
expect(messages.errors.some((e) => e.includes("To fix"))).toBe(false);
|
|
1580
|
+
// Should still suggest prepare-db
|
|
1581
|
+
expect(messages.errors.some((e) => e.includes("postgresai prepare-db"))).toBe(true);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
test("includes both warnings and errors when both are present", () => {
|
|
1585
|
+
const result: init.PreflightPermissionResult = {
|
|
1586
|
+
ok: false,
|
|
1587
|
+
rows: [],
|
|
1588
|
+
missingRequired: [
|
|
1589
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1590
|
+
],
|
|
1591
|
+
missingOptional: [
|
|
1592
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: null },
|
|
1593
|
+
],
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1597
|
+
expect(messages.failed).toBe(true);
|
|
1598
|
+
expect(messages.warnings).toHaveLength(1);
|
|
1599
|
+
expect(messages.errors.length).toBeGreaterThan(0);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("warning without fix_command omits Fix: suffix", () => {
|
|
1603
|
+
const result: init.PreflightPermissionResult = {
|
|
1604
|
+
ok: true,
|
|
1605
|
+
rows: [],
|
|
1606
|
+
missingRequired: [],
|
|
1607
|
+
missingOptional: [
|
|
1608
|
+
{ permission_name: "some optional check", status: "optional", granted: false, fix_command: null },
|
|
1609
|
+
],
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1613
|
+
expect(messages.warnings[0]).not.toContain("Fix:");
|
|
1614
|
+
expect(messages.warnings[0]).toContain("some optional check");
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
describe("Permission check integration (checkup command)", () => {
|
|
1619
|
+
/**
|
|
1620
|
+
* Integration tests for the permission check flow in the checkup command.
|
|
1621
|
+
* These tests verify that the permission check integration in postgres-ai.ts
|
|
1622
|
+
* correctly handles different permission scenarios:
|
|
1623
|
+
* - Successful checks allow execution to proceed
|
|
1624
|
+
* - Missing required permissions halt execution with exitCode=1
|
|
1625
|
+
* - Missing optional permissions show warnings but allow execution to continue
|
|
1626
|
+
*/
|
|
1627
|
+
|
|
1628
|
+
function makeMockClientForIntegration(permissionRows: init.PermissionCheckRow[], reportResult?: any) {
|
|
1629
|
+
const queriesExecuted: string[] = [];
|
|
1630
|
+
return {
|
|
1631
|
+
client: {
|
|
1632
|
+
query: async (sql: string) => {
|
|
1633
|
+
queriesExecuted.push(sql);
|
|
1634
|
+
// Return permission check results for the permission check query
|
|
1635
|
+
if (sql.includes("permission_checks")) {
|
|
1636
|
+
return { rows: permissionRows };
|
|
1637
|
+
}
|
|
1638
|
+
// Return empty result for other queries (like report generation)
|
|
1639
|
+
return reportResult || { rows: [] };
|
|
1640
|
+
},
|
|
1641
|
+
end: async () => {},
|
|
1642
|
+
},
|
|
1643
|
+
queriesExecuted,
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
test("successful permission check allows execution to proceed", async () => {
|
|
1648
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1649
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1650
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1651
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1652
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1653
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1654
|
+
];
|
|
1655
|
+
|
|
1656
|
+
const mockClient = makeMockClientForIntegration(rows);
|
|
1657
|
+
const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
|
|
1658
|
+
const permMessages = init.formatPermissionCheckMessages(permCheck);
|
|
1659
|
+
|
|
1660
|
+
// Verify permission check passed
|
|
1661
|
+
expect(permMessages.failed).toBe(false);
|
|
1662
|
+
expect(permMessages.warnings).toHaveLength(0);
|
|
1663
|
+
expect(permMessages.errors).toHaveLength(0);
|
|
1664
|
+
|
|
1665
|
+
// In the actual integration, process.exitCode would not be set and execution continues
|
|
1666
|
+
// This simulates the successful path where reports would be generated
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
test("missing required permissions halt execution with clear error messages", async () => {
|
|
1670
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1671
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1672
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1673
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: false, fix_command: "grant select on pg_catalog.pg_index to testuser;" },
|
|
1674
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
|
|
1675
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
|
|
1676
|
+
];
|
|
1677
|
+
|
|
1678
|
+
const mockClient = makeMockClientForIntegration(rows);
|
|
1679
|
+
const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
|
|
1680
|
+
const permMessages = init.formatPermissionCheckMessages(permCheck);
|
|
1681
|
+
|
|
1682
|
+
// Verify permission check failed
|
|
1683
|
+
expect(permMessages.failed).toBe(true);
|
|
1684
|
+
expect(permMessages.errors.length).toBeGreaterThan(0);
|
|
1685
|
+
|
|
1686
|
+
// Verify error messages include the missing permissions
|
|
1687
|
+
const errorText = permMessages.errors.join("\n");
|
|
1688
|
+
expect(errorText).toContain("pg_monitor role membership");
|
|
1689
|
+
expect(errorText).toContain("select on pg_catalog.pg_index");
|
|
1690
|
+
|
|
1691
|
+
// Verify fix commands are included
|
|
1692
|
+
expect(errorText).toContain("grant pg_monitor to testuser;");
|
|
1693
|
+
expect(errorText).toContain("grant select on pg_catalog.pg_index to testuser;");
|
|
1694
|
+
|
|
1695
|
+
// Verify alternative fix suggestion
|
|
1696
|
+
expect(errorText).toContain("postgresai prepare-db");
|
|
1697
|
+
|
|
1698
|
+
// In the actual integration, process.exitCode would be set to 1 and execution would halt
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test("missing optional permissions show warnings but allow execution to proceed", async () => {
|
|
1702
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1703
|
+
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
1704
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1705
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1706
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
|
|
1707
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
|
|
1708
|
+
];
|
|
1709
|
+
|
|
1710
|
+
const mockClient = makeMockClientForIntegration(rows);
|
|
1711
|
+
const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
|
|
1712
|
+
const permMessages = init.formatPermissionCheckMessages(permCheck);
|
|
1713
|
+
|
|
1714
|
+
// Verify permission check passed (required permissions OK)
|
|
1715
|
+
expect(permMessages.failed).toBe(false);
|
|
1716
|
+
|
|
1717
|
+
// Verify warnings are present for optional permissions
|
|
1718
|
+
expect(permMessages.warnings).toHaveLength(1);
|
|
1719
|
+
expect(permMessages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
|
|
1720
|
+
expect(permMessages.warnings[0]).toContain("Fix: -- create postgres_ai.pg_statistic view");
|
|
1721
|
+
|
|
1722
|
+
// Verify no errors (only warnings)
|
|
1723
|
+
expect(permMessages.errors).toHaveLength(0);
|
|
1724
|
+
|
|
1725
|
+
// In the actual integration, warnings would be printed to stderr but execution continues
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
test("permission check integration handles multiple missing required permissions", async () => {
|
|
1729
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1730
|
+
{ permission_name: "connect on database postgres", status: "required", granted: false, fix_command: "grant connect on database postgres to testuser;" },
|
|
1731
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
|
|
1732
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: false, fix_command: "grant select on pg_catalog.pg_index to testuser;" },
|
|
1733
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
|
|
1734
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
|
|
1735
|
+
];
|
|
1736
|
+
|
|
1737
|
+
const mockClient = makeMockClientForIntegration(rows);
|
|
1738
|
+
const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
|
|
1739
|
+
const permMessages = init.formatPermissionCheckMessages(permCheck);
|
|
1740
|
+
|
|
1741
|
+
// Verify permission check failed
|
|
1742
|
+
expect(permMessages.failed).toBe(true);
|
|
1743
|
+
|
|
1744
|
+
// Verify all missing required permissions are reported
|
|
1745
|
+
const errorText = permMessages.errors.join("\n");
|
|
1746
|
+
expect(errorText).toContain("connect on database postgres");
|
|
1747
|
+
expect(errorText).toContain("pg_monitor role membership");
|
|
1748
|
+
expect(errorText).toContain("select on pg_catalog.pg_index");
|
|
1749
|
+
|
|
1750
|
+
// Verify all fix commands are included
|
|
1751
|
+
expect(errorText).toContain("grant connect on database postgres to testuser;");
|
|
1752
|
+
expect(errorText).toContain("grant pg_monitor to testuser;");
|
|
1753
|
+
expect(errorText).toContain("grant select on pg_catalog.pg_index to testuser;");
|
|
1754
|
+
|
|
1755
|
+
// Verify warning for optional permission
|
|
1756
|
+
expect(permMessages.warnings).toHaveLength(1);
|
|
1757
|
+
expect(permMessages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
test("permission check integration handles null granted values correctly", async () => {
|
|
1761
|
+
const rows: init.PermissionCheckRow[] = [
|
|
1762
|
+
{ permission_name: "connect on database postgres", status: "required", granted: null, fix_command: null },
|
|
1763
|
+
{ permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
|
|
1764
|
+
{ permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
|
|
1765
|
+
{ permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: null, fix_command: null },
|
|
1766
|
+
{ permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
|
|
1767
|
+
];
|
|
1768
|
+
|
|
1769
|
+
const mockClient = makeMockClientForIntegration(rows);
|
|
1770
|
+
const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
|
|
1771
|
+
const permMessages = init.formatPermissionCheckMessages(permCheck);
|
|
1772
|
+
|
|
1773
|
+
// Verify null on required permission is treated as failure (fail-safe)
|
|
1774
|
+
expect(permMessages.failed).toBe(true);
|
|
1775
|
+
const errorText = permMessages.errors.join("\n");
|
|
1776
|
+
expect(errorText).toContain("connect on database postgres");
|
|
1777
|
+
|
|
1778
|
+
// Verify null on optional permissions does not generate warnings (skipped checks)
|
|
1779
|
+
expect(permMessages.warnings).toHaveLength(0);
|
|
1780
|
+
});
|
|
1781
|
+
});
|
package/test/monitoring.test.ts
CHANGED
|
@@ -286,13 +286,13 @@ describe("demo mode instances.demo.yml", () => {
|
|
|
286
286
|
expect(content).toMatch(/^\s+conn_str:/m);
|
|
287
287
|
expect(content).toMatch(/^\s+preset_metrics: full/m);
|
|
288
288
|
expect(content).toMatch(/^\s+is_enabled: true/m);
|
|
289
|
-
// ~sink_type~ is a
|
|
289
|
+
// ~sink_type~ is a sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
|
|
290
290
|
expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
test("instances.yml is gitignored (not tracked)", () => {
|
|
294
294
|
const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
|
|
295
|
-
expect(gitignore).
|
|
295
|
+
expect(gitignore).toMatch(/^instances\.yml$/m);
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
test("demo config can be copied to instances.yml in temp dir", () => {
|