3

I have the following interceptor that tries to use a OAuth refresh_token whenever any 401 (error) response is obtained.

Basically a refresh token is obtained on the first 401 request and after it is obtained, the code waits 2,5 seconds. In most cases the second request will not trigger an error, but if it does (token couldn't be refreshed or whatever), the user is redirect to the login page.

export class RefreshAuthenticationInterceptor implements HttpInterceptor {
    constructor(
        private router: Router,
        private tokenService: TokenService,
    ) {}

    public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(request)
            .pipe(
                // this catches 401 requests and tries to refresh the auth token and try again.
                retryWhen(errors => {
                    // this subject is returned to retryWhen
                    const subject = new Subject();

                    // didn't know a better way to keep track if this is the first
                    let first = true;

                    errors.subscribe((errorStatus) => {
                        // first time either pass the error through or get new token
                        if (first) {
this.authenticationService.authTokenGet('refresh_token', environment.clientId, environment.clientSecret, this.tokenService.getToken().refresh_token).subscribe((token: OauthAccessToken) => {
                                this.tokenService.save(token);
                            });

                        // second time still error means redirect to login
                        } else {
                            this.router.navigateByUrl('/auth/login')
                                .then(() => subject.complete());

                            return;
                        }

                        // and of course make sure the second time is indeed seen as second time
                        first = false;

                        // trigger retry after 2,5 second to give ample time for token request to succeed
                        setTimeout(() => subject.next(), 2500);
                    });

                    return subject;
                }),
    }
}

The problem lies within the test. Everything works, except for the final check if the router was actually nagivated to /auth/login. It isn't, so the test fails.

With debugging, I know for sure the setTimeout callback is executed, but the subject.next() does not seem to start a new request.

I read somewhere that when normally using rxjs retry() on http mock requests, you should flush the request again. This is commented out in the code below, but gives a "Cannot flush a cancelled request."

    it('should catch 401 invalid_grant errors to try to refresh token the first time, redirect to login the second', fakeAsync(inject([HttpClient, HttpTestingController], (http: HttpClient, mock: HttpTestingController) => {
        const oauthAccessToken: OauthAccessToken = {
            // ...
        };
        authenticationService.authTokenGet.and.returnValue(of(oauthAccessToken));
        tokenService.getToken.and.returnValue(oauthAccessToken);

        // first request
        http.get('/api');

        const req = mock.expectOne('/api');
        req.flush({error: 'invalid_grant'}, {
            status: 401,
            statusText: 'Unauthorized'
        });

        expect(authenticationService.authTokenGet).toHaveBeenCalled();

        // second request
        authenticationService.authTokenGet.calls.reset();

        // req.flush({error: 'invalid_grant'}, {
        //    status: 401,
        //    statusText: 'Unauthorized'
        // });

        tick(2500);
        expect(authenticationService.authTokenGet).not.toHaveBeenCalled();
        expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');

        mock.verify();
    })));

Does anyone know how to fix this test?

PS: Any pointers on the code itself are also welcome :)

Rein Baarsma
  • 1,466
  • 13
  • 22
  • 1
    Too much code here. Can you narrow it down to a minimal example necessary to reproduce the issue? – theMayer May 03 '19 at 14:34
  • @theMayer I've simplified it – Rein Baarsma May 03 '19 at 14:39
  • Looks better. Still not altogether clear on what the issue you're receiving, or where it is happening. – theMayer May 03 '19 at 14:39
  • @theMayer in the automated test, the `tick(2500)` triggers the timeout and therefore a retry. The retry should actually have a 200 response, but I can't seem to add another `req.flush()` to test that – Rein Baarsma May 03 '19 at 14:42
  • hmm in refacturing & simplifying I've lost the logic that does the navigateByUrl :P lol. It's supposed to happen when it's retried, but still an error – Rein Baarsma May 03 '19 at 14:43
  • It sounds like you might be getting tripped up by the asynchronous nature of what is happening here. I don't think the `tick` is in the correct place for what you are trying to do. – theMayer May 03 '19 at 14:44
  • I've changed the code back to the original code, as the refactured version didn't actually do something on failure of the retry. The `tick(2500)` is simply to trigger the `setTimeout`, which in turn triggers the `subject.next()`, which triggers a retry, which then is no longer the first (`first` is now `false`) and therefore navigates to `/auth/login`. Keep in mind that this works -- the question is simply how to get the test to actually trigger the `/auth/login` call. – Rein Baarsma May 03 '19 at 14:51
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/192799/discussion-between-rein-baarsma-and-themayer). – Rein Baarsma May 03 '19 at 16:26
  • I think fundamentally, the question is not "how to trigger the /auth/login" but rather how to structure a test with asynchronous behavior. I would say this code has missed the mark by a long ways - recommend reading https://wecodetheweb.com/2015/09/01/unit-testing-asynchronous-angular-services/ – theMayer May 04 '19 at 19:00

1 Answers1

4

Eventually I refactored the code to not use the first trick above, which helped me solve the problem.

For anyone else struggling with retryWhen and unit tests, here's my final code:

The code in the interceptor (simplified)

retryWhen((errors: Observable<any>) => errors.pipe(
    flatMap((error, index) => {
        // any other error than 401 with {error: 'invalid_grant'} should be ignored by this retryWhen
        if (!error.status || error.status !== 401 || error.error.error !== 'invalid_grant') {
            return throwError(error);
        }

        if (index === 0) {
            // first time execute refresh token logic...
        } else {
            this.router.navigateByUrl('/auth/login');
        }

        return of(error).pipe(delay(2500));
    }),
    take(2) // first request should refresh token and retry, if there's still an error the second time is the last time and should navigate to login
) ),

The code in the unit test:

it('should catch 401 invalid_grant errors to try to refresh token the first time, redirect to login the second', fakeAsync(inject([HttpClient, HttpTestingController], (http: HttpClient, mock: HttpTestingController) => {    
    // first request
    http.get('/api').subscribe();

    const req = mock.expectOne('/api');
    req.flush({error: 'invalid_grant'}, {
        status: 401,
        statusText: 'Unauthorized'
    });

    // the expected delay of 2500 after the first retry 
    tick(2500);

    // second request also unauthorized, should lead to redirect to /auth/login
    const req2 = mock.expectOne('/api');
    req2.flush({error: 'invalid_grant'}, {
        status: 401,
        statusText: 'Unauthorized'
    });

    expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');

    // somehow the take(2) will have another delay for another request, which is cancelled before it is executed.. maybe someone else would know how to fix this properly.. but I don't really care anymore at this point ;)
    tick(2500);

    const req3 = mock.expectOne('/api');
    expect(req3.cancelled).toBeTruthy();

    mock.verify();
})));
Rein Baarsma
  • 1,466
  • 13
  • 22
  • The first call isn't a "retry" but just a "try". That's why you need that extra `tick`. with 2 reties, there are 3 tries. So `expected delay after first retry ` is really `expected delay before first retry ` – Richard Nov 02 '20 at 14:13