postgresai 0.14.0-dev.8 → 0.14.0-dev.80
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 +161 -61
- package/bin/postgres-ai.ts +2596 -428
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +31218 -1575
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/03.permissions.sql +38 -0
- package/dist/sql/04.optional_rds.sql +6 -0
- package/dist/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/03.permissions.sql +38 -0
- package/dist/sql/sql/04.optional_rds.sql +6 -0
- package/dist/sql/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup-dictionary.ts +113 -0
- package/lib/checkup.ts +1435 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +655 -189
- package/lib/issues.ts +848 -193
- package/lib/mcp-server.ts +391 -91
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +824 -0
- package/lib/util.ts +61 -0
- package/package.json +22 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-checkup-dictionary.ts +106 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.extensions.sql +8 -0
- package/sql/03.permissions.sql +38 -0
- package/sql/04.optional_rds.sql +6 -0
- package/sql/05.optional_self_managed.sql +8 -0
- package/sql/06.helpers.sql +439 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1116 -0
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +508 -0
- package/test/init.test.ts +916 -0
- package/test/issues.cli.test.ts +538 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +1527 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -64
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -399
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -76
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that config files are consistent with what the CLI expects.
|
|
3
|
+
* Catches schema mismatches like pg_statistic in wrong schema.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
|
|
9
|
+
const configDir = resolve(import.meta.dir, "../../config");
|
|
10
|
+
|
|
11
|
+
describe("Config consistency", () => {
|
|
12
|
+
test("target-db/init.sql creates pg_statistic in postgres_ai schema", () => {
|
|
13
|
+
const initSql = readFileSync(resolve(configDir, "target-db/init.sql"), "utf8");
|
|
14
|
+
|
|
15
|
+
// Must create postgres_ai schema
|
|
16
|
+
expect(initSql).toMatch(/create\s+schema\s+if\s+not\s+exists\s+postgres_ai/i);
|
|
17
|
+
|
|
18
|
+
// Must create view in postgres_ai schema, not public
|
|
19
|
+
expect(initSql).toMatch(/create\s+or\s+replace\s+view\s+postgres_ai\.pg_statistic/i);
|
|
20
|
+
expect(initSql).not.toMatch(/create\s+or\s+replace\s+view\s+public\.pg_statistic/i);
|
|
21
|
+
|
|
22
|
+
// Must grant on postgres_ai.pg_statistic
|
|
23
|
+
expect(initSql).toMatch(/grant\s+select\s+on\s+postgres_ai\.pg_statistic/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("pgwatch metrics.yml uses postgres_ai.pg_statistic", () => {
|
|
27
|
+
const metricsYml = readFileSync(
|
|
28
|
+
resolve(configDir, "pgwatch-prometheus/metrics.yml"),
|
|
29
|
+
"utf8"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Should reference postgres_ai.pg_statistic, not public.pg_statistic
|
|
33
|
+
expect(metricsYml).not.toMatch(/public\.pg_statistic/);
|
|
34
|
+
expect(metricsYml).toMatch(/postgres_ai\.pg_statistic/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for prepare-db command
|
|
3
|
+
* Requires PostgreSQL binaries (initdb, postgres) to be available
|
|
4
|
+
* These tests spin up a temporary PostgreSQL instance for realistic testing
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as net from "net";
|
|
11
|
+
import { Client } from "pg";
|
|
12
|
+
|
|
13
|
+
function sqlLiteral(value: string): string {
|
|
14
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findOnPath(cmd: string): string | null {
|
|
18
|
+
const result = Bun.spawnSync(["sh", "-c", `command -v ${cmd}`]);
|
|
19
|
+
if (result.exitCode === 0) {
|
|
20
|
+
return new TextDecoder().decode(result.stdout).trim();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findPgBin(cmd: string): string | null {
|
|
26
|
+
const p = findOnPath(cmd);
|
|
27
|
+
if (p) return p;
|
|
28
|
+
|
|
29
|
+
// Debian/Ubuntu (GitLab CI node:*-bullseye images): binaries usually live here.
|
|
30
|
+
const probe = Bun.spawnSync([
|
|
31
|
+
"sh",
|
|
32
|
+
"-c",
|
|
33
|
+
`ls -1 /usr/lib/postgresql/*/bin/${cmd} 2>/dev/null | head -n 1 || true`,
|
|
34
|
+
]);
|
|
35
|
+
const out = new TextDecoder().decode(probe.stdout).trim();
|
|
36
|
+
if (out) return out;
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function havePostgresBinaries(): boolean {
|
|
42
|
+
return !!(findPgBin("initdb") && findPgBin("postgres"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRunningAsRoot(): boolean {
|
|
46
|
+
return process.getuid?.() === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getFreePort(): Promise<number> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const srv = net.createServer();
|
|
52
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
53
|
+
const addr = srv.address() as net.AddressInfo;
|
|
54
|
+
srv.close((err) => {
|
|
55
|
+
if (err) return reject(err);
|
|
56
|
+
resolve(addr.port);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
srv.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitFor<T>(
|
|
64
|
+
fn: () => Promise<T>,
|
|
65
|
+
{ timeoutMs = 10000, intervalMs = 100 } = {}
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
while (true) {
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (Date.now() - start > timeoutMs) throw e;
|
|
73
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface TempPostgres {
|
|
79
|
+
port: number;
|
|
80
|
+
socketDir: string;
|
|
81
|
+
adminUri: string;
|
|
82
|
+
postgresPassword: string;
|
|
83
|
+
cleanup: () => Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function createTempPostgres(): Promise<TempPostgres> {
|
|
87
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "postgresai-init-"));
|
|
88
|
+
const dataDir = path.join(tmpRoot, "data");
|
|
89
|
+
const socketDir = path.join(tmpRoot, "sock");
|
|
90
|
+
fs.mkdirSync(socketDir, { recursive: true });
|
|
91
|
+
|
|
92
|
+
const initdb = findPgBin("initdb");
|
|
93
|
+
const postgresBin = findPgBin("postgres");
|
|
94
|
+
if (!initdb || !postgresBin) {
|
|
95
|
+
throw new Error("PostgreSQL binaries not found (need initdb and postgres)");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const init = Bun.spawnSync([initdb, "-D", dataDir, "-U", "postgres", "-A", "trust"]);
|
|
99
|
+
if (init.exitCode !== 0) {
|
|
100
|
+
throw new Error(new TextDecoder().decode(init.stderr) || new TextDecoder().decode(init.stdout));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Configure: local socket trust, TCP scram.
|
|
104
|
+
const hbaPath = path.join(dataDir, "pg_hba.conf");
|
|
105
|
+
fs.appendFileSync(
|
|
106
|
+
hbaPath,
|
|
107
|
+
"\n# Added by postgresai init integration tests\nlocal all all trust\nhost all all 127.0.0.1/32 scram-sha-256\nhost all all ::1/128 scram-sha-256\n",
|
|
108
|
+
"utf8"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const port = await getFreePort();
|
|
112
|
+
|
|
113
|
+
const postgresProc = Bun.spawn(
|
|
114
|
+
[postgresBin, "-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)],
|
|
115
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const cleanup = async () => {
|
|
119
|
+
postgresProc.kill("SIGTERM");
|
|
120
|
+
try {
|
|
121
|
+
// 30s timeout to handle slower CI environments gracefully
|
|
122
|
+
await waitFor(
|
|
123
|
+
async () => {
|
|
124
|
+
if (postgresProc.exitCode === null) throw new Error("still running");
|
|
125
|
+
},
|
|
126
|
+
{ timeoutMs: 30000, intervalMs: 100 }
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
postgresProc.kill("SIGKILL");
|
|
130
|
+
}
|
|
131
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const connectLocal = async (database = "postgres"): Promise<Client> => {
|
|
135
|
+
const c = new Client({ host: socketDir, port, user: "postgres", database });
|
|
136
|
+
await c.connect();
|
|
137
|
+
return c;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Wait for Postgres to start (30s timeout for slower CI environments)
|
|
141
|
+
await waitFor(async () => {
|
|
142
|
+
const c = await connectLocal();
|
|
143
|
+
await c.end();
|
|
144
|
+
}, { timeoutMs: 30000, intervalMs: 100 });
|
|
145
|
+
|
|
146
|
+
const postgresPassword = "postgrespw";
|
|
147
|
+
{
|
|
148
|
+
const c = await connectLocal();
|
|
149
|
+
await c.query(`alter user postgres password ${sqlLiteral(postgresPassword)};`);
|
|
150
|
+
await c.query("create database testdb");
|
|
151
|
+
await c.end();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const adminUri = `postgresql://postgres:${postgresPassword}@127.0.0.1:${port}/testdb`;
|
|
155
|
+
return { port, socketDir, adminUri, postgresPassword, cleanup };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function runCliInit(
|
|
159
|
+
args: string[],
|
|
160
|
+
env: Record<string, string> = {}
|
|
161
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
162
|
+
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
163
|
+
const result = Bun.spawnSync(["bun", cliPath, "prepare-db", ...args], {
|
|
164
|
+
env: { ...process.env, ...env },
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
status: result.exitCode,
|
|
168
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
169
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Skip all tests if PostgreSQL binaries are not available or running as root
|
|
174
|
+
// (initdb cannot be run as root)
|
|
175
|
+
const skipTests = !havePostgresBinaries() || isRunningAsRoot();
|
|
176
|
+
|
|
177
|
+
describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
178
|
+
let pg: TempPostgres;
|
|
179
|
+
|
|
180
|
+
// Use a shared postgres instance for all tests in this describe block
|
|
181
|
+
// Each test will reset state as needed
|
|
182
|
+
|
|
183
|
+
test("supports URI / conninfo / psql-like connection styles", async () => {
|
|
184
|
+
pg = await createTempPostgres();
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// 1) positional URI
|
|
188
|
+
{
|
|
189
|
+
const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
190
|
+
expect(r.status).toBe(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 2) conninfo
|
|
194
|
+
{
|
|
195
|
+
const conninfo = `dbname=testdb host=127.0.0.1 port=${pg.port} user=postgres password=${pg.postgresPassword}`;
|
|
196
|
+
const r = runCliInit([conninfo, "--password", "monpw2", "--skip-optional-permissions"]);
|
|
197
|
+
expect(r.status).toBe(0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3) psql-like options (+ PGPASSWORD)
|
|
201
|
+
{
|
|
202
|
+
const r = runCliInit(
|
|
203
|
+
[
|
|
204
|
+
"-h", "127.0.0.1",
|
|
205
|
+
"-p", String(pg.port),
|
|
206
|
+
"-U", "postgres",
|
|
207
|
+
"-d", "testdb",
|
|
208
|
+
"--password", "monpw3",
|
|
209
|
+
"--skip-optional-permissions",
|
|
210
|
+
],
|
|
211
|
+
{ PGPASSWORD: pg.postgresPassword }
|
|
212
|
+
);
|
|
213
|
+
expect(r.status).toBe(0);
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
await pg.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("requires explicit monitoring password in non-interactive mode", async () => {
|
|
221
|
+
pg = await createTempPostgres();
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Should fail without --print-password in non-interactive mode
|
|
225
|
+
{
|
|
226
|
+
const r = runCliInit([pg.adminUri, "--skip-optional-permissions"]);
|
|
227
|
+
expect(r.status).not.toBe(0);
|
|
228
|
+
expect(r.stderr).toMatch(/not printed in non-interactive mode/i);
|
|
229
|
+
expect(r.stderr).toMatch(/--print-password/);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// With explicit opt-in, it should succeed
|
|
233
|
+
{
|
|
234
|
+
const r = runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
|
|
235
|
+
expect(r.status).toBe(0);
|
|
236
|
+
expect(r.stderr).toMatch(/Generated monitoring password for postgres_ai_mon/i);
|
|
237
|
+
expect(r.stderr).toMatch(/PGAI_MON_PASSWORD=/);
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
await pg.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test(
|
|
245
|
+
"fixes slightly-off permissions idempotently",
|
|
246
|
+
async () => {
|
|
247
|
+
pg = await createTempPostgres();
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
// Create monitoring role with wrong password, no grants.
|
|
251
|
+
{
|
|
252
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
253
|
+
await c.connect();
|
|
254
|
+
await c.query(
|
|
255
|
+
"do $$ begin if not exists (select 1 from pg_roles where rolname='postgres_ai_mon') then create role postgres_ai_mon login password 'wrong'; end if; end $$;"
|
|
256
|
+
);
|
|
257
|
+
await c.end();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Run init (should grant everything).
|
|
261
|
+
{
|
|
262
|
+
const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
|
|
263
|
+
expect(r.status).toBe(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Verify privileges.
|
|
267
|
+
{
|
|
268
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
269
|
+
await c.connect();
|
|
270
|
+
const dbOk = await c.query(
|
|
271
|
+
"select has_database_privilege('postgres_ai_mon', current_database(), 'CONNECT') as ok"
|
|
272
|
+
);
|
|
273
|
+
expect(dbOk.rows[0].ok).toBe(true);
|
|
274
|
+
const roleOk = await c.query("select pg_has_role('postgres_ai_mon', 'pg_monitor', 'member') as ok");
|
|
275
|
+
expect(roleOk.rows[0].ok).toBe(true);
|
|
276
|
+
const idxOk = await c.query(
|
|
277
|
+
"select has_table_privilege('postgres_ai_mon', 'pg_catalog.pg_index', 'SELECT') as ok"
|
|
278
|
+
);
|
|
279
|
+
expect(idxOk.rows[0].ok).toBe(true);
|
|
280
|
+
const viewOk = await c.query(
|
|
281
|
+
"select has_table_privilege('postgres_ai_mon', 'postgres_ai.pg_statistic', 'SELECT') as ok"
|
|
282
|
+
);
|
|
283
|
+
expect(viewOk.rows[0].ok).toBe(true);
|
|
284
|
+
const sp = await c.query("select rolconfig from pg_roles where rolname='postgres_ai_mon'");
|
|
285
|
+
expect(Array.isArray(sp.rows[0].rolconfig)).toBe(true);
|
|
286
|
+
expect(sp.rows[0].rolconfig.some((v: string) => String(v).includes("search_path="))).toBe(true);
|
|
287
|
+
await c.end();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Run init again (idempotent).
|
|
291
|
+
{
|
|
292
|
+
const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
|
|
293
|
+
expect(r.status).toBe(0);
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
await pg.cleanup();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{ timeout: 15000 }
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
test(
|
|
303
|
+
"reports nicely when lacking permissions",
|
|
304
|
+
async () => {
|
|
305
|
+
pg = await createTempPostgres();
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// Create limited user that can connect but cannot create roles / grant.
|
|
309
|
+
const limitedPw = "limitedpw";
|
|
310
|
+
{
|
|
311
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
312
|
+
await c.connect();
|
|
313
|
+
await c.query(`do $$ begin
|
|
314
|
+
if not exists (select 1 from pg_roles where rolname='limited') then
|
|
315
|
+
begin
|
|
316
|
+
create role limited login password ${sqlLiteral(limitedPw)};
|
|
317
|
+
exception when duplicate_object then
|
|
318
|
+
null;
|
|
319
|
+
end;
|
|
320
|
+
end if;
|
|
321
|
+
end $$;`);
|
|
322
|
+
await c.query("grant connect on database testdb to limited");
|
|
323
|
+
await c.end();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
|
|
327
|
+
const r = runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
328
|
+
expect(r.status).not.toBe(0);
|
|
329
|
+
expect(r.stderr).toMatch(/Error: prepare-db:/);
|
|
330
|
+
expect(r.stderr).toMatch(/Failed at step "/);
|
|
331
|
+
expect(r.stderr).toMatch(/Fix: connect as a superuser/i);
|
|
332
|
+
} finally {
|
|
333
|
+
await pg.cleanup();
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{ timeout: 15000 }
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
test(
|
|
340
|
+
"--verify returns 0 when ok and non-zero when missing",
|
|
341
|
+
async () => {
|
|
342
|
+
pg = await createTempPostgres();
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
// Prepare: run init
|
|
346
|
+
{
|
|
347
|
+
const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
348
|
+
expect(r.status).toBe(0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Verify should pass
|
|
352
|
+
{
|
|
353
|
+
const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
354
|
+
expect(r.status).toBe(0);
|
|
355
|
+
expect(r.stdout).toMatch(/prepare-db verify: OK/i);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Break a required privilege and ensure verify fails
|
|
359
|
+
{
|
|
360
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
361
|
+
await c.connect();
|
|
362
|
+
await c.query("revoke select on pg_catalog.pg_index from public");
|
|
363
|
+
await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
|
|
364
|
+
await c.end();
|
|
365
|
+
}
|
|
366
|
+
{
|
|
367
|
+
const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
368
|
+
expect(r.status).not.toBe(0);
|
|
369
|
+
expect(r.stderr).toMatch(/prepare-db verify failed/i);
|
|
370
|
+
expect(r.stderr).toMatch(/pg_catalog\.pg_index/i);
|
|
371
|
+
}
|
|
372
|
+
} finally {
|
|
373
|
+
await pg.cleanup();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
test("--reset-password updates the monitoring role login password", async () => {
|
|
379
|
+
pg = await createTempPostgres();
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
// Initial setup with password pw1
|
|
383
|
+
{
|
|
384
|
+
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
385
|
+
expect(r.status).toBe(0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Reset to pw2
|
|
389
|
+
{
|
|
390
|
+
const r = runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
|
|
391
|
+
expect(r.status).toBe(0);
|
|
392
|
+
expect(r.stdout).toMatch(/password reset/i);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Connect as monitoring user with new password should work
|
|
396
|
+
{
|
|
397
|
+
const c = new Client({
|
|
398
|
+
connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
|
|
399
|
+
});
|
|
400
|
+
await c.connect();
|
|
401
|
+
const ok = await c.query("select 1 as ok");
|
|
402
|
+
expect(ok.rows[0].ok).toBe(1);
|
|
403
|
+
await c.end();
|
|
404
|
+
}
|
|
405
|
+
} finally {
|
|
406
|
+
await pg.cleanup();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// 60s timeout for PostgreSQL startup + multiple SQL queries in slow CI
|
|
411
|
+
test("explain_generic validates input and prevents SQL injection", async () => {
|
|
412
|
+
pg = await createTempPostgres();
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
// Run init first
|
|
416
|
+
{
|
|
417
|
+
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
418
|
+
expect(r.status).toBe(0);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
422
|
+
await c.connect();
|
|
423
|
+
|
|
424
|
+
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
|
+
|
|
501
|
+
} finally {
|
|
502
|
+
await c.end();
|
|
503
|
+
}
|
|
504
|
+
} finally {
|
|
505
|
+
await pg.cleanup();
|
|
506
|
+
}
|
|
507
|
+
}, { timeout: 60000 });
|
|
508
|
+
});
|