@@ -5,15 +5,21 @@ function createCoreMock() {
55 getIDToken : jest . fn ( ) . mockResolvedValue ( 'mock-id-token' ) ,
66 setOutput : jest . fn ( ) ,
77 setFailed : jest . fn ( ) ,
8+ summary : {
9+ addHeading : jest . fn ( ) . mockReturnThis ( ) ,
10+ addEOL : jest . fn ( ) . mockReturnThis ( ) ,
11+ addRaw : jest . fn ( ) . mockReturnThis ( ) ,
12+ write : jest . fn ( ) . mockResolvedValue ( undefined ) ,
13+ } ,
814 } ;
915}
1016
1117function okResponse ( token ) {
1218 return { ok : true , status : 200 , statusText : 'OK' , body : { token } } ;
1319}
1420
15- function errorResponse ( status , statusText ) {
16- return { ok : false , status, statusText } ;
21+ function errorResponse ( status , statusText , body ) {
22+ return { ok : false , status, statusText, body } ;
1723}
1824
1925function createFetchMock ( responses ) {
@@ -28,7 +34,10 @@ function createFetchMock(responses) {
2834 ok : resp . ok ,
2935 status : resp . status ,
3036 statusText : resp . statusText ,
31- json : async ( ) => resp . body ,
37+ json : async ( ) => {
38+ if ( resp . body === undefined ) throw new SyntaxError ( 'Unexpected end of JSON input' ) ;
39+ return resp . body ;
40+ } ,
3241 } ;
3342 } ) ;
3443}
@@ -42,6 +51,36 @@ const DEFAULT_ENV = {
4251const LOGIN_URL = 'https://api.example.com/api/v1/tokens/auth/login' ;
4352const TOKEN_URL = 'https://api.example.com/api/v1/tokens/pat/org/repo' ;
4453
54+ function createGithubMock ( existingComments = [ ] ) {
55+ return {
56+ rest : {
57+ issues : {
58+ listComments : jest . fn ( ) . mockResolvedValue ( { data : existingComments } ) ,
59+ createComment : jest . fn ( ) . mockResolvedValue ( { data : { html_url : 'https://github.com/test-owner/test-repo/issues/1#issuecomment-new' } } ) ,
60+ updateComment : jest . fn ( ) . mockResolvedValue ( { data : { html_url : 'https://github.com/test-owner/test-repo/issues/1#issuecomment-updated' } } ) ,
61+ } ,
62+ } ,
63+ } ;
64+ }
65+
66+ function createContextMock ( prNumber ) {
67+ return {
68+ payload : {
69+ pull_request : prNumber ? { number : prNumber } : undefined ,
70+ } ,
71+ repo : { owner : 'test-owner' , repo : 'test-repo' } ,
72+ } ;
73+ }
74+
75+ function limitExceededResponse ( limit , used ) {
76+ return errorResponse ( 403 , 'Forbidden' , {
77+ error : 'LIMIT_EXCEEDED' ,
78+ detail : {
79+ limits : [ { limit, used } ] ,
80+ } ,
81+ } ) ;
82+ }
83+
4584describe ( 'pipelines-credentials action' , ( ) => {
4685 describe ( 'happy path' , ( ) => {
4786 test ( 'OIDC login and token fetch' , async ( ) => {
@@ -111,6 +150,128 @@ describe('pipelines-credentials action', () => {
111150 } ) ;
112151 } ) ;
113152
153+ describe ( 'LIMIT_EXCEEDED behavior' , ( ) => {
154+ test ( 'writes job summary and falls back to FALLBACK_TOKEN' , async ( ) => {
155+ const core = createCoreMock ( ) ;
156+ const fetch = createFetchMock ( [ limitExceededResponse ( 100 , 120 ) ] ) ;
157+
158+ await runAction ( {
159+ coreMock : core ,
160+ fetchMock : fetch ,
161+ env : DEFAULT_ENV ,
162+ contextMock : createContextMock ( ) ,
163+ } ) ;
164+
165+ expect ( core . summary . addHeading ) . toHaveBeenCalledWith ( 'Your Pipelines have been paused' ) ;
166+ expect ( core . summary . addRaw ) . toHaveBeenCalledWith (
167+ expect . stringContaining ( '**100** infrastructure units' )
168+ ) ;
169+ expect ( core . summary . write ) . toHaveBeenCalled ( ) ;
170+ expect ( core . setOutput ) . toHaveBeenCalledWith ( 'PIPELINES_TOKEN' , 'fallback-pat' ) ;
171+ } ) ;
172+
173+ test ( 'creates PR comment when no existing comment' , async ( ) => {
174+ const core = createCoreMock ( ) ;
175+ const fetch = createFetchMock ( [ limitExceededResponse ( 100 , 120 ) ] ) ;
176+ const githubMock = createGithubMock ( ) ;
177+ const contextMock = createContextMock ( 42 ) ;
178+
179+ await runAction ( {
180+ coreMock : core ,
181+ fetchMock : fetch ,
182+ env : DEFAULT_ENV ,
183+ githubMock,
184+ contextMock,
185+ } ) ;
186+
187+ expect ( githubMock . rest . issues . createComment ) . toHaveBeenCalledWith ( {
188+ owner : 'test-owner' ,
189+ repo : 'test-repo' ,
190+ issue_number : 42 ,
191+ body : expect . stringContaining ( 'Your Gruntwork Pipelines have been paused' ) ,
192+ } ) ;
193+ expect ( githubMock . rest . issues . updateComment ) . not . toHaveBeenCalled ( ) ;
194+ expect ( core . setOutput ) . toHaveBeenCalledWith ( 'PIPELINES_TOKEN' , 'fallback-pat' ) ;
195+ } ) ;
196+
197+ test ( 'updates existing PR comment instead of creating a new one' , async ( ) => {
198+ const core = createCoreMock ( ) ;
199+ const fetch = createFetchMock ( [ limitExceededResponse ( 100 , 120 ) ] ) ;
200+ const existingComment = { id : 999 , body : '<!-- pipelines-limit-exceeded -->\n## old content' } ;
201+ const githubMock = createGithubMock ( [ existingComment ] ) ;
202+ const contextMock = createContextMock ( 42 ) ;
203+
204+ await runAction ( {
205+ coreMock : core ,
206+ fetchMock : fetch ,
207+ env : DEFAULT_ENV ,
208+ githubMock,
209+ contextMock,
210+ } ) ;
211+
212+ expect ( githubMock . rest . issues . updateComment ) . toHaveBeenCalledWith ( {
213+ owner : 'test-owner' ,
214+ repo : 'test-repo' ,
215+ comment_id : 999 ,
216+ body : expect . stringContaining ( 'Your Gruntwork Pipelines have been paused' ) ,
217+ } ) ;
218+ expect ( githubMock . rest . issues . createComment ) . not . toHaveBeenCalled ( ) ;
219+ } ) ;
220+
221+ test ( 'does NOT write summary or comment for a normal 403' , async ( ) => {
222+ const core = createCoreMock ( ) ;
223+ const fetch = createFetchMock ( [ errorResponse ( 403 , 'Forbidden' ) ] ) ;
224+ const githubMock = createGithubMock ( ) ;
225+ const contextMock = createContextMock ( 42 ) ;
226+
227+ await runAction ( {
228+ coreMock : core ,
229+ fetchMock : fetch ,
230+ env : DEFAULT_ENV ,
231+ githubMock,
232+ contextMock,
233+ } ) ;
234+
235+ expect ( core . summary . write ) . not . toHaveBeenCalled ( ) ;
236+ expect ( githubMock . rest . issues . createComment ) . not . toHaveBeenCalled ( ) ;
237+ expect ( core . setOutput ) . toHaveBeenCalledWith ( 'PIPELINES_TOKEN' , 'fallback-pat' ) ;
238+ } ) ;
239+
240+ test ( 'handles PR comment failure gracefully' , async ( ) => {
241+ const core = createCoreMock ( ) ;
242+ const fetch = createFetchMock ( [ limitExceededResponse ( 100 , 120 ) ] ) ;
243+ const githubMock = createGithubMock ( ) ;
244+ githubMock . rest . issues . listComments . mockRejectedValue ( new Error ( 'Resource not accessible by integration' ) ) ;
245+ const contextMock = createContextMock ( 42 ) ;
246+
247+ await runAction ( {
248+ coreMock : core ,
249+ fetchMock : fetch ,
250+ env : DEFAULT_ENV ,
251+ githubMock,
252+ contextMock,
253+ } ) ;
254+
255+ expect ( core . summary . write ) . toHaveBeenCalled ( ) ;
256+ expect ( core . setOutput ) . toHaveBeenCalledWith ( 'PIPELINES_TOKEN' , 'fallback-pat' ) ;
257+ } ) ;
258+
259+ test ( 'calls setFailed when LIMIT_EXCEEDED and no FALLBACK_TOKEN' , async ( ) => {
260+ const core = createCoreMock ( ) ;
261+ const fetch = createFetchMock ( [ limitExceededResponse ( 100 , 120 ) ] ) ;
262+
263+ await runAction ( {
264+ coreMock : core ,
265+ fetchMock : fetch ,
266+ env : { ...DEFAULT_ENV , FALLBACK_TOKEN : '' } ,
267+ contextMock : createContextMock ( ) ,
268+ } ) ;
269+
270+ expect ( core . summary . write ) . toHaveBeenCalled ( ) ;
271+ expect ( core . setFailed ) . toHaveBeenCalled ( ) ;
272+ } ) ;
273+ } ) ;
274+
114275 describe ( 'retry behavior' , ( ) => {
115276 test . each ( [
116277 {
0 commit comments