@@ -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" , ( ) => {
@@ -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 ( / 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) ;
1489+ expect ( r . stderr ) . toMatch ( / a u t h l o g i n / ) ;
1490+ expect ( r . stderr ) . toMatch ( / - - n o - u p l o a d / ) ;
1491+ // Falls back to local-only mode instead of failing fast
1492+ expect ( r . stderr ) . not . toMatch ( / A P I k e y i s r e q u i r e d / i) ;
1493+ // Run continued to the database connection stage
1494+ expect ( r . status ) . not . toBe ( 0 ) ;
1495+ 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) ;
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 ( / 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) ;
1505+ 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) ;
1506+ expect ( r . stderr ) . not . toMatch ( / A P I k e y i s r e q u i r e d / 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 ( / 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 \) / ) ;
1529+ expect ( r . stderr ) . toMatch ( / a u t h l o g i n / ) ;
1530+ expect ( r . stderr ) . toMatch ( / - - n o - u p l o a d / ) ;
1531+ // Stopped BEFORE connecting to the database / running checks
1532+ expect ( r . stderr ) . not . toMatch ( / 1 2 7 \. 0 \. 0 \. 1 : 2 | E C O N N R E F U S E D / ) ;
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 ( / 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 / ) ;
1557+ 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) ;
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 ( / 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) ;
1573+ 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 / ) ;
1574+ // Run continued to the database connection stage
1575+ expect ( r . status ) . not . toBe ( 0 ) ;
1576+ 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) ;
1577+ } ) ;
1578+ } ) ;
1579+
14551580// Tests for checkup-api module
14561581describe ( "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 ( / H T T P 5 0 0 / ) ;
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 ( / p l a i n t e x t H T T P / ) ;
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