Skip to content

Commit da7bee3

Browse files
committed
fix(cli): validate auth before running checkup checks (fail fast on 401)
1 parent 73746da commit da7bee3

3 files changed

Lines changed: 328 additions & 2 deletions

File tree

cli/bin/postgres-ai.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { createInterface } from "readline";
2424
import * as childProcess from "child_process";
2525
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
2626
import { getCheckupEntry } from "../lib/checkup-dictionary";
27-
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
27+
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry, verifyApiKey } from "../lib/checkup-api";
2828
import { generateCheckSummary } from "../lib/checkup-summary";
2929
import {
3030
type Instance,
@@ -334,7 +334,13 @@ function prepareUploadConfig(
334334
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
335335
return null; // Signal to exit
336336
}
337-
return undefined; // Skip upload silently
337+
// No credentials and upload not explicitly requested: fall back to
338+
// local-only mode, but say so prominently — skipping the upload silently
339+
// hides the fact that results never reach the Console.
340+
console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
341+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
342+
console.error(" To run locally without this notice, pass --no-upload.");
343+
return undefined; // Skip upload, run checks locally
338344
}
339345

340346
const cfg = config.readConfig();
@@ -2076,6 +2082,30 @@ program
20762082
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
20772083
shouldUpload = !!uploadCfg;
20782084

