postgresai 0.16.0-dev.0 → 0.16.0-dev.10

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.
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  registerMonitoringInstance,
21
21
  resolveAdoptedProject,
22
+ planMonitoringRegistration,
22
23
  } from "../bin/postgres-ai";
23
24
 
24
25
  /**
@@ -241,12 +242,13 @@ describe("registerMonitoringInstance", () => {
241
242
  });
242
243
 
243
244
  // Issue platform-all#311: console-provisioned installs pass instance_id so
244
- // the platform adopts the provisioned instance instead of self-registering
245
- // a duplicate under an auto-created "postgres-ai-monitoring" project.
245
+ // the platform adopts the provisioned instance and returns its real project
246
+ // the CLI sends NO project_name on the adopt path (the hardcoded
247
+ // "postgres-ai-monitoring" default was removed).
246
248
  test("includes instance_id in body when adopting a provisioned instance", async () => {
247
249
  const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
248
250
 
249
- await registerMonitoringInstance("key", "postgres-ai-monitoring", opts({ instanceId }));
251
+ await registerMonitoringInstance("key", undefined, opts({ instanceId }));
250
252
 
251
253
  const body = JSON.parse(fetchCalls[0].options.body as string);
252
254
  expect(body.instance_id).toBe(instanceId);
@@ -255,6 +257,22 @@ describe("registerMonitoringInstance", () => {
255
257
  expect(headers["instance-id"]).toBeUndefined();
256
258
  });
257
259
 
260
+ test("omits project_name from the body when undefined (adopt path)", async () => {
261
+ await registerMonitoringInstance("key", undefined, opts({ instanceId: "i" }));
262
+
263
+ const body = JSON.parse(fetchCalls[0].options.body as string);
264
+ // The adopt path sends no name; the platform returns the real project.
265
+ expect("project_name" in body).toBe(false);
266
+ expect(body.api_token).toBe("key");
267
+ });
268
+
269
+ test("omits project_name from the body when empty/whitespace", async () => {
270
+ await registerMonitoringInstance("key", " ", opts({ instanceId: "i" }));
271
+
272
+ const body = JSON.parse(fetchCalls[0].options.body as string);
273
+ expect("project_name" in body).toBe(false);
274
+ });
275
+
258
276
  test("omits instance_id from the body for legacy self-registration", async () => {
259
277
  await registerMonitoringInstance("key", "my-project", opts());
260
278
 
@@ -262,6 +280,8 @@ describe("registerMonitoringInstance", () => {
262
280
  // PostgREST matches the 3-arg function via its default — the key must be
263
281
  // ABSENT (not null) so legacy CLIs and the new one hit the same overload.
264
282
  expect("instance_id" in body).toBe(false);
283
+ // A real project name still rides in the body for legacy registration.
284
+ expect(body.project_name).toBe("my-project");
265
285
  });
266
286
 
267
287
  test("a 200 with {project_id, project_name} returns a populated result", async () => {
@@ -323,6 +343,37 @@ describe("registerMonitoringInstance", () => {
323
343
  });
324
344
  });
325
345
 
346
+ describe("planMonitoringRegistration — mon local-install registration decision", () => {
347
+ test("instance id present → adopt, no project name required", () => {
348
+ const plan = planMonitoringRegistration({ instanceId: "i-123" });
349
+ expect(plan.kind).toBe("adopt");
350
+ expect(plan.projectName).toBeUndefined();
351
+ });
352
+
353
+ test("instance id present + project → adopt, carries the trimmed name", () => {
354
+ const plan = planMonitoringRegistration({ instanceId: "i-123", project: " prod-db " });
355
+ expect(plan.kind).toBe("adopt");
356
+ expect(plan.projectName).toBe("prod-db");
357
+ });
358
+
359
+ test("no instance id + no project → error-missing-project (exit 1 path)", () => {
360
+ const plan = planMonitoringRegistration({});
361
+ expect(plan.kind).toBe("error-missing-project");
362
+ expect(plan.projectName).toBeUndefined();
363
+ });
364
+
365
+ test("no instance id + empty/whitespace project → error-missing-project", () => {
366
+ expect(planMonitoringRegistration({ project: "" }).kind).toBe("error-missing-project");
367
+ expect(planMonitoringRegistration({ project: " " }).kind).toBe("error-missing-project");
368
+ });
369
+
370
+ test("no instance id + real project → legacy self-register with the trimmed name", () => {
371
+ const plan = planMonitoringRegistration({ project: " my-project " });
372
+ expect(plan.kind).toBe("self-register");
373
+ expect(plan.projectName).toBe("my-project");
374
+ });
375
+ });
376
+
326
377
  describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
327
378
  test("prefers the numeric project_id over the name (survives renames)", () => {
328
379
  expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");
package/test/util.test.ts CHANGED
@@ -1,6 +1,31 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { formatHttpError } from "../lib/util";
3
+ import { formatHttpError, describeFetchError, redactSecretsForLog } from "../lib/util";
4
+
5
+ describe("describeFetchError", () => {
6
+ test("surfaces the connection cause + url instead of a bare 'fetch failed'", () => {
7
+ // Node's fetch (undici) throws TypeError('fetch failed') and stashes the real
8
+ // reason (ECONNREFUSED etc.) in err.cause — the CLI must surface it, not the
9
+ // opaque top-level message.
10
+ const err = Object.assign(new TypeError("fetch failed"), {
11
+ cause: { code: "ECONNREFUSED", message: "connect ECONNREFUSED 127.0.0.1:1" },
12
+ });
13
+ const msg = describeFetchError("Failed to list projects", "http://127.0.0.1:1", err);
14
+ expect(msg).toContain("Failed to list projects");
15
+ expect(msg).toContain("could not reach http://127.0.0.1:1");
16
+ expect(msg).toContain("ECONNREFUSED");
17
+ expect(msg).not.toBe("fetch failed");
18
+ });
19
+
20
+ test("falls back to cause.message, then to the error message", () => {
21
+ const withMsg = Object.assign(new TypeError("fetch failed"), {
22
+ cause: { message: "bad port" },
23
+ });
24
+ expect(describeFetchError("op", "http://h:1", withMsg)).toContain("bad port");
25
+ const bare = new Error("boom");
26
+ expect(describeFetchError("op", "http://h", bare)).toContain("boom");
27
+ });
28
+ });
4
29
 
5
30
  describe("formatHttpError", () => {
6
31
  test("appends auth remediation hint on 401 with JSON body", () => {
@@ -41,4 +66,89 @@ describe("formatHttpError", () => {
41
66
  expect(msg.indexOf("Invalid token")).toBeGreaterThan(-1);
42
67
  expect(msg.indexOf("Invalid token")).toBeLessThan(msg.indexOf("Run 'postgresai auth'"));
43
68
  });
69
+
70
+ // PostgREST v9's PTxyz custom-status convention carries the RPC's user-facing
71
+ // `message` in the HTTP reason phrase (statusText); the JSON body holds only
72
+ // `hint`/`details` (no `message`/`code`). The formatter must surface the
73
+ // reason phrase as the headline instead of a hardcoded generic label, and
74
+ // still show the `details` (plural) line.
75
+ test("surfaces the PostgREST PTxyz reason-phrase message, not a generic label", () => {
76
+ const msg = formatHttpError(
77
+ "Failed to submit plan command",
78
+ 403,
79
+ '{"hint":null,"details":"The LLM-facing Joe surface is gated on the organization\'s ai_enabled setting."}',
80
+ "AI features disabled for this org — enable in AI Assistant Settings"
81
+ );
82
+
83
+ expect(msg).toContain("HTTP 403");
84
+ expect(msg).toContain("AI features disabled for this org — enable in AI Assistant Settings");
85
+ // the real reason must not be masked by the hardcoded generic label
86
+ expect(msg).not.toContain("Forbidden - access denied");
87
+ // the supplementary `details` (plural) line is still shown
88
+ expect(msg).toContain("gated on the organization's ai_enabled setting");
89
+ // and we never dump the raw JSON object
90
+ expect(msg).not.toContain('"hint"');
91
+ });
92
+
93
+ test("prefers a JSON body `message` over the reason phrase when both exist", () => {
94
+ const msg = formatHttpError("op", 400, '{"message":"real message","details":"more"}', "Bad Request");
95
+ expect(msg).toContain("real message");
96
+ expect(msg).toContain("more");
97
+ });
98
+
99
+ test("falls back to the generic label for a standard (non-custom) reason phrase", () => {
100
+ // a bare standard reason phrase must not replace the friendlier generic label
101
+ const msg = formatHttpError("op", 403, undefined, "Forbidden");
102
+ expect(msg).toContain("Forbidden - access denied");
103
+ });
104
+ });
105
+
106
+ describe("redactSecretsForLog", () => {
107
+ test("redacts password-named keys at any depth (request body shape)", () => {
108
+ const body = JSON.stringify({
109
+ instance_id: "7",
110
+ action: "/clone",
111
+ method: "post",
112
+ data: { protected: false, db: { username: "clone_user", password: "hunter2" } },
113
+ });
114
+ const out = redactSecretsForLog(body);
115
+ expect(out).not.toContain("hunter2");
116
+ expect(out).toContain('"password":"[REDACTED]"');
117
+ // Non-secret fields survive untouched.
118
+ expect(out).toContain('"username":"clone_user"');
119
+ expect(out).toContain('"action":"/clone"');
120
+ });
121
+
122
+ test("redacts connStr and password in response bodies (clone create/status shape)", () => {
123
+ const reply = JSON.stringify({
124
+ id: "c1",
125
+ db: { connStr: "host=h port=6002 user=joe password=pw-xyz", password: "pw-xyz", username: "joe" },
126
+ });
127
+ const out = redactSecretsForLog(reply);
128
+ expect(out).not.toContain("pw-xyz");
129
+ expect(out).toContain('"connStr":"[REDACTED]"');
130
+ expect(out).toContain('"username":"joe"');
131
+ });
132
+
133
+ test("matches key variants case-insensitively (Password, DB_PASSWORD, dbPassword, connstr)", () => {
134
+ const out = redactSecretsForLog(
135
+ JSON.stringify({ Password: "a", DB_PASSWORD: "b", dbPassword: "c", connstr: "d", ok: "keep" })
136
+ );
137
+ expect(out).not.toContain('"a"');
138
+ expect(out).not.toContain('"b"');
139
+ expect(out).not.toContain('"c"');
140
+ expect(out).not.toContain('"d"');
141
+ expect(out).toContain('"ok":"keep"');
142
+ });
143
+
144
+ test("traverses arrays (e.g. result rows carrying a password column)", () => {
145
+ const out = redactSecretsForLog(JSON.stringify({ rows: [{ usename: "app", password: "s3cr3t" }] }));
146
+ expect(out).not.toContain("s3cr3t");
147
+ expect(out).toContain('"usename":"app"');
148
+ });
149
+
150
+ test("keeps null secrets as null (shape stays readable) and passes non-JSON through", () => {
151
+ expect(redactSecretsForLog(JSON.stringify({ password: null }))).toContain('"password":null');
152
+ expect(redactSecretsForLog("plain text, not json")).toBe("plain text, not json");
153
+ });
44
154
  });