postgresai 0.14.0-dev.14 → 0.14.0-dev.16
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 +20 -13
- package/bin/postgres-ai.ts +37 -39
- package/dist/bin/postgres-ai.js +37 -37
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/init.d.ts +4 -2
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/init.js +162 -104
- package/dist/lib/init.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/init.ts +178 -125
- package/package.json +1 -1
- package/sql/01.role.sql +8 -7
- package/test/init.integration.test.cjs +35 -21
- package/test/init.test.cjs +192 -23
package/test/init.test.cjs
CHANGED
|
@@ -4,6 +4,7 @@ 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;
|
|
7
8
|
|
|
8
9
|
function runCli(args, env = {}) {
|
|
9
10
|
const { spawnSync } = require("node:child_process");
|
|
@@ -16,6 +17,17 @@ function runCli(args, env = {}) {
|
|
|
16
17
|
});
|
|
17
18
|
}
|
|
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
|
+
|
|
19
31
|
test("maskConnectionString hides password when present", () => {
|
|
20
32
|
const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
|
|
21
33
|
assert.match(masked, /postgresql:\/\/user:\*{5}@localhost:5432\/mydb/);
|
|
@@ -37,41 +49,89 @@ test("parseLibpqConninfo supports quoted values", () => {
|
|
|
37
49
|
assert.equal(cfg.host, "local host");
|
|
38
50
|
});
|
|
39
51
|
|
|
40
|
-
test("buildInitPlan includes
|
|
52
|
+
test("buildInitPlan includes a race-safe role DO block", async () => {
|
|
41
53
|
const plan = await init.buildInitPlan({
|
|
42
54
|
database: "mydb",
|
|
43
|
-
monitoringUser:
|
|
55
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
44
56
|
monitoringPassword: "pw",
|
|
45
57
|
includeOptionalPermissions: false,
|
|
46
|
-
roleExists: false,
|
|
47
58
|
});
|
|
48
59
|
|
|
49
60
|
assert.equal(plan.database, "mydb");
|
|
50
61
|
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
51
62
|
assert.ok(roleStep);
|
|
63
|
+
assert.match(roleStep.sql, /do\s+\$\$/i);
|
|
52
64
|
assert.match(roleStep.sql, /create\s+user/i);
|
|
65
|
+
assert.match(roleStep.sql, /alter\s+user/i);
|
|
53
66
|
assert.ok(!plan.steps.some((s) => s.optional));
|
|
54
67
|
});
|
|
55
68
|
|
|
56
|
-
test("buildInitPlan
|
|
69
|
+
test("buildInitPlan handles special characters in monitoring user and database identifiers", async () => {
|
|
70
|
+
const monitoringUser = 'user "with" quotes ✓';
|
|
71
|
+
const database = 'db name "with" quotes ✓';
|
|
57
72
|
const plan = await init.buildInitPlan({
|
|
58
|
-
database
|
|
59
|
-
monitoringUser
|
|
73
|
+
database,
|
|
74
|
+
monitoringUser,
|
|
60
75
|
monitoringPassword: "pw",
|
|
61
76
|
includeOptionalPermissions: false,
|
|
62
77
|
});
|
|
78
|
+
|
|
63
79
|
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
64
80
|
assert.ok(roleStep);
|
|
65
|
-
|
|
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`;
|
|
92
|
+
const plan = await init.buildInitPlan({
|
|
93
|
+
database: "mydb",
|
|
94
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
95
|
+
monitoringPassword: pw,
|
|
96
|
+
includeOptionalPermissions: false,
|
|
97
|
+
});
|
|
98
|
+
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
99
|
+
assert.ok(roleStep);
|
|
100
|
+
assert.ok(roleStep.sql.includes(`password '${pw}'`));
|
|
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
|
+
);
|
|
66
127
|
});
|
|
67
128
|
|
|
68
129
|
test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
|
|
69
130
|
const plan = await init.buildInitPlan({
|
|
70
131
|
database: "mydb",
|
|
71
|
-
monitoringUser:
|
|
132
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
72
133
|
monitoringPassword: "pa'ss",
|
|
73
134
|
includeOptionalPermissions: false,
|
|
74
|
-
roleExists: false,
|
|
75
135
|
});
|
|
76
136
|
const step = plan.steps.find((s) => s.name === "01.role");
|
|
77
137
|
assert.ok(step);
|
|
@@ -79,18 +139,13 @@ test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", asyn
|
|
|
79
139
|
assert.equal(step.params, undefined);
|
|
80
140
|
});
|
|
81
141
|
|
|
82
|
-
test("buildInitPlan includes
|
|
142
|
+
test("buildInitPlan includes optional steps when enabled", async () => {
|
|
83
143
|
const plan = await init.buildInitPlan({
|
|
84
144
|
database: "mydb",
|
|
85
|
-
monitoringUser:
|
|
145
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
86
146
|
monitoringPassword: "pw",
|
|
87
147
|
includeOptionalPermissions: true,
|
|
88
|
-
roleExists: true,
|
|
89
148
|
});
|
|
90
|
-
|
|
91
|
-
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
92
|
-
assert.ok(roleStep);
|
|
93
|
-
assert.match(roleStep.sql, /alter\s+user/i);
|
|
94
149
|
assert.ok(plan.steps.some((s) => s.optional));
|
|
95
150
|
});
|
|
96
151
|
|
|
@@ -118,8 +173,8 @@ test("resolveAdminConnection rejects when only PGPASSWORD is provided (no connec
|
|
|
118
173
|
assert.throws(() => init.resolveAdminConnection({ envPassword: "pw" }), /Connection is required/);
|
|
119
174
|
});
|
|
120
175
|
|
|
121
|
-
test("resolveAdminConnection
|
|
122
|
-
assert.throws(() => init.resolveAdminConnection({}), /
|
|
176
|
+
test("resolveAdminConnection rejects when connection is missing", () => {
|
|
177
|
+
assert.throws(() => init.resolveAdminConnection({}), /Connection is required/);
|
|
123
178
|
});
|
|
124
179
|
|
|
125
180
|
test("cli: init with missing connection prints init help/options", () => {
|
|
@@ -130,17 +185,125 @@ test("cli: init with missing connection prints init help/options", () => {
|
|
|
130
185
|
assert.match(r.stderr, /--monitoring-user/);
|
|
131
186
|
});
|
|
132
187
|
|
|
133
|
-
test("
|
|
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;"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("verifyInitSetup runs inside a repeatable read snapshot and rolls back", async () => {
|
|
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 () => {
|
|
134
298
|
const plan = await init.buildInitPlan({
|
|
135
299
|
database: "mydb",
|
|
136
|
-
monitoringUser:
|
|
300
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
137
301
|
monitoringPassword: "pa'ss",
|
|
138
302
|
includeOptionalPermissions: false,
|
|
139
|
-
roleExists: false,
|
|
140
303
|
});
|
|
141
304
|
const step = plan.steps.find((s) => s.name === "01.role");
|
|
142
305
|
assert.ok(step);
|
|
143
|
-
const redacted = step.sql
|
|
306
|
+
const redacted = init.redactPasswordsInSql(step.sql);
|
|
144
307
|
assert.match(redacted, /password '<redacted>'/i);
|
|
145
308
|
});
|
|
146
309
|
|
|
@@ -148,7 +311,13 @@ test("cli: init --print-sql works without connection (offline mode)", () => {
|
|
|
148
311
|
const r = runCli(["init", "--print-sql", "-d", "mydb", "--password", "monpw"]);
|
|
149
312
|
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
150
313
|
assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
|
|
151
|
-
assert.match(r.stdout,
|
|
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);
|
|
152
321
|
});
|
|
153
322
|
|
|
154
323
|
|