postgresai 0.16.0-rc.4 → 0.16.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.
Files changed (85) hide show
  1. package/README.md +154 -0
  2. package/dist/bin/postgres-ai.js +2911 -255
  3. package/package.json +12 -3
  4. package/schemas/A002.schema.json +63 -0
  5. package/schemas/A003.schema.json +73 -0
  6. package/schemas/A004.schema.json +81 -0
  7. package/schemas/A007.schema.json +71 -0
  8. package/schemas/A013.schema.json +61 -0
  9. package/schemas/D001.schema.json +71 -0
  10. package/schemas/D004.schema.json +136 -0
  11. package/schemas/F001.schema.json +73 -0
  12. package/schemas/F002.schema.json +108 -0
  13. package/schemas/F003.schema.json +138 -0
  14. package/schemas/F004.schema.json +125 -0
  15. package/schemas/F005.schema.json +131 -0
  16. package/schemas/F009.schema.json +155 -0
  17. package/schemas/G001.schema.json +135 -0
  18. package/schemas/G003.schema.json +90 -0
  19. package/schemas/H001.schema.json +141 -0
  20. package/schemas/H002.schema.json +129 -0
  21. package/schemas/H004.schema.json +128 -0
  22. package/schemas/I001.schema.json +149 -0
  23. package/schemas/K001.schema.json +161 -0
  24. package/schemas/K003.schema.json +163 -0
  25. package/schemas/K004.schema.json +110 -0
  26. package/schemas/K005.schema.json +110 -0
  27. package/schemas/K006.schema.json +110 -0
  28. package/schemas/K007.schema.json +110 -0
  29. package/schemas/K008.schema.json +110 -0
  30. package/schemas/M001.schema.json +119 -0
  31. package/schemas/M002.schema.json +110 -0
  32. package/schemas/M003.schema.json +128 -0
  33. package/schemas/N001.schema.json +161 -0
  34. package/schemas/query.schema.json +62 -0
  35. package/CHANGELOG.md +0 -11
  36. package/bin/postgres-ai.ts +0 -5578
  37. package/bun.lock +0 -258
  38. package/bunfig.toml +0 -20
  39. package/lib/aas-onboard.ts +0 -251
  40. package/lib/auth-server.ts +0 -285
  41. package/lib/checkup-api.ts +0 -526
  42. package/lib/checkup-dictionary.ts +0 -103
  43. package/lib/checkup-summary.ts +0 -338
  44. package/lib/checkup.ts +0 -2261
  45. package/lib/config.ts +0 -171
  46. package/lib/init.ts +0 -1152
  47. package/lib/instances.ts +0 -245
  48. package/lib/issues.ts +0 -1060
  49. package/lib/mcp-server.ts +0 -667
  50. package/lib/metrics-loader.ts +0 -134
  51. package/lib/pkce.ts +0 -79
  52. package/lib/reports.ts +0 -373
  53. package/lib/storage.ts +0 -367
  54. package/lib/supabase.ts +0 -826
  55. package/lib/util.ts +0 -134
  56. package/packages/postgres-ai/README.md +0 -26
  57. package/packages/postgres-ai/bin/postgres-ai.js +0 -27
  58. package/packages/postgres-ai/package.json +0 -27
  59. package/scripts/embed-checkup-dictionary.ts +0 -115
  60. package/scripts/embed-metrics.ts +0 -160
  61. package/scripts/generate-release-notes.ts +0 -668
  62. package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
  63. package/test/aas-onboard.test.ts +0 -301
  64. package/test/auth.test.ts +0 -287
  65. package/test/checkup.integration.test.ts +0 -413
  66. package/test/checkup.test.ts +0 -3626
  67. package/test/compose-cmd.test.ts +0 -120
  68. package/test/config-consistency.test.ts +0 -352
  69. package/test/init.integration.test.ts +0 -438
  70. package/test/init.test.ts +0 -1816
  71. package/test/issues.cli.test.ts +0 -1162
  72. package/test/issues.test.ts +0 -456
  73. package/test/mcp-server.test.ts +0 -2530
  74. package/test/monitoring.test.ts +0 -746
  75. package/test/permission-check-sql.test.ts +0 -116
  76. package/test/reports.cli.test.ts +0 -793
  77. package/test/reports.test.ts +0 -977
  78. package/test/schema-validation.test.ts +0 -231
  79. package/test/storage.test.ts +0 -935
  80. package/test/supabase.test.ts +0 -709
  81. package/test/targets-add-config.test.ts +0 -28
  82. package/test/test-utils.ts +0 -190
  83. package/test/upgrade.test.ts +0 -1056
  84. package/test/util.test.ts +0 -44
  85. package/tsconfig.json +0 -20
