The middleware fetches a json document from a microservice endpoint and attaches it to the request.
The good path test is ok, but I can't get the bad path test to throw the ForbiddenException and prevent it from calling next().
Outside of a Jest test, the middleware will block a request if it fails to fetch the json document.
Any help is appreciated :)
Middleware:
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
import { NextFunction, Request, Response } from 'express'
import { catchError, firstValueFrom } from 'rxjs'
import { LoggerService } from '../logger.service'
@Injectable()
export class MyMiddleware implements NestMiddleware {
constructor(
private logger: LoggerService,
private httpService: HttpService,
) {}
async use(req: Request, res: Response, next: NextFunction) {
const response = await firstValueFrom(
this.httpService
.get('https://myservice/document-endpoint', {
headers: {
Cookie: req.headers['cookie'],
},
})
.pipe(
catchError((error: Error) => {
this.logger.error(error.message);
throw new ForbiddenException()
}),
),
)
req.document = response.data.value
next()
}
}
Tests:
'Bad path' test is the problem ... the nextFunction method is getting called and I can't manage to get it to throw the error.
import { MyMiddleware } from './mymiddleware'
import { NextFunction, Request, Response } from 'express'
import { LoggerService } from '../logger/logger.service'
import { of } from 'rxjs'
import { createMock } from '@golevelup/nestjs-testing'
import { HttpService } from '@nestjs/axios'
import { AxiosResponse } from 'axios'
describe('Authorization middleware', () => {
let middleware: MyMiddleware
let mockRequest: Partial<Request>
let mockResponse: Partial<Response>
let nextFunction: NextFunction = jest.fn()
let mockHttpService = createMock<HttpService>()
mockRequest = {
headers: {
cookie: 'idToken=asdasdasd'
}
beforeEach(async () => {
mockResponse = {
json: jest.fn(),
}
middleware = new MyMiddleware(new LoggerService(), mockHttpService)
})
test('bad path', async () => {
jest.clearAllMocks()
const mockDocumentResponse: AxiosResponse = {
status: 404,
statusText: '',
headers: {},
config: {},
data: { error: 'Not Found' }
}
const httpSpy = jest.spyOn(mockHttpService, 'get')
.mockReturnValue(of(mockDocumentResponse))
await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
expect(nextFunction).toBeCalledTimes(0)
})
test('good path', async () => {
jest.clearAllMocks()
const mockDocumentResponse: AxiosResponse = {
status: 200,
statusText: '',
headers: {},
config: {},
data: {
value: 'example document',
}
}
jest.spyOn(mockHttpService, 'get').mockImplementationOnce(() => of(mockDocumentResponse));
await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
expect(nextFunction).toBeCalledTimes(1)
})
})
I never got around to posting the solution, but it seems like the question gets a few visits so here it is.
The key point is that NestJS integrates RxJS as a first class citizen and embraces the concept of Observables. The HttpService (a wrapper around axios) returns Observables by default.
Observables are different to promises (https://rxjs.dev/guide/observable), and in the middleware code this is dealt with by using the firstValueFrom method (imported from RxJS), which converts the Observable into a regular javascript promise.
So, when the 'bad path' is being tested, we are simulating an error using Observables. This can be done by returning a new Observable that immediately throws an error using the s.error(err) method.
import { Observable, of } from 'rxjs'
...
test('bad path', async () => {
jest.clearAllMocks()
const err = { response: 'resp', status: '500' }
// Instead of returning an error response, we simulate throwing an error.
const httpSpy = jest.spyOn(mockHttpService, 'get')
.mockImplementationOnce(() => new Observable(subscriber => subscriber.error(err)))
// Handle the expected ForbiddenException
await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
.catch((error) => {
expect(error).toBeInstanceOf(ForbiddenException)
expect(nextFunction).toBeCalledTimes(0)
})
})