postgresai 0.15.0-dev.7 → 0.15.0-rc.0
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 +93 -57
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +855 -222
- 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 +469 -4
- 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
|
@@ -1288,12 +1288,12 @@ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
|
|
|
1288
1288
|
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
1289
1289
|
}, 60000);
|
|
1290
1290
|
|
|
1291
|
-
test("existing registry and
|
|
1291
|
+
test("existing registry and passwords are preserved while tag is updated", () => {
|
|
1292
1292
|
const testDir = resolve(tempDir, "preserve-test");
|
|
1293
1293
|
fs.mkdirSync(testDir, { recursive: true });
|
|
1294
|
-
// Create .env with stale tag but valid registry and
|
|
1294
|
+
// Create .env with stale tag but valid registry and passwords
|
|
1295
1295
|
fs.writeFileSync(resolve(testDir, ".env"),
|
|
1296
|
-
"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");
|
|
1297
1297
|
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
1298
1298
|
|
|
1299
1299
|
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
@@ -1309,8 +1309,473 @@ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
|
|
|
1309
1309
|
// Tag should be updated (not stale-tag)
|
|
1310
1310
|
expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
|
|
1311
1311
|
|
|
1312
|
-
// But registry and
|
|
1312
|
+
// But registry and passwords should be preserved
|
|
1313
1313
|
expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
|
|
1314
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/);
|
|
1315
1318
|
}, 60000);
|
|
1316
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
|
+
});
|
|
@@ -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
|
+
});
|