Skip to content

Commit 684a206

Browse files
committed
Merge branch 'fix/cli-auth-preflight' into 'main'
fix(cli): validate auth before running checkup checks (fail fast on 401) See merge request postgres-ai/postgresai!315
2 parents 86957a8 + da7bee3 commit 684a206

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", () => {
@@ -1636,6 +1654,113 @@ describe("CLI tests", () => {
16361654
});
16371655
});
16381656

1657+
// CLI-level tests for the checkup auth pre-flight: missing/invalid credentials
1658+
// must surface BEFORE checks run (previously the upload at the end of the run
1659+
// was the first authenticated call, wasting minutes of work on a 401).
1660+
describe("checkup auth pre-flight (CLI)", () => {
1661+
// Dead Postgres port: if the pre-flight correctly stops the run, the CLI
1662+
// never attempts this connection; if the run continues, the connection
1663+
// failure mentions this address.
1664+
const DEAD_DB = "postgresql://test:test@127.0.0.1:2/test";
1665+
1666+
test("no API key: prominent notice, run continues locally", () => {
1667+
const env = {
1668+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-nokey-${process.pid}`,
1669+
PGAI_API_KEY: "",
1670+
};
1671+
const r = runCli(["checkup", DEAD_DB], env);
1672+
expect(r.stderr).toMatch(/results will NOT be uploaded/i);
1673+
expect(r.stderr).toMatch(/auth login/);
1674+
expect(r.stderr).toMatch(/--no-upload/);
1675+
// Falls back to local-only mode instead of failing fast
1676+
expect(r.stderr).not.toMatch(/API key is required/i);
1677+
// Run continued to the database connection stage
1678+
expect(r.status).not.toBe(0);
1679+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1680+
});
1681+
1682+
test("--no-upload: no notice, pre-flight skipped entirely", () => {
1683+
const env = {
1684+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-noupload-${process.pid}`,
1685+
PGAI_API_KEY: "",
1686+
};
1687+
const r = runCli(["checkup", DEAD_DB, "--no-upload"], env);
1688+
expect(r.stderr).not.toMatch(/results will NOT be uploaded/i);
1689+
expect(r.stderr).not.toMatch(/could not verify API key/i);
1690+
expect(r.stderr).not.toMatch(/API key is required/i);
1691+
});
1692+
1693+
test("invalid API key (HTTP 401): fails fast before running checks", async () => {
1694+
const server = Bun.serve({
1695+
hostname: "127.0.0.1",
1696+
port: 0,
1697+
fetch() {
1698+
return new Response(JSON.stringify({ message: "Invalid token" }), {
1699+
status: 401,
1700+
headers: { "Content-Type": "application/json" },
1701+
});
1702+
},
1703+
});
1704+
try {
1705+
const env = {
1706+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-${process.pid}`,
1707+
PGAI_API_KEY: "bad-token",
1708+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1709+
};
1710+
const r = await runCliAsync(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1711+
expect(r.status).not.toBe(0);
1712+
expect(r.stderr).toMatch(/rejected by the PostgresAI API \(HTTP 401\)/);
1713+
expect(r.stderr).toMatch(/auth login/);
1714+
expect(r.stderr).toMatch(/--no-upload/);
1715+
// Stopped BEFORE connecting to the database / running checks
1716+
expect(r.stderr).not.toMatch(/127\.0\.0\.1:2|ECONNREFUSED/);
1717+
} finally {
1718+
server.stop(true);
1719+
}
1720+
});
1721+
1722+
test("--no-upload skips pre-flight even when an invalid key is configured", async () => {
1723+
const server = Bun.serve({
1724+
hostname: "127.0.0.1",
1725+
port: 0,
1726+
fetch() {
1727+
return new Response(JSON.stringify({ message: "Invalid token" }), {
1728+
status: 401,
1729+
headers: { "Content-Type": "application/json" },
1730+
});
1731+
},
1732+
});
1733+
try {
1734+
const env = {
1735+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-badkey-noupload-${process.pid}`,
1736+
PGAI_API_KEY: "bad-token",
1737+
PGAI_API_BASE_URL: `http://127.0.0.1:${server.port}`,
1738+
};
1739+
const r = await runCliAsync(["checkup", DEAD_DB, "--no-upload"], env);
1740+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1741+
expect(r.stderr).not.toMatch(/could not verify API key/i);
1742+
// Fails later at the database connection stage instead
1743+
expect(r.status).not.toBe(0);
1744+
} finally {
1745+
server.stop(true);
1746+
}
1747+
});
1748+
1749+
test("transient pre-flight failure (network error): warns and continues", () => {
1750+
const env = {
1751+
XDG_CONFIG_HOME: `/tmp/postgresai-test-preflight-netfail-${process.pid}`,
1752+
PGAI_API_KEY: "some-token",
1753+
PGAI_API_BASE_URL: "http://127.0.0.1:1", // connect refused — transient, not a 401/403
1754+
};
1755+
const r = runCli(["checkup", DEAD_DB, "--project", "preflight-test"], env);
1756+
expect(r.stderr).toMatch(/Warning: could not verify API key/i);
1757+
expect(r.stderr).not.toMatch(/rejected by the PostgresAI API/);
1758+
// Run continued to the database connection stage
1759+
expect(r.status).not.toBe(0);
1760+
expect(r.stderr).toMatch(/127\.0\.0\.1:2|ECONNREFUSED|connect/i);
1761+
});
1762+
});
1763+
16391764
// Tests for checkup-api module
16401765
describe("checkup-api", () => {
16411766
test("formatRpcErrorForDisplay formats details/hint nicely", () => {
@@ -1882,6 +2007,102 @@ describe("checkup-api", () => {
18822007
}
18832008
});
18842009
});
2010+
2011+
// Auth pre-flight: verifyApiKey checks the key with a cheap authenticated
2012+
// GET before checkup runs expensive checks. Only definitive 401/403 is
2013+
// "invalid"; anything transient must come back "unknown" (warn + continue).
2014+
describe("verifyApiKey (auth pre-flight)", () => {
2015+
function serveStatus(status: number, body = "[]") {
2016+
return Bun.serve({
2017+
hostname: "127.0.0.1",
2018+
port: 0,
2019+
fetch() {
2020+
return new Response(body, { status, headers: { "Content-Type": "application/json" } });
2021+
},
2022+
});
2023+
}
2024+
2025+
test("returns valid on HTTP 200", async () => {
2026+
const server = serveStatus(200);
2027+
try {
2028+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2029+
expect(r.status).toBe("valid");
2030+
} finally {
2031+
server.stop(true);
2032+
}
2033+
});
2034+
2035+
test("returns invalid on HTTP 401", async () => {
2036+
const server = serveStatus(401, JSON.stringify({ message: "Invalid token" }));
2037+
try {
2038+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2039+
expect(r.status).toBe("invalid");
2040+
expect((r as { statusCode: number }).statusCode).toBe(401);
2041+
} finally {
2042+
server.stop(true);
2043+
}
2044+
});
2045+
2046+
test("returns invalid on HTTP 403", async () => {
2047+
const server = serveStatus(403);
2048+
try {
2049+
const r = await api.verifyApiKey({ apiKey: "bad", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2050+
expect(r.status).toBe("invalid");
2051+
expect((r as { statusCode: number }).statusCode).toBe(403);
2052+
} finally {
2053+
server.stop(true);
2054+
}
2055+
});
2056+
2057+
test("returns unknown on HTTP 500 (not a definitive rejection)", async () => {
2058+
const server = serveStatus(500);
2059+
try {
2060+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: `http://127.0.0.1:${server.port}` });
2061+
expect(r.status).toBe("unknown");
2062+
expect((r as { detail: string }).detail).toMatch(/HTTP 500/);
2063+
} finally {
2064+
server.stop(true);
2065+
}
2066+
});
2067+
2068+
test("returns unknown on connection refused", async () => {
2069+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://127.0.0.1:1" }); // port 1 — connect refused
2070+
expect(r.status).toBe("unknown");
2071+
});
2072+
2073+
test("returns unknown on timeout", async () => {
2074+
const server = Bun.serve({
2075+
hostname: "127.0.0.1",
2076+
port: 0,
2077+
async fetch() {
2078+
await new Promise((resolve) => setTimeout(resolve, 5000));
2079+
return new Response("[]");
2080+
},
2081+
});
2082+
try {
2083+
const r = await api.verifyApiKey({
2084+
apiKey: "k",
2085+
apiBaseUrl: `http://127.0.0.1:${server.port}`,
2086+
timeoutMs: 100,
2087+
});
2088+
expect(r.status).toBe("unknown");
2089+
} finally {
2090+
server.stop(true);
2091+
}
2092+
});
2093+
2094+
test("does not send the key over plaintext HTTP to non-loopback hosts", async () => {
2095+
const saved = process.env.CHECKUP_ALLOW_HTTP;
2096+
delete process.env.CHECKUP_ALLOW_HTTP;
2097+
try {
2098+
const r = await api.verifyApiKey({ apiKey: "k", apiBaseUrl: "http://example.com/api" });
2099+
expect(r.status).toBe("unknown");
2100+
expect((r as { detail: string }).detail).toMatch(/plaintext HTTP/);
2101+
} finally {
2102+
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
2103+
}
2104+
});
2105+
});
18852106
});
18862107

18872108
// Tests for checkup-summary module

0 commit comments

Comments
 (0)