package/test/init.test.ts DELETED
@@ -1,1816 +0,0 @@
1
- import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
- import path, { resolve } from "path";
3
- import * as fs from "fs";
4
- import * as os from "os";
5
-
6
- // Import from source directly since we're using Bun
7
- import * as init from "../lib/init";
8
- const DEFAULT_MONITORING_USER = init.DEFAULT_MONITORING_USER;
9
-
10
- function runCli(args: string[], env: Record<string, string> = {}) {
11
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
12
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
13
- const result = Bun.spawnSync([bunBin, cliPath, ...args], {
14
- env: { ...process.env, ...env },
15
- });
16
- return {
17
- status: result.exitCode,
18
- stdout: new TextDecoder().decode(result.stdout),
19
- stderr: new TextDecoder().decode(result.stderr),
20
- };
21
- }
22
-
23
- function runPgai(args: string[], env: Record<string, string> = {}) {
24
- // For testing, run the CLI directly since pgai is just a thin wrapper
25
- // In production, pgai wrapper will properly resolve and spawn the postgresai CLI
26
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
27
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
28
- const result = Bun.spawnSync([bunBin, cliPath, ...args], {
29
- env: { ...process.env, ...env },
30
- });
31
- return {
32
- status: result.exitCode,
33
- stdout: new TextDecoder().decode(result.stdout),
34
- stderr: new TextDecoder().decode(result.stderr),
35
- };
36
- }
37
-
38
- describe("init module", () => {
39
- test("maskConnectionString hides password when present", () => {
40
- const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
41
- expect(masked).toMatch(/postgresql:\/\/user:\*{5}@localhost:5432\/mydb/);
42
- expect(masked).not.toMatch(/secret/);
43
- });
44
-
45
- test("parseLibpqConninfo parses basic host/dbname/user/port/password", () => {
46
- const cfg = init.parseLibpqConninfo("dbname=mydb host=localhost user=alice port=5432 password=secret");
47
- expect(cfg.database).toBe("mydb");
48
- expect(cfg.host).toBe("localhost");
49
- expect(cfg.user).toBe("alice");
50
- expect(cfg.port).toBe(5432);
51
- expect(cfg.password).toBe("secret");
52
- });
53
-
54
- test("parseLibpqConninfo supports quoted values", () => {
55
- const cfg = init.parseLibpqConninfo("dbname='my db' host='local host'");
56
- expect(cfg.database).toBe("my db");
57
- expect(cfg.host).toBe("local host");
58
- });
59
-
60
- test("buildInitPlan includes a race-safe role DO block", async () => {
61
- const plan = await init.buildInitPlan({
62
- database: "mydb",
63
- monitoringUser: DEFAULT_MONITORING_USER,
64
- monitoringPassword: "pw",
65
- includeOptionalPermissions: false,
66
- });
67
-
68
- expect(plan.database).toBe("mydb");
69
- const roleStep = plan.steps.find((s: { name: string }) => s.name === "01.role");
70
- expect(roleStep).toBeTruthy();
71
- expect(roleStep!.sql).toMatch(/do\s+\$\$/i);
72
- expect(roleStep!.sql).toMatch(/create\s+user/i);
73
- expect(roleStep!.sql).toMatch(/alter\s+user/i);
74
- expect(plan.steps.some((s: { optional?: boolean }) => s.optional)).toBe(false);
75
- });
76
-
77
- test("buildInitPlan handles special characters in monitoring user and database identifiers", async () => {
78
- const monitoringUser = 'user "with" quotes ✓';
79
- const database = 'db name "with" quotes ✓';
80
- const plan = await init.buildInitPlan({
81
- database,
82
- monitoringUser,
83
- monitoringPassword: "pw",
84
- includeOptionalPermissions: false,
85
- });
86
-
87
- const roleStep = plan.steps.find((s: { name: string }) => s.name === "01.role");
88
- expect(roleStep).toBeTruthy();
89
- expect(roleStep!.sql).toMatch(/create\s+user\s+"user ""with"" quotes ✓"/i);
90
- expect(roleStep!.sql).toMatch(/alter\s+user\s+"user ""with"" quotes ✓"/i);
91
-
92
- const permStep = plan.steps.find((s: { name: string }) => s.name === "03.permissions");
93
- expect(permStep).toBeTruthy();
94
- expect(permStep!.sql).toMatch(/grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
95
- });
96
-
97
- test("buildInitPlan keeps backslashes in passwords (no unintended escaping)", async () => {
98
- const pw = String.raw`pw\with\backslash`;
99
- const plan = await init.buildInitPlan({
100
- database: "mydb",
101
- monitoringUser: DEFAULT_MONITORING_USER,
102
- monitoringPassword: pw,
103
- includeOptionalPermissions: false,
104
- });
105
- const roleStep = plan.steps.find((s: { name: string }) => s.name === "01.role");
106
- expect(roleStep).toBeTruthy();
107
- expect(roleStep!.sql).toContain(`password '${pw}'`);
108
- });
109
-
110
- test("buildInitPlan rejects identifiers with null bytes", async () => {
111
- await expect(
112
- init.buildInitPlan({
113
- database: "mydb",
114
- monitoringUser: "bad\0user",
115
- monitoringPassword: "pw",
116
- includeOptionalPermissions: false,
117
- })
118
- ).rejects.toThrow(/Identifier cannot contain null bytes/);
119
- });
120
-
121
- test("buildInitPlan rejects literals with null bytes", async () => {
122
- await expect(
123
- init.buildInitPlan({
124
- database: "mydb",
125
- monitoringUser: DEFAULT_MONITORING_USER,
126
- monitoringPassword: "pw\0bad",
127
- includeOptionalPermissions: false,
128
- })
129
- ).rejects.toThrow(/Literal cannot contain null bytes/);
130
- });
131
-
132
- test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
133
- const plan = await init.buildInitPlan({
134
- database: "mydb",
135
- monitoringUser: DEFAULT_MONITORING_USER,
136
- monitoringPassword: "pa'ss",
137
- includeOptionalPermissions: false,
138
- });
139
- const step = plan.steps.find((s: { name: string }) => s.name === "01.role");
140
- expect(step).toBeTruthy();
141
- expect(step!.sql).toMatch(/password 'pa''ss'/);
142
- expect(step!.params).toBeUndefined();
143
- });
144
-
145
- test("buildInitPlan includes optional steps when enabled", async () => {
146
- const plan = await init.buildInitPlan({
147
- database: "mydb",
148
- monitoringUser: DEFAULT_MONITORING_USER,
149
- monitoringPassword: "pw",
150
- includeOptionalPermissions: true,
151
- });
152
- expect(plan.steps.some((s: { optional?: boolean }) => s.optional)).toBe(true);
153
- });
154
-
155
- test("buildInitPlan skips role creation for supabase provider", async () => {
156
- const plan = await init.buildInitPlan({
157
- database: "mydb",
158
- monitoringUser: DEFAULT_MONITORING_USER,
159
- monitoringPassword: "pw",
160
- includeOptionalPermissions: false,
161
- provider: "supabase",
162
- });
163
- expect(plan.steps.some((s) => s.name === "01.role")).toBe(false);
164
- expect(plan.steps.some((s) => s.name === "03.permissions")).toBe(true);
165
- });
166
-
167
- test("buildInitPlan removes ALTER USER for supabase provider", async () => {
168
- const plan = await init.buildInitPlan({
169
- database: "mydb",
170
- monitoringUser: DEFAULT_MONITORING_USER,
171
- monitoringPassword: "pw",
172
- includeOptionalPermissions: false,
173
- provider: "supabase",
174
- });
175
- const permStep = plan.steps.find((s) => s.name === "03.permissions");
176
- expect(permStep).toBeDefined();
177
- expect(permStep!.sql.toLowerCase()).not.toMatch(/alter user/);
178
- });
179
-
180
- test("buildInitPlan includes role creation for unknown provider", async () => {
181
- const plan = await init.buildInitPlan({
182
- database: "mydb",
183
- monitoringUser: DEFAULT_MONITORING_USER,
184
- monitoringPassword: "pw",
185
- includeOptionalPermissions: false,
186
- provider: "some-custom-provider",
187
- });
188
- expect(plan.steps.some((s) => s.name === "01.role")).toBe(true);
189
- });
190
-
191
- test("resolveAdminConnection accepts positional URI", () => {
192
- const r = init.resolveAdminConnection({ conn: "postgresql://u:p@h:5432/d" });
193
- expect(r.clientConfig.connectionString).toBeTruthy();
194
- expect(r.display).not.toMatch(/:p@/);
195
- });
196
-
197
- test("resolveAdminConnection accepts positional conninfo", () => {
198
- const r = init.resolveAdminConnection({ conn: "dbname=mydb host=localhost user=alice" });
199
- expect(r.clientConfig.database).toBe("mydb");
200
- expect(r.clientConfig.host).toBe("localhost");
201
- expect(r.clientConfig.user).toBe("alice");
202
- });
203
-
204
- test("resolveAdminConnection rejects invalid psql-like port", () => {
205
- expect(() => init.resolveAdminConnection({ host: "localhost", port: "abc", username: "u", dbname: "d" }))
206
- .toThrow(/Invalid port value/);
207
- });
208
-
209
- test("resolveAdminConnection rejects when only PGPASSWORD is provided (no connection details)", () => {
210
- expect(() => init.resolveAdminConnection({ envPassword: "pw" })).toThrow(/Connection is required/);
211
- });
212
-
213
- test("resolveAdminConnection rejects when connection is missing", () => {
214
- expect(() => init.resolveAdminConnection({})).toThrow(/Connection is required/);
215
- });
216
-
217
- test("resolveMonitoringPassword auto-generates a strong, URL-safe password by default", async () => {
218
- const r = await init.resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
219
- expect(r.generated).toBe(true);
220
- expect(typeof r.password).toBe("string");
221
- expect(r.password.length).toBeGreaterThanOrEqual(30);
222
- expect(r.password).toMatch(/^[A-Za-z0-9_-]+$/);
223
- });
224
-
225
- test("applyInitPlan preserves Postgres error fields on step failures", async () => {
226
- const plan = {
227
- monitoringUser: DEFAULT_MONITORING_USER,
228
- database: "mydb",
229
- steps: [{ name: "01.role", sql: "select 1" }],
230
- };
231
-
232
- const pgErr = Object.assign(new Error("permission denied to create role"), {
233
- code: "42501",
234
- detail: "some detail",
235
- hint: "some hint",
236
- schema: "pg_catalog",
237
- table: "pg_roles",
238
- constraint: "some_constraint",
239
- routine: "aclcheck_error",
240
- });
241
-
242
- const calls: string[] = [];
243
- const client = {
244
- query: async (sql: string) => {
245
- calls.push(sql);
246
- if (sql === "begin;") return { rowCount: 1 };
247
- if (sql === "rollback;") return { rowCount: 1 };
248
- if (sql === "select 1") throw pgErr;
249
- throw new Error(`unexpected sql: ${sql}`);
250
- },
251
- };
252
-
253
- try {
254
- await init.applyInitPlan({ client: client as any, plan: plan as any });
255
- expect(true).toBe(false); // Should not reach here
256
- } catch (e: any) {
257
- expect(e).toBeInstanceOf(Error);
258
- expect(e.message).toMatch(/Failed at step "01\.role":/);
259
- expect(e.code).toBe("42501");
260
- expect(e.detail).toBe("some detail");
261
- expect(e.hint).toBe("some hint");
262
- expect(e.schema).toBe("pg_catalog");
263
- expect(e.table).toBe("pg_roles");
264
- expect(e.constraint).toBe("some_constraint");
265
- expect(e.routine).toBe("aclcheck_error");
266
- }
267
-
268
- expect(calls).toEqual(["begin;", "select 1", "rollback;"]);
269
- });
270
-
271
- test("verifyInitSetup runs inside a repeatable read snapshot and rolls back", async () => {
272
- const calls: string[] = [];
273
- const client = {
274
- query: async (sql: string, params?: any) => {
275
- calls.push(String(sql));
276
-
277
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
278
- return { rowCount: 1, rows: [] };
279
- }
280
- if (String(sql).toLowerCase() === "rollback;") {
281
- return { rowCount: 1, rows: [] };
282
- }
283
- if (String(sql).includes("select rolconfig")) {
284
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
285
- }
286
- if (String(sql).includes("from pg_catalog.pg_roles")) {
287
- return { rowCount: 1, rows: [] };
288
- }
289
- if (String(sql).includes("has_database_privilege")) {
290
- return { rowCount: 1, rows: [{ ok: true }] };
291
- }
292
- if (String(sql).includes("pg_has_role")) {
293
- return { rowCount: 1, rows: [{ ok: true }] };
294
- }
295
- if (String(sql).includes("has_table_privilege") && String(sql).includes("pg_catalog.pg_index")) {
296
- return { rowCount: 1, rows: [{ ok: true }] };
297
- }
298
- if (String(sql).includes("to_regclass('postgres_ai.pg_statistic')")) {
299
- return { rowCount: 1, rows: [{ ok: true }] };
300
- }
301
- if (String(sql).includes("has_table_privilege") && String(sql).includes("postgres_ai.pg_statistic")) {
302
- return { rowCount: 1, rows: [{ ok: true }] };
303
- }
304
- if (String(sql).includes("has_function_privilege")) {
305
- return { rowCount: 1, rows: [{ ok: true }] };
306
- }
307
- if (String(sql).includes("has_schema_privilege")) {
308
- return { rowCount: 1, rows: [{ ok: true }] };
309
- }
310
- // Query for pg_stat_statements extension schema location
311
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
312
- return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
313
- }
314
-
315
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
316
- },
317
- };
318
-
319
- const r = await init.verifyInitSetup({
320
- client: client as any,
321
- database: "mydb",
322
- monitoringUser: DEFAULT_MONITORING_USER,
323
- includeOptionalPermissions: false,
324
- });
325
- expect(r.ok).toBe(true);
326
- expect(r.missingRequired.length).toBe(0);
327
-
328
- expect(calls.length).toBeGreaterThan(2);
329
- expect(calls[0].toLowerCase()).toMatch(/^begin isolation level repeatable read/);
330
- expect(calls[calls.length - 1].toLowerCase()).toBe("rollback;");
331
- });
332
-
333
- test("verifyInitSetup skips search_path check for supabase provider", async () => {
334
- const calls: string[] = [];
335
- const client = {
336
- query: async (sql: string, params?: any) => {
337
- calls.push(String(sql));
338
-
339
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
340
- return { rowCount: 1, rows: [] };
341
- }
342
- if (String(sql).toLowerCase() === "rollback;") {
343
- return { rowCount: 1, rows: [] };
344
- }
345
- // Return empty rolconfig - would fail without provider=supabase
346
- if (String(sql).includes("select rolconfig")) {
347
- return { rowCount: 1, rows: [{ rolconfig: null }] };
348
- }
349
- if (String(sql).includes("from pg_catalog.pg_roles")) {
350
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
351
- }
352
- if (String(sql).includes("has_database_privilege")) {
353
- return { rowCount: 1, rows: [{ ok: true }] };
354
- }
355
- if (String(sql).includes("pg_has_role")) {
356
- return { rowCount: 1, rows: [{ ok: true }] };
357
- }
358
- if (String(sql).includes("has_table_privilege")) {
359
- return { rowCount: 1, rows: [{ ok: true }] };
360
- }
361
- if (String(sql).includes("to_regclass")) {
362
- return { rowCount: 1, rows: [{ ok: true }] };
363
- }
364
- if (String(sql).includes("has_function_privilege")) {
365
- return { rowCount: 1, rows: [{ ok: true }] };
366
- }
367
- if (String(sql).includes("has_schema_privilege")) {
368
- return { rowCount: 1, rows: [{ ok: true }] };
369
- }
370
- // Query for pg_stat_statements extension schema location (Supabase uses 'extensions' schema)
371
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
372
- return { rowCount: 1, rows: [{ schema: "extensions" }] };
373
- }
374
-
375
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
376
- },
377
- };
378
-
379
- // With provider=supabase, should pass even without search_path
380
- const r = await init.verifyInitSetup({
381
- client: client as any,
382
- database: "mydb",
383
- monitoringUser: DEFAULT_MONITORING_USER,
384
- includeOptionalPermissions: false,
385
- provider: "supabase",
386
- });
387
- expect(r.ok).toBe(true);
388
- expect(r.missingRequired.length).toBe(0);
389
- // Should not have queried for rolconfig since we skip search_path check
390
- expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
391
- });
392
-
393
- test("verifyInitSetup checks extensions schema when pg_stat_statements is there", async () => {
394
- const calls: string[] = [];
395
- const client = {
396
- query: async (sql: string, params?: any) => {
397
- calls.push(String(sql));
398
-
399
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
400
- return { rowCount: 1, rows: [] };
401
- }
402
- if (String(sql).toLowerCase() === "rollback;") {
403
- return { rowCount: 1, rows: [] };
404
- }
405
- if (String(sql).includes("select rolconfig")) {
406
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
407
- }
408
- if (String(sql).includes("from pg_catalog.pg_roles")) {
409
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
410
- }
411
- if (String(sql).includes("has_database_privilege")) {
412
- return { rowCount: 1, rows: [{ ok: true }] };
413
- }
414
- if (String(sql).includes("pg_has_role")) {
415
- return { rowCount: 1, rows: [{ ok: true }] };
416
- }
417
- if (String(sql).includes("has_table_privilege")) {
418
- return { rowCount: 1, rows: [{ ok: true }] };
419
- }
420
- if (String(sql).includes("to_regclass")) {
421
- return { rowCount: 1, rows: [{ ok: true }] };
422
- }
423
- if (String(sql).includes("has_function_privilege")) {
424
- return { rowCount: 1, rows: [{ ok: true }] };
425
- }
426
- // pg_stat_statements is in 'extensions' schema
427
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
428
- return { rowCount: 1, rows: [{ schema: "extensions" }] };
429
- }
430
- // Check for USAGE on extensions schema
431
- if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
432
- return { rowCount: 1, rows: [{ ok: true }] };
433
- }
434
- if (String(sql).includes("has_schema_privilege")) {
435
- return { rowCount: 1, rows: [{ ok: true }] };
436
- }
437
-
438
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
439
- },
440
- };
441
-
442
- const r = await init.verifyInitSetup({
443
- client: client as any,
444
- database: "mydb",
445
- monitoringUser: DEFAULT_MONITORING_USER,
446
- includeOptionalPermissions: false,
447
- });
448
- expect(r.ok).toBe(true);
449
- expect(r.missingRequired.length).toBe(0);
450
- // Should have queried for pg_stat_statements schema location
451
- expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
452
- });
453
-
454
- test("verifyInitSetup reports missing extensions schema access", async () => {
455
- const client = {
456
- query: async (sql: string, params?: any) => {
457
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
458
- return { rowCount: 1, rows: [] };
459
- }
460
- if (String(sql).toLowerCase() === "rollback;") {
461
- return { rowCount: 1, rows: [] };
462
- }
463
- if (String(sql).includes("select rolconfig")) {
464
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
465
- }
466
- if (String(sql).includes("from pg_catalog.pg_roles")) {
467
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
468
- }
469
- if (String(sql).includes("has_database_privilege")) {
470
- return { rowCount: 1, rows: [{ ok: true }] };
471
- }
472
- if (String(sql).includes("pg_has_role")) {
473
- return { rowCount: 1, rows: [{ ok: true }] };
474
- }
475
- if (String(sql).includes("has_table_privilege")) {
476
- return { rowCount: 1, rows: [{ ok: true }] };
477
- }
478
- if (String(sql).includes("to_regclass")) {
479
- return { rowCount: 1, rows: [{ ok: true }] };
480
- }
481
- if (String(sql).includes("has_function_privilege")) {
482
- return { rowCount: 1, rows: [{ ok: true }] };
483
- }
484
- // pg_stat_statements is in 'extensions' schema
485
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
486
- return { rowCount: 1, rows: [{ schema: "extensions" }] };
487
- }
488
- // No USAGE on extensions schema
489
- if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
490
- return { rowCount: 1, rows: [{ ok: false }] };
491
- }
492
- if (String(sql).includes("has_schema_privilege")) {
493
- return { rowCount: 1, rows: [{ ok: true }] };
494
- }
495
-
496
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
497
- },
498
- };
499
-
500
- const r = await init.verifyInitSetup({
501
- client: client as any,
502
- database: "mydb",
503
- monitoringUser: DEFAULT_MONITORING_USER,
504
- includeOptionalPermissions: false,
505
- });
506
- expect(r.ok).toBe(false);
507
- // Should report missing USAGE on extensions schema
508
- expect(r.missingRequired.some((m) => m.includes("extensions") && m.includes("pg_stat_statements"))).toBe(true);
509
- // Should also report missing extensions in search_path
510
- expect(r.missingRequired.some((m) => m.includes("search_path") && m.includes("extensions"))).toBe(true);
511
- });
512
-
513
- test("buildInitPlan includes dynamic search_path with extension schema detection", async () => {
514
- const plan = await init.buildInitPlan({
515
- database: "mydb",
516
- monitoringUser: DEFAULT_MONITORING_USER,
517
- monitoringPassword: "pw",
518
- includeOptionalPermissions: false,
519
- });
520
-
521
- const permStep = plan.steps.find((s) => s.name === "03.permissions");
522
- expect(permStep).toBeTruthy();
523
- // Should use dynamic DO block to set search_path based on detected extension schema
524
- expect(permStep!.sql).toMatch(/alter\s+user.*set\s+search_path\s*=/i);
525
- // Should detect pg_stat_statements extension schema dynamically
526
- expect(permStep!.sql).toMatch(/quote_ident\(ext_schema\)/i);
527
- });
528
-
529
- test("buildInitPlan includes dynamic extension schema grant", async () => {
530
- const plan = await init.buildInitPlan({
531
- database: "mydb",
532
- monitoringUser: DEFAULT_MONITORING_USER,
533
- monitoringPassword: "pw",
534
- includeOptionalPermissions: false,
535
- });
536
-
537
- const permStep = plan.steps.find((s) => s.name === "03.permissions");
538
- expect(permStep).toBeTruthy();
539
- // Should include DO block that grants USAGE on extension schema
540
- expect(permStep!.sql).toMatch(/do\s+\$\$/i);
541
- expect(permStep!.sql).toMatch(/pg_stat_statements/);
542
- expect(permStep!.sql).toMatch(/grant usage on schema/i);
543
- });
544
-
545
- test("verifyInitSetup handles pg_stat_statements not installed", async () => {
546
- const calls: string[] = [];
547
- const client = {
548
- query: async (sql: string, params?: any) => {
549
- calls.push(String(sql));
550
-
551
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
552
- return { rowCount: 1, rows: [] };
553
- }
554
- if (String(sql).toLowerCase() === "rollback;") {
555
- return { rowCount: 1, rows: [] };
556
- }
557
- if (String(sql).includes("select rolconfig")) {
558
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
559
- }
560
- if (String(sql).includes("from pg_catalog.pg_roles")) {
561
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
562
- }
563
- if (String(sql).includes("has_database_privilege")) {
564
- return { rowCount: 1, rows: [{ ok: true }] };
565
- }
566
- if (String(sql).includes("pg_has_role")) {
567
- return { rowCount: 1, rows: [{ ok: true }] };
568
- }
569
- if (String(sql).includes("has_table_privilege")) {
570
- return { rowCount: 1, rows: [{ ok: true }] };
571
- }
572
- if (String(sql).includes("to_regclass")) {
573
- return { rowCount: 1, rows: [{ ok: true }] };
574
- }
575
- if (String(sql).includes("has_function_privilege")) {
576
- return { rowCount: 1, rows: [{ ok: true }] };
577
- }
578
- // pg_stat_statements is NOT installed - empty result
579
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
580
- return { rowCount: 0, rows: [] };
581
- }
582
- if (String(sql).includes("has_schema_privilege")) {
583
- return { rowCount: 1, rows: [{ ok: true }] };
584
- }
585
-
586
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
587
- },
588
- };
589
-
590
- const r = await init.verifyInitSetup({
591
- client: client as any,
592
- database: "mydb",
593
- monitoringUser: DEFAULT_MONITORING_USER,
594
- includeOptionalPermissions: false,
595
- });
596
- // Should pass without errors - missing extension shouldn't cause failure
597
- expect(r.ok).toBe(true);
598
- expect(r.missingRequired.length).toBe(0);
599
- // Should have queried for pg_stat_statements schema location
600
- expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
601
- });
602
-
603
- test("verifyInitSetup skips extension schema check when in pg_catalog", async () => {
604
- const calls: string[] = [];
605
- const client = {
606
- query: async (sql: string, params?: any) => {
607
- calls.push(String(sql));
608
-
609
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
610
- return { rowCount: 1, rows: [] };
611
- }
612
- if (String(sql).toLowerCase() === "rollback;") {
613
- return { rowCount: 1, rows: [] };
614
- }
615
- if (String(sql).includes("select rolconfig")) {
616
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
617
- }
618
- if (String(sql).includes("from pg_catalog.pg_roles")) {
619
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
620
- }
621
- if (String(sql).includes("has_database_privilege")) {
622
- return { rowCount: 1, rows: [{ ok: true }] };
623
- }
624
- if (String(sql).includes("pg_has_role")) {
625
- return { rowCount: 1, rows: [{ ok: true }] };
626
- }
627
- if (String(sql).includes("has_table_privilege")) {
628
- return { rowCount: 1, rows: [{ ok: true }] };
629
- }
630
- if (String(sql).includes("to_regclass")) {
631
- return { rowCount: 1, rows: [{ ok: true }] };
632
- }
633
- if (String(sql).includes("has_function_privilege")) {
634
- return { rowCount: 1, rows: [{ ok: true }] };
635
- }
636
- // pg_stat_statements is in pg_catalog (standard location)
637
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
638
- return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
639
- }
640
- if (String(sql).includes("has_schema_privilege")) {
641
- return { rowCount: 1, rows: [{ ok: true }] };
642
- }
643
-
644
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
645
- },
646
- };
647
-
648
- const r = await init.verifyInitSetup({
649
- client: client as any,
650
- database: "mydb",
651
- monitoringUser: DEFAULT_MONITORING_USER,
652
- includeOptionalPermissions: false,
653
- });
654
- // Should pass - pg_catalog doesn't need extra USAGE grant
655
- expect(r.ok).toBe(true);
656
- expect(r.missingRequired.length).toBe(0);
657
- // Should NOT have queried for has_schema_privilege on pg_catalog specifically
658
- // (the code skips the check for pg_catalog and public schemas)
659
- const pgCatalogPrivCheck = calls.filter(
660
- (c) => c.includes("has_schema_privilege") && c.includes("pg_catalog")
661
- );
662
- // Should only have the standard public schema check, not a pg_catalog check for extension
663
- expect(pgCatalogPrivCheck.length).toBe(0);
664
- });
665
-
666
- test("verifyInitSetup skips extension schema check when in public", async () => {
667
- const calls: string[] = [];
668
- const client = {
669
- query: async (sql: string, params?: any) => {
670
- calls.push(String(sql));
671
-
672
- if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
673
- return { rowCount: 1, rows: [] };
674
- }
675
- if (String(sql).toLowerCase() === "rollback;") {
676
- return { rowCount: 1, rows: [] };
677
- }
678
- if (String(sql).includes("select rolconfig")) {
679
- return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
680
- }
681
- if (String(sql).includes("from pg_catalog.pg_roles")) {
682
- return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
683
- }
684
- if (String(sql).includes("has_database_privilege")) {
685
- return { rowCount: 1, rows: [{ ok: true }] };
686
- }
687
- if (String(sql).includes("pg_has_role")) {
688
- return { rowCount: 1, rows: [{ ok: true }] };
689
- }
690
- if (String(sql).includes("has_table_privilege")) {
691
- return { rowCount: 1, rows: [{ ok: true }] };
692
- }
693
- if (String(sql).includes("to_regclass")) {
694
- return { rowCount: 1, rows: [{ ok: true }] };
695
- }
696
- if (String(sql).includes("has_function_privilege")) {
697
- return { rowCount: 1, rows: [{ ok: true }] };
698
- }
699
- // pg_stat_statements is in public schema
700
- if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
701
- return { rowCount: 1, rows: [{ schema: "public" }] };
702
- }
703
- if (String(sql).includes("has_schema_privilege")) {
704
- return { rowCount: 1, rows: [{ ok: true }] };
705
- }
706
-
707
- throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
708
- },
709
- };
710
-
711
- const r = await init.verifyInitSetup({
712
- client: client as any,
713
- database: "mydb",
714
- monitoringUser: DEFAULT_MONITORING_USER,
715
- includeOptionalPermissions: false,
716
- });
717
- // Should pass - public doesn't need extra USAGE grant for extension
718
- expect(r.ok).toBe(true);
719
- expect(r.missingRequired.length).toBe(0);
720
- });
721
-
722
- test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
723
- const plan = await init.buildInitPlan({
724
- database: "mydb",
725
- monitoringUser: DEFAULT_MONITORING_USER,
726
- monitoringPassword: "pw",
727
- includeOptionalPermissions: false,
728
- provider: "supabase",
729
- });
730
- const permStep = plan.steps.find((s) => s.name === "03.permissions");
731
- expect(permStep).toBeDefined();
732
- // Should have removed ALTER USER but kept comments
733
- expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
734
- // Should still have comment lines
735
- expect(permStep!.sql).toMatch(/^--/m);
736
- });
737
-
738
- test("validateProvider returns null for known providers", () => {
739
- expect(init.validateProvider(undefined)).toBe(null);
740
- expect(init.validateProvider("self-managed")).toBe(null);
741
- expect(init.validateProvider("supabase")).toBe(null);
742
- });
743
-
744
- test("validateProvider returns warning for unknown providers", () => {
745
- const warning = init.validateProvider("unknown-provider");
746
- expect(warning).not.toBe(null);
747
- expect(warning).toMatch(/Unknown provider/);
748
- expect(warning).toMatch(/unknown-provider/);
749
- });
750
-
751
- test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
752
- const plan = await init.buildInitPlan({
753
- database: "mydb",
754
- monitoringUser: DEFAULT_MONITORING_USER,
755
- monitoringPassword: "pa'ss",
756
- includeOptionalPermissions: false,
757
- });
758
- const step = plan.steps.find((s: { name: string }) => s.name === "01.role");
759
- expect(step).toBeTruthy();
760
- const redacted = init.redactPasswordsInSql(step!.sql);
761
- expect(redacted).toMatch(/password '<redacted>'/i);
762
- });
763
-
764
- // Tests for buildUninitPlan
765
- test("buildUninitPlan generates correct steps with dropRole=true", async () => {
766
- const plan = await init.buildUninitPlan({
767
- database: "mydb",
768
- monitoringUser: DEFAULT_MONITORING_USER,
769
- dropRole: true,
770
- });
771
-
772
- expect(plan.database).toBe("mydb");
773
- expect(plan.monitoringUser).toBe(DEFAULT_MONITORING_USER);
774
- expect(plan.dropRole).toBe(true);
775
- expect(plan.steps.length).toBe(3);
776
- expect(plan.steps.map((s) => s.name)).toEqual([
777
- "01.drop_helpers",
778
- "02.revoke_permissions",
779
- "03.drop_role",
780
- ]);
781
- });
782
-
783
- test("buildUninitPlan skips role drop when dropRole=false", async () => {
784
- const plan = await init.buildUninitPlan({
785
- database: "mydb",
786
- monitoringUser: DEFAULT_MONITORING_USER,
787
- dropRole: false,
788
- });
789
-
790
- expect(plan.dropRole).toBe(false);
791
- expect(plan.steps.length).toBe(2);
792
- expect(plan.steps.map((s) => s.name)).toEqual([
793
- "01.drop_helpers",
794
- "02.revoke_permissions",
795
- ]);
796
- });
797
-
798
- test("buildUninitPlan skips role drop for supabase provider", async () => {
799
- const plan = await init.buildUninitPlan({
800
- database: "mydb",
801
- monitoringUser: DEFAULT_MONITORING_USER,
802
- dropRole: true,
803
- provider: "supabase",
804
- });
805
-
806
- // Even with dropRole=true, supabase provider skips role operations
807
- expect(plan.steps.length).toBe(2);
808
- expect(plan.steps.some((s) => s.name === "03.drop_role")).toBe(false);
809
- });
810
-
811
- test("buildUninitPlan handles special characters in identifiers", async () => {
812
- const monitoringUser = 'user "with" quotes';
813
- const database = 'db "name"';
814
- const plan = await init.buildUninitPlan({
815
- database,
816
- monitoringUser,
817
- dropRole: true,
818
- });
819
-
820
- // Check that identifiers are properly quoted in SQL
821
- const dropHelpersStep = plan.steps.find((s) => s.name === "01.drop_helpers");
822
- expect(dropHelpersStep).toBeTruthy();
823
-
824
- const revokeStep = plan.steps.find((s) => s.name === "02.revoke_permissions");
825
- expect(revokeStep).toBeTruthy();
826
- expect(revokeStep!.sql).toContain('"user ""with"" quotes"');
827
- expect(revokeStep!.sql).toContain('"db ""name"""');
828
-
829
- const dropRoleStep = plan.steps.find((s) => s.name === "03.drop_role");
830
- expect(dropRoleStep).toBeTruthy();
831
- // Uses ROLE_LITERAL (single-quoted) for format('%I', ...) in dynamic SQL
832
- expect(dropRoleStep!.sql).toContain("'user \"with\" quotes'");
833
- });
834
-
835
- test("buildUninitPlan rejects identifiers with null bytes", async () => {
836
- await expect(
837
- init.buildUninitPlan({
838
- database: "mydb",
839
- monitoringUser: "bad\0user",
840
- dropRole: true,
841
- })
842
- ).rejects.toThrow(/Identifier cannot contain null bytes/);
843
- });
844
-
845
- test("applyUninitPlan continues on errors and reports them", async () => {
846
- const plan = {
847
- monitoringUser: DEFAULT_MONITORING_USER,
848
- database: "mydb",
849
- dropRole: true,
850
- steps: [
851
- { name: "01.drop_helpers", sql: "drop function if exists postgres_ai.test()" },
852
- { name: "02.revoke_permissions", sql: "select 1/0" }, // Will fail
853
- { name: "03.drop_role", sql: "select 1" },
854
- ],
855
- };
856
-
857
- const calls: string[] = [];
858
- const client = {
859
- query: async (sql: string) => {
860
- calls.push(sql);
861
- if (sql === "begin;") return { rowCount: 1 };
862
- if (sql === "commit;") return { rowCount: 1 };
863
- if (sql === "rollback;") return { rowCount: 1 };
864
- if (sql.includes("1/0")) throw new Error("division by zero");
865
- return { rowCount: 1 };
866
- },
867
- };
868
-
869
- const result = await init.applyUninitPlan({ client: client as any, plan: plan as any });
870
-
871
- // Should have applied steps 1 and 3, with step 2 in errors
872
- expect(result.applied).toContain("01.drop_helpers");
873
- expect(result.applied).toContain("03.drop_role");
874
- expect(result.applied).not.toContain("02.revoke_permissions");
875
- expect(result.errors.length).toBe(1);
876
- expect(result.errors[0]).toMatch(/02\.revoke_permissions.*division by zero/);
877
- });
878
-
879
- test("buildInitPlan includes 02.extensions step with pg_stat_statements", async () => {
880
- const plan = await init.buildInitPlan({
881
- database: "mydb",
882
- monitoringUser: DEFAULT_MONITORING_USER,
883
- monitoringPassword: "pw",
884
- includeOptionalPermissions: false,
885
- });
886
-
887
- const extStep = plan.steps.find((s) => s.name === "02.extensions");
888
- expect(extStep).toBeTruthy();
889
- // Should create pg_stat_statements with IF NOT EXISTS
890
- expect(extStep!.sql).toMatch(/create extension if not exists pg_stat_statements/i);
891
- });
892
-
893
- test("buildInitPlan creates extensions before permissions", async () => {
894
- const plan = await init.buildInitPlan({
895
- database: "mydb",
896
- monitoringUser: DEFAULT_MONITORING_USER,
897
- monitoringPassword: "pw",
898
- includeOptionalPermissions: false,
899
- });
900
-
901
- const stepNames = plan.steps.map((s) => s.name);
902
- const extIndex = stepNames.indexOf("02.extensions");
903
- const permIndex = stepNames.indexOf("03.permissions");
904
- expect(extIndex).toBeGreaterThanOrEqual(0);
905
- expect(permIndex).toBeGreaterThanOrEqual(0);
906
- // Extensions should come before permissions
907
- expect(extIndex).toBeLessThan(permIndex);
908
- });
909
-
910
- test("buildInitPlan uses IF NOT EXISTS for postgres_ai schema (idempotent)", async () => {
911
- const plan = await init.buildInitPlan({
912
- database: "mydb",
913
- monitoringUser: DEFAULT_MONITORING_USER,
914
- monitoringPassword: "pw",
915
- includeOptionalPermissions: false,
916
- });
917
-
918
- const permStep = plan.steps.find((s) => s.name === "03.permissions");
919
- expect(permStep).toBeTruthy();
920
- // Should use IF NOT EXISTS for idempotent behavior
921
- expect(permStep!.sql).toMatch(/create schema if not exists postgres_ai/i);
922
- });
923
-
924
- test("buildUninitPlan does NOT drop pg_stat_statements extension", async () => {
925
- const plan = await init.buildUninitPlan({
926
- database: "mydb",
927
- monitoringUser: DEFAULT_MONITORING_USER,
928
- dropRole: true,
929
- });
930
-
931
- // Check all steps - none should drop pg_stat_statements
932
- for (const step of plan.steps) {
933
- expect(step.sql.toLowerCase()).not.toMatch(/drop extension.*pg_stat_statements/);
934
- }
935
- });
936
- });
937
-
938
- describe("CLI commands", () => {
939
- test("cli: prepare-db with missing connection prints help/options", () => {
940
- const r = runCli(["prepare-db"]);
941
- expect(r.status).not.toBe(0);
942
- expect(r.stderr).toMatch(/--print-sql/);
943
- expect(r.stderr).toMatch(/--monitoring-user/);
944
- });
945
-
946
- test("cli: prepare-db --print-sql works without connection (offline mode)", () => {
947
- const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw"]);
948
- expect(r.status).toBe(0);
949
- expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
950
- expect(r.stdout).toMatch(new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
951
- });
952
-
953
- test("cli: prepare-db --print-sql with --provider supabase skips role step", () => {
954
- const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "supabase"]);
955
- expect(r.status).toBe(0);
956
- expect(r.stdout).toMatch(/provider: supabase/);
957
- // Should not have 01.role step
958
- expect(r.stdout).not.toMatch(/-- 01\.role/);
959
- // Should have 02.extensions and 03.permissions steps
960
- expect(r.stdout).toMatch(/-- 02\.extensions/);
961
- expect(r.stdout).toMatch(/-- 03\.permissions/);
962
- });
963
-
964
- test("cli: prepare-db warns about unknown provider", () => {
965
- const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "unknown-cloud"]);
966
- expect(r.status).toBe(0);
967
- // Should warn about unknown provider
968
- expect(r.stderr).toMatch(/Unknown provider.*unknown-cloud/);
969
- });
970
-
971
- test("cli: prepare-db --reset-password with supabase provider would have no role step", async () => {
972
- // When using supabase provider, the role creation step is skipped.
973
- // This means --reset-password (which only runs 01.role) would have no steps.
974
- // The CLI should error in this case. We test the underlying plan logic here.
975
- const plan = await (await import("../lib/init")).buildInitPlan({
976
- database: "mydb",
977
- monitoringUser: "mon",
978
- monitoringPassword: "pw",
979
- includeOptionalPermissions: false,
980
- provider: "supabase",
981
- });
982
- // Simulate what --reset-password does: filter to only 01.role step
983
- const resetPasswordSteps = plan.steps.filter((s) => s.name === "01.role");
984
- // For supabase, this should be empty (role creation is skipped)
985
- expect(resetPasswordSteps.length).toBe(0);
986
- });
987
-
988
- test("pgai wrapper forwards to postgresai CLI", () => {
989
- const r = runPgai(["--help"]);
990
- expect(r.status).toBe(0);
991
- expect(r.stdout).toMatch(/postgresai|PostgresAI/i);
992
- });
993
-
994
- test("cli: prepare-db command exists and shows help", () => {
995
- const r = runCli(["prepare-db", "--help"]);
996
- expect(r.status).toBe(0);
997
- expect(r.stdout).toMatch(/monitoring user/i);
998
- expect(r.stdout).toMatch(/--print-sql/);
999
- });
1000
-
1001
- test("cli: mon local-install command exists and shows help", () => {
1002
- const r = runCli(["mon", "local-install", "--help"]);
1003
- expect(r.status).toBe(0);
1004
- expect(r.stdout).toMatch(/--demo/);
1005
- expect(r.stdout).toMatch(/--api-key/);
1006
- });
1007
-
1008
- test("cli: mon local-install --api-key and --db-url skip interactive prompts", () => {
1009
- // This test verifies that when --api-key and --db-url are provided,
1010
- // the CLI uses them directly without prompting for input.
1011
- // The command will fail later (no Docker, invalid DB), but we check
1012
- // that the options were parsed and used correctly.
1013
- const r = runCli([
1014
- "mon", "local-install",
1015
- "--api-key", "test-api-key-12345",
1016
- "--db-url", "postgresql://user:pass@localhost:5432/testdb"
1017
- ]);
1018
-
1019
- // Should show that API key was provided via CLI option (not prompting)
1020
- expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
1021
- // Should show that DB URL was provided via CLI option (not prompting)
1022
- expect(r.stdout).toMatch(/Using database URL provided via --db-url parameter/);
1023
- });
1024
-
1025
- test("cli: auth login --help shows --set-key option", () => {
1026
- const r = runCli(["auth", "login", "--help"]);
1027
- expect(r.status).toBe(0);
1028
- expect(r.stdout).toMatch(/--set-key/);
1029
- });
1030
-
1031
- test("cli: mon local-install reads global --api-key option", () => {
1032
- // The fix ensures --api-key works when passed as a global option (before subcommand)
1033
- // Commander.js routes global options to program.opts(), not subcommand opts
1034
- const r = runCli([
1035
- "--api-key", "global-api-key-test",
1036
- "mon", "local-install",
1037
- "--db-url", "postgresql://user:pass@localhost:5432/testdb"
1038
- ]);
1039
-
1040
- // Should detect the API key from global options
1041
- expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
1042
- });
1043
-
1044
- test("cli: mon local-install works with --api-key after subcommand", () => {
1045
- // Test that --api-key works when passed after the subcommand
1046
- // Note: Commander.js routes --api-key to global opts, the fix reads from both
1047
- const r = runCli([
1048
- "mon", "local-install",
1049
- "--api-key", "test-key-after-subcommand",
1050
- "--db-url", "postgresql://user:pass@localhost:5432/testdb"
1051
- ]);
1052
-
1053
- // Should detect the API key regardless of position
1054
- expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
1055
- // Verify the key was saved
1056
- expect(r.stdout).toMatch(/API key saved/);
1057
- });
1058
-
1059
- test("cli: mon local-install with --yes and no --api-key skips API setup", () => {
1060
- // When --yes is provided without --api-key, the CLI should skip
1061
- // the interactive prompt and proceed without API key
1062
- const r = runCli([
1063
- "mon", "local-install",
1064
- "--db-url", "postgresql://user:pass@localhost:5432/testdb",
1065
- "--yes"
1066
- ]);
1067
-
1068
- // Should indicate auto-yes mode without API key
1069
- expect(r.stdout).toMatch(/Auto-yes mode: no API key provided/);
1070
- expect(r.stderr).toMatch(/Reports will be generated locally only/);
1071
- });
1072
-
1073
- test("cli: mon local-install --demo configures demo monitoring target", () => {
1074
- // --demo should copy instances.demo.yml to instances.yml and print confirmation.
1075
- // The command will fail later (no Docker), but we verify the demo target step succeeded.
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
- }
1134
- });
1135
-
1136
- test("cli: mon local-install --demo with global --api-key shows error", () => {
1137
- // When --demo is used with global --api-key, it should still be detected and error
1138
- const r = runCli([
1139
- "--api-key", "global-api-key-test",
1140
- "mon", "local-install",
1141
- "--demo"
1142
- ]);
1143
-
1144
- // Should reject demo mode with API key (from global option)
1145
- expect(r.status).not.toBe(0);
1146
- expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
1147
- });
1148
-
1149
- // Tests for unprepare-db command
1150
- test("cli: unprepare-db with missing connection prints help/options", () => {
1151
- const r = runCli(["unprepare-db"]);
1152
- expect(r.status).not.toBe(0);
1153
- expect(r.stderr).toMatch(/--print-sql/);
1154
- expect(r.stderr).toMatch(/--monitoring-user/);
1155
- });
1156
-
1157
- test("cli: unprepare-db --print-sql works without connection (offline mode)", () => {
1158
- const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb"]);
1159
- expect(r.status).toBe(0);
1160
- expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
1161
- expect(r.stdout).toMatch(/drop schema if exists postgres_ai/i);
1162
- });
1163
-
1164
- test("cli: unprepare-db --print-sql with --keep-role skips role drop", () => {
1165
- const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--keep-role"]);
1166
- expect(r.status).toBe(0);
1167
- expect(r.stdout).toMatch(/drop role: false/);
1168
- // Should not have 03.drop_role step
1169
- expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
1170
- // Should have 01 and 02 steps
1171
- expect(r.stdout).toMatch(/-- 01\.drop_helpers/);
1172
- expect(r.stdout).toMatch(/-- 02\.revoke_permissions/);
1173
- });
1174
-
1175
- test("cli: unprepare-db --print-sql with --provider supabase skips role step", () => {
1176
- const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--provider", "supabase"]);
1177
- expect(r.status).toBe(0);
1178
- expect(r.stdout).toMatch(/provider: supabase/);
1179
- // Should not have 03.drop_role step
1180
- expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
1181
- });
1182
-
1183
- test("cli: unprepare-db command exists and shows help", () => {
1184
- const r = runCli(["unprepare-db", "--help"]);
1185
- expect(r.status).toBe(0);
1186
- expect(r.stdout).toMatch(/--keep-role/);
1187
- expect(r.stdout).toMatch(/--print-sql/);
1188
- expect(r.stdout).toMatch(/--force/);
1189
- });
1190
- });
1191
-
1192
- // Check if Docker is available for imageTag tests
1193
- function isDockerAvailable(): boolean {
1194
- try {
1195
- const result = Bun.spawnSync(["docker", "info"], { timeout: 5000 });
1196
- return result.exitCode === 0;
1197
- } catch {
1198
- return false;
1199
- }
1200
- }
1201
-
1202
- const dockerAvailable = isDockerAvailable();
1203
-
1204
- describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
1205
- // Tests for the imageTag priority: --tag flag > PGAI_TAG env var > pkg.version
1206
- // This verifies the fix that prevents stale .env PGAI_TAG from being used
1207
- // These tests require Docker and spawn subprocesses so need longer timeout
1208
-
1209
- let tempDir: string;
1210
-
1211
- beforeAll(() => {
1212
- tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-test-"));
1213
- });
1214
-
1215
- afterAll(() => {
1216
- if (tempDir && fs.existsSync(tempDir)) {
1217
- fs.rmSync(tempDir, { recursive: true, force: true });
1218
- }
1219
- });
1220
-
1221
- test("stale .env PGAI_TAG is NOT used - CLI version takes precedence", () => {
1222
- // Create a stale .env with an old tag value
1223
- const testDir = resolve(tempDir, "stale-tag-test");
1224
- fs.mkdirSync(testDir, { recursive: true });
1225
- fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=beta\n");
1226
- // Create minimal docker-compose.yml so resolvePaths() finds it
1227
- fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
1228
-
1229
- // Run from the test directory (so resolvePaths finds docker-compose.yml)
1230
- // Note: Command may hang on Docker check in CI without Docker, so we use a timeout
1231
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
1232
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
1233
- const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
1234
- env: { ...process.env, PGAI_TAG: undefined },
1235
- cwd: testDir,
1236
- timeout: 30000, // Kill subprocess after 30s if it hangs on Docker
1237
- });
1238
-
1239
- // Read the .env that was written
1240
- const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
1241
-
1242
- // The .env should NOT contain the stale "beta" tag - it should use pkg.version
1243
- expect(envContent).not.toMatch(/PGAI_TAG=beta/);
1244
- // It should contain the CLI version (0.0.0-dev.0 in dev)
1245
- expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
1246
- }, 60000);
1247
-
1248
- test("--tag flag takes priority over pkg.version", () => {
1249
- const testDir = resolve(tempDir, "tag-flag-test");
1250
- fs.mkdirSync(testDir, { recursive: true });
1251
- fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
1252
-
1253
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
1254
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
1255
- const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--tag", "v1.2.3-custom", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
1256
- env: { ...process.env, PGAI_TAG: undefined },
1257
- cwd: testDir,
1258
- timeout: 30000,
1259
- });
1260
-
1261
- const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
1262
- expect(envContent).toMatch(/PGAI_TAG=v1\.2\.3-custom/);
1263
-
1264
- // Verify stdout confirms the tag being used
1265
- const stdout = new TextDecoder().decode(result.stdout);
1266
- expect(stdout).toMatch(/Using image tag: v1\.2\.3-custom/);
1267
- }, 60000);
1268
-
1269
- test("PGAI_TAG env var is intentionally ignored (Bun auto-loads .env)", () => {
1270
- // Note: We do NOT use process.env.PGAI_TAG because Bun auto-loads .env files,
1271
- // which would cause stale .env values to pollute the environment.
1272
- // Users should use --tag flag to override, not env vars.
1273
- const testDir = resolve(tempDir, "env-var-ignored-test");
1274
- fs.mkdirSync(testDir, { recursive: true });
1275
- fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
1276
-
1277
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
1278
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
1279
- const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
1280
- env: { ...process.env, PGAI_TAG: "v2.0.0-from-env" },
1281
- cwd: testDir,
1282
- timeout: 30000,
1283
- });
1284
-
1285
- const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
1286
- // PGAI_TAG env var should be IGNORED - uses pkg.version instead
1287
- expect(envContent).not.toMatch(/PGAI_TAG=v2\.0\.0-from-env/);
1288
- expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
1289
- }, 60000);
1290
-
1291
- test("existing registry and passwords are preserved while tag is updated", () => {
1292
- const testDir = resolve(tempDir, "preserve-test");
1293
- fs.mkdirSync(testDir, { recursive: true });
1294
- // Create .env with stale tag but valid registry and passwords
1295
- fs.writeFileSync(resolve(testDir, ".env"),
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
- fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
1298
-
1299
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
1300
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
1301
- const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
1302
- env: { ...process.env, PGAI_TAG: undefined },
1303
- cwd: testDir,
1304
- timeout: 30000,
1305
- });
1306
-
1307
- const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
1308
-
1309
- // Tag should be updated (not stale-tag)
1310
- expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
1311
-
1312
- // But registry and passwords should be preserved
1313
- expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
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/);
1318
- }, 60000);
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("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
-
1510
- test("returns all rows for inspection", async () => {
1511
- const rows: init.PermissionCheckRow[] = [
1512
- { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
1513
- { permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
1514
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
1515
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
1516
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
1517
- ];
1518
-
1519
- const result = await init.checkCurrentUserPermissions(makeMockClient(rows) as any);
1520
- expect(result.rows).toHaveLength(5);
1521
- expect(result.rows).toEqual(rows);
1522
- });
1523
-
1524
- test("handles empty rows (no permission checks returned)", async () => {
1525
- const result = await init.checkCurrentUserPermissions(makeMockClient([]) as any);
1526
- expect(result.ok).toBe(true);
1527
- expect(result.rows).toHaveLength(0);
1528
- expect(result.missingRequired).toHaveLength(0);
1529
- expect(result.missingOptional).toHaveLength(0);
1530
- });
1531
- });
1532
-
1533
- describe("formatPermissionCheckMessages", () => {
1534
- test("returns no warnings or errors when all permissions granted", () => {
1535
- const result: init.PreflightPermissionResult = {
1536
- ok: true,
1537
- rows: [],
1538
- missingRequired: [],
1539
- missingOptional: [],
1540
- };
1541
-
1542
- const messages = init.formatPermissionCheckMessages(result);
1543
- expect(messages.failed).toBe(false);
1544
- expect(messages.warnings).toHaveLength(0);
1545
- expect(messages.errors).toHaveLength(0);
1546
- });
1547
-
1548
- test("returns warnings for missing optional permissions", () => {
1549
- const result: init.PreflightPermissionResult = {
1550
- ok: true,
1551
- rows: [],
1552
- missingRequired: [],
1553
- missingOptional: [
1554
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create view" },
1555
- ],
1556
- };
1557
-
1558
- const messages = init.formatPermissionCheckMessages(result);
1559
- expect(messages.failed).toBe(false);
1560
- expect(messages.warnings).toHaveLength(1);
1561
- expect(messages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
1562
- expect(messages.warnings[0]).toContain("Fix: -- create view");
1563
- expect(messages.errors).toHaveLength(0);
1564
- });
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
-
1583
- test("returns errors with fix commands for missing required permissions", () => {
1584
- const result: init.PreflightPermissionResult = {
1585
- ok: false,
1586
- rows: [],
1587
- missingRequired: [
1588
- { permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
1589
- ],
1590
- missingOptional: [],
1591
- };
1592
-
1593
- const messages = init.formatPermissionCheckMessages(result);
1594
- expect(messages.failed).toBe(true);
1595
- expect(messages.errors.some((e) => e.includes("pg_monitor role membership"))).toBe(true);
1596
- expect(messages.errors.some((e) => e.includes("grant pg_monitor to testuser;"))).toBe(true);
1597
- expect(messages.errors.some((e) => e.includes("postgresai prepare-db"))).toBe(true);
1598
- });
1599
-
1600
- test("omits fix section when all fix_commands are null", () => {
1601
- const result: init.PreflightPermissionResult = {
1602
- ok: false,
1603
- rows: [],
1604
- missingRequired: [
1605
- { permission_name: "pg_monitor role membership", status: "required", granted: null, fix_command: null },
1606
- ],
1607
- missingOptional: [],
1608
- };
1609
-
1610
- const messages = init.formatPermissionCheckMessages(result);
1611
- expect(messages.failed).toBe(true);
1612
- expect(messages.errors.some((e) => e.includes("pg_monitor role membership"))).toBe(true);
1613
- // Should NOT have "To fix" section when no fix commands
1614
- expect(messages.errors.some((e) => e.includes("To fix"))).toBe(false);
1615
- // Should still suggest prepare-db
1616
- expect(messages.errors.some((e) => e.includes("postgresai prepare-db"))).toBe(true);
1617
- });
1618
-
1619
- test("includes both warnings and errors when both are present", () => {
1620
- const result: init.PreflightPermissionResult = {
1621
- ok: false,
1622
- rows: [],
1623
- missingRequired: [
1624
- { permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
1625
- ],
1626
- missingOptional: [
1627
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: null },
1628
- ],
1629
- };
1630
-
1631
- const messages = init.formatPermissionCheckMessages(result);
1632
- expect(messages.failed).toBe(true);
1633
- expect(messages.warnings).toHaveLength(1);
1634
- expect(messages.errors.length).toBeGreaterThan(0);
1635
- });
1636
-
1637
- test("warning without fix_command omits Fix: suffix", () => {
1638
- const result: init.PreflightPermissionResult = {
1639
- ok: true,
1640
- rows: [],
1641
- missingRequired: [],
1642
- missingOptional: [
1643
- { permission_name: "some optional check", status: "optional", granted: false, fix_command: null },
1644
- ],
1645
- };
1646
-
1647
- const messages = init.formatPermissionCheckMessages(result);
1648
- expect(messages.warnings[0]).not.toContain("Fix:");
1649
- expect(messages.warnings[0]).toContain("some optional check");
1650
- });
1651
- });
1652
-
1653
- describe("Permission check integration (checkup command)", () => {
1654
- /**
1655
- * Integration tests for the permission check flow in the checkup command.
1656
- * These tests verify that the permission check integration in postgres-ai.ts
1657
- * correctly handles different permission scenarios:
1658
- * - Successful checks allow execution to proceed
1659
- * - Missing required permissions halt execution with exitCode=1
1660
- * - Missing optional permissions show warnings but allow execution to continue
1661
- */
1662
-
1663
- function makeMockClientForIntegration(permissionRows: init.PermissionCheckRow[], reportResult?: any) {
1664
- const queriesExecuted: string[] = [];
1665
- return {
1666
- client: {
1667
- query: async (sql: string) => {
1668
- queriesExecuted.push(sql);
1669
- // Return permission check results for the permission check query
1670
- if (sql.includes("permission_checks")) {
1671
- return { rows: permissionRows };
1672
- }
1673
- // Return empty result for other queries (like report generation)
1674
- return reportResult || { rows: [] };
1675
- },
1676
- end: async () => {},
1677
- },
1678
- queriesExecuted,
1679
- };
1680
- }
1681
-
1682
- test("successful permission check allows execution to proceed", async () => {
1683
- const rows: init.PermissionCheckRow[] = [
1684
- { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
1685
- { permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
1686
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
1687
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
1688
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
1689
- ];
1690
-
1691
- const mockClient = makeMockClientForIntegration(rows);
1692
- const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
1693
- const permMessages = init.formatPermissionCheckMessages(permCheck);
1694
-
1695
- // Verify permission check passed
1696
- expect(permMessages.failed).toBe(false);
1697
- expect(permMessages.warnings).toHaveLength(0);
1698
- expect(permMessages.errors).toHaveLength(0);
1699
-
1700
- // In the actual integration, process.exitCode would not be set and execution continues
1701
- // This simulates the successful path where reports would be generated
1702
- });
1703
-
1704
- test("missing required permissions halt execution with clear error messages", async () => {
1705
- const rows: init.PermissionCheckRow[] = [
1706
- { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
1707
- { permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
1708
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: false, fix_command: "grant select on pg_catalog.pg_index to testuser;" },
1709
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: true, fix_command: null },
1710
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: true, fix_command: null },
1711
- ];
1712
-
1713
- const mockClient = makeMockClientForIntegration(rows);
1714
- const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
1715
- const permMessages = init.formatPermissionCheckMessages(permCheck);
1716
-
1717
- // Verify permission check failed
1718
- expect(permMessages.failed).toBe(true);
1719
- expect(permMessages.errors.length).toBeGreaterThan(0);
1720
-
1721
- // Verify error messages include the missing permissions
1722
- const errorText = permMessages.errors.join("\n");
1723
- expect(errorText).toContain("pg_monitor role membership");
1724
- expect(errorText).toContain("select on pg_catalog.pg_index");
1725
-
1726
- // Verify fix commands are included
1727
- expect(errorText).toContain("grant pg_monitor to testuser;");
1728
- expect(errorText).toContain("grant select on pg_catalog.pg_index to testuser;");
1729
-
1730
- // Verify alternative fix suggestion
1731
- expect(errorText).toContain("postgresai prepare-db");
1732
-
1733
- // In the actual integration, process.exitCode would be set to 1 and execution would halt
1734
- });
1735
-
1736
- test("missing optional permissions show warnings but allow execution to proceed", async () => {
1737
- const rows: init.PermissionCheckRow[] = [
1738
- { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
1739
- { permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
1740
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
1741
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
1742
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
1743
- ];
1744
-
1745
- const mockClient = makeMockClientForIntegration(rows);
1746
- const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
1747
- const permMessages = init.formatPermissionCheckMessages(permCheck);
1748
-
1749
- // Verify permission check passed (required permissions OK)
1750
- expect(permMessages.failed).toBe(false);
1751
-
1752
- // Verify warnings are present for optional permissions
1753
- expect(permMessages.warnings).toHaveLength(1);
1754
- expect(permMessages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
1755
- expect(permMessages.warnings[0]).toContain("Fix: -- create postgres_ai.pg_statistic view");
1756
-
1757
- // Verify no errors (only warnings)
1758
- expect(permMessages.errors).toHaveLength(0);
1759
-
1760
- // In the actual integration, warnings would be printed to stderr but execution continues
1761
- });
1762
-
1763
- test("permission check integration handles multiple missing required permissions", async () => {
1764
- const rows: init.PermissionCheckRow[] = [
1765
- { permission_name: "connect on database postgres", status: "required", granted: false, fix_command: "grant connect on database postgres to testuser;" },
1766
- { permission_name: "pg_monitor role membership", status: "required", granted: false, fix_command: "grant pg_monitor to testuser;" },
1767
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: false, fix_command: "grant select on pg_catalog.pg_index to testuser;" },
1768
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: false, fix_command: "-- create postgres_ai.pg_statistic view (see setup script)" },
1769
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
1770
- ];
1771
-
1772
- const mockClient = makeMockClientForIntegration(rows);
1773
- const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
1774
- const permMessages = init.formatPermissionCheckMessages(permCheck);
1775
-
1776
- // Verify permission check failed
1777
- expect(permMessages.failed).toBe(true);
1778
-
1779
- // Verify all missing required permissions are reported
1780
- const errorText = permMessages.errors.join("\n");
1781
- expect(errorText).toContain("connect on database postgres");
1782
- expect(errorText).toContain("pg_monitor role membership");
1783
- expect(errorText).toContain("select on pg_catalog.pg_index");
1784
-
1785
- // Verify all fix commands are included
1786
- expect(errorText).toContain("grant connect on database postgres to testuser;");
1787
- expect(errorText).toContain("grant pg_monitor to testuser;");
1788
- expect(errorText).toContain("grant select on pg_catalog.pg_index to testuser;");
1789
-
1790
- // Verify warning for optional permission
1791
- expect(permMessages.warnings).toHaveLength(1);
1792
- expect(permMessages.warnings[0]).toContain("postgres_ai.pg_statistic view exists");
1793
- });
1794
-
1795
- test("permission check integration handles null granted values correctly", async () => {
1796
- const rows: init.PermissionCheckRow[] = [
1797
- { permission_name: "connect on database postgres", status: "required", granted: null, fix_command: null },
1798
- { permission_name: "pg_monitor role membership", status: "required", granted: true, fix_command: null },
1799
- { permission_name: "select on pg_catalog.pg_index", status: "required", granted: true, fix_command: null },
1800
- { permission_name: "postgres_ai.pg_statistic view exists", status: "optional", granted: null, fix_command: null },
1801
- { permission_name: "select on postgres_ai.pg_statistic", status: "optional", granted: null, fix_command: null },
1802
- ];
1803
-
1804
- const mockClient = makeMockClientForIntegration(rows);
1805
- const permCheck = await init.checkCurrentUserPermissions(mockClient.client as any);
1806
- const permMessages = init.formatPermissionCheckMessages(permCheck);
1807
-
1808
- // Verify null on required permission is treated as failure (fail-safe)
1809
- expect(permMessages.failed).toBe(true);
1810
- const errorText = permMessages.errors.join("\n");
1811
- expect(errorText).toContain("connect on database postgres");
1812
-
1813
- // Verify null on optional permissions does not generate warnings (skipped checks)
1814
- expect(permMessages.warnings).toHaveLength(0);
1815
- });
1816
- });