postgresai 0.16.0-dev.2 → 0.16.0-dev.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.
- package/bin/postgres-ai.ts +657 -0
- package/dist/bin/postgres-ai.js +2472 -249
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/dblab.ts +457 -0
- package/lib/init.ts +0 -8
- package/lib/joe.ts +730 -0
- package/lib/mcp-server.ts +597 -0
- package/lib/supabase.ts +0 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/dblab.cli.test.ts +339 -0
- package/test/dblab.test.ts +408 -0
- package/test/init.integration.test.ts +9 -79
- package/test/joe.cli.test.ts +298 -0
- package/test/joe.test.ts +726 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
resolveDblabInstanceId,
|
|
4
|
+
isNumericProjectRef,
|
|
5
|
+
createClone,
|
|
6
|
+
listClones,
|
|
7
|
+
getClone,
|
|
8
|
+
resetClone,
|
|
9
|
+
destroyClone,
|
|
10
|
+
listBranches,
|
|
11
|
+
createBranch,
|
|
12
|
+
deleteBranch,
|
|
13
|
+
branchLog,
|
|
14
|
+
listSnapshots,
|
|
15
|
+
createSnapshot,
|
|
16
|
+
destroySnapshot,
|
|
17
|
+
} from "../lib/dblab";
|
|
18
|
+
import { handleToolCall } from "../lib/mcp-server";
|
|
19
|
+
|
|
20
|
+
const originalFetch = globalThis.fetch;
|
|
21
|
+
const API = "https://api.example.com";
|
|
22
|
+
|
|
23
|
+
interface Captured {
|
|
24
|
+
url: string;
|
|
25
|
+
options: RequestInit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stub fetch, returning `body` for every call, capturing the last request. */
|
|
29
|
+
function stubFetch(body: unknown, status = 200): { get: () => Captured | null } {
|
|
30
|
+
let captured: Captured | null = null;
|
|
31
|
+
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
|
32
|
+
captured = { url, options };
|
|
33
|
+
return Promise.resolve(
|
|
34
|
+
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
}) as unknown as typeof fetch;
|
|
40
|
+
return { get: () => captured };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Route fetch by URL: `/dblab_instances` → instances, `/rpc/dblab_api_call` → reply. */
|
|
44
|
+
function routeFetch(instances: unknown[], reply: unknown): { calls: Captured[] } {
|
|
45
|
+
const calls: Captured[] = [];
|
|
46
|
+
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
|
47
|
+
calls.push({ url, options });
|
|
48
|
+
if (String(url).includes("/dblab_instances")) {
|
|
49
|
+
return Promise.resolve(new Response(JSON.stringify(instances), { status: 200 }));
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve(new Response(JSON.stringify(reply), { status: 200 }));
|
|
52
|
+
}) as unknown as typeof fetch;
|
|
53
|
+
return { calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bodyOf(c: Captured | null): any {
|
|
57
|
+
return JSON.parse((c!.options.body as string) ?? "{}");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
globalThis.fetch = originalFetch;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// resolveDblabInstanceId
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("resolveDblabInstanceId", () => {
|
|
69
|
+
const INSTANCES = [
|
|
70
|
+
{ id: 7, project_id: 12, project_name: "Main DB", project_alias: "main-db", project_label_or_name: "Main DB" },
|
|
71
|
+
{ id: 9, project_id: 34, project_name: "Analytics", project_alias: "analytics", project_label_or_name: "Analytics" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
test("throws when apiKey is missing", async () => {
|
|
75
|
+
await expect(
|
|
76
|
+
resolveDblabInstanceId({ apiKey: "", apiBaseUrl: API, project: "12" })
|
|
77
|
+
).rejects.toThrow("API key is required");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("throws when project is missing", async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: " " })
|
|
83
|
+
).rejects.toThrow("project is required");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("isNumericProjectRef distinguishes id from alias", () => {
|
|
87
|
+
expect(isNumericProjectRef("12")).toBe(true);
|
|
88
|
+
expect(isNumericProjectRef(" 34 ")).toBe(true);
|
|
89
|
+
expect(isNumericProjectRef("main-db")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolves a numeric project id to its instance id (string)", async () => {
|
|
93
|
+
const cap = stubFetch(INSTANCES);
|
|
94
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "34" });
|
|
95
|
+
expect(id).toBe("9");
|
|
96
|
+
const c = cap.get()!;
|
|
97
|
+
expect(c.url).toContain(`${API}/dblab_instances`);
|
|
98
|
+
expect(c.url).toContain("select=");
|
|
99
|
+
expect(c.options.method).toBe("GET");
|
|
100
|
+
expect((c.options.headers as Record<string, string>)["access-token"]).toBe("k");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resolves an alias (case-insensitive) to its instance id", async () => {
|
|
104
|
+
stubFetch(INSTANCES);
|
|
105
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "Main-DB" });
|
|
106
|
+
expect(id).toBe("7");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolves a project name to its instance id", async () => {
|
|
110
|
+
stubFetch(INSTANCES);
|
|
111
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "analytics" });
|
|
112
|
+
expect(id).toBe("9");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("adds an org_id filter when orgId is provided", async () => {
|
|
116
|
+
const cap = stubFetch(INSTANCES);
|
|
117
|
+
await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "12", orgId: 5 });
|
|
118
|
+
expect(decodeURIComponent(cap.get()!.url)).toContain("org_id=eq.5");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("throws a helpful error when no instance matches", async () => {
|
|
122
|
+
stubFetch(INSTANCES);
|
|
123
|
+
await expect(
|
|
124
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "nope" })
|
|
125
|
+
).rejects.toThrow(/No DBLab instance found for project 'nope'/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("surfaces an HTTP error from the listing", async () => {
|
|
129
|
+
stubFetch('{"message":"boom"}', 500);
|
|
130
|
+
await expect(
|
|
131
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "12" })
|
|
132
|
+
).rejects.toThrow(/Failed to resolve project's DBLab instance/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// dblab_api_call proxy — action/method/data per verb
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe("clone verbs → dblab_api_call", () => {
|
|
141
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
142
|
+
|
|
143
|
+
test("createClone posts /clone with a minimal data body", async () => {
|
|
144
|
+
const cap = stubFetch({ id: "c-1" });
|
|
145
|
+
await createClone({ ...common });
|
|
146
|
+
const c = cap.get()!;
|
|
147
|
+
expect(c.url).toBe(`${API}/rpc/dblab_api_call`);
|
|
148
|
+
expect(c.options.method).toBe("POST");
|
|
149
|
+
const b = bodyOf(c);
|
|
150
|
+
expect(b.instance_id).toBe("7");
|
|
151
|
+
expect(b.action).toBe("/clone");
|
|
152
|
+
expect(b.method).toBe("post");
|
|
153
|
+
expect(b.data).toEqual({ protected: false });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("createClone maps branch / snapshot / db / protected into data", async () => {
|
|
157
|
+
const cap = stubFetch({ id: "c-1" });
|
|
158
|
+
await createClone({
|
|
159
|
+
...common,
|
|
160
|
+
cloneId: "c-1",
|
|
161
|
+
branch: "feature-idx",
|
|
162
|
+
snapshotId: "s-9",
|
|
163
|
+
dbUser: "u",
|
|
164
|
+
dbPassword: "p",
|
|
165
|
+
isProtected: true,
|
|
166
|
+
});
|
|
167
|
+
expect(bodyOf(cap.get()).data).toEqual({
|
|
168
|
+
protected: true,
|
|
169
|
+
id: "c-1",
|
|
170
|
+
branch: "feature-idx",
|
|
171
|
+
snapshot: { id: "s-9" },
|
|
172
|
+
db: { username: "u", password: "p" },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("listClones gets /clones with no data", async () => {
|
|
177
|
+
const cap = stubFetch([]);
|
|
178
|
+
await listClones({ ...common });
|
|
179
|
+
const b = bodyOf(cap.get());
|
|
180
|
+
expect(b.action).toBe("/clones");
|
|
181
|
+
expect(b.method).toBe("get");
|
|
182
|
+
expect(b.data).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("getClone gets /clone/<id> (url-encoded)", async () => {
|
|
186
|
+
const cap = stubFetch({ id: "c/1" });
|
|
187
|
+
await getClone({ ...common, cloneId: "c/1" });
|
|
188
|
+
const b = bodyOf(cap.get());
|
|
189
|
+
expect(b.action).toBe("/clone/c%2F1");
|
|
190
|
+
expect(b.method).toBe("get");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("resetClone posts /clone/<id>/reset with latest:true when no snapshot", async () => {
|
|
194
|
+
const cap = stubFetch(true);
|
|
195
|
+
await resetClone({ ...common, cloneId: "c-1" });
|
|
196
|
+
const b = bodyOf(cap.get());
|
|
197
|
+
expect(b.action).toBe("/clone/c-1/reset");
|
|
198
|
+
expect(b.method).toBe("post");
|
|
199
|
+
expect(b.data).toEqual({ latest: true });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("resetClone with a snapshot sends snapshotID + latest:false", async () => {
|
|
203
|
+
const cap = stubFetch(true);
|
|
204
|
+
await resetClone({ ...common, cloneId: "c-1", snapshotId: "s-3" });
|
|
205
|
+
expect(bodyOf(cap.get()).data).toEqual({ latest: false, snapshotID: "s-3" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("destroyClone deletes /clone/<id>", async () => {
|
|
209
|
+
const cap = stubFetch("");
|
|
210
|
+
await destroyClone({ ...common, cloneId: "c-1" });
|
|
211
|
+
const b = bodyOf(cap.get());
|
|
212
|
+
expect(b.action).toBe("/clone/c-1");
|
|
213
|
+
expect(b.method).toBe("delete");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("destroyClone requires cloneId", async () => {
|
|
217
|
+
await expect(destroyClone({ ...common, cloneId: "" })).rejects.toThrow("cloneId is required");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("branch verbs → dblab_api_call", () => {
|
|
222
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
223
|
+
|
|
224
|
+
test("listBranches gets /branches", async () => {
|
|
225
|
+
const cap = stubFetch([]);
|
|
226
|
+
await listBranches({ ...common });
|
|
227
|
+
const b = bodyOf(cap.get());
|
|
228
|
+
expect(b.action).toBe("/branches");
|
|
229
|
+
expect(b.method).toBe("get");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("createBranch posts /branch with branchName and optional base/snapshot", async () => {
|
|
233
|
+
const cap = stubFetch({ name: "feature-idx" });
|
|
234
|
+
await createBranch({ ...common, branchName: "feature-idx", baseBranch: "main", snapshotId: "s-1" });
|
|
235
|
+
const b = bodyOf(cap.get());
|
|
236
|
+
expect(b.action).toBe("/branch");
|
|
237
|
+
expect(b.method).toBe("post");
|
|
238
|
+
expect(b.data).toEqual({ branchName: "feature-idx", baseBranch: "main", snapshotID: "s-1" });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("createBranch omits base/snapshot when not given", async () => {
|
|
242
|
+
const cap = stubFetch({ name: "b" });
|
|
243
|
+
await createBranch({ ...common, branchName: "b" });
|
|
244
|
+
expect(bodyOf(cap.get()).data).toEqual({ branchName: "b" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("deleteBranch deletes /branch/<name> (encoded)", async () => {
|
|
248
|
+
const cap = stubFetch(true);
|
|
249
|
+
await deleteBranch({ ...common, branchName: "feature/x" });
|
|
250
|
+
const b = bodyOf(cap.get());
|
|
251
|
+
expect(b.action).toBe("/branch/feature%2Fx");
|
|
252
|
+
expect(b.method).toBe("delete");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("branchLog gets /branch/<name>/log", async () => {
|
|
256
|
+
const cap = stubFetch([]);
|
|
257
|
+
await branchLog({ ...common, branchName: "feature-idx" });
|
|
258
|
+
const b = bodyOf(cap.get());
|
|
259
|
+
expect(b.action).toBe("/branch/feature-idx/log");
|
|
260
|
+
expect(b.method).toBe("get");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("snapshot verbs → dblab_api_call", () => {
|
|
265
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
266
|
+
|
|
267
|
+
test("listSnapshots gets /snapshots (no query when unfiltered)", async () => {
|
|
268
|
+
const cap = stubFetch([]);
|
|
269
|
+
await listSnapshots({ ...common });
|
|
270
|
+
const b = bodyOf(cap.get());
|
|
271
|
+
expect(b.action).toBe("/snapshots");
|
|
272
|
+
expect(b.method).toBe("get");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("listSnapshots appends branch and dataset query params", async () => {
|
|
276
|
+
const cap = stubFetch([]);
|
|
277
|
+
await listSnapshots({ ...common, branchName: "main", dataset: "ds1" });
|
|
278
|
+
expect(bodyOf(cap.get()).action).toBe("/snapshots?branch=main&dataset=ds1");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("createSnapshot posts /branch/snapshot with cloneID + message", async () => {
|
|
282
|
+
const cap = stubFetch({ snapshotID: "s-1" });
|
|
283
|
+
await createSnapshot({ ...common, cloneId: "c-1", message: "before idx" });
|
|
284
|
+
const b = bodyOf(cap.get());
|
|
285
|
+
expect(b.action).toBe("/branch/snapshot");
|
|
286
|
+
expect(b.method).toBe("post");
|
|
287
|
+
expect(b.data).toEqual({ cloneID: "c-1", message: "before idx" });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("destroySnapshot deletes /snapshot/<id>?force=<bool>", async () => {
|
|
291
|
+
const cap = stubFetch(true);
|
|
292
|
+
await destroySnapshot({ ...common, snapshotId: "s-1", force: true });
|
|
293
|
+
const b = bodyOf(cap.get());
|
|
294
|
+
expect(b.action).toBe("/snapshot/s-1?force=true");
|
|
295
|
+
expect(b.method).toBe("delete");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("destroySnapshot defaults force=false", async () => {
|
|
299
|
+
const cap = stubFetch(true);
|
|
300
|
+
await destroySnapshot({ ...common, snapshotId: "s-1" });
|
|
301
|
+
expect(bodyOf(cap.get()).action).toBe("/snapshot/s-1?force=false");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// error / scope-gating paths
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
describe("proxy error paths", () => {
|
|
310
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
311
|
+
|
|
312
|
+
test("a PT403 (scope) surfaces as a formatted 403 error", async () => {
|
|
313
|
+
stubFetch('{"message":"insufficient scope: joe:admin required"}', 403);
|
|
314
|
+
await expect(destroyClone({ ...common, cloneId: "c-1" })).rejects.toThrow(/Failed to destroy clone/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("a 500 surfaces the operation label", async () => {
|
|
318
|
+
stubFetch('{"message":"boom"}', 500);
|
|
319
|
+
await expect(listClones({ ...common })).rejects.toThrow(/Failed to list clones/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("proxy requires an instanceId", async () => {
|
|
323
|
+
await expect(listClones({ ...common, instanceId: "" })).rejects.toThrow("instanceId is required");
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// resolve → proxy end-to-end (project → instance → call)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
describe("project → instance → dblab_api_call", () => {
|
|
332
|
+
test("a verb driven off a resolved instance sends both requests", async () => {
|
|
333
|
+
const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
|
|
334
|
+
const { calls } = routeFetch(instances, { id: "c-1", status: "ready" });
|
|
335
|
+
const instanceId = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "main-db" });
|
|
336
|
+
await createClone({ apiKey: "k", apiBaseUrl: API, instanceId });
|
|
337
|
+
expect(calls.length).toBe(2);
|
|
338
|
+
expect(calls[0].url).toContain("/dblab_instances");
|
|
339
|
+
expect(calls[1].url).toBe(`${API}/rpc/dblab_api_call`);
|
|
340
|
+
expect(bodyOf(calls[1]).instance_id).toBe("7");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// MCP tool parity — each tool resolves the instance then proxies dblab_api_call
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("MCP DBLab tools (handleToolCall)", () => {
|
|
349
|
+
const rootOpts = { apiKey: "k", apiBaseUrl: API };
|
|
350
|
+
|
|
351
|
+
test("clone_create resolves project then proxies /clone POST", async () => {
|
|
352
|
+
const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
|
|
353
|
+
const { calls } = routeFetch(instances, { ok: true });
|
|
354
|
+
const res = await handleToolCall(
|
|
355
|
+
{ params: { name: "clone_create", arguments: { project: "main-db" } } },
|
|
356
|
+
rootOpts
|
|
357
|
+
);
|
|
358
|
+
expect(res.isError).toBeFalsy();
|
|
359
|
+
expect(calls[0].url).toContain("/dblab_instances");
|
|
360
|
+
const b = bodyOf(calls[1]);
|
|
361
|
+
expect(b.instance_id).toBe("7");
|
|
362
|
+
expect(b.action).toBe("/clone");
|
|
363
|
+
expect(b.method).toBe("post");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("clone_destroy proxies /clone/<id> DELETE", async () => {
|
|
367
|
+
const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
|
|
368
|
+
const { calls } = routeFetch(instances, { ok: true });
|
|
369
|
+
const res = await handleToolCall(
|
|
370
|
+
{ params: { name: "clone_destroy", arguments: { project: "main-db", clone_id: "c-1" } } },
|
|
371
|
+
rootOpts
|
|
372
|
+
);
|
|
373
|
+
expect(res.isError).toBeFalsy();
|
|
374
|
+
const b = bodyOf(calls[1]);
|
|
375
|
+
expect(b.action).toBe("/clone/c-1");
|
|
376
|
+
expect(b.method).toBe("delete");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("snapshot_create proxies /branch/snapshot POST with cloneID", async () => {
|
|
380
|
+
const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
|
|
381
|
+
const { calls } = routeFetch(instances, { ok: true });
|
|
382
|
+
await handleToolCall(
|
|
383
|
+
{ params: { name: "snapshot_create", arguments: { project: "main-db", clone_id: "c-1", message: "m" } } },
|
|
384
|
+
rootOpts
|
|
385
|
+
);
|
|
386
|
+
const b = bodyOf(calls[1]);
|
|
387
|
+
expect(b.action).toBe("/branch/snapshot");
|
|
388
|
+
expect(b.data).toEqual({ cloneID: "c-1", message: "m" });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("a DBLab tool without project returns an isError response", async () => {
|
|
392
|
+
const res = await handleToolCall(
|
|
393
|
+
{ params: { name: "clone_list", arguments: {} } },
|
|
394
|
+
rootOpts
|
|
395
|
+
);
|
|
396
|
+
expect(res.isError).toBe(true);
|
|
397
|
+
expect(res.content[0].text).toContain("project is required");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("clone_status requires clone_id", async () => {
|
|
401
|
+
const res = await handleToolCall(
|
|
402
|
+
{ params: { name: "clone_status", arguments: { project: "main-db" } } },
|
|
403
|
+
rootOpts
|
|
404
|
+
);
|
|
405
|
+
expect(res.isError).toBe(true);
|
|
406
|
+
expect(res.content[0].text).toContain("clone_id is required");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
|
407
407
|
}
|
|
408
408
|
}, { timeout: TEST_TIMEOUT });
|
|
409
409
|
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
}
|