11import { createRequire } from "node:module" ;
2+ import { inspect } from "node:util" ;
23import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
34
45const require = createRequire ( import . meta. url ) ;
56const { API_READ_OPERATIONS , apiError, createAPIBudget, isTransientError, retryNotBefore, getAPIReadFailure } = require ( "./daily_aic_api_budget.cjs" ) ;
7+ const { getGitHubAPIDiagnostic } = require ( "./github_api_diagnostics.cjs" ) ;
68
79beforeEach ( ( ) => {
810 vi . useFakeTimers ( ) ;
@@ -16,6 +18,27 @@ afterEach(() => {
1618const response = ( status = 200 , headers = { "x-ratelimit-remaining" : "4000" } ) => ( { status, headers } ) ;
1719
1820describe ( "bounded daily AIC read retries" , ( ) => {
21+ it ( "classifies a headerless installation rate limit without retrying or publishing its payload" , async ( ) => {
22+ const error = apiError ( 403 , { } , "API rate limit exceeded for installation; secret-test-value" ) ;
23+ const request = vi . fn ( ) . mockRejectedValue ( error ) ;
24+ await expect ( createAPIBudget ( ) . request ( request , true , API_READ_OPERATIONS . CURRENT_RUN ) ) . rejects . toBe ( error ) ;
25+ expect ( getAPIReadFailure ( error ) ) . toMatchObject ( { attempts : 1 , reason : "quota_stop" , operation : "current_run" } ) ;
26+ expect ( request ) . toHaveBeenCalledOnce ( ) ;
27+ const logs = global . core . info . mock . calls . flat ( ) . join ( "\n" ) ;
28+ expect ( logs ) . toContain ( "current_run: GitHub API rate_limited" ) ;
29+ expect ( logs ) . toContain ( "remaining=unknown" ) ;
30+ expect ( logs ) . not . toContain ( "secret-test-value" ) ;
31+ } ) ;
32+
33+ it ( "does not let malformed reset metadata replace the original failure" , async ( ) => {
34+ const request = vi . fn ( ) . mockRejectedValue ( apiError ( 403 , { "x-ratelimit-remaining" : "0" , "x-ratelimit-reset" : "9999999999999999" } , "private payload" ) ) ;
35+ const budget = createAPIBudget ( ) ;
36+ await expect ( budget . request ( request ) ) . rejects . toMatchObject ( { code : "DAILY_AIC_QUOTA_STOP" } ) ;
37+ expect ( budget . snapshot ( ) . reset ) . toBe ( "" ) ;
38+ expect ( retryNotBefore ( { "x-ratelimit-remaining" : "0" , "x-ratelimit-reset" : "9999999999999999" } ) ) . toBe ( "" ) ;
39+ expect ( request ) . toHaveBeenCalledOnce ( ) ;
40+ } ) ;
41+
1942 it ( "retries only the failed read and observes each response once" , async ( ) => {
2043 const budget = createAPIBudget ( ) ;
2144 const request = vi
@@ -58,6 +81,69 @@ describe("bounded daily AIC read retries", () => {
5881 expect ( global . core . info ) . toHaveBeenCalledWith ( `[daily-workflow-aic] Stopped daily AIC read: {"attempts":1,"status":${ status } ,"reason":"quota_stop","requests":1,"operation":"unknown"}` ) ;
5982 } ) ;
6083
84+ it ( "classifies a synthetic HTTP 403 reserve stop without permission evidence as quota_reserve" , ( ) => {
85+ let stopped ;
86+ try {
87+ createAPIBudget ( 100 ) . observe ( response ( 403 , { "x-ratelimit-remaining" : "50" } ) ) ;
88+ } catch ( error ) {
89+ stopped = error ;
90+ }
91+ expect ( stopped ) . toMatchObject ( { code : "DAILY_AIC_QUOTA_STOP" , status : 403 } ) ;
92+ expect ( getGitHubAPIDiagnostic ( stopped ) ) . toMatchObject ( { quota : true , classification : "quota_reserve" } ) ;
93+ } ) ;
94+
95+ it . each ( [
96+ [ 401 , "Bad credentials" , "50" , "authentication_failed" ] ,
97+ [ 403 , "Resource not accessible by integration" , "50" , "permission_denied" ] ,
98+ [ 403 , "Resource not accessible by integration" , "0" , "permission_denied" ] ,
99+ [ 403 , "API rate limit exceeded for installation" , "0" , "quota_exhausted" ] ,
100+ [ 403 , "Unspecified error" , "50" , "quota_reserve" ] ,
101+ ] ) ( "preserves original HTTP %i access evidence while stopping quota-restricted reads" , async ( status , message , remaining , classification ) => {
102+ const original = apiError ( status , { "x-ratelimit-remaining" : remaining } , `${ message } ; secret-test-value` ) ;
103+ const request = vi . fn ( ) . mockRejectedValue ( original ) ;
104+ const stopped = await createAPIBudget ( )
105+ . request ( request )
106+ . catch ( error => error ) ;
107+ expect ( stopped ) . toMatchObject ( { code : "DAILY_AIC_QUOTA_STOP" , status } ) ;
108+ expect ( getGitHubAPIDiagnostic ( stopped ) ) . toMatchObject ( { quota : true , classification } ) ;
109+ if ( classification === "quota_exhausted" || classification === "quota_reserve" ) {
110+ expect ( stopped . cause ) . toBeUndefined ( ) ;
111+ } else {
112+ expect ( stopped . cause ) . not . toBe ( original ) ;
113+ expect ( stopped . cause ) . toMatchObject ( { status, message : status === 401 ? "Bad credentials" : "Permission denied" } ) ;
114+ expect ( Object . keys ( stopped . cause ) ) . toEqual ( [ "status" ] ) ;
115+ }
116+ expect ( request ) . toHaveBeenCalledOnce ( ) ;
117+ expect ( vi . getTimerCount ( ) ) . toBe ( 0 ) ;
118+ const logs = global . core . info . mock . calls . flat ( ) . join ( "\n" ) ;
119+ expect ( logs ) . toContain ( `GitHub API ${ classification } ` ) ;
120+ expect ( logs ) . toContain ( '"reason":"quota_stop"' ) ;
121+ expect ( logs ) . not . toContain ( "secret-test-value" ) ;
122+ } ) ;
123+
124+ it . each ( [
125+ [ 401 , "Bad credentials" , "authentication_failed" ] ,
126+ [ 403 , "Resource not accessible by integration" , "permission_denied" ] ,
127+ [ 403 , "Resource not accessible by personal access token" , "permission_denied" ] ,
128+ [ 403 , "API rate limit exceeded" , "quota_exhausted" ] ,
129+ ] ) ( "never exposes original HTTP %i error properties during terminal inspection" , async ( status , message , classification ) => {
130+ const original = apiError ( status , { "x-ratelimit-remaining" : "0" } , `${ message } ; secret-message-value` ) ;
131+ original . request = { url : "https://example.invalid/?token=secret-url-value" , headers : { authorization : "secret-request-header-value" } , body : "secret-request-body-value" } ;
132+ original . response . headers . authorization = "secret-response-header-value" ;
133+ original . response . headers . location = "https://example.invalid/?token=secret-location-value" ;
134+ original . response . body = "secret-response-body-value" ;
135+ original . cause = new Error ( "secret-nested-cause-value" ) ;
136+ const stopped = await createAPIBudget ( )
137+ . request ( ( ) => Promise . reject ( original ) )
138+ . catch ( error => error ) ;
139+ expect ( stopped ) . toMatchObject ( { code : "DAILY_AIC_QUOTA_STOP" , status } ) ;
140+ expect ( getGitHubAPIDiagnostic ( stopped ) ) . toMatchObject ( { quota : true , classification } ) ;
141+ const terminal = inspect ( stopped , { depth : null , showHidden : true } ) ;
142+ expect ( terminal ) . not . toMatch ( / s e c r e t - [ \w - ] + - v a l u e | e x a m p l e \. i n v a l i d / ) ;
143+ expect ( terminal ) . toContain ( "DAILY_AIC_QUOTA_STOP" ) ;
144+ expect ( vi . getTimerCount ( ) ) . toBe ( 0 ) ;
145+ } ) ;
146+
61147 it ( "does not retry a server error with an explicit retry restriction" , async ( ) => {
62148 const request = vi . fn ( async ( ) => response ( 503 , { "retry-after" : "120" } ) ) ;
63149 await expect ( createAPIBudget ( ) . request ( request ) ) . rejects . toMatchObject ( { status : 503 } ) ;
0 commit comments