postgresai 0.14.0-dev.8 → 0.14.0-dev.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -61
- package/bin/postgres-ai.ts +2596 -428
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +31218 -1575
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/03.permissions.sql +38 -0
- package/dist/sql/04.optional_rds.sql +6 -0
- package/dist/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/03.permissions.sql +38 -0
- package/dist/sql/sql/04.optional_rds.sql +6 -0
- package/dist/sql/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup-dictionary.ts +113 -0
- package/lib/checkup.ts +1435 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +655 -189
- package/lib/issues.ts +848 -193
- package/lib/mcp-server.ts +391 -91
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +824 -0
- package/lib/util.ts +61 -0
- package/package.json +22 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-checkup-dictionary.ts +106 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.extensions.sql +8 -0
- package/sql/03.permissions.sql +38 -0
- package/sql/04.optional_rds.sql +6 -0
- package/sql/05.optional_self_managed.sql +8 -0
- package/sql/06.helpers.sql +439 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1116 -0
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +508 -0
- package/test/init.test.ts +916 -0
- package/test/issues.cli.test.ts +538 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +1527 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -64
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -399
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -76
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { 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, "$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
|
+
|
|
311
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const r = await init.verifyInitSetup({
|
|
316
|
+
client: client as any,
|
|
317
|
+
database: "mydb",
|
|
318
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
319
|
+
includeOptionalPermissions: false,
|
|
320
|
+
});
|
|
321
|
+
expect(r.ok).toBe(true);
|
|
322
|
+
expect(r.missingRequired.length).toBe(0);
|
|
323
|
+
|
|
324
|
+
expect(calls.length).toBeGreaterThan(2);
|
|
325
|
+
expect(calls[0].toLowerCase()).toMatch(/^begin isolation level repeatable read/);
|
|
326
|
+
expect(calls[calls.length - 1].toLowerCase()).toBe("rollback;");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("verifyInitSetup skips search_path check for supabase provider", async () => {
|
|
330
|
+
const calls: string[] = [];
|
|
331
|
+
const client = {
|
|
332
|
+
query: async (sql: string, params?: any) => {
|
|
333
|
+
calls.push(String(sql));
|
|
334
|
+
|
|
335
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
336
|
+
return { rowCount: 1, rows: [] };
|
|
337
|
+
}
|
|
338
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
339
|
+
return { rowCount: 1, rows: [] };
|
|
340
|
+
}
|
|
341
|
+
// Return empty rolconfig - would fail without provider=supabase
|
|
342
|
+
if (String(sql).includes("select rolconfig")) {
|
|
343
|
+
return { rowCount: 1, rows: [{ rolconfig: null }] };
|
|
344
|
+
}
|
|
345
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
346
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
347
|
+
}
|
|
348
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
349
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
350
|
+
}
|
|
351
|
+
if (String(sql).includes("pg_has_role")) {
|
|
352
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
353
|
+
}
|
|
354
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
355
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
356
|
+
}
|
|
357
|
+
if (String(sql).includes("to_regclass")) {
|
|
358
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
359
|
+
}
|
|
360
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
361
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
362
|
+
}
|
|
363
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
364
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// With provider=supabase, should pass even without search_path
|
|
372
|
+
const r = await init.verifyInitSetup({
|
|
373
|
+
client: client as any,
|
|
374
|
+
database: "mydb",
|
|
375
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
376
|
+
includeOptionalPermissions: false,
|
|
377
|
+
provider: "supabase",
|
|
378
|
+
});
|
|
379
|
+
expect(r.ok).toBe(true);
|
|
380
|
+
expect(r.missingRequired.length).toBe(0);
|
|
381
|
+
// Should not have queried for rolconfig since we skip search_path check
|
|
382
|
+
expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
|
|
386
|
+
const plan = await init.buildInitPlan({
|
|
387
|
+
database: "mydb",
|
|
388
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
389
|
+
monitoringPassword: "pw",
|
|
390
|
+
includeOptionalPermissions: false,
|
|
391
|
+
provider: "supabase",
|
|
392
|
+
});
|
|
393
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
394
|
+
expect(permStep).toBeDefined();
|
|
395
|
+
// Should have removed ALTER USER but kept comments
|
|
396
|
+
expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
|
|
397
|
+
// Should still have comment lines
|
|
398
|
+
expect(permStep!.sql).toMatch(/^--/m);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("validateProvider returns null for known providers", () => {
|
|
402
|
+
expect(init.validateProvider(undefined)).toBe(null);
|
|
403
|
+
expect(init.validateProvider("self-managed")).toBe(null);
|
|
404
|
+
expect(init.validateProvider("supabase")).toBe(null);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("validateProvider returns warning for unknown providers", () => {
|
|
408
|
+
const warning = init.validateProvider("unknown-provider");
|
|
409
|
+
expect(warning).not.toBe(null);
|
|
410
|
+
expect(warning).toMatch(/Unknown provider/);
|
|
411
|
+
expect(warning).toMatch(/unknown-provider/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
|
|
415
|
+
const plan = await init.buildInitPlan({
|
|
416
|
+
database: "mydb",
|
|
417
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
418
|
+
monitoringPassword: "pa'ss",
|
|
419
|
+
includeOptionalPermissions: false,
|
|
420
|
+
});
|
|
421
|
+
const step = plan.steps.find((s: { name: string }) => s.name === "01.role");
|
|
422
|
+
expect(step).toBeTruthy();
|
|
423
|
+
const redacted = init.redactPasswordsInSql(step.sql);
|
|
424
|
+
expect(redacted).toMatch(/password '<redacted>'/i);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Tests for buildUninitPlan
|
|
428
|
+
test("buildUninitPlan generates correct steps with dropRole=true", async () => {
|
|
429
|
+
const plan = await init.buildUninitPlan({
|
|
430
|
+
database: "mydb",
|
|
431
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
432
|
+
dropRole: true,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(plan.database).toBe("mydb");
|
|
436
|
+
expect(plan.monitoringUser).toBe(DEFAULT_MONITORING_USER);
|
|
437
|
+
expect(plan.dropRole).toBe(true);
|
|
438
|
+
expect(plan.steps.length).toBe(3);
|
|
439
|
+
expect(plan.steps.map((s) => s.name)).toEqual([
|
|
440
|
+
"01.drop_helpers",
|
|
441
|
+
"02.revoke_permissions",
|
|
442
|
+
"03.drop_role",
|
|
443
|
+
]);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("buildUninitPlan skips role drop when dropRole=false", async () => {
|
|
447
|
+
const plan = await init.buildUninitPlan({
|
|
448
|
+
database: "mydb",
|
|
449
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
450
|
+
dropRole: false,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(plan.dropRole).toBe(false);
|
|
454
|
+
expect(plan.steps.length).toBe(2);
|
|
455
|
+
expect(plan.steps.map((s) => s.name)).toEqual([
|
|
456
|
+
"01.drop_helpers",
|
|
457
|
+
"02.revoke_permissions",
|
|
458
|
+
]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("buildUninitPlan skips role drop for supabase provider", async () => {
|
|
462
|
+
const plan = await init.buildUninitPlan({
|
|
463
|
+
database: "mydb",
|
|
464
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
465
|
+
dropRole: true,
|
|
466
|
+
provider: "supabase",
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Even with dropRole=true, supabase provider skips role operations
|
|
470
|
+
expect(plan.steps.length).toBe(2);
|
|
471
|
+
expect(plan.steps.some((s) => s.name === "03.drop_role")).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("buildUninitPlan handles special characters in identifiers", async () => {
|
|
475
|
+
const monitoringUser = 'user "with" quotes';
|
|
476
|
+
const database = 'db "name"';
|
|
477
|
+
const plan = await init.buildUninitPlan({
|
|
478
|
+
database,
|
|
479
|
+
monitoringUser,
|
|
480
|
+
dropRole: true,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Check that identifiers are properly quoted in SQL
|
|
484
|
+
const dropHelpersStep = plan.steps.find((s) => s.name === "01.drop_helpers");
|
|
485
|
+
expect(dropHelpersStep).toBeTruthy();
|
|
486
|
+
|
|
487
|
+
const revokeStep = plan.steps.find((s) => s.name === "02.revoke_permissions");
|
|
488
|
+
expect(revokeStep).toBeTruthy();
|
|
489
|
+
expect(revokeStep!.sql).toContain('"user ""with"" quotes"');
|
|
490
|
+
expect(revokeStep!.sql).toContain('"db ""name"""');
|
|
491
|
+
|
|
492
|
+
const dropRoleStep = plan.steps.find((s) => s.name === "03.drop_role");
|
|
493
|
+
expect(dropRoleStep).toBeTruthy();
|
|
494
|
+
// Uses ROLE_LITERAL (single-quoted) for format('%I', ...) in dynamic SQL
|
|
495
|
+
expect(dropRoleStep!.sql).toContain("'user \"with\" quotes'");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("buildUninitPlan rejects identifiers with null bytes", async () => {
|
|
499
|
+
await expect(
|
|
500
|
+
init.buildUninitPlan({
|
|
501
|
+
database: "mydb",
|
|
502
|
+
monitoringUser: "bad\0user",
|
|
503
|
+
dropRole: true,
|
|
504
|
+
})
|
|
505
|
+
).rejects.toThrow(/Identifier cannot contain null bytes/);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("applyUninitPlan continues on errors and reports them", async () => {
|
|
509
|
+
const plan = {
|
|
510
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
511
|
+
database: "mydb",
|
|
512
|
+
dropRole: true,
|
|
513
|
+
steps: [
|
|
514
|
+
{ name: "01.drop_helpers", sql: "drop function if exists postgres_ai.test()" },
|
|
515
|
+
{ name: "02.revoke_permissions", sql: "select 1/0" }, // Will fail
|
|
516
|
+
{ name: "03.drop_role", sql: "select 1" },
|
|
517
|
+
],
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const calls: string[] = [];
|
|
521
|
+
const client = {
|
|
522
|
+
query: async (sql: string) => {
|
|
523
|
+
calls.push(sql);
|
|
524
|
+
if (sql === "begin;") return { rowCount: 1 };
|
|
525
|
+
if (sql === "commit;") return { rowCount: 1 };
|
|
526
|
+
if (sql === "rollback;") return { rowCount: 1 };
|
|
527
|
+
if (sql.includes("1/0")) throw new Error("division by zero");
|
|
528
|
+
return { rowCount: 1 };
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const result = await init.applyUninitPlan({ client: client as any, plan: plan as any });
|
|
533
|
+
|
|
534
|
+
// Should have applied steps 1 and 3, with step 2 in errors
|
|
535
|
+
expect(result.applied).toContain("01.drop_helpers");
|
|
536
|
+
expect(result.applied).toContain("03.drop_role");
|
|
537
|
+
expect(result.applied).not.toContain("02.revoke_permissions");
|
|
538
|
+
expect(result.errors.length).toBe(1);
|
|
539
|
+
expect(result.errors[0]).toMatch(/02\.revoke_permissions.*division by zero/);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("buildInitPlan includes 02.extensions step with pg_stat_statements", async () => {
|
|
543
|
+
const plan = await init.buildInitPlan({
|
|
544
|
+
database: "mydb",
|
|
545
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
546
|
+
monitoringPassword: "pw",
|
|
547
|
+
includeOptionalPermissions: false,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const extStep = plan.steps.find((s) => s.name === "02.extensions");
|
|
551
|
+
expect(extStep).toBeTruthy();
|
|
552
|
+
// Should create pg_stat_statements with IF NOT EXISTS
|
|
553
|
+
expect(extStep!.sql).toMatch(/create extension if not exists pg_stat_statements/i);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("buildInitPlan creates extensions before permissions", async () => {
|
|
557
|
+
const plan = await init.buildInitPlan({
|
|
558
|
+
database: "mydb",
|
|
559
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
560
|
+
monitoringPassword: "pw",
|
|
561
|
+
includeOptionalPermissions: false,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const stepNames = plan.steps.map((s) => s.name);
|
|
565
|
+
const extIndex = stepNames.indexOf("02.extensions");
|
|
566
|
+
const permIndex = stepNames.indexOf("03.permissions");
|
|
567
|
+
expect(extIndex).toBeGreaterThanOrEqual(0);
|
|
568
|
+
expect(permIndex).toBeGreaterThanOrEqual(0);
|
|
569
|
+
// Extensions should come before permissions
|
|
570
|
+
expect(extIndex).toBeLessThan(permIndex);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("buildInitPlan uses IF NOT EXISTS for postgres_ai schema (idempotent)", async () => {
|
|
574
|
+
const plan = await init.buildInitPlan({
|
|
575
|
+
database: "mydb",
|
|
576
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
577
|
+
monitoringPassword: "pw",
|
|
578
|
+
includeOptionalPermissions: false,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
582
|
+
expect(permStep).toBeTruthy();
|
|
583
|
+
// Should use IF NOT EXISTS for idempotent behavior
|
|
584
|
+
expect(permStep!.sql).toMatch(/create schema if not exists postgres_ai/i);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("buildUninitPlan does NOT drop pg_stat_statements extension", async () => {
|
|
588
|
+
const plan = await init.buildUninitPlan({
|
|
589
|
+
database: "mydb",
|
|
590
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
591
|
+
dropRole: true,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Check all steps - none should drop pg_stat_statements
|
|
595
|
+
for (const step of plan.steps) {
|
|
596
|
+
expect(step.sql.toLowerCase()).not.toMatch(/drop extension.*pg_stat_statements/);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("CLI commands", () => {
|
|
602
|
+
test("cli: prepare-db with missing connection prints help/options", () => {
|
|
603
|
+
const r = runCli(["prepare-db"]);
|
|
604
|
+
expect(r.status).not.toBe(0);
|
|
605
|
+
expect(r.stderr).toMatch(/--print-sql/);
|
|
606
|
+
expect(r.stderr).toMatch(/--monitoring-user/);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("cli: prepare-db --print-sql works without connection (offline mode)", () => {
|
|
610
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw"]);
|
|
611
|
+
expect(r.status).toBe(0);
|
|
612
|
+
expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
|
|
613
|
+
expect(r.stdout).toMatch(new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("cli: prepare-db --print-sql with --provider supabase skips role step", () => {
|
|
617
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "supabase"]);
|
|
618
|
+
expect(r.status).toBe(0);
|
|
619
|
+
expect(r.stdout).toMatch(/provider: supabase/);
|
|
620
|
+
// Should not have 01.role step
|
|
621
|
+
expect(r.stdout).not.toMatch(/-- 01\.role/);
|
|
622
|
+
// Should have 02.extensions and 03.permissions steps
|
|
623
|
+
expect(r.stdout).toMatch(/-- 02\.extensions/);
|
|
624
|
+
expect(r.stdout).toMatch(/-- 03\.permissions/);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("cli: prepare-db warns about unknown provider", () => {
|
|
628
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "unknown-cloud"]);
|
|
629
|
+
expect(r.status).toBe(0);
|
|
630
|
+
// Should warn about unknown provider
|
|
631
|
+
expect(r.stderr).toMatch(/Unknown provider.*unknown-cloud/);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("cli: prepare-db --reset-password with supabase provider would have no role step", async () => {
|
|
635
|
+
// When using supabase provider, the role creation step is skipped.
|
|
636
|
+
// This means --reset-password (which only runs 01.role) would have no steps.
|
|
637
|
+
// The CLI should error in this case. We test the underlying plan logic here.
|
|
638
|
+
const plan = await (await import("../lib/init")).buildInitPlan({
|
|
639
|
+
database: "mydb",
|
|
640
|
+
monitoringUser: "mon",
|
|
641
|
+
monitoringPassword: "pw",
|
|
642
|
+
includeOptionalPermissions: false,
|
|
643
|
+
provider: "supabase",
|
|
644
|
+
});
|
|
645
|
+
// Simulate what --reset-password does: filter to only 01.role step
|
|
646
|
+
const resetPasswordSteps = plan.steps.filter((s) => s.name === "01.role");
|
|
647
|
+
// For supabase, this should be empty (role creation is skipped)
|
|
648
|
+
expect(resetPasswordSteps.length).toBe(0);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("pgai wrapper forwards to postgresai CLI", () => {
|
|
652
|
+
const r = runPgai(["--help"]);
|
|
653
|
+
expect(r.status).toBe(0);
|
|
654
|
+
expect(r.stdout).toMatch(/postgresai|PostgresAI/i);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("cli: prepare-db command exists and shows help", () => {
|
|
658
|
+
const r = runCli(["prepare-db", "--help"]);
|
|
659
|
+
expect(r.status).toBe(0);
|
|
660
|
+
expect(r.stdout).toMatch(/monitoring user/i);
|
|
661
|
+
expect(r.stdout).toMatch(/--print-sql/);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("cli: mon local-install command exists and shows help", () => {
|
|
665
|
+
const r = runCli(["mon", "local-install", "--help"]);
|
|
666
|
+
expect(r.status).toBe(0);
|
|
667
|
+
expect(r.stdout).toMatch(/--demo/);
|
|
668
|
+
expect(r.stdout).toMatch(/--api-key/);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("cli: mon local-install --api-key and --db-url skip interactive prompts", () => {
|
|
672
|
+
// This test verifies that when --api-key and --db-url are provided,
|
|
673
|
+
// the CLI uses them directly without prompting for input.
|
|
674
|
+
// The command will fail later (no Docker, invalid DB), but we check
|
|
675
|
+
// that the options were parsed and used correctly.
|
|
676
|
+
const r = runCli([
|
|
677
|
+
"mon", "local-install",
|
|
678
|
+
"--api-key", "test-api-key-12345",
|
|
679
|
+
"--db-url", "postgresql://user:pass@localhost:5432/testdb"
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
// Should show that API key was provided via CLI option (not prompting)
|
|
683
|
+
expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
|
|
684
|
+
// Should show that DB URL was provided via CLI option (not prompting)
|
|
685
|
+
expect(r.stdout).toMatch(/Using database URL provided via --db-url parameter/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("cli: auth login --help shows --set-key option", () => {
|
|
689
|
+
const r = runCli(["auth", "login", "--help"]);
|
|
690
|
+
expect(r.status).toBe(0);
|
|
691
|
+
expect(r.stdout).toMatch(/--set-key/);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("cli: mon local-install reads global --api-key option", () => {
|
|
695
|
+
// The fix ensures --api-key works when passed as a global option (before subcommand)
|
|
696
|
+
// Commander.js routes global options to program.opts(), not subcommand opts
|
|
697
|
+
const r = runCli([
|
|
698
|
+
"--api-key", "global-api-key-test",
|
|
699
|
+
"mon", "local-install",
|
|
700
|
+
"--db-url", "postgresql://user:pass@localhost:5432/testdb"
|
|
701
|
+
]);
|
|
702
|
+
|
|
703
|
+
// Should detect the API key from global options
|
|
704
|
+
expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("cli: mon local-install works with --api-key after subcommand", () => {
|
|
708
|
+
// Test that --api-key works when passed after the subcommand
|
|
709
|
+
// Note: Commander.js routes --api-key to global opts, the fix reads from both
|
|
710
|
+
const r = runCli([
|
|
711
|
+
"mon", "local-install",
|
|
712
|
+
"--api-key", "test-key-after-subcommand",
|
|
713
|
+
"--db-url", "postgresql://user:pass@localhost:5432/testdb"
|
|
714
|
+
]);
|
|
715
|
+
|
|
716
|
+
// Should detect the API key regardless of position
|
|
717
|
+
expect(r.stdout).toMatch(/Using API key provided via --api-key parameter/);
|
|
718
|
+
// Verify the key was saved
|
|
719
|
+
expect(r.stdout).toMatch(/API key saved/);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("cli: mon local-install with --yes and no --api-key skips API setup", () => {
|
|
723
|
+
// When --yes is provided without --api-key, the CLI should skip
|
|
724
|
+
// the interactive prompt and proceed without API key
|
|
725
|
+
const r = runCli([
|
|
726
|
+
"mon", "local-install",
|
|
727
|
+
"--db-url", "postgresql://user:pass@localhost:5432/testdb",
|
|
728
|
+
"--yes"
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
// Should indicate auto-yes mode without API key
|
|
732
|
+
expect(r.stdout).toMatch(/Auto-yes mode: no API key provided/);
|
|
733
|
+
expect(r.stdout).toMatch(/Reports will be generated locally only/);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("cli: mon local-install --demo with global --api-key shows error", () => {
|
|
737
|
+
// When --demo is used with global --api-key, it should still be detected and error
|
|
738
|
+
const r = runCli([
|
|
739
|
+
"--api-key", "global-api-key-test",
|
|
740
|
+
"mon", "local-install",
|
|
741
|
+
"--demo"
|
|
742
|
+
]);
|
|
743
|
+
|
|
744
|
+
// Should reject demo mode with API key (from global option)
|
|
745
|
+
expect(r.status).not.toBe(0);
|
|
746
|
+
expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// Tests for unprepare-db command
|
|
750
|
+
test("cli: unprepare-db with missing connection prints help/options", () => {
|
|
751
|
+
const r = runCli(["unprepare-db"]);
|
|
752
|
+
expect(r.status).not.toBe(0);
|
|
753
|
+
expect(r.stderr).toMatch(/--print-sql/);
|
|
754
|
+
expect(r.stderr).toMatch(/--monitoring-user/);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("cli: unprepare-db --print-sql works without connection (offline mode)", () => {
|
|
758
|
+
const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb"]);
|
|
759
|
+
expect(r.status).toBe(0);
|
|
760
|
+
expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
|
|
761
|
+
expect(r.stdout).toMatch(/drop schema if exists postgres_ai/i);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("cli: unprepare-db --print-sql with --keep-role skips role drop", () => {
|
|
765
|
+
const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--keep-role"]);
|
|
766
|
+
expect(r.status).toBe(0);
|
|
767
|
+
expect(r.stdout).toMatch(/drop role: false/);
|
|
768
|
+
// Should not have 03.drop_role step
|
|
769
|
+
expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
|
|
770
|
+
// Should have 01 and 02 steps
|
|
771
|
+
expect(r.stdout).toMatch(/-- 01\.drop_helpers/);
|
|
772
|
+
expect(r.stdout).toMatch(/-- 02\.revoke_permissions/);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("cli: unprepare-db --print-sql with --provider supabase skips role step", () => {
|
|
776
|
+
const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--provider", "supabase"]);
|
|
777
|
+
expect(r.status).toBe(0);
|
|
778
|
+
expect(r.stdout).toMatch(/provider: supabase/);
|
|
779
|
+
// Should not have 03.drop_role step
|
|
780
|
+
expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("cli: unprepare-db command exists and shows help", () => {
|
|
784
|
+
const r = runCli(["unprepare-db", "--help"]);
|
|
785
|
+
expect(r.status).toBe(0);
|
|
786
|
+
expect(r.stdout).toMatch(/--keep-role/);
|
|
787
|
+
expect(r.stdout).toMatch(/--print-sql/);
|
|
788
|
+
expect(r.stdout).toMatch(/--force/);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Check if Docker is available for imageTag tests
|
|
793
|
+
function isDockerAvailable(): boolean {
|
|
794
|
+
try {
|
|
795
|
+
const result = Bun.spawnSync(["docker", "info"], { timeout: 5000 });
|
|
796
|
+
return result.exitCode === 0;
|
|
797
|
+
} catch {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const dockerAvailable = isDockerAvailable();
|
|
803
|
+
|
|
804
|
+
describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
|
|
805
|
+
// Tests for the imageTag priority: --tag flag > PGAI_TAG env var > pkg.version
|
|
806
|
+
// This verifies the fix that prevents stale .env PGAI_TAG from being used
|
|
807
|
+
// These tests require Docker and spawn subprocesses so need longer timeout
|
|
808
|
+
|
|
809
|
+
let tempDir: string;
|
|
810
|
+
|
|
811
|
+
beforeAll(() => {
|
|
812
|
+
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-test-"));
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
afterAll(() => {
|
|
816
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
817
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("stale .env PGAI_TAG is NOT used - CLI version takes precedence", () => {
|
|
822
|
+
// Create a stale .env with an old tag value
|
|
823
|
+
const testDir = resolve(tempDir, "stale-tag-test");
|
|
824
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
825
|
+
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=beta\n");
|
|
826
|
+
// Create minimal docker-compose.yml so resolvePaths() finds it
|
|
827
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
828
|
+
|
|
829
|
+
// Run from the test directory (so resolvePaths finds docker-compose.yml)
|
|
830
|
+
// Note: Command may hang on Docker check in CI without Docker, so we use a timeout
|
|
831
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
832
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
833
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
834
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
835
|
+
cwd: testDir,
|
|
836
|
+
timeout: 30000, // Kill subprocess after 30s if it hangs on Docker
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// Read the .env that was written
|
|
840
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
841
|
+
|
|
842
|
+
// The .env should NOT contain the stale "beta" tag - it should use pkg.version
|
|
843
|
+
expect(envContent).not.toMatch(/PGAI_TAG=beta/);
|
|
844
|
+
// It should contain the CLI version (0.0.0-dev.0 in dev)
|
|
845
|
+
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
846
|
+
}, 60000);
|
|
847
|
+
|
|
848
|
+
test("--tag flag takes priority over pkg.version", () => {
|
|
849
|
+
const testDir = resolve(tempDir, "tag-flag-test");
|
|
850
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
851
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
852
|
+
|
|
853
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
854
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
855
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--tag", "v1.2.3-custom", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
856
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
857
|
+
cwd: testDir,
|
|
858
|
+
timeout: 30000,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
862
|
+
expect(envContent).toMatch(/PGAI_TAG=v1\.2\.3-custom/);
|
|
863
|
+
|
|
864
|
+
// Verify stdout confirms the tag being used
|
|
865
|
+
const stdout = new TextDecoder().decode(result.stdout);
|
|
866
|
+
expect(stdout).toMatch(/Using image tag: v1\.2\.3-custom/);
|
|
867
|
+
}, 60000);
|
|
868
|
+
|
|
869
|
+
test("PGAI_TAG env var is intentionally ignored (Bun auto-loads .env)", () => {
|
|
870
|
+
// Note: We do NOT use process.env.PGAI_TAG because Bun auto-loads .env files,
|
|
871
|
+
// which would cause stale .env values to pollute the environment.
|
|
872
|
+
// Users should use --tag flag to override, not env vars.
|
|
873
|
+
const testDir = resolve(tempDir, "env-var-ignored-test");
|
|
874
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
875
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
876
|
+
|
|
877
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
878
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
879
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
880
|
+
env: { ...process.env, PGAI_TAG: "v2.0.0-from-env" },
|
|
881
|
+
cwd: testDir,
|
|
882
|
+
timeout: 30000,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
886
|
+
// PGAI_TAG env var should be IGNORED - uses pkg.version instead
|
|
887
|
+
expect(envContent).not.toMatch(/PGAI_TAG=v2\.0\.0-from-env/);
|
|
888
|
+
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
889
|
+
}, 60000);
|
|
890
|
+
|
|
891
|
+
test("existing registry and password are preserved while tag is updated", () => {
|
|
892
|
+
const testDir = resolve(tempDir, "preserve-test");
|
|
893
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
894
|
+
// Create .env with stale tag but valid registry and password
|
|
895
|
+
fs.writeFileSync(resolve(testDir, ".env"),
|
|
896
|
+
"PGAI_TAG=stale-tag\nPGAI_REGISTRY=my.registry.com\nGF_SECURITY_ADMIN_PASSWORD=secret123\n");
|
|
897
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
898
|
+
|
|
899
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
900
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
901
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
902
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
903
|
+
cwd: testDir,
|
|
904
|
+
timeout: 30000,
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
908
|
+
|
|
909
|
+
// Tag should be updated (not stale-tag)
|
|
910
|
+
expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
|
|
911
|
+
|
|
912
|
+
// But registry and password should be preserved
|
|
913
|
+
expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
|
|
914
|
+
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=secret123/);
|
|
915
|
+
}, 60000);
|
|
916
|
+
});
|