postgresai 0.14.0-dev.13 → 0.14.0-dev.15

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.
@@ -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");
@@ -37,41 +38,89 @@ test("parseLibpqConninfo supports quoted values", () => {
37
38
  assert.equal(cfg.host, "local host");
38
39
  });
39
40
 
40
- test("buildInitPlan includes create user when role does not exist", async () => {
41
+ test("buildInitPlan includes a race-safe role DO block", async () => {
41
42
  const plan = await init.buildInitPlan({
42
43
  database: "mydb",
43
- monitoringUser: "postgres_ai_mon",
44
+ monitoringUser: DEFAULT_MONITORING_USER,
44
45
  monitoringPassword: "pw",
45
46
  includeOptionalPermissions: false,
46
- roleExists: false,
47
47
  });
48
48
 
49
49
  assert.equal(plan.database, "mydb");
50
50
  const roleStep = plan.steps.find((s) => s.name === "01.role");
51
51
  assert.ok(roleStep);
52
+ assert.match(roleStep.sql, /do\s+\$\$/i);
52
53
  assert.match(roleStep.sql, /create\s+user/i);
54
+ assert.match(roleStep.sql, /alter\s+user/i);
53
55
  assert.ok(!plan.steps.some((s) => s.optional));
54
56
  });
55
57
 
56
- test("buildInitPlan includes role step when roleExists is omitted", async () => {
58
+ test("buildInitPlan handles special characters in monitoring user and database identifiers", async () => {
59
+ const monitoringUser = 'user "with" quotes ✓';
60
+ const database = 'db name "with" quotes ✓';
57
61
  const plan = await init.buildInitPlan({
58
- database: "mydb",
59
- monitoringUser: "postgres_ai_mon",
62
+ database,
63
+ monitoringUser,
60
64
  monitoringPassword: "pw",
61
65
  includeOptionalPermissions: false,
62
66
  });
67
+
63
68
  const roleStep = plan.steps.find((s) => s.name === "01.role");
64
69
  assert.ok(roleStep);
65
- assert.match(roleStep.sql, /do\s+\$\$/i);
70
+ // Double quotes inside identifiers must be doubled.
71
+ assert.match(roleStep.sql, /create\s+user\s+"user ""with"" quotes ✓"/i);
72
+ assert.match(roleStep.sql, /alter\s+user\s+"user ""with"" quotes ✓"/i);
73
+
74
+ const permStep = plan.steps.find((s) => s.name === "02.permissions");
75
+ assert.ok(permStep);
76
+ assert.match(permStep.sql, /grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
77
+ });
78
+
79
+ test("buildInitPlan keeps backslashes in passwords (no unintended escaping)", async () => {
80
+ const pw = String.raw`pw\with\backslash`;
81
+ const plan = await init.buildInitPlan({
82
+ database: "mydb",
83
+ monitoringUser: DEFAULT_MONITORING_USER,
84
+ monitoringPassword: pw,
85
+ includeOptionalPermissions: false,
86
+ });
87
+ const roleStep = plan.steps.find((s) => s.name === "01.role");
88
+ assert.ok(roleStep);
89
+ assert.ok(roleStep.sql.includes(`password '${pw}'`));
90
+ });
91
+
92
+ test("buildInitPlan rejects identifiers with null bytes", async () => {
93
+ await assert.rejects(
94
+ () =>
95
+ init.buildInitPlan({
96
+ database: "mydb",
97
+ monitoringUser: "bad\0user",
98
+ monitoringPassword: "pw",
99
+ includeOptionalPermissions: false,
100
+ }),
101
+ /Identifier cannot contain null bytes/
102
+ );
103
+ });
104
+
105
+ test("buildInitPlan rejects literals with null bytes", async () => {
106
+ await assert.rejects(
107
+ () =>
108
+ init.buildInitPlan({
109
+ database: "mydb",
110
+ monitoringUser: DEFAULT_MONITORING_USER,
111
+ monitoringPassword: "pw\0bad",
112
+ includeOptionalPermissions: false,
113
+ }),
114
+ /Literal cannot contain null bytes/
115
+ );
66
116
  });
67
117
 
68
118
  test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
69
119
  const plan = await init.buildInitPlan({
70
120
  database: "mydb",
71
- monitoringUser: "postgres_ai_mon",
121
+ monitoringUser: DEFAULT_MONITORING_USER,
72
122
  monitoringPassword: "pa'ss",
73
123
  includeOptionalPermissions: false,
74
- roleExists: false,
75
124
  });
76
125
  const step = plan.steps.find((s) => s.name === "01.role");
77
126
  assert.ok(step);
@@ -79,18 +128,13 @@ test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", asyn
79
128
  assert.equal(step.params, undefined);
80
129
  });
81
130
 
82
- test("buildInitPlan includes alter user when role exists", async () => {
131
+ test("buildInitPlan includes optional steps when enabled", async () => {
83
132
  const plan = await init.buildInitPlan({
84
133
  database: "mydb",
85
- monitoringUser: "postgres_ai_mon",
134
+ monitoringUser: DEFAULT_MONITORING_USER,
86
135
  monitoringPassword: "pw",
87
136
  includeOptionalPermissions: true,
88
- roleExists: true,
89
137
  });
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
138
  assert.ok(plan.steps.some((s) => s.optional));
95
139
  });
96
140
 
@@ -130,17 +174,125 @@ test("cli: init with missing connection prints init help/options", () => {
130
174
  assert.match(r.stderr, /--monitoring-user/);
131
175
  });
132
176
 
133
- test("print-sql redaction regex matches password literal with embedded quotes", async () => {
177
+ test("resolveMonitoringPassword auto-generates a strong, URL-safe password by default", async () => {
178
+ const r = await init.resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
179
+ assert.equal(r.generated, true);
180
+ assert.ok(typeof r.password === "string" && r.password.length >= 30);
181
+ assert.match(r.password, /^[A-Za-z0-9_-]+$/);
182
+ });
183
+
184
+ test("applyInitPlan preserves Postgres error fields on step failures", async () => {
185
+ const plan = {
186
+ monitoringUser: DEFAULT_MONITORING_USER,
187
+ database: "mydb",
188
+ steps: [{ name: "01.role", sql: "select 1" }],
189
+ };
190
+
191
+ const pgErr = Object.assign(new Error("permission denied to create role"), {
192
+ code: "42501",
193
+ detail: "some detail",
194
+ hint: "some hint",
195
+ schema: "pg_catalog",
196
+ table: "pg_roles",
197
+ constraint: "some_constraint",
198
+ routine: "aclcheck_error",
199
+ });
200
+
201
+ const calls = [];
202
+ const client = {
203
+ query: async (sql) => {
204
+ calls.push(sql);
205
+ if (sql === "begin;") return { rowCount: 1 };
206
+ if (sql === "rollback;") return { rowCount: 1 };
207
+ if (sql === "select 1") throw pgErr;
208
+ throw new Error(`unexpected sql: ${sql}`);
209
+ },
210
+ };
211
+
212
+ await assert.rejects(
213
+ () => init.applyInitPlan({ client, plan }),
214
+ (e) => {
215
+ assert.ok(e instanceof Error);
216
+ assert.match(e.message, /Failed at step "01\.role":/);
217
+ assert.equal(e.code, "42501");
218
+ assert.equal(e.detail, "some detail");
219
+ assert.equal(e.hint, "some hint");
220
+ assert.equal(e.schema, "pg_catalog");
221
+ assert.equal(e.table, "pg_roles");
222
+ assert.equal(e.constraint, "some_constraint");
223
+ assert.equal(e.routine, "aclcheck_error");
224
+ return true;
225
+ }
226
+ );
227
+
228
+ assert.deepEqual(calls, ["begin;", "select 1", "rollback;"]);
229
+ });
230
+
231
+ test("verifyInitSetup runs inside a repeatable read snapshot and rolls back", async () => {
232
+ const calls = [];
233
+ const client = {
234
+ query: async (sql, params) => {
235
+ calls.push(String(sql));
236
+
237
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
238
+ return { rowCount: 1, rows: [] };
239
+ }
240
+ if (String(sql).toLowerCase() === "rollback;") {
241
+ return { rowCount: 1, rows: [] };
242
+ }
243
+ if (String(sql).includes("select rolconfig")) {
244
+ return { rowCount: 1, rows: [{ rolconfig: ['search_path="$user", public, pg_catalog'] }] };
245
+ }
246
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
247
+ return { rowCount: 1, rows: [] };
248
+ }
249
+ if (String(sql).includes("has_database_privilege")) {
250
+ return { rowCount: 1, rows: [{ ok: true }] };
251
+ }
252
+ if (String(sql).includes("pg_has_role")) {
253
+ return { rowCount: 1, rows: [{ ok: true }] };
254
+ }
255
+ if (String(sql).includes("has_table_privilege") && String(sql).includes("pg_catalog.pg_index")) {
256
+ return { rowCount: 1, rows: [{ ok: true }] };
257
+ }
258
+ if (String(sql).includes("to_regclass('public.pg_statistic')")) {
259
+ return { rowCount: 1, rows: [{ ok: true }] };
260
+ }
261
+ if (String(sql).includes("has_table_privilege") && String(sql).includes("public.pg_statistic")) {
262
+ return { rowCount: 1, rows: [{ ok: true }] };
263
+ }
264
+ if (String(sql).includes("has_schema_privilege")) {
265
+ return { rowCount: 1, rows: [{ ok: true }] };
266
+ }
267
+
268
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
269
+ },
270
+ };
271
+
272
+ const r = await init.verifyInitSetup({
273
+ client,
274
+ database: "mydb",
275
+ monitoringUser: DEFAULT_MONITORING_USER,
276
+ includeOptionalPermissions: false,
277
+ });
278
+ assert.equal(r.ok, true);
279
+ assert.equal(r.missingRequired.length, 0);
280
+
281
+ assert.ok(calls.length > 2);
282
+ assert.match(calls[0].toLowerCase(), /^begin isolation level repeatable read/);
283
+ assert.equal(calls[calls.length - 1].toLowerCase(), "rollback;");
284
+ });
285
+
286
+ test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
134
287
  const plan = await init.buildInitPlan({
135
288
  database: "mydb",
136
- monitoringUser: "postgres_ai_mon",
289
+ monitoringUser: DEFAULT_MONITORING_USER,
137
290
  monitoringPassword: "pa'ss",
138
291
  includeOptionalPermissions: false,
139
- roleExists: false,
140
292
  });
141
293
  const step = plan.steps.find((s) => s.name === "01.role");
142
294
  assert.ok(step);
143
- const redacted = step.sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
295
+ const redacted = init.redactPasswordsInSql(step.sql);
144
296
  assert.match(redacted, /password '<redacted>'/i);
145
297
  });
146
298
 
@@ -148,7 +300,7 @@ test("cli: init --print-sql works without connection (offline mode)", () => {
148
300
  const r = runCli(["init", "--print-sql", "-d", "mydb", "--password", "monpw"]);
149
301
  assert.equal(r.status, 0, r.stderr || r.stdout);
150
302
  assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
151
- assert.match(r.stdout, /grant connect on database "mydb" to "postgres_ai_mon"/i);
303
+ assert.match(r.stdout, new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
152
304
  });
153
305
 
154
306