@@ -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
4664describe ( "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 ( / r e s u l t s w i l l N O T b e u p l o a d e d / i) ;
1673+ expect ( r . stderr ) . toMatch ( / a u t h l o g i n / ) ;
1674+ expect ( r . stderr ) . toMatch ( / - - n o - u p l o a d / ) ;
1675+ // Falls back to local-only mode instead of failing fast
1676+ expect ( r . stderr ) . not . toMatch ( / A P I k e y i s r e q u i r e d / i) ;
1677+ // Run continued to the database connection stage
1678+ expect ( r . status ) . not . toBe ( 0 ) ;
1679+ expect ( r . stderr ) . toMatch ( / 1 2 7 \. 0 \. 0 \. 1 : 2 | E C O N N R E F U S E D | c o n n e c t / 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 ( / r e s u l t s w i l l N O T b e u p l o a d e d / i) ;
1689+ expect ( r . stderr ) . not . toMatch ( / c o u l d n o t v e r i f y A P I k e y / i) ;
1690+ expect ( r . stderr ) . not . toMatch ( / A P I k e y i s r e q u i r e d / 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 ( / r e j e c t e d b y t h e P o s t g r e s A I A P I \( H T T P 4 0 1 \) / ) ;
1713+ expect ( r . stderr ) . toMatch ( / a u t h l o g i n / ) ;
1714+ expect ( r . stderr ) . toMatch ( / - - n o - u p l o a d / ) ;
1715+ // Stopped BEFORE connecting to the database / running checks
1716+ expect ( r . stderr ) . not . toMatch ( / 1 2 7 \. 0 \. 0 \. 1 : 2 | E C O N N R E F U S E D / ) ;
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 ( / r e j e c t e d b y t h e P o s t g r e s A I A P I / ) ;
1741+ expect ( r . stderr ) . not . toMatch ( / c o u l d n o t v e r i f y A P I k e y / 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 ( / W a r n i n g : c o u l d n o t v e r i f y A P I k e y / i) ;
1757+ expect ( r . stderr ) . not . toMatch ( / r e j e c t e d b y t h e P o s t g r e s A I A P I / ) ;
1758+ // Run continued to the database connection stage
1759+ expect ( r . status ) . not . toBe ( 0 ) ;
1760+ expect ( r . stderr ) . toMatch ( / 1 2 7 \. 0 \. 0 \. 1 : 2 | E C O N N R E F U S E D | c o n n e c t / i) ;
1761+ } ) ;
1762+ } ) ;
1763+
16391764// Tests for checkup-api module
16401765describe ( "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 ( / H T T P 5 0 0 / ) ;
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 ( / p l a i n t e x t H T T P / ) ;
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