postgresai 0.14.0-dev.54 → 0.14.0-dev.56
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/README.md +31 -3
- package/bin/postgres-ai.ts +25 -8
- package/bunfig.toml +11 -3
- package/dist/bin/postgres-ai.js +35 -14
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +415 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.permissions.sql +37 -0
- package/dist/sql/sql/03.optional_rds.sql +6 -0
- package/dist/sql/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/sql/05.helpers.sql +415 -0
- package/lib/checkup.ts +3 -0
- package/lib/init.ts +9 -3
- package/lib/metrics-embedded.ts +2 -2
- package/package.json +4 -2
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +46 -0
- package/test/checkup.test.ts +3 -2
- package/test/schema-validation.test.ts +1 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
import * as util from "../lib/util";
|
|
5
|
+
import * as pkce from "../lib/pkce";
|
|
6
|
+
import * as authServer from "../lib/auth-server";
|
|
7
|
+
|
|
8
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
9
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
10
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
11
|
+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
12
|
+
env: { ...process.env, ...env },
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
status: result.exitCode,
|
|
16
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
17
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("URL resolution", () => {
|
|
22
|
+
test("resolveBaseUrls returns correct production defaults", () => {
|
|
23
|
+
const result = util.resolveBaseUrls();
|
|
24
|
+
expect(result.apiBaseUrl).toBe("https://postgres.ai/api/general");
|
|
25
|
+
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveBaseUrls strips trailing slashes", () => {
|
|
29
|
+
const result = util.resolveBaseUrls({
|
|
30
|
+
apiBaseUrl: "https://example.com/api/",
|
|
31
|
+
uiBaseUrl: "https://example.com/",
|
|
32
|
+
});
|
|
33
|
+
expect(result.apiBaseUrl).toBe("https://example.com/api");
|
|
34
|
+
expect(result.uiBaseUrl).toBe("https://example.com");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("resolveBaseUrls respects environment variables", () => {
|
|
38
|
+
const originalApiUrl = process.env.PGAI_API_BASE_URL;
|
|
39
|
+
const originalUiUrl = process.env.PGAI_UI_BASE_URL;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/";
|
|
43
|
+
process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/";
|
|
44
|
+
|
|
45
|
+
const result = util.resolveBaseUrls();
|
|
46
|
+
expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api");
|
|
47
|
+
expect(result.uiBaseUrl).toBe("https://custom-ui.example.com");
|
|
48
|
+
} finally {
|
|
49
|
+
if (originalApiUrl === undefined) {
|
|
50
|
+
delete process.env.PGAI_API_BASE_URL;
|
|
51
|
+
} else {
|
|
52
|
+
process.env.PGAI_API_BASE_URL = originalApiUrl;
|
|
53
|
+
}
|
|
54
|
+
if (originalUiUrl === undefined) {
|
|
55
|
+
delete process.env.PGAI_UI_BASE_URL;
|
|
56
|
+
} else {
|
|
57
|
+
process.env.PGAI_UI_BASE_URL = originalUiUrl;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("resolveBaseUrls prefers CLI options over env vars", () => {
|
|
63
|
+
const originalApiUrl = process.env.PGAI_API_BASE_URL;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
process.env.PGAI_API_BASE_URL = "https://env.example.com/api/";
|
|
67
|
+
|
|
68
|
+
const result = util.resolveBaseUrls({
|
|
69
|
+
apiBaseUrl: "https://cli-option.example.com/api/",
|
|
70
|
+
});
|
|
71
|
+
expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api");
|
|
72
|
+
} finally {
|
|
73
|
+
if (originalApiUrl === undefined) {
|
|
74
|
+
delete process.env.PGAI_API_BASE_URL;
|
|
75
|
+
} else {
|
|
76
|
+
process.env.PGAI_API_BASE_URL = originalApiUrl;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("resolveBaseUrls uses config baseUrl for API", () => {
|
|
82
|
+
const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" });
|
|
83
|
+
expect(result.apiBaseUrl).toBe("https://config.example.com/api");
|
|
84
|
+
// UI should still use default since config doesn't have uiBaseUrl
|
|
85
|
+
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("normalizeBaseUrl throws on invalid URL", () => {
|
|
89
|
+
expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("normalizeBaseUrl accepts valid URLs", () => {
|
|
93
|
+
expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com");
|
|
94
|
+
expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com");
|
|
95
|
+
expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("PKCE module", () => {
|
|
100
|
+
test("generateCodeVerifier returns correct length string", () => {
|
|
101
|
+
const verifier = pkce.generateCodeVerifier();
|
|
102
|
+
expect(typeof verifier).toBe("string");
|
|
103
|
+
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|
104
|
+
expect(verifier.length).toBeLessThanOrEqual(128);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("generateCodeChallenge returns base64url encoded SHA256", () => {
|
|
108
|
+
const verifier = pkce.generateCodeVerifier();
|
|
109
|
+
const challenge = pkce.generateCodeChallenge(verifier);
|
|
110
|
+
expect(typeof challenge).toBe("string");
|
|
111
|
+
expect(challenge.length).toBeGreaterThan(0);
|
|
112
|
+
// Base64url encoding should not contain + or / characters
|
|
113
|
+
expect(challenge).not.toMatch(/[+/]/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("generateState returns random string", () => {
|
|
117
|
+
const state1 = pkce.generateState();
|
|
118
|
+
const state2 = pkce.generateState();
|
|
119
|
+
expect(typeof state1).toBe("string");
|
|
120
|
+
expect(state1.length).toBeGreaterThan(0);
|
|
121
|
+
expect(state1).not.toBe(state2); // Should be random
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("generatePKCEParams returns all required parameters", () => {
|
|
125
|
+
const params = pkce.generatePKCEParams();
|
|
126
|
+
expect(params.codeVerifier).toBeTruthy();
|
|
127
|
+
expect(params.codeChallenge).toBeTruthy();
|
|
128
|
+
expect(params.codeChallengeMethod).toBe("S256");
|
|
129
|
+
expect(params.state).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("Auth callback server", () => {
|
|
134
|
+
test("createCallbackServer returns correct interface", () => {
|
|
135
|
+
const server = authServer.createCallbackServer(0, "test-state", 1000);
|
|
136
|
+
expect(server.server).toBeTruthy();
|
|
137
|
+
expect(server.server.stop).toBeInstanceOf(Function);
|
|
138
|
+
expect(server.promise).toBeInstanceOf(Promise);
|
|
139
|
+
expect(server.ready).toBeInstanceOf(Promise);
|
|
140
|
+
expect(server.getPort).toBeInstanceOf(Function);
|
|
141
|
+
|
|
142
|
+
// Clean up
|
|
143
|
+
server.server.stop();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("createCallbackServer binds to a port", async () => {
|
|
147
|
+
const server = authServer.createCallbackServer(0, "test-state", 5000);
|
|
148
|
+
const port = await server.ready;
|
|
149
|
+
expect(typeof port).toBe("number");
|
|
150
|
+
expect(port).toBeGreaterThan(0);
|
|
151
|
+
|
|
152
|
+
// Clean up
|
|
153
|
+
server.server.stop();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("createCallbackServer responds to callback requests", async () => {
|
|
157
|
+
const testState = "test-state-" + Math.random().toString(36).substring(7);
|
|
158
|
+
const server = authServer.createCallbackServer(0, testState, 5000);
|
|
159
|
+
const port = await server.ready;
|
|
160
|
+
|
|
161
|
+
// Simulate OAuth callback
|
|
162
|
+
const testCode = "test-auth-code";
|
|
163
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`;
|
|
164
|
+
|
|
165
|
+
const fetchPromise = fetch(callbackUrl);
|
|
166
|
+
const result = await server.promise;
|
|
167
|
+
|
|
168
|
+
expect(result.code).toBe(testCode);
|
|
169
|
+
expect(result.state).toBe(testState);
|
|
170
|
+
|
|
171
|
+
// Check response
|
|
172
|
+
const response = await fetchPromise;
|
|
173
|
+
expect(response.status).toBe(200);
|
|
174
|
+
const text = await response.text();
|
|
175
|
+
expect(text).toMatch(/Authentication successful/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("createCallbackServer rejects on state mismatch", async () => {
|
|
179
|
+
const server = authServer.createCallbackServer(0, "expected-state", 5000);
|
|
180
|
+
const port = await server.ready;
|
|
181
|
+
|
|
182
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`;
|
|
183
|
+
|
|
184
|
+
const fetchPromise = fetch(callbackUrl);
|
|
185
|
+
|
|
186
|
+
await expect(server.promise).rejects.toThrow(/State mismatch/);
|
|
187
|
+
|
|
188
|
+
const response = await fetchPromise;
|
|
189
|
+
expect(response.status).toBe(400);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("createCallbackServer handles OAuth errors", async () => {
|
|
193
|
+
const server = authServer.createCallbackServer(0, "test-state", 5000);
|
|
194
|
+
const port = await server.ready;
|
|
195
|
+
|
|
196
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`;
|
|
197
|
+
|
|
198
|
+
const fetchPromise = fetch(callbackUrl);
|
|
199
|
+
|
|
200
|
+
await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/);
|
|
201
|
+
|
|
202
|
+
const response = await fetchPromise;
|
|
203
|
+
expect(response.status).toBe(400);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("createCallbackServer times out", async () => {
|
|
207
|
+
const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout
|
|
208
|
+
await server.ready;
|
|
209
|
+
|
|
210
|
+
await expect(server.promise).rejects.toThrow(/timeout/i);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("CLI auth commands", () => {
|
|
215
|
+
test("cli: auth login --help shows all options", () => {
|
|
216
|
+
const r = runCli(["auth", "login", "--help"]);
|
|
217
|
+
expect(r.status).toBe(0);
|
|
218
|
+
expect(r.stdout).toMatch(/--set-key/);
|
|
219
|
+
expect(r.stdout).toMatch(/--debug/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("cli: auth show-key --help works", () => {
|
|
223
|
+
const r = runCli(["auth", "show-key", "--help"]);
|
|
224
|
+
expect(r.status).toBe(0);
|
|
225
|
+
expect(r.stdout).toMatch(/show.*key/i);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("cli: auth remove-key --help works", () => {
|
|
229
|
+
const r = runCli(["auth", "remove-key", "--help"]);
|
|
230
|
+
expect(r.status).toBe(0);
|
|
231
|
+
expect(r.stdout).toMatch(/remove.*key/i);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("maskSecret utility", () => {
|
|
236
|
+
test("masks short secrets completely", () => {
|
|
237
|
+
expect(util.maskSecret("abc")).toBe("****");
|
|
238
|
+
expect(util.maskSecret("12345678")).toBe("****");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("masks medium secrets with visible ends", () => {
|
|
242
|
+
const masked = util.maskSecret("1234567890123456");
|
|
243
|
+
// maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings
|
|
244
|
+
expect(masked).toMatch(/^1234\*+3456$/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("masks long secrets appropriately", () => {
|
|
248
|
+
const secret = "abcdefghij1234567890klmnopqrstuvwxyz";
|
|
249
|
+
const masked = util.maskSecret(secret);
|
|
250
|
+
expect(masked.startsWith("abcdefghij12")).toBe(true);
|
|
251
|
+
expect(masked.endsWith("wxyz")).toBe(true);
|
|
252
|
+
expect(masked).toMatch(/\*+/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("handles empty string", () => {
|
|
256
|
+
expect(util.maskSecret("")).toBe("");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -253,6 +253,52 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
253
253
|
expect(typeof nodeResult.data).toBe("object");
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
+
test("H001 returns index_definition with CREATE INDEX statement", async () => {
|
|
257
|
+
// Create a table and an index, then mark the index as invalid
|
|
258
|
+
await client.query(`
|
|
259
|
+
CREATE TABLE IF NOT EXISTS test_invalid_idx_table (id serial PRIMARY KEY, value text);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS test_invalid_idx ON test_invalid_idx_table(value);
|
|
261
|
+
`);
|
|
262
|
+
|
|
263
|
+
// Mark the index as invalid (simulating a failed CONCURRENTLY build)
|
|
264
|
+
await client.query(`
|
|
265
|
+
UPDATE pg_index SET indisvalid = false
|
|
266
|
+
WHERE indexrelid = 'test_invalid_idx'::regclass;
|
|
267
|
+
`);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const report = await checkup.generateH001(client, "test-node");
|
|
271
|
+
validateAgainstSchema(report, "H001");
|
|
272
|
+
|
|
273
|
+
const nodeResult = report.results["test-node"];
|
|
274
|
+
const dbName = Object.keys(nodeResult.data)[0];
|
|
275
|
+
expect(dbName).toBeTruthy();
|
|
276
|
+
|
|
277
|
+
const dbData = nodeResult.data[dbName] as any;
|
|
278
|
+
expect(dbData.invalid_indexes).toBeDefined();
|
|
279
|
+
expect(dbData.invalid_indexes.length).toBeGreaterThan(0);
|
|
280
|
+
|
|
281
|
+
// Find our test index
|
|
282
|
+
const testIndex = dbData.invalid_indexes.find(
|
|
283
|
+
(idx: any) => idx.index_name === "test_invalid_idx"
|
|
284
|
+
);
|
|
285
|
+
expect(testIndex).toBeDefined();
|
|
286
|
+
|
|
287
|
+
// Verify index_definition contains the actual CREATE INDEX statement
|
|
288
|
+
expect(testIndex.index_definition).toMatch(/^CREATE INDEX/);
|
|
289
|
+
expect(testIndex.index_definition).toContain("test_invalid_idx");
|
|
290
|
+
expect(testIndex.index_definition).toContain("test_invalid_idx_table");
|
|
291
|
+
} finally {
|
|
292
|
+
// Cleanup: restore the index and drop test objects
|
|
293
|
+
await client.query(`
|
|
294
|
+
UPDATE pg_index SET indisvalid = true
|
|
295
|
+
WHERE indexrelid = 'test_invalid_idx'::regclass;
|
|
296
|
+
DROP INDEX IF EXISTS test_invalid_idx;
|
|
297
|
+
DROP TABLE IF EXISTS test_invalid_idx_table;
|
|
298
|
+
`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
256
302
|
test("H002 (unused indexes) has correct data structure", async () => {
|
|
257
303
|
const report = await checkup.generateH002(client, "test-node");
|
|
258
304
|
validateAgainstSchema(report, "H002");
|
package/test/checkup.test.ts
CHANGED
|
@@ -480,7 +480,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
480
480
|
test("getInvalidIndexes returns invalid indexes", async () => {
|
|
481
481
|
const mockClient = createMockClient({
|
|
482
482
|
invalidIndexesRows: [
|
|
483
|
-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false },
|
|
483
|
+
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
|
|
484
484
|
],
|
|
485
485
|
});
|
|
486
486
|
|
|
@@ -491,6 +491,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
491
491
|
expect(indexes[0].index_name).toBe("users_email_idx");
|
|
492
492
|
expect(indexes[0].index_size_bytes).toBe(1048576);
|
|
493
493
|
expect(indexes[0].index_size_pretty).toBeTruthy();
|
|
494
|
+
expect(indexes[0].index_definition).toMatch(/^CREATE INDEX/);
|
|
494
495
|
expect(indexes[0].relation_name).toBe("users");
|
|
495
496
|
expect(indexes[0].supports_fk).toBe(false);
|
|
496
497
|
});
|
|
@@ -502,7 +503,7 @@ describe("H001 - Invalid indexes", () => {
|
|
|
502
503
|
{ name: "server_version_num", setting: "160003" },
|
|
503
504
|
],
|
|
504
505
|
invalidIndexesRows: [
|
|
505
|
-
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", supports_fk: false },
|
|
506
|
+
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)", supports_fk: false },
|
|
506
507
|
],
|
|
507
508
|
}
|
|
508
509
|
);
|
|
@@ -30,7 +30,7 @@ const indexTestData = {
|
|
|
30
30
|
emptyRows: { invalidIndexesRows: [] },
|
|
31
31
|
dataRows: {
|
|
32
32
|
invalidIndexesRows: [
|
|
33
|
-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", supports_fk: false },
|
|
33
|
+
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
|
|
34
34
|
],
|
|
35
35
|
},
|
|
36
36
|
},
|