postgresai 0.16.0-rc.2 → 0.16.0-rc.4
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/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +42 -19
- package/dist/bin/postgres-ai.js +149 -25
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -8
- package/lib/supabase.ts +0 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.integration.test.ts +9 -79
- package/test/init.test.ts +35 -0
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
package/test/checkup.test.ts
CHANGED
|
@@ -1539,12 +1539,13 @@ describe("CLI tests", () => {
|
|
|
1539
1539
|
const r = runCli(["checkup", "--help"]);
|
|
1540
1540
|
expect(r.status).toBe(0);
|
|
1541
1541
|
expect(r.stdout).toMatch(/--markdown/);
|
|
1542
|
-
expect(r.stdout).toMatch(/
|
|
1542
|
+
expect(r.stdout).toMatch(/PostgresAI API/i);
|
|
1543
|
+
expect(r.stdout).toMatch(/transmits the full\s+report JSON/i);
|
|
1543
1544
|
});
|
|
1544
1545
|
|
|
1545
1546
|
test("checkup --markdown is recognized as valid option", () => {
|
|
1546
1547
|
// Should not produce "unknown option" error for --markdown
|
|
1547
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1548
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"]);
|
|
1548
1549
|
// Connection will fail, but option parsing should succeed
|
|
1549
1550
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1550
1551
|
expect(r.stderr).not.toMatch(/did you mean/i);
|
|
@@ -1554,11 +1555,13 @@ describe("CLI tests", () => {
|
|
|
1554
1555
|
// Use empty config dir to ensure no API key is configured
|
|
1555
1556
|
const env = { XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config" };
|
|
1556
1557
|
// --markdown should work even without API key
|
|
1557
|
-
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"
|
|
1558
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--markdown"], env);
|
|
1558
1559
|
// Connection will fail, but --markdown flag should be recognized
|
|
1559
1560
|
expect(r.status).not.toBe(0);
|
|
1560
1561
|
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1561
1562
|
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1563
|
+
expect(r.stderr).toMatch(/full report JSON will still be sent/i);
|
|
1564
|
+
expect(r.stderr).toMatch(/markdown conversion/i);
|
|
1562
1565
|
});
|
|
1563
1566
|
|
|
1564
1567
|
test("checkup with --no-upload and no output flags shows summary", () => {
|
|
@@ -1663,6 +1666,21 @@ describe("checkup auth pre-flight (CLI)", () => {
|
|
|
1663
1666
|
// failure mentions this address.
|
|
1664
1667
|
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
|
|
1665
1668
|
|
|
1669
|
+
test("--no-upload rejects --markdown before database or API work", () => {
|
|
1670
|
+
const env = {
|
|
1671
|
+
XDG_CONFIG_HOME: `/tmp/postgresai-test-no-upload-markdown-${process.pid}`,
|
|
1672
|
+
PGAI_API_KEY: "configured-token",
|
|
1673
|
+
PGAI_API_BASE_URL: "http://127.0.0.1:1",
|
|
1674
|
+
};
|
|
1675
|
+
const r = runCli(["checkup", DEAD_DB, "--no-upload", "--markdown"], env);
|
|
1676
|
+
|
|
1677
|
+
expect(r.status).not.toBe(0);
|
|
1678
|
+
expect(r.stderr).toMatch(/--no-upload and --markdown are mutually exclusive/i);
|
|
1679
|
+
expect(r.stderr).toMatch(/markdown conversion is performed by the PostgresAI API/i);
|
|
1680
|
+
expect(r.stderr).toMatch(/use --json or --output/i);
|
|
1681
|
+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1666
1684
|
test("no API key: prominent notice, run continues locally", () => {
|
|
1667
1685
|
const env = {
|
|
1668
1686
|
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
|
|
@@ -2173,6 +2191,31 @@ describe("checkup-summary", () => {
|
|
|
2173
2191
|
);
|
|
2174
2192
|
});
|
|
2175
2193
|
|
|
2194
|
+
for (const checkId of ["F004", "F005"]) {
|
|
2195
|
+
test(`generateCheckSummary for degraded ${checkId} is never ok`, () => {
|
|
2196
|
+
const report = {
|
|
2197
|
+
results: {
|
|
2198
|
+
node1: {
|
|
2199
|
+
data: {
|
|
2200
|
+
db1: {
|
|
2201
|
+
status: {
|
|
2202
|
+
ok: false,
|
|
2203
|
+
reason: "missing_schema",
|
|
2204
|
+
error: 'schema "postgres_ai" does not exist',
|
|
2205
|
+
},
|
|
2206
|
+
total_count: 0,
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
const result = summary.generateCheckSummary(checkId, report);
|
|
2214
|
+
expect(result.status).toBe("warning");
|
|
2215
|
+
expect(result.message).toMatch(/degraded.*missing schema/i);
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2176
2219
|
test("generateCheckSummary for H001 with no issues", () => {
|
|
2177
2220
|
const report = {
|
|
2178
2221
|
results: {
|
|
@@ -3371,6 +3414,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3371
3414
|
autovacuum_vacuum_scale_factor: expectedAutovacuumSetting,
|
|
3372
3415
|
});
|
|
3373
3416
|
expect(reports.F004.results["test-node"].data.testdb).toEqual({
|
|
3417
|
+
status: { ok: true, reason: null, error: null },
|
|
3374
3418
|
bloated_tables: [],
|
|
3375
3419
|
total_count: 0,
|
|
3376
3420
|
total_bloat_size_bytes: 0,
|
|
@@ -3379,6 +3423,7 @@ describe("Postgres version compatibility (PG13-PG18)", () => {
|
|
|
3379
3423
|
database_size_pretty: "1.00 GiB",
|
|
3380
3424
|
});
|
|
3381
3425
|
expect(reports.F005.results["test-node"].data.testdb).toEqual({
|
|
3426
|
+
status: { ok: true, reason: null, error: null },
|
|
3382
3427
|
bloated_indexes: [],
|
|
3383
3428
|
total_count: 0,
|
|
3384
3429
|
total_bloat_size_bytes: 0,
|
|
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
|
407
407
|
}
|
|
408
408
|
}, { timeout: TEST_TIMEOUT });
|
|
409
409
|
|
|
410
|
-
//
|
|
411
|
-
|
|
410
|
+
// Security regression for #387: explain_generic was a SECURITY DEFINER helper that
|
|
411
|
+
// ran caller-supplied SQL through EXPLAIN. The planner folds IMMUTABLE/STABLE functions
|
|
412
|
+
// at plan time in the definer's (superuser) context, making it an RCE/data-exfil vector.
|
|
413
|
+
// It has been removed; prepare-db must NOT create it.
|
|
414
|
+
test("explain_generic is not created by prepare-db (RCE helper removed, #387)", async () => {
|
|
412
415
|
pg = await createTempPostgres();
|
|
413
416
|
|
|
414
417
|
try {
|
|
415
|
-
// Run init first
|
|
416
418
|
{
|
|
417
419
|
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
418
420
|
expect(r.status).toBe(0);
|
|
@@ -422,82 +424,10 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
|
422
424
|
await c.connect();
|
|
423
425
|
|
|
424
426
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (version < 160000) {
|
|
430
|
-
// Skip this test on older PostgreSQL versions
|
|
431
|
-
console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Test 1: Empty query should be rejected
|
|
436
|
-
await expect(
|
|
437
|
-
c.query("select postgres_ai.explain_generic('')")
|
|
438
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
439
|
-
|
|
440
|
-
// Test 2: Null query should be rejected
|
|
441
|
-
await expect(
|
|
442
|
-
c.query("select postgres_ai.explain_generic(null)")
|
|
443
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
444
|
-
|
|
445
|
-
// Test 3: Multiple statements (semicolon in middle) should be rejected
|
|
446
|
-
await expect(
|
|
447
|
-
c.query("select postgres_ai.explain_generic('select 1; select 2')")
|
|
448
|
-
).rejects.toThrow(/semicolon|multiple statements/i);
|
|
449
|
-
|
|
450
|
-
// Test 4: Trailing semicolon should be stripped and work
|
|
451
|
-
{
|
|
452
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
|
|
453
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
454
|
-
expect(res.rows[0].result).toMatch(/Result/i);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Test 5: Valid query should work
|
|
458
|
-
{
|
|
459
|
-
const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
|
|
460
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Test 6: JSON format should work
|
|
464
|
-
{
|
|
465
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
|
|
466
|
-
const plan = JSON.parse(res.rows[0].result);
|
|
467
|
-
expect(Array.isArray(plan)).toBe(true);
|
|
468
|
-
expect(plan[0].Plan).toBeTruthy();
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Test 7: Whitespace-only query should be rejected
|
|
472
|
-
await expect(
|
|
473
|
-
c.query("select postgres_ai.explain_generic(' ')")
|
|
474
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
475
|
-
|
|
476
|
-
// Test 8: Semicolon in string literal is rejected (documented limitation)
|
|
477
|
-
// Note: This is a known limitation - the simple heuristic cannot parse SQL strings
|
|
478
|
-
await expect(
|
|
479
|
-
c.query("select postgres_ai.explain_generic('select ''hello;world''')")
|
|
480
|
-
).rejects.toThrow(/semicolon/i);
|
|
481
|
-
|
|
482
|
-
// Test 9: SQL comments should work (no semicolons)
|
|
483
|
-
{
|
|
484
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
|
|
485
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Test 10: Escaped quotes should work (no semicolons)
|
|
489
|
-
{
|
|
490
|
-
const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
|
|
491
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Test 11: Case-insensitive format parameter
|
|
495
|
-
{
|
|
496
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
|
|
497
|
-
const plan = JSON.parse(res.rows[0].result);
|
|
498
|
-
expect(Array.isArray(plan)).toBe(true);
|
|
499
|
-
}
|
|
500
|
-
|
|
427
|
+
const res = await c.query(
|
|
428
|
+
"select count(*)::int as n from pg_proc where proname = 'explain_generic' and pronamespace = (select oid from pg_namespace where nspname = 'postgres_ai')"
|
|
429
|
+
);
|
|
430
|
+
expect(res.rows[0].n).toBe(0);
|
|
501
431
|
} finally {
|
|
502
432
|
await c.end();
|
|
503
433
|
}
|
package/test/init.test.ts
CHANGED
|
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
|
|
|
1489
1489
|
).rejects.toThrow("permission denied for relation pg_roles");
|
|
1490
1490
|
});
|
|
1491
1491
|
|
|
1492
|
+
test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
|
|
1493
|
+
let capturedSql = "";
|
|
1494
|
+
const client = {
|
|
1495
|
+
query: async (sql: string) => {
|
|
1496
|
+
capturedSql = sql;
|
|
1497
|
+
return { rows: [] };
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
await init.checkCurrentUserPermissions(client as any);
|
|
1502
|
+
|
|
1503
|
+
expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
|
|
1504
|
+
expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
|
|
1505
|
+
expect(capturedSql).toMatch(
|
|
1506
|
+
/when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
|
|
1507
|
+
);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1492
1510
|
test("returns all rows for inspection", async () => {
|
|
1493
1511
|
const rows: init.PermissionCheckRow[] = [
|
|
1494
1512
|
{ permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
|
|
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
|
|
|
1545
1563
|
expect(messages.errors).toHaveLength(0);
|
|
1546
1564
|
});
|
|
1547
1565
|
|
|
1566
|
+
test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
|
|
1567
|
+
const result: init.PreflightPermissionResult = {
|
|
1568
|
+
ok: true,
|
|
1569
|
+
rows: [],
|
|
1570
|
+
missingRequired: [],
|
|
1571
|
+
missingOptional: [
|
|
1572
|
+
{ permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
|
|
1573
|
+
],
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
const messages = init.formatPermissionCheckMessages(result);
|
|
1577
|
+
expect(messages.failed).toBe(false);
|
|
1578
|
+
expect(messages.warnings).toEqual([
|
|
1579
|
+
"Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
|
|
1580
|
+
]);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1548
1583
|
test("returns errors with fix commands for missing required permissions", () => {
|
|
1549
1584
|
const result: init.PreflightPermissionResult = {
|
|
1550
1585
|
ok: false,
|
|
@@ -99,6 +99,46 @@ describe("Schema validation", () => {
|
|
|
99
99
|
validateAgainstSchema(report, "F003");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
for (const checkId of ["F004", "F005"]) {
|
|
103
|
+
test(`${checkId} distinguishes a healthy empty result from missing schema`, async () => {
|
|
104
|
+
const healthyClient = createMockClient();
|
|
105
|
+
const healthy = await checkup.REPORT_GENERATORS[checkId](healthyClient as any, "node-01");
|
|
106
|
+
const healthyDb = healthy.results["node-01"].data.testdb;
|
|
107
|
+
expect(healthyDb.status).toEqual({ ok: true, reason: null, error: null });
|
|
108
|
+
validateAgainstSchema(healthy, checkId);
|
|
109
|
+
|
|
110
|
+
const missingSchemaClient = createMockClient({
|
|
111
|
+
bloatCapabilityRows: [
|
|
112
|
+
{ schema_exists: false, schema_usage: false, view_exists: false, view_select: false },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
const degraded = await checkup.REPORT_GENERATORS[checkId](missingSchemaClient as any, "node-01");
|
|
116
|
+
const degradedDb = degraded.results["node-01"].data.testdb;
|
|
117
|
+
expect(degradedDb.status).toEqual({
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "missing_schema",
|
|
120
|
+
error: 'schema "postgres_ai" does not exist',
|
|
121
|
+
});
|
|
122
|
+
validateAgainstSchema(degraded, checkId);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`${checkId} exposes missing-grant degradation`, async () => {
|
|
126
|
+
const client = createMockClient({
|
|
127
|
+
bloatCapabilityRows: [
|
|
128
|
+
{ schema_exists: true, schema_usage: true, view_exists: true, view_select: false },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const report = await checkup.REPORT_GENERATORS[checkId](client as any, "node-01");
|
|
132
|
+
const dbEntry = report.results["node-01"].data.testdb;
|
|
133
|
+
expect(dbEntry.status).toEqual({
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "missing_grant",
|
|
136
|
+
error: "permission denied for relation postgres_ai.pg_statistic",
|
|
137
|
+
});
|
|
138
|
+
validateAgainstSchema(report, checkId);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
// Settings reports (D004, F001, G001) - single test each
|
|
103
143
|
for (const checkId of ["D004", "F001", "G001"]) {
|
|
104
144
|
test(`${checkId} validates against schema`, async () => {
|
package/test/test-utils.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface MockClientOptions {
|
|
|
19
19
|
indexBloatRows?: any[];
|
|
20
20
|
deadTuplesRows?: any[];
|
|
21
21
|
vacuumStatsRows?: any[];
|
|
22
|
+
bloatCapabilityRows?: any[];
|
|
22
23
|
deadlockStatsRows?: any[];
|
|
23
24
|
pgStatStatementsExtensionRows?: any[];
|
|
24
25
|
pgStatStatementsStatsRows?: any[];
|
|
@@ -61,6 +62,7 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
61
62
|
indexBloatRows = [],
|
|
62
63
|
deadTuplesRows = [],
|
|
63
64
|
vacuumStatsRows = [],
|
|
65
|
+
bloatCapabilityRows = [{ schema_exists: true, schema_usage: true, view_exists: true, view_select: true }],
|
|
64
66
|
deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
|
|
65
67
|
pgStatStatementsExtensionRows = [],
|
|
66
68
|
pgStatStatementsStatsRows = [],
|
|
@@ -128,6 +130,9 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
128
130
|
return { rows: deadTuplesRows };
|
|
129
131
|
}
|
|
130
132
|
// F004/F005: bloat metrics from metrics.yml
|
|
133
|
+
if (sql.includes("to_regnamespace('postgres_ai')") && sql.includes("view_select")) {
|
|
134
|
+
return { rows: bloatCapabilityRows };
|
|
135
|
+
}
|
|
131
136
|
if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
|
|
132
137
|
return { rows: indexBloatRows };
|
|
133
138
|
}
|