postgresai 0.14.0-dev.43 → 0.14.0-dev.44

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 (58) hide show
  1. package/bin/postgres-ai.ts +649 -310
  2. package/bun.lock +258 -0
  3. package/dist/bin/postgres-ai.js +29491 -1910
  4. package/dist/sql/01.role.sql +16 -0
  5. package/dist/sql/02.permissions.sql +37 -0
  6. package/dist/sql/03.optional_rds.sql +6 -0
  7. package/dist/sql/04.optional_self_managed.sql +8 -0
  8. package/dist/sql/05.helpers.sql +415 -0
  9. package/lib/auth-server.ts +58 -97
  10. package/lib/checkup-api.ts +175 -0
  11. package/lib/checkup.ts +833 -0
  12. package/lib/config.ts +3 -0
  13. package/lib/init.ts +106 -74
  14. package/lib/issues.ts +121 -194
  15. package/lib/mcp-server.ts +6 -17
  16. package/lib/metrics-loader.ts +156 -0
  17. package/package.json +13 -9
  18. package/sql/02.permissions.sql +9 -5
  19. package/sql/05.helpers.sql +415 -0
  20. package/test/checkup.test.ts +953 -0
  21. package/test/init.integration.test.ts +396 -0
  22. package/test/init.test.ts +345 -0
  23. package/test/schema-validation.test.ts +188 -0
  24. package/tsconfig.json +12 -20
  25. package/dist/bin/postgres-ai.d.ts +0 -3
  26. package/dist/bin/postgres-ai.d.ts.map +0 -1
  27. package/dist/bin/postgres-ai.js.map +0 -1
  28. package/dist/lib/auth-server.d.ts +0 -31
  29. package/dist/lib/auth-server.d.ts.map +0 -1
  30. package/dist/lib/auth-server.js +0 -263
  31. package/dist/lib/auth-server.js.map +0 -1
  32. package/dist/lib/config.d.ts +0 -45
  33. package/dist/lib/config.d.ts.map +0 -1
  34. package/dist/lib/config.js +0 -181
  35. package/dist/lib/config.js.map +0 -1
  36. package/dist/lib/init.d.ts +0 -85
  37. package/dist/lib/init.d.ts.map +0 -1
  38. package/dist/lib/init.js +0 -644
  39. package/dist/lib/init.js.map +0 -1
  40. package/dist/lib/issues.d.ts +0 -75
  41. package/dist/lib/issues.d.ts.map +0 -1
  42. package/dist/lib/issues.js +0 -336
  43. package/dist/lib/issues.js.map +0 -1
  44. package/dist/lib/mcp-server.d.ts +0 -9
  45. package/dist/lib/mcp-server.d.ts.map +0 -1
  46. package/dist/lib/mcp-server.js +0 -168
  47. package/dist/lib/mcp-server.js.map +0 -1
  48. package/dist/lib/pkce.d.ts +0 -32
  49. package/dist/lib/pkce.d.ts.map +0 -1
  50. package/dist/lib/pkce.js +0 -101
  51. package/dist/lib/pkce.js.map +0 -1
  52. package/dist/lib/util.d.ts +0 -27
  53. package/dist/lib/util.d.ts.map +0 -1
  54. package/dist/lib/util.js +0 -46
  55. package/dist/lib/util.js.map +0 -1
  56. package/dist/package.json +0 -46
  57. package/test/init.integration.test.cjs +0 -382
  58. package/test/init.test.cjs +0 -392
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Integration tests for prepare-db command
3
+ * Requires PostgreSQL binaries (initdb, postgres) to be available
4
+ * These tests spin up a temporary PostgreSQL instance for realistic testing
5
+ */
6
+ import { describe, test, expect, afterAll } from "bun:test";
7
+ import * as fs from "fs";
8
+ import * as os from "os";
9
+ import * as path from "path";
10
+ import * as net from "net";
11
+ import { Client } from "pg";
12
+
13
+ function sqlLiteral(value: string): string {
14
+ return `'${String(value).replace(/'/g, "''")}'`;
15
+ }
16
+
17
+ function findOnPath(cmd: string): string | null {
18
+ const result = Bun.spawnSync(["sh", "-c", `command -v ${cmd}`]);
19
+ if (result.exitCode === 0) {
20
+ return new TextDecoder().decode(result.stdout).trim();
21
+ }
22
+ return null;
23
+ }
24
+
25
+ function findPgBin(cmd: string): string | null {
26
+ const p = findOnPath(cmd);
27
+ if (p) return p;
28
+
29
+ // Debian/Ubuntu (GitLab CI node:*-bullseye images): binaries usually live here.
30
+ const probe = Bun.spawnSync([
31
+ "sh",
32
+ "-c",
33
+ `ls -1 /usr/lib/postgresql/*/bin/${cmd} 2>/dev/null | head -n 1 || true`,
34
+ ]);
35
+ const out = new TextDecoder().decode(probe.stdout).trim();
36
+ if (out) return out;
37
+
38
+ return null;
39
+ }
40
+
41
+ function havePostgresBinaries(): boolean {
42
+ return !!(findPgBin("initdb") && findPgBin("postgres"));
43
+ }
44
+
45
+ function isRunningAsRoot(): boolean {
46
+ return process.getuid?.() === 0;
47
+ }
48
+
49
+ async function getFreePort(): Promise<number> {
50
+ return new Promise((resolve, reject) => {
51
+ const srv = net.createServer();
52
+ srv.listen(0, "127.0.0.1", () => {
53
+ const addr = srv.address() as net.AddressInfo;
54
+ srv.close((err) => {
55
+ if (err) return reject(err);
56
+ resolve(addr.port);
57
+ });
58
+ });
59
+ srv.on("error", reject);
60
+ });
61
+ }
62
+
63
+ async function waitFor<T>(
64
+ fn: () => Promise<T>,
65
+ { timeoutMs = 10000, intervalMs = 100 } = {}
66
+ ): Promise<T> {
67
+ const start = Date.now();
68
+ while (true) {
69
+ try {
70
+ return await fn();
71
+ } catch (e) {
72
+ if (Date.now() - start > timeoutMs) throw e;
73
+ await new Promise((r) => setTimeout(r, intervalMs));
74
+ }
75
+ }
76
+ }
77
+
78
+ interface TempPostgres {
79
+ port: number;
80
+ socketDir: string;
81
+ adminUri: string;
82
+ postgresPassword: string;
83
+ cleanup: () => Promise<void>;
84
+ }
85
+
86
+ async function createTempPostgres(): Promise<TempPostgres> {
87
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "postgresai-init-"));
88
+ const dataDir = path.join(tmpRoot, "data");
89
+ const socketDir = path.join(tmpRoot, "sock");
90
+ fs.mkdirSync(socketDir, { recursive: true });
91
+
92
+ const initdb = findPgBin("initdb");
93
+ const postgresBin = findPgBin("postgres");
94
+ if (!initdb || !postgresBin) {
95
+ throw new Error("PostgreSQL binaries not found (need initdb and postgres)");
96
+ }
97
+
98
+ const init = Bun.spawnSync([initdb, "-D", dataDir, "-U", "postgres", "-A", "trust"]);
99
+ if (init.exitCode !== 0) {
100
+ throw new Error(new TextDecoder().decode(init.stderr) || new TextDecoder().decode(init.stdout));
101
+ }
102
+
103
+ // Configure: local socket trust, TCP scram.
104
+ const hbaPath = path.join(dataDir, "pg_hba.conf");
105
+ fs.appendFileSync(
106
+ hbaPath,
107
+ "\n# Added by postgresai init integration tests\nlocal all all trust\nhost all all 127.0.0.1/32 scram-sha-256\nhost all all ::1/128 scram-sha-256\n",
108
+ "utf8"
109
+ );
110
+
111
+ const port = await getFreePort();
112
+
113
+ const postgresProc = Bun.spawn(
114
+ [postgresBin, "-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)],
115
+ { stdio: ["ignore", "pipe", "pipe"] }
116
+ );
117
+
118
+ const cleanup = async () => {
119
+ postgresProc.kill("SIGTERM");
120
+ try {
121
+ await waitFor(
122
+ async () => {
123
+ if (postgresProc.exitCode === null) throw new Error("still running");
124
+ },
125
+ { timeoutMs: 5000, intervalMs: 100 }
126
+ );
127
+ } catch {
128
+ postgresProc.kill("SIGKILL");
129
+ }
130
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
131
+ };
132
+
133
+ const connectLocal = async (database = "postgres"): Promise<Client> => {
134
+ const c = new Client({ host: socketDir, port, user: "postgres", database });
135
+ await c.connect();
136
+ return c;
137
+ };
138
+
139
+ await waitFor(async () => {
140
+ const c = await connectLocal();
141
+ await c.end();
142
+ });
143
+
144
+ const postgresPassword = "postgrespw";
145
+ {
146
+ const c = await connectLocal();
147
+ await c.query(`alter user postgres password ${sqlLiteral(postgresPassword)};`);
148
+ await c.query("create database testdb");
149
+ await c.end();
150
+ }
151
+
152
+ const adminUri = `postgresql://postgres:${postgresPassword}@127.0.0.1:${port}/testdb`;
153
+ return { port, socketDir, adminUri, postgresPassword, cleanup };
154
+ }
155
+
156
+ function runCliInit(
157
+ args: string[],
158
+ env: Record<string, string> = {}
159
+ ): { status: number | null; stdout: string; stderr: string } {
160
+ const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
161
+ const result = Bun.spawnSync(["bun", cliPath, "prepare-db", ...args], {
162
+ env: { ...process.env, ...env },
163
+ });
164
+ return {
165
+ status: result.exitCode,
166
+ stdout: new TextDecoder().decode(result.stdout),
167
+ stderr: new TextDecoder().decode(result.stderr),
168
+ };
169
+ }
170
+
171
+ // Skip all tests if PostgreSQL binaries are not available or running as root
172
+ // (initdb cannot be run as root)
173
+ const skipTests = !havePostgresBinaries() || isRunningAsRoot();
174
+
175
+ describe.skipIf(skipTests)("integration: prepare-db", () => {
176
+ let pg: TempPostgres;
177
+
178
+ // Use a shared postgres instance for all tests in this describe block
179
+ // Each test will reset state as needed
180
+
181
+ test("supports URI / conninfo / psql-like connection styles", async () => {
182
+ pg = await createTempPostgres();
183
+
184
+ try {
185
+ // 1) positional URI
186
+ {
187
+ const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
188
+ expect(r.status).toBe(0);
189
+ }
190
+
191
+ // 2) conninfo
192
+ {
193
+ const conninfo = `dbname=testdb host=127.0.0.1 port=${pg.port} user=postgres password=${pg.postgresPassword}`;
194
+ const r = runCliInit([conninfo, "--password", "monpw2", "--skip-optional-permissions"]);
195
+ expect(r.status).toBe(0);
196
+ }
197
+
198
+ // 3) psql-like options (+ PGPASSWORD)
199
+ {
200
+ const r = runCliInit(
201
+ [
202
+ "-h", "127.0.0.1",
203
+ "-p", String(pg.port),
204
+ "-U", "postgres",
205
+ "-d", "testdb",
206
+ "--password", "monpw3",
207
+ "--skip-optional-permissions",
208
+ ],
209
+ { PGPASSWORD: pg.postgresPassword }
210
+ );
211
+ expect(r.status).toBe(0);
212
+ }
213
+ } finally {
214
+ await pg.cleanup();
215
+ }
216
+ });
217
+
218
+ test("requires explicit monitoring password in non-interactive mode", async () => {
219
+ pg = await createTempPostgres();
220
+
221
+ try {
222
+ // Should fail without --print-password in non-interactive mode
223
+ {
224
+ const r = runCliInit([pg.adminUri, "--skip-optional-permissions"]);
225
+ expect(r.status).not.toBe(0);
226
+ expect(r.stderr).toMatch(/not printed in non-interactive mode/i);
227
+ expect(r.stderr).toMatch(/--print-password/);
228
+ }
229
+
230
+ // With explicit opt-in, it should succeed
231
+ {
232
+ const r = runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
233
+ expect(r.status).toBe(0);
234
+ expect(r.stderr).toMatch(/Generated monitoring password for postgres_ai_mon/i);
235
+ expect(r.stderr).toMatch(/PGAI_MON_PASSWORD=/);
236
+ }
237
+ } finally {
238
+ await pg.cleanup();
239
+ }
240
+ });
241
+
242
+ test("fixes slightly-off permissions idempotently", async () => {
243
+ pg = await createTempPostgres();
244
+
245
+ try {
246
+ // Create monitoring role with wrong password, no grants.
247
+ {
248
+ const c = new Client({ connectionString: pg.adminUri });
249
+ await c.connect();
250
+ await c.query(
251
+ "do $$ begin if not exists (select 1 from pg_roles where rolname='postgres_ai_mon') then create role postgres_ai_mon login password 'wrong'; end if; end $$;"
252
+ );
253
+ await c.end();
254
+ }
255
+
256
+ // Run init (should grant everything).
257
+ {
258
+ const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
259
+ expect(r.status).toBe(0);
260
+ }
261
+
262
+ // Verify privileges.
263
+ {
264
+ const c = new Client({ connectionString: pg.adminUri });
265
+ await c.connect();
266
+ const dbOk = await c.query(
267
+ "select has_database_privilege('postgres_ai_mon', current_database(), 'CONNECT') as ok"
268
+ );
269
+ expect(dbOk.rows[0].ok).toBe(true);
270
+ const roleOk = await c.query("select pg_has_role('postgres_ai_mon', 'pg_monitor', 'member') as ok");
271
+ expect(roleOk.rows[0].ok).toBe(true);
272
+ const idxOk = await c.query(
273
+ "select has_table_privilege('postgres_ai_mon', 'pg_catalog.pg_index', 'SELECT') as ok"
274
+ );
275
+ expect(idxOk.rows[0].ok).toBe(true);
276
+ const viewOk = await c.query(
277
+ "select has_table_privilege('postgres_ai_mon', 'postgres_ai.pg_statistic', 'SELECT') as ok"
278
+ );
279
+ expect(viewOk.rows[0].ok).toBe(true);
280
+ const sp = await c.query("select rolconfig from pg_roles where rolname='postgres_ai_mon'");
281
+ expect(Array.isArray(sp.rows[0].rolconfig)).toBe(true);
282
+ expect(sp.rows[0].rolconfig.some((v: string) => String(v).includes("search_path="))).toBe(true);
283
+ await c.end();
284
+ }
285
+
286
+ // Run init again (idempotent).
287
+ {
288
+ const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
289
+ expect(r.status).toBe(0);
290
+ }
291
+ } finally {
292
+ await pg.cleanup();
293
+ }
294
+ });
295
+
296
+ test("reports nicely when lacking permissions", async () => {
297
+ pg = await createTempPostgres();
298
+
299
+ try {
300
+ // Create limited user that can connect but cannot create roles / grant.
301
+ const limitedPw = "limitedpw";
302
+ {
303
+ const c = new Client({ connectionString: pg.adminUri });
304
+ await c.connect();
305
+ await c.query(`do $$ begin
306
+ if not exists (select 1 from pg_roles where rolname='limited') then
307
+ begin
308
+ create role limited login password ${sqlLiteral(limitedPw)};
309
+ exception when duplicate_object then
310
+ null;
311
+ end;
312
+ end if;
313
+ end $$;`);
314
+ await c.query("grant connect on database testdb to limited");
315
+ await c.end();
316
+ }
317
+
318
+ const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
319
+ const r = runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
320
+ expect(r.status).not.toBe(0);
321
+ expect(r.stderr).toMatch(/Error: prepare-db:/);
322
+ expect(r.stderr).toMatch(/Failed at step "/);
323
+ expect(r.stderr).toMatch(/Fix: connect as a superuser/i);
324
+ } finally {
325
+ await pg.cleanup();
326
+ }
327
+ });
328
+
329
+ test("--verify returns 0 when ok and non-zero when missing", async () => {
330
+ pg = await createTempPostgres();
331
+
332
+ try {
333
+ // Prepare: run init
334
+ {
335
+ const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
336
+ expect(r.status).toBe(0);
337
+ }
338
+
339
+ // Verify should pass
340
+ {
341
+ const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
342
+ expect(r.status).toBe(0);
343
+ expect(r.stdout).toMatch(/prepare-db verify: OK/i);
344
+ }
345
+
346
+ // Break a required privilege and ensure verify fails
347
+ {
348
+ const c = new Client({ connectionString: pg.adminUri });
349
+ await c.connect();
350
+ await c.query("revoke select on pg_catalog.pg_index from public");
351
+ await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
352
+ await c.end();
353
+ }
354
+ {
355
+ const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
356
+ expect(r.status).not.toBe(0);
357
+ expect(r.stderr).toMatch(/prepare-db verify failed/i);
358
+ expect(r.stderr).toMatch(/pg_catalog\.pg_index/i);
359
+ }
360
+ } finally {
361
+ await pg.cleanup();
362
+ }
363
+ });
364
+
365
+ test("--reset-password updates the monitoring role login password", async () => {
366
+ pg = await createTempPostgres();
367
+
368
+ try {
369
+ // Initial setup with password pw1
370
+ {
371
+ const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
372
+ expect(r.status).toBe(0);
373
+ }
374
+
375
+ // Reset to pw2
376
+ {
377
+ const r = runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
378
+ expect(r.status).toBe(0);
379
+ expect(r.stdout).toMatch(/password reset/i);
380
+ }
381
+
382
+ // Connect as monitoring user with new password should work
383
+ {
384
+ const c = new Client({
385
+ connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
386
+ });
387
+ await c.connect();
388
+ const ok = await c.query("select 1 as ok");
389
+ expect(ok.rows[0].ok).toBe(1);
390
+ await c.end();
391
+ }
392
+ } finally {
393
+ await pg.cleanup();
394
+ }
395
+ });
396
+ });