postgresai 0.16.0-rc.1 → 0.16.0-rc.3

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.
@@ -2,126 +2,6 @@
2
2
  -- These functions use SECURITY DEFINER to allow the monitoring user to perform
3
3
  -- operations they don't have direct permissions for.
4
4
 
5
- /*
6
- * explain_generic
7
- *
8
- * Function to get generic explain plans with optional HypoPG index testing.
9
- * Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
10
- *
11
- * Security notes:
12
- * - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13
- * - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14
- * - Input validation uses a simple heuristic to detect multiple statements
15
- * (Note: may reject valid queries containing semicolons in string literals)
16
- *
17
- * Usage examples:
18
- * -- Basic generic plan
19
- * select postgres_ai.explain_generic('select * from users where id = $1');
20
- *
21
- * -- JSON format
22
- * select postgres_ai.explain_generic('select * from users where id = $1', 'json');
23
- *
24
- * -- Test a hypothetical index
25
- * select postgres_ai.explain_generic(
26
- * 'select * from users where email = $1',
27
- * 'text',
28
- * 'create index on users (email)'
29
- * );
30
- */
31
- create or replace function postgres_ai.explain_generic(
32
- in query text,
33
- in format text default 'text',
34
- in hypopg_index text default null,
35
- out result text
36
- )
37
- language plpgsql
38
- security definer
39
- set search_path = pg_catalog, public
40
- as $$
41
- declare
42
- v_line record;
43
- v_lines text[] := '{}';
44
- v_explain_query text;
45
- v_hypo_result record;
46
- v_version int;
47
- v_hypopg_available boolean;
48
- v_clean_query text;
49
- begin
50
- -- Check PostgreSQL version (generic_plan requires 16+)
51
- select current_setting('server_version_num')::int into v_version;
52
-
53
- if v_version < 160000 then
54
- raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
55
- current_setting('server_version');
56
- end if;
57
-
58
- -- Input validation: reject empty queries
59
- if query is null or trim(query) = '' then
60
- raise exception 'query cannot be empty';
61
- end if;
62
-
63
- -- Input validation: detect multiple statements (defense-in-depth)
64
- -- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65
- -- Limitation: Queries with semicolons inside string literals will be rejected
66
- v_clean_query := trim(query);
67
- if v_clean_query like '%;%' then
68
- -- Strip trailing semicolon if present (common user convenience)
69
- v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70
- -- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71
- if v_clean_query like '%;%' then
72
- raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73
- end if;
74
- end if;
75
-
76
- -- Check if HypoPG extension is available
77
- if hypopg_index is not null then
78
- select exists(
79
- select 1 from pg_extension where extname = 'hypopg'
80
- ) into v_hypopg_available;
81
-
82
- if not v_hypopg_available then
83
- raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
84
- end if;
85
-
86
- -- Create hypothetical index
87
- select * into v_hypo_result from hypopg_create_index(hypopg_index);
88
- raise notice 'Created hypothetical index: % (oid: %)',
89
- v_hypo_result.indexname, v_hypo_result.indexrelid;
90
- end if;
91
-
92
- -- Build and execute EXPLAIN query
93
- -- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
94
- begin
95
- if lower(format) = 'json' then
96
- execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97
- into result;
98
- else
99
- for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
100
- v_lines := array_append(v_lines, v_line."QUERY PLAN");
101
- end loop;
102
- result := array_to_string(v_lines, e'\n');
103
- end if;
104
- exception when others then
105
- -- Clean up hypothetical index before re-raising
106
- if hypopg_index is not null then
107
- perform hypopg_reset();
108
- end if;
109
- raise;
110
- end;
111
-
112
- -- Clean up hypothetical index
113
- if hypopg_index is not null then
114
- perform hypopg_reset();
115
- end if;
116
- end;
117
- $$;
118
-
119
- comment on function postgres_ai.explain_generic(text, text, text) is
120
- 'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
121
-
122
- -- Grant execute to the monitoring user
123
- grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
124
-
125
5
  /*
126
6
  * table_describe
127
7
  *
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
435
315
  'Returns comprehensive table information in compact text format for LLM analysis';
436
316
 
437
317
  grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
438
-
439
-
@@ -0,0 +1,301 @@
1
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { addInstanceToFile, buildInstance } from "../lib/instances";
6
+ import {
7
+ parseVcpus,
8
+ resolveAasLabels,
9
+ registerAasCollection,
10
+ } from "../lib/aas-onboard";
11
+
12
+ /** Minimal Response-like stub for mocking fetch. */
13
+ function res(ok: boolean, status: number, jsonBody: unknown, textBody = ""): Response {
14
+ return {
15
+ ok,
16
+ status,
17
+ json: async () => jsonBody,
18
+ text: async () => textBody,
19
+ } as unknown as Response;
20
+ }
21
+
22
+ describe("parseVcpus", () => {
23
+ test("non-positive / junk → 0 (the 'unknown' fallback)", () => {
24
+ expect(parseVcpus(undefined)).toBe(0);
25
+ expect(parseVcpus(null)).toBe(0);
26
+ expect(parseVcpus("")).toBe(0);
27
+ expect(parseVcpus("0")).toBe(0);
28
+ expect(parseVcpus("-4")).toBe(0);
29
+ expect(parseVcpus("abc")).toBe(0);
30
+ });
31
+ test("positive values → integer", () => {
32
+ expect(parseVcpus("16")).toBe(16);
33
+ expect(parseVcpus(8)).toBe(8);
34
+ expect(parseVcpus("12.9")).toBe(12);
35
+ expect(parseVcpus(" 4 ")).toBe(4);
36
+ });
37
+ });
38
+
39
+ describe("resolveAasLabels", () => {
40
+ let dir: string;
41
+ let file: string;
42
+ beforeEach(() => {
43
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "aas-labels-"));
44
+ file = path.join(dir, "instances.yml");
45
+ });
46
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
47
+
48
+ test("single enabled target → its (cluster, node_name) from custom_tags", () => {
49
+ addInstanceToFile(file, buildInstance("appdb", "postgresql://u@h:5432/db"));
50
+ expect(resolveAasLabels(file)).toEqual({ cluster: "default", node: "appdb" });
51
+ });
52
+
53
+ test("no targets → null", () => {
54
+ fs.writeFileSync(file, "# empty\n");
55
+ expect(resolveAasLabels(file)).toBeNull();
56
+ });
57
+
58
+ test("more than one enabled target → null (cannot disambiguate)", () => {
59
+ addInstanceToFile(file, buildInstance("a", "postgresql://u@h:5432/a"));
60
+ addInstanceToFile(file, buildInstance("b", "postgresql://u@h:5432/b"));
61
+ expect(resolveAasLabels(file)).toBeNull();
62
+ });
63
+
64
+ test("missing file → null (no throw)", () => {
65
+ expect(resolveAasLabels(path.join(dir, "nope.yml"))).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe("registerAasCollection", () => {
70
+ let dir: string;
71
+ let instancesPath: string;
72
+ let fetchSpy: ReturnType<typeof spyOn>;
73
+ let calls: Array<{ url: string; method: string; body?: string }>;
74
+
75
+ // Route a fetch by URL+method to canned Grafana/RPC responses. Options let a
76
+ // test exercise the existing-SA branch, datasource ambiguity, a keyless mint,
77
+ // and RPC success/failure.
78
+ function installFetch(opts: {
79
+ rpc?: { ok: boolean; status: number; text?: string };
80
+ existingSa?: boolean; // search finds an existing pgai-aas-collect SA
81
+ prometheusCount?: number; // # of prometheus-typed datasources (default 1)
82
+ mintKey?: string | null; // token .key; null => mint returns no key
83
+ } = {}) {
84
+ const rpc = opts.rpc ?? { ok: true, status: 200 };
85
+ const existingSa = opts.existingSa ?? false;
86
+ const promCount = opts.prometheusCount ?? 1;
87
+ const mintKey = opts.mintKey === undefined ? "glsa_mock_token_xyz" : opts.mintKey;
88
+ calls = [];
89
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
90
+ const url = String(input);
91
+ const method = (init?.method || "GET").toUpperCase();
92
+ calls.push({ url, method, body: init?.body });
93
+ if (url.includes("/api/serviceaccounts/search"))
94
+ return res(true, 200, existingSa ? { serviceAccounts: [{ id: 99, name: "pgai-aas-collect" }] } : { serviceAccounts: [] });
95
+ if (url.match(/\/tokens$/) && method === "POST") return res(true, 200, mintKey === null ? {} : { key: mintKey });
96
+ if (url.endsWith("/api/serviceaccounts") && method === "POST") return res(true, 201, { id: 42, name: "pgai-aas-collect" });
97
+ if (url.includes("/api/datasources")) {
98
+ const dss: Array<Record<string, unknown>> = [];
99
+ for (let i = 0; i < promCount; i++) dss.push({ id: 8 + i, uid: `prom${i}`, type: "prometheus" });
100
+ dss.push({ id: 3, uid: "loki1", type: "loki" });
101
+ return res(true, 200, dss);
102
+ }
103
+ if (url.includes("/rpc/monitoring_instance_aas_register")) return res(rpc.ok, rpc.status, {}, rpc.text || "");
104
+ return res(false, 404, {});
105
+ }) as unknown as typeof fetch);
106
+ }
107
+
108
+ beforeEach(() => {
109
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "aas-reg-"));
110
+ instancesPath = path.join(dir, "instances.yml");
111
+ addInstanceToFile(instancesPath, buildInstance("appdb", "postgresql://u@h:5432/db"));
112
+ });
113
+ afterEach(() => {
114
+ fetchSpy?.mockRestore();
115
+ fs.rmSync(dir, { recursive: true, force: true });
116
+ });
117
+
118
+ test("happy path: mints SA, resolves datasource, POSTs the RPC with the right body", async () => {
119
+ installFetch();
120
+ const r = await registerAasCollection("apikey-1", "inst-123", {
121
+ grafanaPassword: "pw",
122
+ instancesPath,
123
+ vcpus: 16,
124
+ apiBaseUrl: "https://api.test",
125
+ });
126
+ expect(r.ok).toBe(true);
127
+
128
+ const rpc = calls.find((c) => c.url.includes("/rpc/monitoring_instance_aas_register"));
129
+ expect(rpc).toBeDefined();
130
+ expect(rpc!.url).toBe("https://api.test/rpc/monitoring_instance_aas_register");
131
+ const body = JSON.parse(rpc!.body!);
132
+ expect(body).toMatchObject({
133
+ api_token: "apikey-1",
134
+ instance_id: "inst-123",
135
+ sa_token: "glsa_mock_token_xyz",
136
+ cluster_name: "default",
137
+ node_name: "appdb",
138
+ vcpus: 16,
139
+ datasource_id: 8, // the prometheus one, not loki
140
+ });
141
+ // a fresh SA was created (search found none) and a token minted on its id.
142
+ expect(calls.some((c) => c.url.endsWith("/api/serviceaccounts") && c.method === "POST")).toBe(true);
143
+ expect(calls.some((c) => c.url.match(/\/serviceaccounts\/42\/tokens$/) && c.method === "POST")).toBe(true);
144
+ });
145
+
146
+ test("platform error → ok:false, reason carries the status (best-effort, no throw)", async () => {
147
+ installFetch({ rpc: { ok: false, status: 403, text: "forbidden" } });
148
+ const r = await registerAasCollection("apikey-1", "inst-123", {
149
+ grafanaPassword: "pw",
150
+ instancesPath,
151
+ vcpus: 16,
152
+ apiBaseUrl: "https://api.test",
153
+ });
154
+ expect(r.ok).toBe(false);
155
+ expect(r.reason).toContain("403");
156
+ });
157
+
158
+ test("no resolvable target → ok:false and NO outbound calls (labels checked first)", async () => {
159
+ installFetch();
160
+ const empty = path.join(dir, "empty.yml");
161
+ fs.writeFileSync(empty, "# none\n");
162
+ const r = await registerAasCollection("apikey-1", "inst-123", {
163
+ grafanaPassword: "pw",
164
+ instancesPath: empty,
165
+ vcpus: 16,
166
+ apiBaseUrl: "https://api.test",
167
+ });
168
+ expect(r.ok).toBe(false);
169
+ expect(r.reason).toContain("cluster");
170
+ expect(calls.length).toBe(0); // bailed before any HTTP
171
+ });
172
+
173
+ test("missing api key / instance id → ok:false, no calls", async () => {
174
+ installFetch();
175
+ const r = await registerAasCollection("", "inst-123", {
176
+ grafanaPassword: "pw",
177
+ instancesPath,
178
+ vcpus: 0,
179
+ apiBaseUrl: "https://api.test",
180
+ });
181
+ expect(r.ok).toBe(false);
182
+ expect(calls.length).toBe(0);
183
+ });
184
+
185
+ test("existing service account is reused (no create), token minted on its id", async () => {
186
+ installFetch({ existingSa: true });
187
+ const r = await registerAasCollection("apikey-1", "inst-123", {
188
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
189
+ });
190
+ expect(r.ok).toBe(true);
191
+ expect(calls.some((c) => c.url.endsWith("/api/serviceaccounts") && c.method === "POST")).toBe(false);
192
+ expect(calls.some((c) => c.url.match(/\/serviceaccounts\/99\/tokens$/) && c.method === "POST")).toBe(true);
193
+ });
194
+
195
+ test("absent or ambiguous (>1) prometheus datasource → ok:false, no RPC call", async () => {
196
+ for (const n of [0, 2]) {
197
+ fetchSpy?.mockRestore();
198
+ installFetch({ prometheusCount: n });
199
+ const r = await registerAasCollection("apikey-1", "inst-123", {
200
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
201
+ // 0/>1 is a definitive skip; cap the readiness retry so the test stays fast.
202
+ datasourceMaxAttempts: 2, datasourceRetryDelayMs: 0,
203
+ });
204
+ expect(r.ok).toBe(false);
205
+ expect(r.reason).toContain("datasource");
206
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
207
+ }
208
+ });
209
+
210
+ test("polls the datasource until Grafana is ready, then registers", async () => {
211
+ // Grafana isn't ready on the first probes (no prometheus datasource yet),
212
+ // then it provisions — the readiness retry must keep going and then succeed.
213
+ let dsProbes = 0;
214
+ calls = [];
215
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
216
+ const url = String(input);
217
+ const method = (init?.method || "GET").toUpperCase();
218
+ calls.push({ url, method, body: init?.body });
219
+ if (url.includes("/api/serviceaccounts/search")) return res(true, 200, { serviceAccounts: [] });
220
+ if (url.match(/\/tokens$/) && method === "POST") return res(true, 200, { key: "glsa_mock" });
221
+ if (url.endsWith("/api/serviceaccounts") && method === "POST") return res(true, 201, { id: 42 });
222
+ if (url.includes("/api/datasources")) {
223
+ dsProbes++;
224
+ return dsProbes < 3
225
+ ? res(true, 200, [{ id: 3, type: "loki" }]) // not ready yet
226
+ : res(true, 200, [{ id: 8, type: "prometheus" }, { id: 3, type: "loki" }]);
227
+ }
228
+ if (url.includes("/rpc/monitoring_instance_aas_register")) return res(true, 200, {});
229
+ return res(false, 404, {});
230
+ }) as unknown as typeof fetch);
231
+
232
+ const r = await registerAasCollection("apikey-1", "inst-123", {
233
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
234
+ datasourceMaxAttempts: 6, datasourceRetryDelayMs: 0,
235
+ });
236
+ expect(r.ok).toBe(true);
237
+ expect(dsProbes).toBeGreaterThanOrEqual(3); // kept polling past the not-ready probes
238
+ const rpc = calls.find((c) => c.url.includes("/rpc/monitoring_instance_aas_register"));
239
+ expect(rpc).toBeDefined();
240
+ expect(JSON.parse(rpc!.body!).datasource_id).toBe(8);
241
+ });
242
+
243
+ test(">1 prometheus datasource is a definitive skip: one probe, no retry", async () => {
244
+ // The >1 case is permanent (the datasource count only grows), so the
245
+ // readiness loop must bail after a single probe, not burn its whole budget.
246
+ let dsProbes = 0;
247
+ calls = [];
248
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
249
+ const url = String(input);
250
+ const method = (init?.method || "GET").toUpperCase();
251
+ calls.push({ url, method, body: init?.body });
252
+ if (url.includes("/api/datasources")) {
253
+ dsProbes++;
254
+ return res(true, 200, [{ id: 8, type: "prometheus" }, { id: 9, type: "prometheus" }, { id: 3, type: "loki" }]);
255
+ }
256
+ return res(false, 404, {});
257
+ }) as unknown as typeof fetch);
258
+
259
+ const r = await registerAasCollection("apikey-1", "inst-123", {
260
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
261
+ datasourceMaxAttempts: 5, datasourceRetryDelayMs: 0,
262
+ });
263
+ expect(r.ok).toBe(false);
264
+ expect(r.reason).toContain("datasource");
265
+ expect(dsProbes).toBe(1); // bailed after one probe; did NOT retry 5x
266
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
267
+ });
268
+
269
+ test("never-ready datasource: polls exactly maxAttempts times, then ok:false", async () => {
270
+ // Bounds the readiness loop: a never-appearing datasource must probe exactly
271
+ // maxAttempts times (N probes, N-1 sleeps) and then give up — not loop forever.
272
+ let dsProbes = 0;
273
+ calls = [];
274
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
275
+ const url = String(input);
276
+ const method = (init?.method || "GET").toUpperCase();
277
+ calls.push({ url, method, body: init?.body });
278
+ if (url.includes("/api/datasources")) { dsProbes++; return res(true, 200, [{ id: 3, type: "loki" }]); } // never a prometheus
279
+ return res(false, 404, {});
280
+ }) as unknown as typeof fetch);
281
+
282
+ const r = await registerAasCollection("apikey-1", "inst-123", {
283
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
284
+ datasourceMaxAttempts: 3, datasourceRetryDelayMs: 0,
285
+ });
286
+ expect(r.ok).toBe(false);
287
+ expect(r.reason).toContain("datasource");
288
+ expect(dsProbes).toBe(3); // bounded: exactly maxAttempts probes
289
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
290
+ });
291
+
292
+ test("mint returning no key → ok:false, no RPC call", async () => {
293
+ installFetch({ mintKey: null });
294
+ const r = await registerAasCollection("apikey-1", "inst-123", {
295
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
296
+ });
297
+ expect(r.ok).toBe(false);
298
+ expect(r.reason).toContain("service-account token");
299
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
300
+ });
301
+ });
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
407
407
  }
