postgresai 0.14.0-beta.3 → 0.14.0-dev.11
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 +18 -41
- package/bin/postgres-ai.ts +51 -147
- package/dist/bin/postgres-ai.js +45 -139
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/init.d.ts +4 -15
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/init.js +94 -181
- package/dist/lib/init.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/init.ts +106 -215
- package/package.json +1 -1
- package/sql/01.role.sql +7 -8
- package/test/init.integration.test.cjs +18 -98
- package/test/init.test.cjs +22 -217
package/test/init.test.cjs
CHANGED
|
@@ -4,29 +4,6 @@ const assert = require("node:assert/strict");
|
|
|
4
4
|
// These tests intentionally import the compiled JS output.
|
|
5
5
|
// Run via: npm --prefix cli test
|
|
6
6
|
const init = require("../dist/lib/init.js");
|
|
7
|
-
const DEFAULT_MONITORING_USER = init.DEFAULT_MONITORING_USER;
|
|
8
|
-
|
|
9
|
-
function runCli(args, env = {}) {
|
|
10
|
-
const { spawnSync } = require("node:child_process");
|
|
11
|
-
const path = require("node:path");
|
|
12
|
-
const node = process.execPath;
|
|
13
|
-
const cliPath = path.resolve(__dirname, "..", "dist", "bin", "postgres-ai.js");
|
|
14
|
-
return spawnSync(node, [cliPath, ...args], {
|
|
15
|
-
encoding: "utf8",
|
|
16
|
-
env: { ...process.env, ...env },
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function runPgai(args, env = {}) {
|
|
21
|
-
const { spawnSync } = require("node:child_process");
|
|
22
|
-
const path = require("node:path");
|
|
23
|
-
const node = process.execPath;
|
|
24
|
-
const pgaiPath = path.resolve(__dirname, "..", "..", "pgai", "bin", "pgai.js");
|
|
25
|
-
return spawnSync(node, [pgaiPath, ...args], {
|
|
26
|
-
encoding: "utf8",
|
|
27
|
-
env: { ...process.env, ...env },
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
7
|
|
|
31
8
|
test("maskConnectionString hides password when present", () => {
|
|
32
9
|
const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
|
|
@@ -49,89 +26,41 @@ test("parseLibpqConninfo supports quoted values", () => {
|
|
|
49
26
|
assert.equal(cfg.host, "local host");
|
|
50
27
|
});
|
|
51
28
|
|
|
52
|
-
test("buildInitPlan includes
|
|
29
|
+
test("buildInitPlan includes create user when role does not exist", async () => {
|
|
53
30
|
const plan = await init.buildInitPlan({
|
|
54
31
|
database: "mydb",
|
|
55
|
-
monitoringUser:
|
|
32
|
+
monitoringUser: "postgres_ai_mon",
|
|
56
33
|
monitoringPassword: "pw",
|
|
57
34
|
includeOptionalPermissions: false,
|
|
35
|
+
roleExists: false,
|
|
58
36
|
});
|
|
59
37
|
|
|
60
38
|
assert.equal(plan.database, "mydb");
|
|
61
39
|
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
62
40
|
assert.ok(roleStep);
|
|
63
|
-
assert.match(roleStep.sql, /do\s+\$\$/i);
|
|
64
41
|
assert.match(roleStep.sql, /create\s+user/i);
|
|
65
|
-
assert.match(roleStep.sql, /alter\s+user/i);
|
|
66
42
|
assert.ok(!plan.steps.some((s) => s.optional));
|
|
67
43
|
});
|
|
68
44
|
|
|
69
|
-
test("buildInitPlan
|
|
70
|
-
const monitoringUser = 'user "with" quotes ✓';
|
|
71
|
-
const database = 'db name "with" quotes ✓';
|
|
72
|
-
const plan = await init.buildInitPlan({
|
|
73
|
-
database,
|
|
74
|
-
monitoringUser,
|
|
75
|
-
monitoringPassword: "pw",
|
|
76
|
-
includeOptionalPermissions: false,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
80
|
-
assert.ok(roleStep);
|
|
81
|
-
// Double quotes inside identifiers must be doubled.
|
|
82
|
-
assert.match(roleStep.sql, /create\s+user\s+"user ""with"" quotes ✓"/i);
|
|
83
|
-
assert.match(roleStep.sql, /alter\s+user\s+"user ""with"" quotes ✓"/i);
|
|
84
|
-
|
|
85
|
-
const permStep = plan.steps.find((s) => s.name === "02.permissions");
|
|
86
|
-
assert.ok(permStep);
|
|
87
|
-
assert.match(permStep.sql, /grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("buildInitPlan keeps backslashes in passwords (no unintended escaping)", async () => {
|
|
91
|
-
const pw = String.raw`pw\with\backslash`;
|
|
45
|
+
test("buildInitPlan includes role step when roleExists is omitted", async () => {
|
|
92
46
|
const plan = await init.buildInitPlan({
|
|
93
47
|
database: "mydb",
|
|
94
|
-
monitoringUser:
|
|
95
|
-
monitoringPassword: pw,
|
|
48
|
+
monitoringUser: "postgres_ai_mon",
|
|
49
|
+
monitoringPassword: "pw",
|
|
96
50
|
includeOptionalPermissions: false,
|
|
97
51
|
});
|
|
98
52
|
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
99
53
|
assert.ok(roleStep);
|
|
100
|
-
assert.
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("buildInitPlan rejects identifiers with null bytes", async () => {
|
|
104
|
-
await assert.rejects(
|
|
105
|
-
() =>
|
|
106
|
-
init.buildInitPlan({
|
|
107
|
-
database: "mydb",
|
|
108
|
-
monitoringUser: "bad\0user",
|
|
109
|
-
monitoringPassword: "pw",
|
|
110
|
-
includeOptionalPermissions: false,
|
|
111
|
-
}),
|
|
112
|
-
/Identifier cannot contain null bytes/
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("buildInitPlan rejects literals with null bytes", async () => {
|
|
117
|
-
await assert.rejects(
|
|
118
|
-
() =>
|
|
119
|
-
init.buildInitPlan({
|
|
120
|
-
database: "mydb",
|
|
121
|
-
monitoringUser: DEFAULT_MONITORING_USER,
|
|
122
|
-
monitoringPassword: "pw\0bad",
|
|
123
|
-
includeOptionalPermissions: false,
|
|
124
|
-
}),
|
|
125
|
-
/Literal cannot contain null bytes/
|
|
126
|
-
);
|
|
54
|
+
assert.match(roleStep.sql, /do\s+\$\$/i);
|
|
127
55
|
});
|
|
128
56
|
|
|
129
57
|
test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
|
|
130
58
|
const plan = await init.buildInitPlan({
|
|
131
59
|
database: "mydb",
|
|
132
|
-
monitoringUser:
|
|
60
|
+
monitoringUser: "postgres_ai_mon",
|
|
133
61
|
monitoringPassword: "pa'ss",
|
|
134
62
|
includeOptionalPermissions: false,
|
|
63
|
+
roleExists: false,
|
|
135
64
|
});
|
|
136
65
|
const step = plan.steps.find((s) => s.name === "01.role");
|
|
137
66
|
assert.ok(step);
|
|
@@ -139,13 +68,18 @@ test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", asyn
|
|
|
139
68
|
assert.equal(step.params, undefined);
|
|
140
69
|
});
|
|
141
70
|
|
|
142
|
-
test("buildInitPlan includes
|
|
71
|
+
test("buildInitPlan includes alter user when role exists", async () => {
|
|
143
72
|
const plan = await init.buildInitPlan({
|
|
144
73
|
database: "mydb",
|
|
145
|
-
monitoringUser:
|
|
74
|
+
monitoringUser: "postgres_ai_mon",
|
|
146
75
|
monitoringPassword: "pw",
|
|
147
76
|
includeOptionalPermissions: true,
|
|
77
|
+
roleExists: true,
|
|
148
78
|
});
|
|
79
|
+
|
|
80
|
+
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
81
|
+
assert.ok(roleStep);
|
|
82
|
+
assert.match(roleStep.sql, /alter\s+user/i);
|
|
149
83
|
assert.ok(plan.steps.some((s) => s.optional));
|
|
150
84
|
});
|
|
151
85
|
|
|
@@ -173,151 +107,22 @@ test("resolveAdminConnection rejects when only PGPASSWORD is provided (no connec
|
|
|
173
107
|
assert.throws(() => init.resolveAdminConnection({ envPassword: "pw" }), /Connection is required/);
|
|
174
108
|
});
|
|
175
109
|
|
|
176
|
-
test("resolveAdminConnection
|
|
177
|
-
assert.throws(() => init.resolveAdminConnection({}), /
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("cli: init with missing connection prints init help/options", () => {
|
|
181
|
-
const r = runCli(["init"]);
|
|
182
|
-
assert.notEqual(r.status, 0);
|
|
183
|
-
// We should show options, not just the error message.
|
|
184
|
-
assert.match(r.stderr, /--print-sql/);
|
|
185
|
-
assert.match(r.stderr, /--monitoring-user/);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("resolveMonitoringPassword auto-generates a strong, URL-safe password by default", async () => {
|
|
189
|
-
const r = await init.resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
|
|
190
|
-
assert.equal(r.generated, true);
|
|
191
|
-
assert.ok(typeof r.password === "string" && r.password.length >= 30);
|
|
192
|
-
assert.match(r.password, /^[A-Za-z0-9_-]+$/);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("applyInitPlan preserves Postgres error fields on step failures", async () => {
|
|
196
|
-
const plan = {
|
|
197
|
-
monitoringUser: DEFAULT_MONITORING_USER,
|
|
198
|
-
database: "mydb",
|
|
199
|
-
steps: [{ name: "01.role", sql: "select 1" }],
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const pgErr = Object.assign(new Error("permission denied to create role"), {
|
|
203
|
-
code: "42501",
|
|
204
|
-
detail: "some detail",
|
|
205
|
-
hint: "some hint",
|
|
206
|
-
schema: "pg_catalog",
|
|
207
|
-
table: "pg_roles",
|
|
208
|
-
constraint: "some_constraint",
|
|
209
|
-
routine: "aclcheck_error",
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
const calls = [];
|
|
213
|
-
const client = {
|
|
214
|
-
query: async (sql) => {
|
|
215
|
-
calls.push(sql);
|
|
216
|
-
if (sql === "begin;") return { rowCount: 1 };
|
|
217
|
-
if (sql === "rollback;") return { rowCount: 1 };
|
|
218
|
-
if (sql === "select 1") throw pgErr;
|
|
219
|
-
throw new Error(`unexpected sql: ${sql}`);
|
|
220
|
-
},
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
await assert.rejects(
|
|
224
|
-
() => init.applyInitPlan({ client, plan }),
|
|
225
|
-
(e) => {
|
|
226
|
-
assert.ok(e instanceof Error);
|
|
227
|
-
assert.match(e.message, /Failed at step "01\.role":/);
|
|
228
|
-
assert.equal(e.code, "42501");
|
|
229
|
-
assert.equal(e.detail, "some detail");
|
|
230
|
-
assert.equal(e.hint, "some hint");
|
|
231
|
-
assert.equal(e.schema, "pg_catalog");
|
|
232
|
-
assert.equal(e.table, "pg_roles");
|
|
233
|
-
assert.equal(e.constraint, "some_constraint");
|
|
234
|
-
assert.equal(e.routine, "aclcheck_error");
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
assert.deepEqual(calls, ["begin;", "select 1", "rollback;"]);
|
|
110
|
+
test("resolveAdminConnection error message includes examples", () => {
|
|
111
|
+
assert.throws(() => init.resolveAdminConnection({}), /Examples:/);
|
|
240
112
|
});
|
|
241
113
|
|
|
242
|
-
test("
|
|
243
|
-
const calls = [];
|
|
244
|
-
const client = {
|
|
245
|
-
query: async (sql, params) => {
|
|
246
|
-
calls.push(String(sql));
|
|
247
|
-
|
|
248
|
-
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
249
|
-
return { rowCount: 1, rows: [] };
|
|
250
|
-
}
|
|
251
|
-
if (String(sql).toLowerCase() === "rollback;") {
|
|
252
|
-
return { rowCount: 1, rows: [] };
|
|
253
|
-
}
|
|
254
|
-
if (String(sql).includes("select rolconfig")) {
|
|
255
|
-
return { rowCount: 1, rows: [{ rolconfig: ['search_path="$user", public, pg_catalog'] }] };
|
|
256
|
-
}
|
|
257
|
-
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
258
|
-
return { rowCount: 1, rows: [] };
|
|
259
|
-
}
|
|
260
|
-
if (String(sql).includes("has_database_privilege")) {
|
|
261
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
262
|
-
}
|
|
263
|
-
if (String(sql).includes("pg_has_role")) {
|
|
264
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
265
|
-
}
|
|
266
|
-
if (String(sql).includes("has_table_privilege") && String(sql).includes("pg_catalog.pg_index")) {
|
|
267
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
268
|
-
}
|
|
269
|
-
if (String(sql).includes("to_regclass('public.pg_statistic')")) {
|
|
270
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
271
|
-
}
|
|
272
|
-
if (String(sql).includes("has_table_privilege") && String(sql).includes("public.pg_statistic")) {
|
|
273
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
274
|
-
}
|
|
275
|
-
if (String(sql).includes("has_schema_privilege")) {
|
|
276
|
-
return { rowCount: 1, rows: [{ ok: true }] };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
280
|
-
},
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
const r = await init.verifyInitSetup({
|
|
284
|
-
client,
|
|
285
|
-
database: "mydb",
|
|
286
|
-
monitoringUser: DEFAULT_MONITORING_USER,
|
|
287
|
-
includeOptionalPermissions: false,
|
|
288
|
-
});
|
|
289
|
-
assert.equal(r.ok, true);
|
|
290
|
-
assert.equal(r.missingRequired.length, 0);
|
|
291
|
-
|
|
292
|
-
assert.ok(calls.length > 2);
|
|
293
|
-
assert.match(calls[0].toLowerCase(), /^begin isolation level repeatable read/);
|
|
294
|
-
assert.equal(calls[calls.length - 1].toLowerCase(), "rollback;");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
|
|
114
|
+
test("print-sql redaction regex matches password literal with embedded quotes", async () => {
|
|
298
115
|
const plan = await init.buildInitPlan({
|
|
299
116
|
database: "mydb",
|
|
300
|
-
monitoringUser:
|
|
117
|
+
monitoringUser: "postgres_ai_mon",
|
|
301
118
|
monitoringPassword: "pa'ss",
|
|
302
119
|
includeOptionalPermissions: false,
|
|
120
|
+
roleExists: false,
|
|
303
121
|
});
|
|
304
122
|
const step = plan.steps.find((s) => s.name === "01.role");
|
|
305
123
|
assert.ok(step);
|
|
306
|
-
const redacted =
|
|
124
|
+
const redacted = step.sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
|
|
307
125
|
assert.match(redacted, /password '<redacted>'/i);
|
|
308
126
|
});
|
|
309
127
|
|
|
310
|
-
test("cli: init --print-sql works without connection (offline mode)", () => {
|
|
311
|
-
const r = runCli(["init", "--print-sql", "-d", "mydb", "--password", "monpw"]);
|
|
312
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
313
|
-
assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
|
|
314
|
-
assert.match(r.stdout, new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("pgai wrapper forwards to postgresai CLI", () => {
|
|
318
|
-
const r = runPgai(["--help"]);
|
|
319
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
320
|
-
assert.match(r.stdout, /postgresai|PostgresAI/i);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
128
|
|