@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
1212} ) )
1313
1414vi . mock ( '@/lib/credential-groups/application/enrollment-auth' , ( ) => ( {
15- authenticateCredentialGroupEnrollment : mocks . authenticate ,
15+ credentialGroupOAuthAttemptPrincipal : mocks . authenticate ,
1616} ) )
1717
1818vi . mock ( '@/lib/credential-groups/application/public-enrollment' , ( ) => ( {
@@ -27,7 +27,10 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
2727 enforcePublicCredentialGroupIpRateLimit : mocks . rateLimit ,
2828} ) )
2929
30- import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter'
30+ import {
31+ CredentialGroupInvitationUnavailableError ,
32+ CredentialGroupOAuthError ,
33+ } from '@/lib/credential-groups/provider-adapter'
3134import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route'
3235
3336const principal = {
@@ -56,7 +59,7 @@ describe('credential group OAuth callback', () => {
5659 vi . clearAllMocks ( )
5760 mocks . rateLimit . mockResolvedValue ( null )
5861 mocks . consumeAttempt . mockResolvedValue ( attempt )
59- mocks . authenticate . mockResolvedValue ( principal )
62+ mocks . authenticate . mockReturnValue ( principal )
6063 mocks . completeOAuth . mockResolvedValue ( { connectedOptionId : 'option-1' } )
6164 } )
6265
@@ -65,6 +68,7 @@ describe('credential group OAuth callback', () => {
6568 const response = await GET ( callbackRequest , context )
6669
6770 expect ( mocks . consumeAttempt ) . toHaveBeenCalledWith ( 'state-1' )
71+ expect ( mocks . authenticate ) . toHaveBeenCalledWith ( attempt )
6872 expect ( mocks . completeOAuth ) . toHaveBeenCalledWith ( {
6973 principal,
7074 input : { attempt, code : 'code-1' } ,
@@ -113,15 +117,15 @@ describe('credential group OAuth callback', () => {
113117 } )
114118
115119 it ( 'returns an unavailable enrollment redirect when the invitation was revoked in flight' , async ( ) => {
116- mocks . authenticate . mockResolvedValue ( null )
120+ mocks . completeOAuth . mockRejectedValueOnce ( new CredentialGroupInvitationUnavailableError ( ) )
117121
118122 const response = await GET ( request ( 'state=state-1&code=code-1' ) , context )
119123
120124 expect ( response . status ) . toBe ( 307 )
121125 expect ( response . headers . get ( 'location' ) ) . toBe (
122126 '/credential-groups/enroll/invitation-token?oauth=unavailable'
123127 )
124- expect ( mocks . completeOAuth ) . not . toHaveBeenCalled ( )
128+ expect ( mocks . completeOAuth ) . toHaveBeenCalledOnce ( )
125129 } )
126130
127131 it ( 'returns an unavailable enrollment redirect when the invitation is revoked during exchange' , async ( ) => {
@@ -150,4 +154,49 @@ describe('credential group OAuth callback', () => {
150154 expect ( mocks . authenticate ) . not . toHaveBeenCalled ( )
151155 expect ( mocks . completeOAuth ) . not . toHaveBeenCalled ( )
152156 } )
157+
158+ it ( 'returns personal connections to the fixed completion page after invitation rotation' , async ( ) => {
159+ mocks . consumeAttempt . mockResolvedValue ( { ...attempt , completionRedirect : true } )
160+ const response = await GET ( request ( 'state=state-1&code=code-1' ) , context )
161+ expect ( response . status ) . toBe ( 303 )
162+ expect ( response . headers . get ( 'location' ) ) . toBe ( '/credential-groups/complete' )
163+ expect ( response . headers . get ( 'cache-control' ) ) . toBe ( 'no-store' )
164+ expect ( response . headers . get ( 'referrer-policy' ) ) . toBe ( 'no-referrer' )
165+ } )
166+
167+ it . each ( [ [ 'error=access_denied' , 'denied' ] ] ) (
168+ 'shows personal callback failure without reopening a stale invitation: %s' ,
169+ async ( query , status ) => {
170+ mocks . consumeAttempt . mockResolvedValue ( { ...attempt , completionRedirect : true } )
171+ const response = await GET ( request ( `state=state-1&${ query } ` ) , context )
172+ expect ( response . status ) . toBe ( 303 )
173+ expect ( response . headers . get ( 'location' ) ) . toBe ( `/credential-groups/complete?oauth=${ status } ` )
174+ expect ( mocks . completeOAuth ) . not . toHaveBeenCalled ( )
175+ }
176+ )
177+
178+ it . each ( [
179+ [ new CredentialGroupInvitationUnavailableError ( ) , 'unavailable' ] ,
180+ [ new CredentialGroupOAuthError ( 'Sign in with your own account' , 403 ) , 'account_mismatch' ] ,
181+ [ new CredentialGroupOAuthError ( 'Missing scopes' , 403 ) , 'permissions_required' ] ,
182+ [ new CredentialGroupOAuthError ( 'Changed settings' , 409 ) , 'configuration_changed' ] ,
183+ [ new Error ( 'Provider failed' ) , 'failed' ] ,
184+ ] ) ( 'shows failed personal authorization on the completion page' , async ( error , status ) => {
185+ mocks . consumeAttempt . mockResolvedValue ( { ...attempt , completionRedirect : true } )
186+ mocks . completeOAuth . mockRejectedValueOnce ( error )
187+ const response = await GET ( request ( 'state=state-1&code=code-1' ) , context )
188+ expect ( response . status ) . toBe ( 303 )
189+ expect ( response . headers . get ( 'location' ) ) . toBe ( `/credential-groups/complete?oauth=${ status } ` )
190+ } )
191+
192+ it ( 'shows rate limits on the personal completion page without exchanging' , async ( ) => {
193+ mocks . consumeAttempt . mockResolvedValue ( { ...attempt , completionRedirect : true } )
194+ mocks . rateLimit . mockResolvedValue (
195+ NextResponse . json ( { error : 'Too many requests' } , { status : 429 } )
196+ )
197+ const response = await GET ( request ( 'state=state-1&code=code-1' ) , context )
198+ expect ( response . status ) . toBe ( 303 )
199+ expect ( response . headers . get ( 'location' ) ) . toBe ( '/credential-groups/complete?oauth=rate_limited' )
200+ expect ( mocks . completeOAuth ) . not . toHaveBeenCalled ( )
201+ } )
153202} )
0 commit comments