postgresai 0.15.0-rc.8 → 0.16.0-dev.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 -0
- package/bin/postgres-ai.ts +210 -31
- package/dist/bin/postgres-ai.js +7730 -7250
- package/lib/aas-onboard.ts +217 -0
- package/lib/checkup-api.ts +75 -0
- package/lib/checkup-summary.ts +30 -0
- package/lib/checkup.ts +227 -21
- package/lib/metrics-loader.ts +10 -8
- package/lib/util.ts +10 -3
- package/package.json +1 -1
- package/scripts/embed-metrics.ts +7 -6
- package/test/aas-onboard.test.ts +217 -0
- package/test/checkup.integration.test.ts +55 -0
- package/test/checkup.test.ts +471 -1
- package/test/mcp-server.test.ts +4 -0
- package/test/monitoring.test.ts +128 -49
- package/test/schema-validation.test.ts +29 -0
- package/test/test-utils.ts +8 -0
- package/test/util.test.ts +44 -0
package/test/checkup.test.ts
CHANGED
|
@@ -42,6 +42,24 @@ function runCli(args: string[], env: Record<string, string> = {}) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Async variant for tests that need an in-process fake API (Bun.serve):
|
|
46
|
+
// spawnSync would block the event loop and the fake server could never respond.
|
|
47
|
+
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
48
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
49
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
50
|
+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
51
|
+
env: { ...process.env, ...env },
|
|
52
|
+
stdout: "pipe",
|
|
53
|
+
stderr: "pipe",
|
|
54
|
+
});
|
|
55
|
+
const [status, stdout, stderr] = await Promise.all([
|
|
56
|
+
proc.exited,
|
|
57
|
+
new Response(proc.stdout).text(),
|
|
58
|
+
new Response(proc.stderr).text(),
|
|
59
|
+
]);
|
|
60
|
+
return { status, stdout, stderr };
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
// Unit tests for parseVersionNum
|
|
46
64
|
describe("parseVersionNum", () => {
|
|
47
65
|
test("parses PG 16.3 version number", () => {
|
|
@@ -403,7 +421,11 @@ describe("Report generators with mock client", () => {
|
|
|
403
421
|
expect(pg16MetricSql).toContain("sum(coalesce(extends, 0) * op_bytes)");
|
|
404
422
|
expect(pg18MetricSql).toContain("sum(coalesce(read_bytes, 0))");
|
|
405
423
|
expect(pg18MetricSql).toContain("sum(coalesce(extend_bytes, 0))");
|
|
406
|
-
|
|
424
|
+
// PG18 removed op_bytes entirely; writeback bytes cannot be derived, so the SQL emits a constant 0.
|
|
425
|
+
expect(pg18MetricSql).toContain("0::int8 as writeback_bytes_mb");
|
|
426
|
+
// Regression guard: any op_bytes reference in the PG18 branch makes the whole query
|
|
427
|
+
// fail with `column "op_bytes" does not exist`, degrading all of I001.
|
|
428
|
+
expect(pg18MetricSql).not.toContain("op_bytes");
|
|
407
429
|
});
|
|
408
430
|
|
|
409
431
|
test("generateI001 returns unavailable on PostgreSQL 16 when ioStats are empty", async () => {
|
|
@@ -1237,6 +1259,186 @@ describe("H004 - Redundant indexes", () => {
|
|
|
1237
1259
|
// Top-level structure tests removed - covered by schema-validation.test.ts
|
|
1238
1260
|
});
|
|
1239
1261
|
|
|
1262
|
+
// Tests for F003 (Autovacuum: dead tuples)
|
|
1263
|
+
describe("F003 - Dead tuples", () => {
|
|
1264
|
+
// The seeded UX-test case that F004 missed: 8.27M dead tuples,
|
|
1265
|
+
// dead:live ratio 1.3:1 (dead_pct ~56.5%), autovacuum disabled via reloptions.
|
|
1266
|
+
const seededProblemRow = {
|
|
1267
|
+
tag_schemaname: "public",
|
|
1268
|
+
tag_relname: "events",
|
|
1269
|
+
n_live_tup: "6361538",
|
|
1270
|
+
n_dead_tup: "8270000",
|
|
1271
|
+
dead_pct: 56.52,
|
|
1272
|
+
last_autovacuum: "0",
|
|
1273
|
+
last_vacuum: "0",
|
|
1274
|
+
autovacuum_count: "0",
|
|
1275
|
+
vacuum_count: "0",
|
|
1276
|
+
autovacuum_disabled: 1,
|
|
1277
|
+
table_size_b: "2147483648",
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
test("getDeadTuples maps rows and computes flags", async () => {
|
|
1281
|
+
const mockClient = createMockClient({ deadTuplesRows: [seededProblemRow] });
|
|
1282
|
+
|
|
1283
|
+
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1284
|
+
expect(tables.length).toBe(1);
|
|
1285
|
+
const t = tables[0];
|
|
1286
|
+
expect(t.schema_name).toBe("public");
|
|
1287
|
+
expect(t.table_name).toBe("events");
|
|
1288
|
+
expect(t.n_live_tup).toBe(6361538);
|
|
1289
|
+
expect(t.n_dead_tup).toBe(8270000);
|
|
1290
|
+
expect(t.dead_pct).toBe(56.52);
|
|
1291
|
+
expect(t.autovacuum_disabled).toBe(true);
|
|
1292
|
+
expect(t.last_autovacuum).toBeNull();
|
|
1293
|
+
expect(t.last_autovacuum_epoch).toBe(0);
|
|
1294
|
+
expect(t.last_vacuum).toBeNull();
|
|
1295
|
+
expect(t.table_size_bytes).toBe(2147483648);
|
|
1296
|
+
expect(t.table_size_pretty).toBe("2.00 GiB");
|
|
1297
|
+
expect(t.exceeds_dead_tuple_thresholds).toBe(true);
|
|
1298
|
+
expect(t.autovacuum_disabled_flagged).toBe(true);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("getDeadTuples converts non-zero vacuum epochs to ISO timestamps", async () => {
|
|
1302
|
+
const mockClient = createMockClient({
|
|
1303
|
+
deadTuplesRows: [{
|
|
1304
|
+
...seededProblemRow,
|
|
1305
|
+
last_autovacuum: "1704067200", // 2024-01-01T00:00:00Z
|
|
1306
|
+
last_vacuum: "1706745600", // 2024-02-01T00:00:00Z
|
|
1307
|
+
autovacuum_count: "3",
|
|
1308
|
+
vacuum_count: "1",
|
|
1309
|
+
autovacuum_disabled: 0,
|
|
1310
|
+
}],
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
const [t] = await checkup.getDeadTuples(mockClient as any);
|
|
1314
|
+
expect(t.last_autovacuum).toBe("2024-01-01T00:00:00.000Z");
|
|
1315
|
+
expect(t.last_autovacuum_epoch).toBe(1704067200);
|
|
1316
|
+
expect(t.last_vacuum).toBe("2024-02-01T00:00:00.000Z");
|
|
1317
|
+
expect(t.autovacuum_count).toBe(3);
|
|
1318
|
+
expect(t.vacuum_count).toBe(1);
|
|
1319
|
+
expect(t.autovacuum_disabled).toBe(false);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
test("dead-tuple thresholds require BOTH absolute and relative excess", async () => {
|
|
1323
|
+
const mockClient = createMockClient({
|
|
1324
|
+
deadTuplesRows: [
|
|
1325
|
+
// High count, low ratio (large healthy table churning under active autovacuum)
|
|
1326
|
+
{ ...seededProblemRow, tag_relname: "big_churn", n_live_tup: "100000000", n_dead_tup: "150000", dead_pct: 0.15, autovacuum_disabled: 0 },
|
|
1327
|
+
// High ratio, low count (small table - not worth flagging)
|
|
1328
|
+
{ ...seededProblemRow, tag_relname: "tiny", n_live_tup: "100", n_dead_tup: "900", dead_pct: 90, autovacuum_disabled: 0 },
|
|
1329
|
+
// Both high - must be flagged
|
|
1330
|
+
{ ...seededProblemRow, tag_relname: "problem", n_live_tup: "100000", n_dead_tup: "100000", dead_pct: 50, autovacuum_disabled: 0 },
|
|
1331
|
+
],
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1335
|
+
const byName = new Map(tables.map((t) => [t.table_name, t]));
|
|
1336
|
+
expect(byName.get("big_churn")!.exceeds_dead_tuple_thresholds).toBe(false);
|
|
1337
|
+
expect(byName.get("tiny")!.exceeds_dead_tuple_thresholds).toBe(false);
|
|
1338
|
+
expect(byName.get("problem")!.exceeds_dead_tuple_thresholds).toBe(true);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test("autovacuum disabled is flagged only on non-tiny tables", async () => {
|
|
1342
|
+
const mockClient = createMockClient({
|
|
1343
|
+
deadTuplesRows: [
|
|
1344
|
+
{ ...seededProblemRow, tag_relname: "tiny_disabled", n_live_tup: "50", n_dead_tup: "0", dead_pct: 0 },
|
|
1345
|
+
{ ...seededProblemRow, tag_relname: "big_disabled", n_live_tup: "20000", n_dead_tup: "0", dead_pct: 0 },
|
|
1346
|
+
],
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1350
|
+
const byName = new Map(tables.map((t) => [t.table_name, t]));
|
|
1351
|
+
expect(byName.get("tiny_disabled")!.autovacuum_disabled).toBe(true);
|
|
1352
|
+
expect(byName.get("tiny_disabled")!.autovacuum_disabled_flagged).toBe(false);
|
|
1353
|
+
expect(byName.get("big_disabled")!.autovacuum_disabled_flagged).toBe(true);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("buildDeadTuplesConclusions produces concrete conclusions and recommendations", async () => {
|
|
1357
|
+
const mockClient = createMockClient({ deadTuplesRows: [seededProblemRow] });
|
|
1358
|
+
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1359
|
+
|
|
1360
|
+
const { conclusions, recommendations } = checkup.buildDeadTuplesConclusions(tables);
|
|
1361
|
+
expect(conclusions.length).toBe(1);
|
|
1362
|
+
expect(conclusions[0]).toContain('"public"."events"');
|
|
1363
|
+
expect(conclusions[0]).toContain("8,270,000 dead tuples");
|
|
1364
|
+
expect(conclusions[0]).toContain("56.52% of all tuples");
|
|
1365
|
+
expect(conclusions[0]).toContain("autovacuum is disabled");
|
|
1366
|
+
expect(conclusions[0]).toContain("never vacuumed");
|
|
1367
|
+
|
|
1368
|
+
expect(recommendations.length).toBe(1);
|
|
1369
|
+
expect(recommendations[0]).toContain('alter table "public"."events" reset (autovacuum_enabled);');
|
|
1370
|
+
expect(recommendations[0]).toContain('vacuum (analyze) "public"."events";');
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test("buildDeadTuplesConclusions distinguishes vacuum-lag from disabled-autovacuum cases", async () => {
|
|
1374
|
+
const mockClient = createMockClient({
|
|
1375
|
+
deadTuplesRows: [
|
|
1376
|
+
// Dead tuples high but autovacuum enabled -> vacuum + tuning advice
|
|
1377
|
+
{ ...seededProblemRow, tag_relname: "lagging", autovacuum_disabled: 0, last_autovacuum: "1704067200" },
|
|
1378
|
+
// Autovacuum disabled, no dead tuples yet -> re-enable advice
|
|
1379
|
+
{ ...seededProblemRow, tag_relname: "disabled_only", n_live_tup: "50000", n_dead_tup: "0", dead_pct: 0 },
|
|
1380
|
+
],
|
|
1381
|
+
});
|
|
1382
|
+
const tables = await checkup.getDeadTuples(mockClient as any);
|
|
1383
|
+
|
|
1384
|
+
const { conclusions, recommendations } = checkup.buildDeadTuplesConclusions(tables);
|
|
1385
|
+
expect(conclusions.length).toBe(2);
|
|
1386
|
+
const laggingRec = recommendations.find((r) => r.includes('"public"."lagging"'))!;
|
|
1387
|
+
expect(laggingRec).toContain("vacuum (analyze)");
|
|
1388
|
+
expect(laggingRec).toContain("autovacuum_vacuum_scale_factor");
|
|
1389
|
+
expect(laggingRec).not.toContain("reset (autovacuum_enabled)");
|
|
1390
|
+
|
|
1391
|
+
const disabledRec = recommendations.find((r) => r.includes('"public"."disabled_only"'))!;
|
|
1392
|
+
expect(disabledRec).toContain('alter table "public"."disabled_only" reset (autovacuum_enabled);');
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
test("generateF003 creates report with counts, thresholds, and conclusions", async () => {
|
|
1396
|
+
const mockClient = createMockClient({
|
|
1397
|
+
deadTuplesRows: [
|
|
1398
|
+
seededProblemRow,
|
|
1399
|
+
{ ...seededProblemRow, tag_relname: "disabled_only", n_live_tup: "50000", n_dead_tup: "0", dead_pct: 0 },
|
|
1400
|
+
],
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "test-node");
|
|
1404
|
+
expect(report.checkId).toBe("F003");
|
|
1405
|
+
expect(report.checkTitle).toBe("Autovacuum: dead tuples");
|
|
1406
|
+
|
|
1407
|
+
const data = report.results["test-node"].data;
|
|
1408
|
+
expect("testdb" in data).toBe(true);
|
|
1409
|
+
const dbData = data["testdb"] as any;
|
|
1410
|
+
expect(dbData.dead_tuples_tables.length).toBe(2);
|
|
1411
|
+
expect(dbData.total_count).toBe(2);
|
|
1412
|
+
expect(dbData.flagged_count).toBe(1);
|
|
1413
|
+
expect(dbData.autovacuum_disabled_count).toBe(2);
|
|
1414
|
+
expect(dbData.autovacuum_disabled_flagged_count).toBe(2);
|
|
1415
|
+
expect(dbData.total_dead_tuples).toBe(8270000);
|
|
1416
|
+
expect(dbData.thresholds).toEqual({
|
|
1417
|
+
dead_tuples_min: checkup.F003_DEAD_TUPLES_MIN,
|
|
1418
|
+
dead_pct_min: checkup.F003_DEAD_PCT_MIN,
|
|
1419
|
+
autovacuum_disabled_min_rows: checkup.F003_AUTOVACUUM_DISABLED_MIN_ROWS,
|
|
1420
|
+
});
|
|
1421
|
+
expect(dbData.conclusions.length).toBe(2);
|
|
1422
|
+
expect(dbData.recommendations.length).toBe(2);
|
|
1423
|
+
expect(dbData.database_size_bytes).toBeTruthy();
|
|
1424
|
+
expect(report.results["test-node"].postgres_version).toBeTruthy();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("generateF003 handles a healthy database (no rows)", async () => {
|
|
1428
|
+
const mockClient = createMockClient({ deadTuplesRows: [] });
|
|
1429
|
+
|
|
1430
|
+
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "test-node");
|
|
1431
|
+
const dbData = report.results["test-node"].data["testdb"] as any;
|
|
1432
|
+
expect(dbData.dead_tuples_tables).toEqual([]);
|
|
1433
|
+
expect(dbData.total_count).toBe(0);
|
|
1434
|
+
expect(dbData.flagged_count).toBe(0);
|
|
1435
|
+
expect(dbData.autovacuum_disabled_count).toBe(0);
|
|
1436
|
+
expect(dbData.autovacuum_disabled_flagged_count).toBe(0);
|
|
1437
|
+
expect(dbData.conclusions).toEqual([]);
|
|
1438
|
+
expect(dbData.recommendations).toEqual([]);
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1240
1442
|
// CLI tests
|
|
1241
1443
|
describe("CLI tests", () => {
|
|
1242
1444
|
test("checkup command exists and shows help", () => {
|
|
@@ -1452,6 +1654,113 @@ describe("CLI tests", () => {
|
|
|
1452
1654
|
});
|
|
1453
1655
|
});
|
|
1454
1656
|
|
|
1657
|
+
// CLI-level tests for the checkup auth pre-flight: missing/invalid credentials
|
|
1658
|
+
// must surface BEFORE checks run (previously the upload at the end of the run
|
|
1659
|
+
// was the first authenticated call, wasting minutes of work on a 401).
|
|
1660
|
+
describe("checkup auth pre-flight (CLI)", () => {
|
|
1661
|
+
// Dead Postgres port: if the pre-flight correctly stops the run, the CLI
|
|
1662
|
+
// never attempts this connection; if the run continues, the connection
|
|
1663
|
+
// failure mentions this address.
|
|
1664
|
+
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
|
|
1665
|
+
|
|
1666
|
+
test("no API key: prominent notice, run continues locally", () => {
|
|
1667
|
+
const env = {
|
|
1668
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
|
|
1669
|
+
PGAI_API_KEY: "",
|
|
1670
|
+
};
|
|
1671
|
+
const r = runCli(["checkup", DEAD_DB], env);
|
|
1672
|
+
expect(r.stderr).toMatch(/results will NOT be uploaded/i);
|
|
1673
|
+
expect(r.stderr).toMatch(/auth login/);
|
|
1674
|
+
expect(r.stderr).toMatch(/--no-upload/);
|
|
1675
|
+
// Falls back to local-only mode instead of failing fast
|
|
1676
|
+
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1677
|
+
// Run continued to the database connection stage
|
|
1678
|
+
expect(r.status).not.toBe(0);
|
|
1679
|
+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
test("--no-upload: no notice, pre-flight skipped entirely", () => {
|
|
1683
|
+
const env = {
|
|
1684
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-noupload-${process.pid}`,
|
|
1685
|
+
PGAI_API_KEY: "",
|
|
1686
|
+
};
|
|
1687
|
+
const r = runCli(["checkup", DEAD_DB, "--no-upload"], env);
|
|
1688
|
+
expect(r.stderr).not.toMatch(/results will NOT be uploaded/i);
|
|
1689
|
+
expect(r.stderr).not.toMatch(/could not verify API key/i);
|
|
1690
|
+
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("invalid API key (HTTP 401): fails fast before running checks", async () => {
|
|
1694
|
+
const server = Bun.serve({
|
|
1695
|
+
hostname: "127.0.0.1",
|
|
1696
|
+
port: 0,
|
|
1697
|
+
fetch() {
|
|
1698
|
+
return new Response(JSON.stringify({ message: "Invalid token" }), {
|
|
1699
|
+
status: 401,
|
|
1700
|
+
headers: { "Content-Type": "application/json" },
|
|
1701
|
+
});
|
|
1702
|
+
},
|
|
1703
|
+
});
|
|
1704
|
+
try {
|
|
1705
|
+
const env = {
|
|
1706
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-${process.pid}`,
|
|
1707
|
+
PGAI_API_KEY: "bad-token",
|
|
1708
|
+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
|
|
1709
|
+
};
|
|
1710
|
+
const r = await runCliAsync(["checkup", DEAD_DB, "--project", "preflight-test"], env);
|
|
1711
|
+
expect(r.status).not.toBe(0);
|
|
1712
|
+
expect(r.stderr).toMatch(/rejected by the PostgresAI API \(HTTP 401\)/);
|
|
1713
|
+
expect(r.stderr).toMatch(/auth login/);
|
|
1714
|
+
expect(r.stderr).toMatch(/--no-upload/);
|
|
1715
|
+
// Stopped BEFORE connecting to the database / running checks
|
|
1716
|
+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1717
|
+
} finally {
|
|
1718
|
+
server.stop(true);
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
test("--no-upload skips pre-flight even when an invalid key is configured", async () => {
|
|
1723
|
+
const server = Bun.serve({
|
|
1724
|
+
hostname: "127.0.0.1",
|
|
1725
|
+
port: 0,
|
|
1726
|
+
fetch() {
|
|
1727
|
+
return new Response(JSON.stringify({ message: "Invalid token" }), {
|
|
1728
|
+
status: 401,
|
|
1729
|
+
headers: { "Content-Type": "application/json" },
|
|
1730
|
+
});
|
|
1731
|
+
},
|
|
1732
|
+
});
|
|
1733
|
+
try {
|
|
1734
|
+
const env = {
|
|
1735
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-noupload-${process.pid}`,
|
|
1736
|
+
PGAI_API_KEY: "bad-token",
|
|
1737
|
+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
|
|
1738
|
+
};
|
|
1739
|
+
const r = await runCliAsync(["checkup", DEAD_DB, "--no-upload"], env);
|
|
1740
|
+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
|
|
1741
|
+
expect(r.stderr).not.toMatch(/could not verify API key/i);
|
|
1742
|
+
// Fails later at the database connection stage instead
|
|
1743
|
+
expect(r.status).not.toBe(0);
|
|
1744
|
+
} finally {
|
|
1745
|
+
server.stop(true);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
test("transient pre-flight failure (network error): warns and continues", () => {
|
|
1750
|
+
const env = {
|
|
1751
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-netfail-${process.pid}`,
|
|
1752
|
+
PGAI_API_KEY: "some-token",
|
|
1753
|
+
PGAI_API_BASE_URL: "http://127.0.0.1:1", // connect refused — transient, not a 401/403
|
|
1754
|
+
};
|
|
1755
|
+
const r = runCli(["checkup", DEAD_DB, "--project", "preflight-test"], env);
|
|
1756
|
+
expect(r.stderr).toMatch(/Warning: could not verify API key/i);
|
|
1757
|
+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
|
|
1758
|
+
// Run continued to the database connection stage
|
|
1759
|
+
expect(r.status).not.toBe(0);
|
|
1760
|
+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1455
1764
|
// Tests for checkup-api module
|
|
1456
1765
|
describe("checkup-api", () => {
|
|
1457
1766
|
test("formatRpcErrorForDisplay formats details/hint nicely", () => {
|
|
@@ -1698,12 +2007,172 @@ describe("checkup-api", () => {
|
|
|
1698
2007
|
}
|
|
1699
2008
|
});
|
|
1700
2009
|
});
|
|
2010
|
+
|
|
2011
|
+
// Auth pre-flight: verifyApiKey checks the key with a cheap authenticated
|
|
2012
|
+
// GET before checkup runs expensive checks. Only definitive 401/403 is
|
|
2013
|
+
// "invalid"; anything transient must come back "unknown" (warn + continue).
|
|
2014
|
+
describe("verifyApiKey (auth pre-flight)", () => {
|
|
2015
|
+
function serveStatus(status: number, body = "[]") {
|
|
2016
|
+
return Bun.serve({
|
|
2017
|
+
hostname: "127.0.0.1",
|
|
2018
|
+
port: 0,
|
|
2019
|
+
fetch() {
|
|
2020
|
+
return new Response(body, { status, headers: { "Content-Type": "application/json" } });
|
|
2021
|
+
},
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
test("returns valid on HTTP 200", async () => {
|
|
2026
|
+
const server = serveStatus(200);
|
|
2027
|
+
try {
|
|
2028
|
+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2029
|
+
expect(r.status).toBe("valid");
|
|
2030
|
+
} finally {
|
|
2031
|
+
server.stop(true);
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
test("returns invalid on HTTP 401", async () => {
|
|
2036
|
+
const server = serveStatus(401, JSON.stringify({ message: "Invalid token" }));
|
|
2037
|
+
try {
|
|
2038
|
+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2039
|
+
expect(r.status).toBe("invalid");
|
|
2040
|
+
expect((r as { statusCode: number }).statusCode).toBe(401);
|
|
2041
|
+
} finally {
|
|
2042
|
+
server.stop(true);
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
test("returns invalid on HTTP 403", async () => {
|
|
2047
|
+
const server = serveStatus(403);
|
|
2048
|
+
try {
|
|
2049
|
+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2050
|
+
expect(r.status).toBe("invalid");
|
|
2051
|
+
expect((r as { statusCode: number }).statusCode).toBe(403);
|
|
2052
|
+
} finally {
|
|
2053
|
+
server.stop(true);
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
test("returns unknown on HTTP 500 (not a definitive rejection)", async () => {
|
|
2058
|
+
const server = serveStatus(500);
|
|
2059
|
+
try {
|
|
2060
|
+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
|
|
2061
|
+
expect(r.status).toBe("unknown");
|
|
2062
|
+
expect((r as { detail: string }).detail).toMatch(/HTTP 500/);
|
|
2063
|
+
} finally {
|
|
2064
|
+
server.stop(true);
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
test("returns unknown on connection refused", async () => {
|
|
2069
|
+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://127.0.0.1:1" }); // port 1 — connect refused
|
|
2070
|
+
expect(r.status).toBe("unknown");
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
test("returns unknown on timeout", async () => {
|
|
2074
|
+
const server = Bun.serve({
|
|
2075
|
+
hostname: "127.0.0.1",
|
|
2076
|
+
port: 0,
|
|
2077
|
+
async fetch() {
|
|
2078
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
2079
|
+
return new Response("[]");
|
|
2080
|
+
},
|
|
2081
|
+
});
|
|
2082
|
+
try {
|
|
2083
|
+
const r = await api.verifyApiKey({
|
|
2084
|
+
apiKey: "k",
|
|
2085
|
+
apiBaseUrl: `http://127.0.0.1:${server.port}`,
|
|
2086
|
+
timeoutMs: 100,
|
|
2087
|
+
});
|
|
2088
|
+
expect(r.status).toBe("unknown");
|
|
2089
|
+
} finally {
|
|
2090
|
+
server.stop(true);
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test("does not send the key over plaintext HTTP to non-loopback hosts", async () => {
|
|
2095
|
+
const saved = process.env.CHECKUP_ALLOW_HTTP;
|
|
2096
|
+
delete process.env.CHECKUP_ALLOW_HTTP;
|
|
2097
|
+
try {
|
|
2098
|
+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://example.com/api" });
|
|
2099
|
+
expect(r.status).toBe("unknown");
|
|
2100
|
+
expect((r as { detail: string }).detail).toMatch(/plaintext HTTP/);
|
|
2101
|
+
} finally {
|
|
2102
|
+
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
});
|
|
1701
2106
|
});
|
|
1702
2107
|
|
|
1703
2108
|
// Tests for checkup-summary module
|
|
1704
2109
|
describe("checkup-summary", () => {
|
|
1705
2110
|
const summary = require("../lib/checkup-summary");
|
|
1706
2111
|
|
|
2112
|
+
test("generateCheckSummary for F003 with no issues", () => {
|
|
2113
|
+
const report = {
|
|
2114
|
+
results: {
|
|
2115
|
+
node1: {
|
|
2116
|
+
data: {
|
|
2117
|
+
db1: {
|
|
2118
|
+
dead_tuples_tables: [],
|
|
2119
|
+
total_count: 0,
|
|
2120
|
+
flagged_count: 0,
|
|
2121
|
+
autovacuum_disabled_count: 0,
|
|
2122
|
+
autovacuum_disabled_flagged_count: 0,
|
|
2123
|
+
},
|
|
2124
|
+
},
|
|
2125
|
+
},
|
|
2126
|
+
},
|
|
2127
|
+
};
|
|
2128
|
+
const result = summary.generateCheckSummary("F003", report);
|
|
2129
|
+
expect(result.status).toBe("ok");
|
|
2130
|
+
expect(result.message).toMatch(/no significant dead tuple/i);
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
test("generateCheckSummary for F003 ignores tiny disabled-autovacuum tables", () => {
|
|
2134
|
+
const report = {
|
|
2135
|
+
results: {
|
|
2136
|
+
node1: {
|
|
2137
|
+
data: {
|
|
2138
|
+
db1: {
|
|
2139
|
+
dead_tuples_tables: [],
|
|
2140
|
+
total_count: 2,
|
|
2141
|
+
flagged_count: 0,
|
|
2142
|
+
autovacuum_disabled_count: 2,
|
|
2143
|
+
autovacuum_disabled_flagged_count: 0,
|
|
2144
|
+
},
|
|
2145
|
+
},
|
|
2146
|
+
},
|
|
2147
|
+
},
|
|
2148
|
+
};
|
|
2149
|
+
const result = summary.generateCheckSummary("F003", report);
|
|
2150
|
+
expect(result.status).toBe("ok");
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test("generateCheckSummary for F003 with problems", () => {
|
|
2154
|
+
const report = {
|
|
2155
|
+
results: {
|
|
2156
|
+
node1: {
|
|
2157
|
+
data: {
|
|
2158
|
+
db1: {
|
|
2159
|
+
dead_tuples_tables: [],
|
|
2160
|
+
total_count: 3,
|
|
2161
|
+
flagged_count: 1,
|
|
2162
|
+
autovacuum_disabled_count: 2,
|
|
2163
|
+
autovacuum_disabled_flagged_count: 2,
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
},
|
|
2167
|
+
},
|
|
2168
|
+
};
|
|
2169
|
+
const result = summary.generateCheckSummary("F003", report);
|
|
2170
|
+
expect(result.status).toBe("warning");
|
|
2171
|
+
expect(result.message).toBe(
|
|
2172
|
+
"1 table with excessive dead tuples, 2 tables with autovacuum disabled"
|
|
2173
|
+
);
|
|
2174
|
+
});
|
|
2175
|
+
|
|
1707
2176
|
test("generateCheckSummary for H001 with no issues", () => {
|
|
1708
2177
|
const report = {
|
|
1709
2178
|
results: {
|
|
@@ -3099,6 +3568,7 @@ describe("Version-aware SQL query selection (PG13-PG18)", () => {
|
|
|
3099
3568
|
H001: "pg_invalid_indexes",
|
|
3100
3569
|
H002: "unused_indexes",
|
|
3101
3570
|
H004: "redundant_indexes",
|
|
3571
|
+
F003: "pg_dead_tuples",
|
|
3102
3572
|
F004: "pg_table_bloat",
|
|
3103
3573
|
F005: "pg_btree_bloat",
|
|
3104
3574
|
settings: "settings",
|
package/test/mcp-server.test.ts
CHANGED
|
@@ -238,6 +238,8 @@ describe("MCP Server", () => {
|
|
|
238
238
|
|
|
239
239
|
expect(response.isError).toBe(true);
|
|
240
240
|
expect(getResponseText(response)).toContain("401");
|
|
241
|
+
// Agent-facing remediation: invalid key must point at re-auth, not dead-end
|
|
242
|
+
expect(getResponseText(response)).toContain("Run 'postgresai auth'");
|
|
241
243
|
|
|
242
244
|
readConfigSpy.mockRestore();
|
|
243
245
|
});
|
|
@@ -1678,6 +1680,8 @@ describe("MCP Server", () => {
|
|
|
1678
1680
|
|
|
1679
1681
|
expect(response.isError).toBe(true);
|
|
1680
1682
|
expect(getResponseText(response)).toContain("401");
|
|
1683
|
+
// Agent-facing remediation: invalid key must point at re-auth, not dead-end
|
|
1684
|
+
expect(getResponseText(response)).toContain("Run 'postgresai auth'");
|
|
1681
1685
|
|
|
1682
1686
|
readConfigSpy.mockRestore();
|
|
1683
1687
|
});
|