2085+
// Preflight: validate the configured API key with a cheap authenticated
2086+
// call BEFORE connecting / running checks, so an invalid or expired token
2087+
// fails in seconds instead of after minutes of wasted work (previously the
2088+
// upload at the very end of the run was the first authenticated call).
2089+
// Only a definitive 401/403 stops the run; a transient pre-flight failure
2090+
// (network error, timeout, 5xx) warns and continues — the upload may still
2091+
// succeed.
2092+
if (uploadCfg) {
2093+
const verification = await verifyApiKey({
2094+
apiKey: uploadCfg.apiKey,
2095+
apiBaseUrl: uploadCfg.apiBaseUrl,
2096+
});
2097+
if (verification.status === "invalid") {
2098+
console.error(`Error: the configured API key was rejected by the PostgresAI API (HTTP ${verification.statusCode})`);
2099+
console.error("Tip: run 'postgresai auth login' to re-authenticate, or pass a valid --api-key / set PGAI_API_KEY");
2100+
console.error("Tip: pass --no-upload to run checks locally without uploading");
2101+
process.exitCode = 1;
2102+
return;
2103+
}
2104+
if (verification.status === "unknown") {
2105+
console.error(`Warning: could not verify API key before running checks (${verification.detail}); continuing — upload will still be attempted`);
2106+
}
2107+
}
2108+
20792109
// Connect and run checks
20802110
const adminConn = resolveAdminConnection({
20812111
conn,

cli/lib/checkup-api.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,81 @@ async function postRpc<T>(params: {
320320
});
321321
}
322322

323+
/**
324+
* Result of an API key pre-flight verification.
325+
* - "valid": the key was accepted by the API
326+
* - "invalid": the API definitively rejected the key (HTTP 401/403)
327+
* - "unknown": verification could not be completed (network error, timeout,
328+
* unexpected status) — callers should warn and continue, not block the run
329+
*/
330+
export type ApiKeyVerification =
331+
| { status: "valid" }
332+
| { status: "invalid"; statusCode: number }
333+
| { status: "unknown"; detail: string };
334+
335+
// Timeout for the auth pre-flight (shorter than regular RPC timeout: this is
336+
// an optional fast check and must not noticeably delay the run when the API
337+
// is slow or unreachable).
338+
const VERIFY_API_KEY_TIMEOUT_MS = 10_000;
339+
340+
/**
341+
* Verify an API key with a cheap, side-effect-free authenticated call
342+
* (GET /checkup_reports?limit=1 — the same endpoint the `reports` command
343+
* uses) so expensive work can fail fast on bad credentials.
344+
*
345+
* Only a definitive HTTP 401/403 is reported as "invalid". Network errors,
346+
* timeouts, and unexpected statuses are reported as "unknown" so a transient
347+
* pre-flight failure never blocks a run that might otherwise succeed.
348+
*/
349+
export async function verifyApiKey(params: {
350+
apiKey: string;
351+
apiBaseUrl: string;
352+
timeoutMs?: number;
353+
}): Promise<ApiKeyVerification> {
354+
const { apiKey, apiBaseUrl, timeoutMs = VERIFY_API_KEY_TIMEOUT_MS } = params;
355+
const base = normalizeBaseUrl(apiBaseUrl);
356+
const url = new URL(`${base}/checkup_reports`);
357+
url.searchParams.set("limit", "1");
358+
359+
// Same plaintext-HTTP guard as postRpc: never send the API key over plain
360+
// HTTP to a non-loopback host. Report "unknown" rather than aborting the
361+
// run — the upload path raises the definitive, actionable error.
362+
if (url.protocol === "http:") {
363+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
364+
const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
365+
if (!isLoopback && process.env.CHECKUP_ALLOW_HTTP !== "1") {
366+
return {
367+
status: "unknown",
368+
detail: `refusing to send API key over plaintext HTTP to '${url.host}'`,
369+
};
370+
}
371+
}
372+
373+
const controller = new AbortController();
374+
const timer = setTimeout(() => controller.abort(), timeoutMs);
375+
try {
376+
const response = await fetch(url.toString(), {
377+
method: "GET",
378+
headers: { "access-token": apiKey },
379+
signal: controller.signal,
380+
});
381+
// Drain the body so the connection is released cleanly.
382+
await response.text().catch(() => "");
383+
if (response.status === 401 || response.status === 403) {
384+
return { status: "invalid", statusCode: response.status };
385+
}
386+
if (response.ok) {
387+
return { status: "valid" };
388+
}
389+
return { status: "unknown", detail: `HTTP ${response.status}` };
390+
} catch (err) {
391+
const message = err instanceof Error ? err.message : String(err);
392+
return { status: "unknown", detail: message };
393+
} finally {
394+
clearTimeout(timer);
395+
}
396+
}
397+
323398
/**
324399
* Create a new checkup report in the PostgresAI backend.
325400
* This creates the parent report container; individual check results

cli/test/checkup.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ function runCli(args: string[], env: Record<string, string> = {}) {
4242
};
4343
}
4444

45+
// Async variant for tests that need an in-process fake API (Bun.serve):
46+
// spawnSync would block the event loop and the fake server could never respond.
47+
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
48+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
49+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
50+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
51+
env: { ...process.env, ...env },
52+
stdout: "pipe",
53+
stderr: "pipe",
54+
});
55+
const [status, stdout, stderr] = await Promise.all([
56+
proc.exited,
57+
new Response(proc.stdout).text(),
58+
new Response(proc.stderr).text(),
59+
]);
60+
return { status, stdout, stderr };
61+
}
62+
4563
// Unit tests for parseVersionNum
4664
describe("parseVersionNum", () => {
4765
test("parses PG 16.3 version number", () => {
@@ -1452,6 +1470,113 @@ describe("CLI tests", () => {
14521470
});
14531471
});
14541472

1473+
// CLI-level tests for the checkup auth pre-flight: missing/invalid credentials
1474+
// must surface BEFORE checks run (previously the upload at the end of the run
1475+
// was the first authenticated call, wasting minutes of work on a 401).
1476+
describe("checkup auth pre-flight (CLI)", () => {
1477+
// Dead Postgres port: if the pre-flight correctly stops the run, the CLI
1478+
// never attempts this connection; if the run continues, the connection
1479+
// failure mentions this address.
1480+
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1481+
1482+
test("no API key: prominent notice, run continues locally", () => {
1483+
const env = {
1484+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
1485+
PGAI_API_KEY: "",
1486+
};
1487+
const r = runCli(["checkup", DEAD_DB], env);
1488+
expect(r.stderr).toMatch(/results will NOT be uploaded/i);
1489+
expect(r.stderr).toMatch(/auth login/);
1490+
expect(r.stderr).toMatch(/--no-upload/);
1491+
// Falls back to local-only mode instead of failing fast
1492+
expect(r.stderr).not.toMatch(/API key is required/i);
1493+
// Run continued to the database connection stage
1494+
expect(r.status).not.toBe(0);
1495+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1496+
});
1497+
1498+
test("--no-upload: no notice, pre-flight skipped entirely", () => {
1499+
const env = {
1500+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-noupload-${process.pid}`,
1501+
PGAI_API_KEY: "",
1502+
};
1503+
const r = runCli(["checkup", DEAD_DB, "--no-upload"], env);
1504+
expect(r.stderr).not.toMatch(/results will NOT be uploaded/i);
1505+
expect(r.stderr).not.toMatch(/could not verify API key/i);
1506+
expect(r.stderr).not.toMatch(/API key is required/i);
1507+
});
1508+
1509+
test("invalid API key (HTTP 401): fails fast before running checks", async () => {
1510+
const server = Bun.serve({
1511+
hostname: "127.0.0.1",
1512+
port: 0,
1513+
fetch() {
1514+
return new Response(JSON.stringify({ message: "Invalid token" }), {
1515+
status: 401,
1516+
headers: { "Content-Type": "application/json" },
1517+
});
1518+
},
1519+
});
1520+
try {
1521+
const env = {
1522+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-${process.pid}`,
1523+
PGAI_API_KEY: "bad-token",
1524+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1525+
};
1526+
const r = await runCliAsync(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1527+
expect(r.status).not.toBe(0);
1528+
expect(r.stderr).toMatch(/rejected by the PostgresAI API \(HTTP 401\)/);
1529+
expect(r.stderr).toMatch(/auth login/);
1530+
expect(r.stderr).toMatch(/--no-upload/);
1531+
// Stopped BEFORE connecting to the database / running checks
1532+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1533+
} finally {
1534+
server.stop(true);
1535+
}
1536+
});
1537+
1538+
test("--no-upload skips pre-flight even when an invalid key is configured", async () => {
1539+
const server = Bun.serve({
1540+
hostname: "127.0.0.1",
1541+
port: 0,
1542+
fetch() {
1543+
return new Response(JSON.stringify({ message: "Invalid token" }), {
1544+
status: 401,
1545+
headers: { "Content-Type": "application/json" },
1546+
});
1547+
},
1548+
});
1549+
try {
1550+
const env = {
1551+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-noupload-${process.pid}`,
1552+
PGAI_API_KEY: "bad-token",
1553+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1554+
};
1555+
const r = await runCliAsync(["checkup", DEAD_DB, "--no-upload"], env);
1556+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1557+
expect(r.stderr).not.toMatch(/could not verify API key/i);
1558+
// Fails later at the database connection stage instead
1559+
expect(r.status).not.toBe(0);
1560+
} finally {
1561+
server.stop(true);
1562+
}
1563+
});
1564+
1565+
test("transient pre-flight failure (network error): warns and continues", () => {
1566+
const env = {
1567+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-netfail-${process.pid}`,
1568+
PGAI_API_KEY: "some-token",
1569+
PGAI_API_BASE_URL: "http://127.0.0.1:1", // connect refused — transient, not a 401/403
1570+
};
1571+
const r = runCli(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1572+
expect(r.stderr).toMatch(/Warning: could not verify API key/i);
1573+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1574+
// Run continued to the database connection stage
1575+
expect(r.status).not.toBe(0);
1576+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1577+
});
1578+
});
1579+
14551580
// Tests for checkup-api module
14561581
describe("checkup-api", () => {
14571582
test("formatRpcErrorForDisplay formats details/hint nicely", () => {
@@ -1698,6 +1823,102 @@ describe("checkup-api", () => {
16981823
}
16991824
});
17001825
});
1826+
1827+
// Auth pre-flight: verifyApiKey checks the key with a cheap authenticated
1828+
// GET before checkup runs expensive checks. Only definitive 401/403 is
1829+
// "invalid"; anything transient must come back "unknown" (warn + continue).
1830+
describe("verifyApiKey (auth pre-flight)", () => {
1831+
function serveStatus(status: number, body = "[]") {
1832+
return Bun.serve({
1833+
hostname: "127.0.0.1",
1834+
port: 0,
1835+
fetch() {
1836+
return new Response(body, { status, headers: { "Content-Type": "application/json" } });
1837+
},
1838+
});
1839+
}
1840+
1841+
test("returns valid on HTTP 200", async () => {
1842+
const server = serveStatus(200);
1843+
try {
1844+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
1845+
expect(r.status).toBe("valid");
1846+
} finally {
1847+
server.stop(true);
1848+
}
1849+
});
1850+
1851+
test("returns invalid on HTTP 401", async () => {
1852+
const server = serveStatus(401, JSON.stringify({ message: "Invalid token" }));
1853+
try {
1854+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
1855+
expect(r.status).toBe("invalid");
1856+
expect((r as { statusCode: number }).statusCode).toBe(401);
1857+
} finally {
1858+
server.stop(true);
1859+
}
1860+
});
1861+
1862+
test("returns invalid on HTTP 403", async () => {
1863+
const server = serveStatus(403);
1864+
try {
1865+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
1866+
expect(r.status).toBe("invalid");
1867+
expect((r as { statusCode: number }).statusCode).toBe(403);
1868+
} finally {
1869+
server.stop(true);
1870+
}
1871+
});
1872+
1873+
test("returns unknown on HTTP 500 (not a definitive rejection)", async () => {
1874+
const server = serveStatus(500);
1875+
try {
1876+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
1877+
expect(r.status).toBe("unknown");
1878+
expect((r as { detail: string }).detail).toMatch(/HTTP 500/);
1879+
} finally {
1880+
server.stop(true);
1881+
}
1882+
});
1883+
1884+
test("returns unknown on connection refused", async () => {
1885+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://127.0.0.1:1" }); // port 1 — connect refused
1886+
expect(r.status).toBe("unknown");
1887+
});
1888+
1889+
test("returns unknown on timeout", async () => {
1890+
const server = Bun.serve({
1891+
hostname: "127.0.0.1",
1892+
port: 0,
1893+
async fetch() {
1894+
await new Promise((resolve) => setTimeout(resolve, 5000));
1895+
return new Response("[]");
1896+
},
1897+
});
1898+
try {
1899+
const r = await api.verifyApiKey({
1900+
apiKey: "k",
1901+
apiBaseUrl: `http://127.0.0.1:${server.port}`,
1902+
timeoutMs: 100,
1903+
});
1904+
expect(r.status).toBe("unknown");
1905+
} finally {
1906+
server.stop(true);
1907+
}
1908+
});
1909+
1910+
test("does not send the key over plaintext HTTP to non-loopback hosts", async () => {
1911+
const saved = process.env.CHECKUP_ALLOW_HTTP;
1912+
delete process.env.CHECKUP_ALLOW_HTTP;
1913+
try {
1914+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://example.com/api" });
1915+
expect(r.status).toBe("unknown");
1916+
expect((r as { detail: string }).detail).toMatch(/plaintext HTTP/);
1917+
} finally {
1918+
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
1919+
}
1920+
});
1921+
});
17011922
});
17021923

17031924
// Tests for checkup-summary module

0 commit comments

Comments
 (0)