postgresai 0.15.0 → 0.16.0-rc.1

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.
@@ -16,6 +16,10 @@ import {
16
16
  extractSslmode,
17
17
  InstancesParseError,
18
18
  } from "../lib/instances";
19
+ import {
20
+ registerMonitoringInstance,
21
+ resolveAdoptedProject,
22
+ } from "../bin/postgres-ai";
19
23
 
20
24
  /**
21
25
  * Test updatePgwatchConfig function behavior.
@@ -190,14 +194,20 @@ describe("updatePgwatchConfig", () => {
190
194
  describe("registerMonitoringInstance", () => {
191
195
  let originalFetch: typeof global.fetch;
192
196
  let fetchCalls: Array<{ url: string; options: RequestInit }>;
197
+ // Each test sets `respond(call) => Response`; defaults to a 200 with no body.
198
+ let respond: (call: { url: string; options: RequestInit }) => Response;
199
+ const apiBaseUrl = "https://api.example.com";
200
+ const registerUrl = `${apiBaseUrl}/rpc/monitoring_instance_register`;
193
201
 
194
202
  beforeEach(() => {
195
203
  originalFetch = global.fetch;
196
204
  fetchCalls = [];
197
- // Mock fetch to capture calls
205
+ respond = () => new Response(JSON.stringify({ project_id: 7 }), { status: 200 });
206
+ // Mock fetch to capture calls and return the test-configured response.
198
207
  global.fetch = async (url: RequestInfo | URL, options?: RequestInit) => {
199
- fetchCalls.push({ url: url.toString(), options: options || {} });
200
- return new Response(JSON.stringify({ success: true }), { status: 200 });
208
+ const call = { url: url.toString(), options: options || {} };
209
+ fetchCalls.push(call);
210
+ return respond(call);
201
211
  };
202
212
  });
203
213
 
@@ -205,72 +215,141 @@ describe("registerMonitoringInstance", () => {
205
215
  global.fetch = originalFetch;
206
216
  });
207
217
 
208
- test("sends POST request with correct URL and body", async () => {
209
- // Simulate what registerMonitoringInstance does
210
- const apiKey = "test-api-key";
211
- const projectName = "my-project";
212
- const apiBaseUrl = "https://api.example.com";
213
-
214
- await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
215
- method: "POST",
216
- headers: {
217
- "Content-Type": "application/json",
218
- },
219
- body: JSON.stringify({
220
- api_token: apiKey,
221
- project_name: projectName,
222
- }),
223
- });
218
+ // retryDelayMs: 0 keeps the retry path instant under test.
219
+ const opts = (extra?: Record<string, unknown>) => ({ apiBaseUrl, retryDelayMs: 0, ...extra });
220
+
221
+ test("posts api_token + project_name in the body (never in headers), legacy mode", async () => {
222
+ await registerMonitoringInstance("secret-key-12345", "my-project", opts());
224
223
 
225
224
  expect(fetchCalls.length).toBe(1);
226
- expect(fetchCalls[0].url).toBe("https://api.example.com/rpc/monitoring_instance_register");
225
+ expect(fetchCalls[0].url).toBe(registerUrl);
227
226
  expect(fetchCalls[0].options.method).toBe("POST");
228
227
 
229
228
  const headers = fetchCalls[0].options.headers as Record<string, string>;
230
229
  expect(headers["Content-Type"]).toBe("application/json");
231
- // Verify API key is NOT in headers (only in body per security review)
230
+ // API key only in body, never in an access-token header (security review).
232
231
  expect(headers["access-token"]).toBeUndefined();
233
232
 
234
233
  const body = JSON.parse(fetchCalls[0].options.body as string);
235
- expect(body.api_token).toBe("test-api-key");
234
+ expect(body.api_token).toBe("secret-key-12345");
236
235
  expect(body.project_name).toBe("my-project");
237
236
  });
238
237
 
239
- test("includes api_token in body, not in header", async () => {
240
- const apiKey = "secret-key-12345";
241
- const projectName = "test-project";
242
- const apiBaseUrl = "https://postgres.ai/api/general";
243
-
244
- await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
245
- method: "POST",
246
- headers: {
247
- "Content-Type": "application/json",
248
- },
249
- body: JSON.stringify({
250
- api_token: apiKey,
251
- project_name: projectName,
252
- }),
253
- });
238
+ test("honors a custom apiBaseUrl for the endpoint path", async () => {
239
+ await registerMonitoringInstance("key", "proj", opts({ apiBaseUrl: "https://custom.api.com/v2" }));
240
+ expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
241
+ });
254
242
 
243
+ // 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.
246
+ test("includes instance_id in body when adopting a provisioned instance", async () => {
247
+ const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
248
+
249
+ await registerMonitoringInstance("key", "postgres-ai-monitoring", opts({ instanceId }));
250
+
251
+ const body = JSON.parse(fetchCalls[0].options.body as string);
252
+ expect(body.instance_id).toBe(instanceId);
253
+ // instance_id rides in the body next to api_token — never in headers.
255
254
  const headers = fetchCalls[0].options.headers as Record<string, string>;
256
- // Verify no access-token header
257
- expect(Object.keys(headers)).not.toContain("access-token");
255
+ expect(headers["instance-id"]).toBeUndefined();
256
+ });
257
+
258
+ test("omits instance_id from the body for legacy self-registration", async () => {
259
+ await registerMonitoringInstance("key", "my-project", opts());
258
260
 
259
- // Verify token is in body
260
261
  const body = JSON.parse(fetchCalls[0].options.body as string);
261
- expect(body.api_token).toBe("secret-key-12345");
262
+ // PostgREST matches the 3-arg function via its default — the key must be
263
+ // ABSENT (not null) so legacy CLIs and the new one hit the same overload.
264
+ expect("instance_id" in body).toBe(false);
262
265
  });
263
266
 
264
- test("uses correct endpoint path", async () => {
265
- const apiBaseUrl = "https://custom.api.com/v2";
267
+ test("a 200 with {project_id, project_name} returns a populated result", async () => {
268
+ respond = () => new Response(JSON.stringify({ project_id: 42, project_name: "prod-db", created: false }), { status: 200 });
266
269
 
267
- await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
268
- method: "POST",
269
- headers: { "Content-Type": "application/json" },
270
- body: JSON.stringify({ api_token: "key", project_name: "proj" }),
271
- });
270
+ const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
272
271
 
273
- expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
272
+ expect(reg).toEqual({ instanceId: undefined, projectId: 42, projectName: "prod-db", created: false });
273
+ });
274
+
275
+ test("a non-JSON 200 returns {} (success, but no fields) — not null", async () => {
276
+ respond = () => new Response("<html>oops</html>", { status: 200 });
277
+
278
+ const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
279
+
280
+ expect(reg).toEqual({});
281
+ });
282
+
283
+ test("mistyped fields are dropped at runtime (project_id must be a number)", async () => {
284
+ // A spoofed/older platform returning a string id must not poison the
285
+ // persistence decision, which prefers a numeric project_id.
286
+ respond = () => new Response(JSON.stringify({ project_id: "13", project_name: "ok-name", created: "yes" }), { status: 200 });
287
+
288
+ const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
289
+
290
+ expect(reg).toEqual({ instanceId: undefined, projectId: undefined, projectName: "ok-name", created: undefined });
291
+ });
292
+
293
+ test("when adopting, a first non-OK response triggers one retry that succeeds", async () => {
294
+ respond = (call) => {
295
+ // First attempt 503, second 200.
296
+ return fetchCalls.length === 1
297
+ ? new Response("upstream down", { status: 503 })
298
+ : new Response(JSON.stringify({ project_id: 9 }), { status: 200 });
299
+ };
300
+
301
+ const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
302
+
303
+ expect(fetchCalls.length).toBe(2);
304
+ expect(reg).toEqual({ instanceId: undefined, projectId: 9, projectName: undefined, created: undefined });
305
+ });
306
+
307
+ test("when adopting, two failures exhaust the retry and return null", async () => {
308
+ respond = () => new Response("upstream down", { status: 503 });
309
+
310
+ const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
311
+
312
+ expect(fetchCalls.length).toBe(2); // one initial + one retry
313
+ expect(reg).toBeNull();
314
+ });
315
+
316
+ test("legacy mode does NOT retry — a single failure returns null after one attempt", async () => {
317
+ respond = () => new Response("upstream down", { status: 503 });
318
+
319
+ const reg = await registerMonitoringInstance("key", "p", opts());
320
+
321
+ expect(fetchCalls.length).toBe(1);
322
+ expect(reg).toBeNull();
323
+ });
324
+ });
325
+
326
+ describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
327
+ test("prefers the numeric project_id over the name (survives renames)", () => {
328
+ expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");
329
+ });
330
+
331
+ test("project_id === 0 is a valid id and is honored", () => {
332
+ expect(resolveAdoptedProject({ projectId: 0, projectName: "prod-db" })).toBe("0");
333
+ });
334
+
335
+ test("falls back to project_name when there is no id", () => {
336
+ expect(resolveAdoptedProject({ projectName: "prod-db" })).toBe("prod-db");
337
+ });
338
+
339
+ test("returns null for a null response", () => {
340
+ expect(resolveAdoptedProject(null)).toBeNull();
341
+ });
342
+
343
+ test("returns null for a fieldless (succeeded-but-empty) response", () => {
344
+ expect(resolveAdoptedProject({})).toBeNull();
345
+ });
346
+
347
+ test("rejects a project_name with a newline (config-file injection, CWE-93)", () => {
348
+ expect(resolveAdoptedProject({ projectName: "prod\ninjected=evil" })).toBeNull();
349
+ });
350
+
351
+ test("rejects a project_name containing '='", () => {
352
+ expect(resolveAdoptedProject({ projectName: "a=b" })).toBeNull();
274
353
  });
275
354
  });
276
355
 
@@ -70,6 +70,35 @@ describe("Schema validation", () => {
70
70
  });
71
71
  }
72
72
 
73
+ // F003 (Autovacuum: dead tuples) - test empty and with data
74
+ test("F003 validates with empty data", async () => {
75
+ const mockClient = createMockClient({ deadTuplesRows: [] });
76
+ const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "node-01");
77
+ validateAgainstSchema(report, "F003");
78
+ });
79
+
80
+ test("F003 validates with sample data", async () => {
81
+ const mockClient = createMockClient({
82
+ deadTuplesRows: [
83
+ {
84
+ tag_schemaname: "public",
85
+ tag_relname: "events",
86
+ n_live_tup: "6361538",
87
+ n_dead_tup: "8270000",
88
+ dead_pct: 56.52,
89
+ last_autovacuum: "0",
90
+ last_vacuum: "1704067200",
91
+ autovacuum_count: "0",
92
+ vacuum_count: "1",
93
+ autovacuum_disabled: 1,
94
+ table_size_b: "2147483648",
95
+ },
96
+ ],
97
+ });
98
+ const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "node-01");
99
+ validateAgainstSchema(report, "F003");
100
+ });
101
+
73
102
  // Settings reports (D004, F001, G001) - single test each
74
103
  for (const checkId of ["D004", "F001", "G001"]) {
75
104
  test(`${checkId} validates against schema`, async () => {
@@ -17,6 +17,7 @@ export interface MockClientOptions {
17
17
  redundantIndexesRows?: any[];
18
18
  tableBloatRows?: any[];
19
19
  indexBloatRows?: any[];
20
+ deadTuplesRows?: any[];
20
21
  vacuumStatsRows?: any[];
21
22
  deadlockStatsRows?: any[];
22
23
  pgStatStatementsExtensionRows?: any[];
@@ -58,6 +59,7 @@ export function createMockClient(options: MockClientOptions = {}) {
58
59
  redundantIndexesRows = [],
59
60
  tableBloatRows = [],
60
61
  indexBloatRows = [],
62
+ deadTuplesRows = [],
61
63
  vacuumStatsRows = [],
62
64
  deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
63
65
  pgStatStatementsExtensionRows = [],
@@ -119,6 +121,12 @@ export function createMockClient(options: MockClientOptions = {}) {
119
121
  if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) {
120
122
  return { rows: redundantIndexesRows };
121
123
  }
124
+ // F003: dead tuples metric from metrics.yml.
125
+ // Must be matched BEFORE the F004/F005 vacuum-stats route below: the
126
+ // pg_dead_tuples SQL also contains "pg_stat_user_tables" and "last_vacuum".
127
+ if (sql.includes("autovacuum_disabled") && sql.includes("n_dead_tup")) {
128
+ return { rows: deadTuplesRows };
129
+ }
122
130
  // F004/F005: bloat metrics from metrics.yml
123
131
  if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
124
132
  return { rows: indexBloatRows };
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { formatHttpError } from "../lib/util";
4
+
5
+ describe("formatHttpError", () => {
6
+ test("appends auth remediation hint on 401 with JSON body", () => {
7
+ const msg = formatHttpError(
8
+ "Failed to fetch issues",
9
+ 401,
10
+ '{"hint": "Please check validity of token", "details": "Invalid token"}'
11
+ );
12
+
13
+ expect(msg).toContain("HTTP 401");
14
+ expect(msg).toContain("Run 'postgresai auth'");
15
+ expect(msg).toContain("PGAI_API_KEY");
16
+ });
17
+
18
+ test("appends auth remediation hint on 401 with HTML body (early-return path)", () => {
19
+ const msg = formatHttpError("Failed to fetch issues", 401, "<html><body>Unauthorized</body></html>");
20
+
21
+ expect(msg).toContain("HTTP 401");
22
+ expect(msg).toContain("Run 'postgresai auth'");
23
+ });
24
+
25
+ test("appends auth remediation hint on 401 without body", () => {
26
+ const msg = formatHttpError("Failed to fetch issues", 401);
27
+
28
+ expect(msg).toContain("Run 'postgresai auth'");
29
+ });
30
+
31
+ test("does not append auth hint for non-401 statuses", () => {
32
+ expect(formatHttpError("Failed to fetch issues", 403, '{"message": "denied"}')).not.toContain(
33
+ "Run 'postgresai auth'"
34
+ );
35
+ expect(formatHttpError("Failed to fetch issues", 500)).not.toContain("Run 'postgresai auth'");
36
+ });
37
+
38
+ test("keeps structured JSON error details before the hint", () => {
39
+ const msg = formatHttpError("Failed to fetch issues", 401, '{"message": "Invalid token"}');
40
+
41
+ expect(msg.indexOf("Invalid token")).toBeGreaterThan(-1);
42
+ expect(msg.indexOf("Invalid token")).toBeLessThan(msg.indexOf("Run 'postgresai auth'"));
43
+ });
44
+ });