Skip to content

Commit 3b4241c

Browse files
committed
test: port integration tests to Bun test runner
Restore prepare-db integration tests that were removed during Bun migration. Tests cover: - URI/conninfo/psql-like connection parsing - Permission fixing idempotency - --verify mode - --reset-password functionality - Error reporting for insufficient permissions Tests are automatically skipped when: - PostgreSQL binaries (initdb, postgres) are not available - Running as root (initdb cannot run as root)
1 parent 9e1bcd8 commit 3b4241c

1 file changed

Lines changed: 396 additions & 0 deletions

File tree

cli/test/init.integration.test.ts

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
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+
await waitFor(
122+
async () => {
123+
if (postgresProc.exitCode === null) throw new Error("still running");
124+
},
125+
{ timeoutMs: 5000, intervalMs: 100 }
126+
);
127+
} catch {
128+
postgresProc.kill("SIGKILL");
129+
}
130+
fs.rmSync(tmpRoot, { recursive: true, force: true });
131+
};
132+
133+
const connectLocal = async (database = "postgres"): Promise<Client> => {
134+
const c = new Client({ host: socketDir, port, user: "postgres", database });
135+
await c.connect();
136+
return c;
137+
};
138+
139+
await waitFor(async () => {
140+
const c = await connectLocal();
141+
await c.end();
142+
});
143+
144+
const postgresPassword = "postgrespw";
145+
{
146+
const c = await connectLocal();
147+
await c.query(`alter user postgres password ${sqlLiteral(postgresPassword)};`);
148+
await c.query("create database testdb");
149+
await c.end();
150+
}
151+
152+
const adminUri = `postgresql://postgres:${postgresPassword}@127.0.0.1:${port}/testdb`;
153+
return { port, socketDir, adminUri, postgresPassword, cleanup };
154+
}
155+
156+
function runCliInit(
157+
args: string[],
158+
env: Record<string, string> = {}
159+
): { status: number | null; stdout: string; stderr: string } {
160+
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
161+
const result = Bun.spawnSync(["bun", cliPath, "prepare-db", ...args], {
162+
env: { ...process.env, ...env },
163+
});
164+
return {
165+
status: result.exitCode,
166+
stdout: new TextDecoder().decode(result.stdout),
167+
stderr: new TextDecoder().decode(result.stderr),
168+
};
169+
}
170+
171+
// Skip all tests if PostgreSQL binaries are not available or running as root
172+
// (initdb cannot be run as root)
173+
const skipTests = !havePostgresBinaries() || isRunningAsRoot();
174+
175+
describe.skipIf(skipTests)("integration: prepare-db", () => {
176+
let pg: TempPostgres;
177+
178+
// Use a shared postgres instance for all tests in this describe block
179+
// Each test will reset state as needed
180+
181+
test("supports URI / conninfo / psql-like connection styles", async () => {
182+
pg = await createTempPostgres();
183+
184+
try {
185+
// 1) positional URI
186+
{
187+
const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
188+
expect(r.status).toBe(0);
189+
}
190+
191+
// 2) conninfo
192+
{
193+
const conninfo = `dbname=testdb host=127.0.0.1 port=${pg.port} user=postgres password=${pg.postgresPassword}`;
194+
const r = runCliInit([conninfo, "--password", "monpw2", "--skip-optional-permissions"]);
195+
expect(r.status).toBe(0);
196+
}
197+
198+
// 3) psql-like options (+ PGPASSWORD)
199+
{
200+
const r = runCliInit(
201+
[
202+
"-h", "127.0.0.1",
203+
"-p", String(pg.port),
204+
"-U", "postgres",
205+
"-d", "testdb",
206+
"--password", "monpw3",
207+
"--skip-optional-permissions",
208+
],
209+
{ PGPASSWORD: pg.postgresPassword }
210+
);
211+
expect(r.status).toBe(0);
212+
}
213+
} finally {
214+
await pg.cleanup();
215+
}
216+
});
217+
218+
test("requires explicit monitoring password in non-interactive mode", async () => {
219+
pg = await createTempPostgres();
220+
221+
try {
222+
// Should fail without --print-password in non-interactive mode
223+
{
224+
const r = runCliInit([pg.adminUri, "--skip-optional-permissions"]);
225+
expect(r.status).not.toBe(0);
226+
expect(r.stderr).toMatch(/not printed in non-interactive mode/i);
227+
expect(r.stderr).toMatch(/--print-password/);
228+
}
229+
230+
// With explicit opt-in, it should succeed
231+
{
232+
const r = runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
233+
expect(r.status).toBe(0);
234+
expect(r.stderr).toMatch(/Generated monitoring password for postgres_ai_mon/i);
235+
expect(r.stderr).toMatch(/PGAI_MON_PASSWORD=/);
236+
}
237+
} finally {
238+
await pg.cleanup();
239+
}
240+
});
241+
242+
test("fixes slightly-off permissions idempotently", async () => {
243+
pg = await createTempPostgres();
244+
245+
try {
246+
// Create monitoring role with wrong password, no grants.
247+
{
248+
const c = new Client({ connectionString: pg.adminUri });
249+
await c.connect();
250+
await c.query(
251+
"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 $$;"
252+
);
253+
await c.end();
254+
}
255+
256+
// Run init (should grant everything).
257+
{
258+
const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
259+
expect(r.status).toBe(0);
260+
}
261+
262+
// Verify privileges.
263+
{
264+
const c = new Client({ connectionString: pg.adminUri });
265+
await c.connect();
266+
const dbOk = await c.query(
267+
"select has_database_privilege('postgres_ai_mon', current_database(), 'CONNECT') as ok"
268+
);
269+
expect(dbOk.rows[0].ok).toBe(true);
270+
const roleOk = await c.query("select pg_has_role('postgres_ai_mon', 'pg_monitor', 'member') as ok");
271+
expect(roleOk.rows[0].ok).toBe(true);
272+
const idxOk = await c.query(
273+
"select has_table_privilege('postgres_ai_mon', 'pg_catalog.pg_index', 'SELECT') as ok"
274+
);
275+
expect(idxOk.rows[0].ok).toBe(true);
276+
const viewOk = await c.query(
277+
"select has_table_privilege('postgres_ai_mon', 'public.pg_statistic', 'SELECT') as ok"
278+
);
279+
expect(viewOk.rows[0].ok).toBe(true);
280+
const sp = await c.query("select rolconfig from pg_roles where rolname='postgres_ai_mon'");
281+
expect(Array.isArray(sp.rows[0].rolconfig)).toBe(true);
282+
expect(sp.rows[0].rolconfig.some((v: string) => String(v).includes("search_path="))).toBe(true);
283+
await c.end();
284+
}
285+
286+
// Run init again (idempotent).
287+
{
288+
const r = runCliInit([pg.adminUri, "--password", "correctpw", "--skip-optional-permissions"]);
289+
expect(r.status).toBe(0);
290+
}
291+
} finally {
292+
await pg.cleanup();
293+
}
294+
});
295+
296+
test("reports nicely when lacking permissions", async () => {
297+
pg = await createTempPostgres();
298+
299+
try {
300+
// Create limited user that can connect but cannot create roles / grant.
301+
const limitedPw = "limitedpw";
302+
{
303+
const c = new Client({ connectionString: pg.adminUri });
304+
await c.connect();
305+
await c.query(`do $$ begin
306+
if not exists (select 1 from pg_roles where rolname='limited') then
307+
begin
308+
create role limited login password ${sqlLiteral(limitedPw)};
309+
exception when duplicate_object then
310+
null;
311+
end;
312+
end if;
313+
end $$;`);
314+
await c.query("grant connect on database testdb to limited");
315+
await c.end();
316+
}
317+
318+
const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
319+
const r = runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
320+
expect(r.status).not.toBe(0);
321+
expect(r.stderr).toMatch(/Error: prepare-db:/);
322+
expect(r.stderr).toMatch(/Failed at step "/);
323+
expect(r.stderr).toMatch(/Fix: connect as a superuser/i);
324+
} finally {
325+
await pg.cleanup();
326+
}
327+
});
328+
329+
test("--verify returns 0 when ok and non-zero when missing", async () => {
330+
pg = await createTempPostgres();
331+
332+
try {
333+
// Prepare: run init
334+
{
335+
const r = runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
336+
expect(r.status).toBe(0);
337+
}
338+
339+
// Verify should pass
340+
{
341+
const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
342+
expect(r.status).toBe(0);
343+
expect(r.stdout).toMatch(/prepare-db verify: OK/i);
344+
}
345+
346+
// Break a required privilege and ensure verify fails
347+
{
348+
const c = new Client({ connectionString: pg.adminUri });
349+
await c.connect();
350+
await c.query("revoke select on pg_catalog.pg_index from public");
351+
await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
352+
await c.end();
353+
}
354+
{
355+
const r = runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
356+
expect(r.status).not.toBe(0);
357+
expect(r.stderr).toMatch(/prepare-db verify failed/i);
358+
expect(r.stderr).toMatch(/pg_catalog\.pg_index/i);
359+
}
360+
} finally {
361+
await pg.cleanup();
362+
}
363+
});
364+
365+
test("--reset-password updates the monitoring role login password", async () => {
366+
pg = await createTempPostgres();
367+
368+
try {
369+
// Initial setup with password pw1
370+
{
371+
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
372+
expect(r.status).toBe(0);
373+
}
374+
375+
// Reset to pw2
376+
{
377+
const r = runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
378+
expect(r.status).toBe(0);
379+
expect(r.stdout).toMatch(/password reset/i);
380+
}
381+
382+
// Connect as monitoring user with new password should work
383+
{
384+
const c = new Client({
385+
connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
386+
});
387+
await c.connect();
388+
const ok = await c.query("select 1 as ok");
389+
expect(ok.rows[0].ok).toBe(1);
390+
await c.end();
391+
}
392+
} finally {
393+
await pg.cleanup();
394+
}
395+
});
396+
});

0 commit comments

Comments
 (0)