408
408
  }, { timeout: TEST_TIMEOUT });
409
409
 
410
- // 60s timeout for PostgreSQL startup + multiple SQL queries in slow CI
411
- test("explain_generic validates input and prevents SQL injection", async () => {
410
+ // Security regression for #387: explain_generic was a SECURITY DEFINER helper that
411
+ // ran caller-supplied SQL through EXPLAIN. The planner folds IMMUTABLE/STABLE functions
412
+ // at plan time in the definer's (superuser) context, making it an RCE/data-exfil vector.
413
+ // It has been removed; prepare-db must NOT create it.
414
+ test("explain_generic is not created by prepare-db (RCE helper removed, #387)", async () => {
412
415
  pg = await createTempPostgres();
413
416
 
414
417
  try {
415
- // Run init first
416
418
  {
417
419
  const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
418
420
  expect(r.status).toBe(0);
@@ -422,82 +424,10 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
422
424
  await c.connect();
423
425
 
424
426
  try {
425
- // Check PostgreSQL version - generic_plan requires 16+
426
- const versionRes = await c.query("show server_version_num");
427
- const version = parseInt(versionRes.rows[0].server_version_num, 10);
428
-
429
- if (version < 160000) {
430
- // Skip this test on older PostgreSQL versions
431
- console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
432
- return;
433
- }
434
-
435
- // Test 1: Empty query should be rejected
436
- await expect(
437
- c.query("select postgres_ai.explain_generic('')")
438
- ).rejects.toThrow(/query cannot be empty/);
439
-
440
- // Test 2: Null query should be rejected
441
- await expect(
442
- c.query("select postgres_ai.explain_generic(null)")
443
- ).rejects.toThrow(/query cannot be empty/);
444
-
445
- // Test 3: Multiple statements (semicolon in middle) should be rejected
446
- await expect(
447
- c.query("select postgres_ai.explain_generic('select 1; select 2')")
448
- ).rejects.toThrow(/semicolon|multiple statements/i);
449
-
450
- // Test 4: Trailing semicolon should be stripped and work
451
- {
452
- const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
453
- expect(res.rows[0].result).toBeTruthy();
454
- expect(res.rows[0].result).toMatch(/Result/i);
455
- }
456
-
457
- // Test 5: Valid query should work
458
- {
459
- const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
460
- expect(res.rows[0].result).toBeTruthy();
461
- }
462
-
463
- // Test 6: JSON format should work
464
- {
465
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
466
- const plan = JSON.parse(res.rows[0].result);
467
- expect(Array.isArray(plan)).toBe(true);
468
- expect(plan[0].Plan).toBeTruthy();
469
- }
470
-
471
- // Test 7: Whitespace-only query should be rejected
472
- await expect(
473
- c.query("select postgres_ai.explain_generic(' ')")
474
- ).rejects.toThrow(/query cannot be empty/);
475
-
476
- // Test 8: Semicolon in string literal is rejected (documented limitation)
477
- // Note: This is a known limitation - the simple heuristic cannot parse SQL strings
478
- await expect(
479
- c.query("select postgres_ai.explain_generic('select ''hello;world''')")
480
- ).rejects.toThrow(/semicolon/i);
481
-
482
- // Test 9: SQL comments should work (no semicolons)
483
- {
484
- const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
485
- expect(res.rows[0].result).toBeTruthy();
486
- }
487
-
488
- // Test 10: Escaped quotes should work (no semicolons)
489
- {
490
- const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
491
- expect(res.rows[0].result).toBeTruthy();
492
- }
493
-
494
- // Test 11: Case-insensitive format parameter
495
- {
496
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
497
- const plan = JSON.parse(res.rows[0].result);
498
- expect(Array.isArray(plan)).toBe(true);
499
- }
500
-
427
+ const res = await c.query(
428
+ "select count(*)::int as n from pg_proc where proname = 'explain_generic' and pronamespace = (select oid from pg_namespace where nspname = 'postgres_ai')"
429
+ );
430
+ expect(res.rows[0].n).toBe(0);
501
431
  } finally {
502
432
  await c.end();
503
433
  }
@@ -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");