@@ -25,55 +25,50 @@ if (skip) {
2525const PORT = 19131 ; // Avoid collision with production 3131
2626const BASE = `http://localhost:${ PORT } ` ;
2727
28- describeE2E ( 'serve-http OAuth 2.1 E2E (v0.26.1)' , ( ) => {
28+ describeE2E ( 'serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2 )' , ( ) => {
2929 let serverProcess : ReturnType < typeof import ( 'child_process' ) . spawn > | null = null ;
30- let clientId : string ;
31- let clientSecret : string ;
32- let adminToken : string ;
30+ let clientId : string | undefined ;
31+ let clientSecret : string | undefined ;
32+ // DCR-registered clients accumulate here so afterAll can revoke them too
33+ // (one per test that posts to /register).
34+ const dcrClientIds : string [ ] = [ ] ;
3335
3436 beforeAll ( async ( ) => {
3537 const { execSync, spawn } = await import ( 'child_process' ) ;
3638
37- // Clean up orphans from any previous crashed test runs
38- try {
39- const postgres = ( await import ( 'postgres' ) ) . default ;
40- const cleanSql = postgres ( process . env . GBRAIN_DATABASE_URL || process . env . DATABASE_URL || '' , { prepare : false } ) ;
41- await cleanSql `DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')` ;
42- await cleanSql `DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'` ;
43- await cleanSql `DELETE FROM access_tokens WHERE name LIKE 'e2e-%'` ;
44- await cleanSql . end ( ) ;
45- } catch { }
46-
47- // Register a test OAuth client via CLI
39+ // Register a test OAuth client via CLI.
40+ // env: { ...process.env } is required: bun's execSync does NOT inherit
41+ // env mutations done via `process.env.X = ...` (only OS-level env from
42+ // before bun started). helpers.ts loads .env.testing and sets DATABASE_URL
43+ // via process.env mutation, which is invisible to subprocesses unless we
44+ // explicitly re-pass process.env. Same pattern applies to every execSync
45+ // in this file.
4846 const regOutput = execSync (
4947 'bun run src/cli.ts auth register-client e2e-oauth-test --grant-types client_credentials --scopes "read write"' ,
50- { cwd : process . cwd ( ) , encoding : 'utf8' }
48+ { cwd : process . cwd ( ) , encoding : 'utf8' , env : { ... process . env } }
5149 ) ;
5250 const idMatch = regOutput . match ( / C l i e n t I D : \s + ( g b r a i n _ c l _ \S + ) / ) ;
5351 const secretMatch = regOutput . match ( / C l i e n t S e c r e t : \s + ( g b r a i n _ c s _ \S + ) / ) ;
5452 if ( ! idMatch || ! secretMatch ) throw new Error ( 'Failed to register test client:\n' + regOutput ) ;
5553 clientId = idMatch [ 1 ] ;
5654 clientSecret = secretMatch [ 1 ] ;
5755
58- // Start the HTTP server
56+ // Start the HTTP server. v0.26.2 adds --enable-dcr so the /register
57+ // endpoint is reachable for the DCR response-shape test.
5958 serverProcess = spawn ( 'bun' , [
6059 'run' , 'src/cli.ts' , 'serve' , '--http' ,
6160 '--port' , String ( PORT ) ,
6261 '--public-url' , `http://localhost:${ PORT } ` ,
62+ '--enable-dcr' ,
6363 ] , {
6464 cwd : process . cwd ( ) ,
6565 env : process . env ,
6666 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
6767 } ) ;
6868
69- // Collect stderr for debugging failures + admin token extraction
69+ // Collect stderr for debugging failures
7070 let stderr = '' ;
71- serverProcess . stderr ?. on ( 'data' , ( d : Buffer ) => {
72- stderr += d . toString ( ) ;
73- // Extract admin token from startup banner
74- const match = stderr . match ( / A d m i n T o k e n .* \n .* ?( [ a - f 0 - 9 ] { 20 , } ) \s / s) ;
75- if ( match ) adminToken = match [ 1 ] . replace ( / [ ^ a - f 0 - 9 ] / g, '' ) ;
76- } ) ;
71+ serverProcess . stderr ?. on ( 'data' , ( d : Buffer ) => { stderr += d . toString ( ) ; } ) ;
7772
7873 // Wait for server to be ready (up to 15s)
7974 let ready = false ;
@@ -85,39 +80,30 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
8580 await new Promise ( r => setTimeout ( r , 500 ) ) ;
8681 }
8782 if ( ! ready ) throw new Error ( 'Server failed to start within 15s.\nstderr: ' + stderr . slice ( - 500 ) ) ;
88-
89- // Extract admin token (may span two lines in the banner)
90- const tokenLines = stderr . match ( / A d m i n T o k e n .* \n .* ?\n .* ?( [ a - f 0 - 9 \s ] + ) \s * ║ / s) ;
91- if ( tokenLines ) {
92- // Token is split across two ║ lines, concatenate
93- const allHex = stderr . match ( / ║ \s + ( [ a - f 0 - 9 ] + ) \s + ║ / g) ;
94- if ( allHex && allHex . length >= 2 ) {
95- adminToken = allHex . slice ( - 2 ) . map ( l => l . replace ( / [ ^ a - f 0 - 9 ] / g, '' ) ) . join ( '' ) ;
96- }
97- }
98- if ( ! adminToken ) throw new Error ( 'Could not extract admin token from server output.\nstderr tail: ' + stderr . slice ( - 1000 ) ) ;
99- console . log ( '[e2e] Admin token extracted:' , adminToken . substring ( 0 , 12 ) + '...' ) ;
10083 } , 30_000 ) ;
10184
10285 afterAll ( async ( ) => {
103- // Kill server
86+ // Kill server first so it can't issue more tokens during cleanup.
10487 if ( serverProcess ) {
10588 serverProcess . kill ( 'SIGTERM' ) ;
10689 await new Promise ( r => setTimeout ( r , 1000 ) ) ;
10790 if ( ! serverProcess . killed ) serverProcess . kill ( 'SIGKILL' ) ;
10891 }
109- // Nuclear cleanup via direct SQL — CLI revoke is unreliable
110- try {
111- const postgres = ( await import ( 'postgres' ) ) . default ;
112- const sql = postgres ( process . env . GBRAIN_DATABASE_URL || process . env . DATABASE_URL || '' , { prepare : false } ) ;
113- await sql `DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')` ;
114- await sql `DELETE FROM mcp_request_log WHERE token_name IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')` ;
115- await sql `DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'` ;
116- await sql `DELETE FROM mcp_request_log WHERE agent_name LIKE 'e2e-%'` ;
117- await sql `DELETE FROM access_tokens WHERE name LIKE 'e2e-%'` ;
118- await sql . end ( ) ;
119- } catch ( e ) {
120- console . error ( '[e2e] Cleanup failed:' , e instanceof Error ? e . message : e ) ;
92+ // v0.26.2 cleanup contract: only revoke if registration succeeded
93+ // (clientId guard) and surface any cleanup failure to stderr without
94+ // throwing — a real test failure is more interesting than the cleanup
95+ // error that follows it. Same shape applies to DCR-registered clients
96+ // tracked in dcrClientIds.
97+ const { execSync } = await import ( 'child_process' ) ;
98+ const toRevoke = [ ...( clientId ? [ clientId ] : [ ] ) , ...dcrClientIds ] ;
99+ for ( const id of toRevoke ) {
100+ try {
101+ execSync ( `bun run src/cli.ts auth revoke-client "${ id } "` ,
102+ { cwd : process . cwd ( ) , encoding : 'utf8' , env : { ...process . env } } ) ;
103+ } catch ( e : any ) {
104+ // eslint-disable-next-line no-console
105+ console . error ( `[afterAll] revoke-client cleanup failed for ${ id } : ${ e . message } ` ) ;
106+ }
121107 }
122108 } ) ;
123109
@@ -294,7 +280,11 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
294280 const data = await res . json ( ) as any ;
295281 expect ( data . status ) . toBe ( 'ok' ) ;
296282 expect ( data . version ) . toBeDefined ( ) ;
297- expect ( data . page_count ) . toBeGreaterThan ( 0 ) ;
283+ // page_count: the endpoint must return a non-negative integer. The exact
284+ // value depends on the deployment's brain state and is not what this test
285+ // is checking — pre-v0.26.2 this asserted `> 0` and broke on fresh schemas.
286+ expect ( typeof data . page_count ) . toBe ( 'number' ) ;
287+ expect ( data . page_count ) . toBeGreaterThanOrEqual ( 0 ) ;
298288 } ) ;
299289
300290 // =========================================================================
@@ -325,22 +315,75 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
325315 } ) ;
326316
327317 // =========================================================================
328- // Revoke client
318+ // v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract)
319+ // =========================================================================
320+ //
321+ // The user-visible bug v0.26.2 protects against: postgres.js with
322+ // `prepare: false` returns BIGINT columns as strings, and an RFC-strict
323+ // DCR client (Claude Code, Cursor) parses the /register response as JSON
324+ // and rejects timestamps that aren't numbers. This is the HTTP-level test;
325+ // the internal-store shape test in test/oauth.test.ts is not enough on its
326+ // own (Codex flagged it as the wrong seam).
327+
328+ test ( 'DCR /register returns numeric client_id_issued_at (RFC 7591 §3.2.1)' , async ( ) => {
329+ const res = await fetch ( `${ BASE } /register` , {
330+ method : 'POST' ,
331+ headers : { 'Content-Type' : 'application/json' } ,
332+ body : JSON . stringify ( {
333+ client_name : 'e2e-dcr-shape' ,
334+ redirect_uris : [ 'https://example.com/cb' ] ,
335+ grant_types : [ 'authorization_code' ] ,
336+ token_endpoint_auth_method : 'client_secret_basic' ,
337+ scope : 'read' ,
338+ } ) ,
339+ } ) ;
340+ expect ( res . ok ) . toBe ( true ) ;
341+ const body = await res . json ( ) as any ;
342+
343+ // Track for cleanup before any assertion that could throw.
344+ if ( body . client_id ) dcrClientIds . push ( body . client_id ) ;
345+
346+ // The contract: client_id_issued_at is REQUIRED to be a JSON number per
347+ // RFC 7591. Pre-v0.26.2 with prepare:false returned this as a string
348+ // (e.g., "1735689600") and strict clients rejected the registration.
349+ expect ( typeof body . client_id_issued_at ) . toBe ( 'number' ) ;
350+ expect ( Number . isFinite ( body . client_id_issued_at ) ) . toBe ( true ) ;
351+ expect ( body . client_id_issued_at ) . toBeGreaterThan ( 0 ) ;
352+
353+ // client_secret_expires_at is OPTIONAL. If present, it must also be a
354+ // number. Undefined/missing means "does not expire" per the spec.
355+ if ( body . client_secret_expires_at !== undefined ) {
356+ expect ( typeof body . client_secret_expires_at ) . toBe ( 'number' ) ;
357+ expect ( Number . isFinite ( body . client_secret_expires_at ) ) . toBe ( true ) ;
358+ }
359+ } , 15_000 ) ;
360+
361+ // =========================================================================
362+ // v0.26.2: revoke-client CLI subprocess test
329363 // =========================================================================
364+ //
365+ // Validates the actual CLI router in src/commands/auth.ts, not just the
366+ // database deletion semantics. Codex flagged that a unit test in
367+ // test/oauth.test.ts proves DB DELETE works but does NOT prove the
368+ // subcommand exists or routes correctly.
330369
331- test ( 'revoke client via admin API invalidates all tokens' , async ( ) => {
332- // Register a disposable client
370+ test ( 'auth revoke-client (CLI) deletes client + cascades to tokens' , async ( ) => {
333371 const { execSync } = await import ( 'child_process' ) ;
372+
373+ // Step 1: register a throwaway client via CLI.
374+ // env: { ...process.env } per the bun execSync inheritance fix above.
334375 const regOutput = execSync (
335- 'bun run src/cli.ts auth register-client e2e-revoke-test --grant-types client_credentials --scopes " read" ' ,
336- { cwd : process . cwd ( ) , encoding : 'utf8' }
376+ 'bun run src/cli.ts auth register-client e2e-revoke-cli --grant-types client_credentials --scopes read' ,
377+ { cwd : process . cwd ( ) , encoding : 'utf8' , env : { ... process . env } }
337378 ) ;
338- const id = regOutput . match ( / C l i e n t I D : \s + ( g b r a i n _ c l _ \S + ) / ) ?. [ 1 ] ;
339- const secret = regOutput . match ( / C l i e n t S e c r e t : \s + ( g b r a i n _ c s _ \S + ) / ) ?. [ 1 ] ;
340- expect ( id ) . toBeDefined ( ) ;
341- expect ( secret ) . toBeDefined ( ) ;
379+ const idMatch = regOutput . match ( / C l i e n t I D : \s + ( g b r a i n _ c l _ \S + ) / ) ;
380+ const secretMatch = regOutput . match ( / C l i e n t S e c r e t : \s + ( g b r a i n _ c s _ \S + ) / ) ;
381+ expect ( idMatch ) . not . toBeNull ( ) ;
382+ expect ( secretMatch ) . not . toBeNull ( ) ;
383+ const id = idMatch ! [ 1 ] ;
384+ const secret = secretMatch ! [ 1 ] ;
342385
343- // Mint a token — should work
386+ // Step 2: mint a token through the live server.
344387 const tokenRes = await fetch ( `${ BASE } /token` , {
345388 method : 'POST' ,
346389 headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
@@ -349,70 +392,41 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
349392 expect ( tokenRes . ok ) . toBe ( true ) ;
350393 const { access_token } = await tokenRes . json ( ) as any ;
351394
352- // Verify token works
395+ // Sanity: the freshly-minted token works at /mcp.
353396 const before = await mcpCall ( access_token , 'tools/list' ) ;
354397 expect ( before . status ) . not . toBe ( 401 ) ;
355398
356- // Use the magic link to get a session cookie
357- const authRes = await fetch ( `${ BASE } /admin/auth/${ adminToken } ` , { redirect : 'manual' } ) ;
358- const cookie = authRes . headers . get ( 'set-cookie' ) || '' ;
359-
360- const revokeRes = await fetch ( `${ BASE } /admin/api/revoke-client` , {
361- method : 'POST' ,
362- headers : { 'Content-Type' : 'application/json' , 'Cookie' : cookie } ,
363- body : JSON . stringify ( { clientId : id } ) ,
364- } ) ;
365- if ( ! revokeRes . ok ) {
366- const errBody = await revokeRes . text ( ) ;
367- throw new Error ( `Revoke failed ${ revokeRes . status } : ${ errBody } \ncookie: ${ cookie . substring ( 0 , 30 ) } ` ) ;
368- }
369- const revokeData = await revokeRes . json ( ) as any ;
370- expect ( revokeData . revoked ) . toBe ( true ) ;
371-
372- // Token should no longer work
399+ // Step 3: revoke via the CLI subprocess.
400+ const revokeOutput = execSync (
401+ `bun run src/cli.ts auth revoke-client "${ id } "` ,
402+ { cwd : process . cwd ( ) , encoding : 'utf8' , env : { ...process . env } }
403+ ) ;
404+ // The handler prints the human confirmation lines. No exit code != 0
405+ // here since execSync would throw.
406+ expect ( revokeOutput ) . toMatch ( / O A u t h c l i e n t r e v o k e d / ) ;
407+ expect ( revokeOutput ) . toMatch ( / c a s c a d e / i) ;
408+
409+ // Step 4: previously-minted token must now be rejected at /mcp. Cascade
410+ // wiped the oauth_tokens row; verifyAccessToken throws "Invalid token".
411+ // Match the existing pattern at line 156: SDK error mapping varies
412+ // (401/403/500), so we assert non-success status + non-success body
413+ // rather than a single status code.
373414 const after = await mcpCall ( access_token , 'tools/list' ) ;
415+ expect ( after . status ) . toBeGreaterThanOrEqual ( 400 ) ;
374416 const afterBody = await after . text ( ) ;
375- expect ( after . status >= 400 || afterBody . includes ( 'invalid_token' ) || afterBody . includes ( 'error' ) ) . toBe ( true ) ;
376-
377- // Minting new tokens should fail
378- const mintAfter = await fetch ( `${ BASE } /token` , {
379- method : 'POST' ,
380- headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
381- body : `grant_type=client_credentials&client_id=${ id } &client_secret=${ secret } &scope=read` ,
382- } ) ;
383- expect ( mintAfter . ok ) . toBe ( false ) ;
384- } , 30_000 ) ;
385-
386- test ( 'revoke API key via admin API' , async ( ) => {
387- // Get admin session
388- const authRes = await fetch ( `${ BASE } /admin/auth/${ adminToken } ` , { redirect : 'manual' } ) ;
389- const cookie = authRes . headers . get ( 'set-cookie' ) || '' ;
417+ expect ( afterBody ) . not . toContain ( '"tools":[' ) ;
390418
391- // Create key
392- const createRes = await fetch ( `${ BASE } /admin/api/api-keys` , {
393- method : 'POST' ,
394- headers : { 'Content-Type' : 'application/json' , 'Cookie' : cookie } ,
395- body : JSON . stringify ( { name : 'e2e-revoke-key-test' } ) ,
396- } ) ;
397- expect ( createRes . ok ) . toBe ( true ) ;
398- const { token } = await createRes . json ( ) as any ;
399- expect ( token ) . toBeDefined ( ) ;
400-
401- // Token should work at /mcp
402- const before = await mcpCall ( token , 'tools/list' ) ;
403- expect ( before . status ) . not . toBe ( 401 ) ;
404-
405- // Revoke it
406- const revokeRes = await fetch ( `${ BASE } /admin/api/api-keys/revoke` , {
407- method : 'POST' ,
408- headers : { 'Content-Type' : 'application/json' , 'Cookie' : cookie } ,
409- body : JSON . stringify ( { name : 'e2e-revoke-key-test' } ) ,
410- } ) ;
411- expect ( revokeRes . ok ) . toBe ( true ) ;
412-
413- // Token should no longer work
414- const after = await mcpCall ( token , 'tools/list' ) ;
415- const afterBody = await after . text ( ) ;
416- expect ( after . status >= 400 || afterBody . includes ( 'invalid_token' ) || afterBody . includes ( 'error' ) ) . toBe ( true ) ;
419+ // Step 5: re-running revoke-client on the now-deleted id must exit 1.
420+ let secondRunFailed = false ;
421+ let secondRunStderr = '' ;
422+ try {
423+ execSync ( `bun run src/cli.ts auth revoke-client "${ id } "` ,
424+ { cwd : process . cwd ( ) , encoding : 'utf8' , env : { ...process . env } } ) ;
425+ } catch ( e : any ) {
426+ secondRunFailed = true ;
427+ secondRunStderr = ( e . stderr || '' ) . toString ( ) + ( e . stdout || '' ) . toString ( ) ;
428+ }
429+ expect ( secondRunFailed ) . toBe ( true ) ;
430+ expect ( secondRunStderr ) . toMatch ( / N o c l i e n t f o u n d / ) ;
417431 } , 30_000 ) ;
418432} ) ;
0 commit comments