diff --git a/config/config.dev.json b/config/config.dev.json index 518bbe4e0..5653d201b 100644 --- a/config/config.dev.json +++ b/config/config.dev.json @@ -4,7 +4,8 @@ }, "FRONTEND": { "PORT": 9091, - "SESSION_SECRET": "DevelopmentSecret" + "SESSION_SECRET": "DevelopmentSecret", + "BACKEND_ADDRESS": "http://127.0.0.1:9090" }, "DB": { "CLIENT_URL": "postgres://admin:password@127.0.0.1:5432", diff --git a/config/config.test.json b/config/config.test.json index 3d76a02c5..7d59980cb 100644 --- a/config/config.test.json +++ b/config/config.test.json @@ -3,7 +3,8 @@ "PORT": 8080 }, "FRONTEND": { - "PORT": 8081 + "PORT": 8081, + "BACKEND_ADDRESS": "http://127.0.0.1:8080" }, "DB": { "CLIENT_URL": "postgres://127.0.0.1:5432", diff --git a/src/modules/frontend/api/authentication.guard.spec.ts b/src/modules/frontend/api/authentication.guard.spec.ts index eb182318d..108a60eb2 100644 --- a/src/modules/frontend/api/authentication.guard.spec.ts +++ b/src/modules/frontend/api/authentication.guard.spec.ts @@ -1,9 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthenticatedGuard } from './authentication.guard.js'; import { createMock } from '@golevelup/ts-jest'; import { ExecutionContext } from '@nestjs/common'; -import { SessionData } from './session.js'; import { HttpArgumentsHost } from '@nestjs/common/interfaces/index.js'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthenticatedGuard } from './authentication.guard.js'; +import { SessionData } from './frontend.controller.js'; describe('AuthenticatedGuard', () => { let module: TestingModule; diff --git a/src/modules/frontend/api/authentication.guard.ts b/src/modules/frontend/api/authentication.guard.ts index b688c5044..e417c58c1 100644 --- a/src/modules/frontend/api/authentication.guard.ts +++ b/src/modules/frontend/api/authentication.guard.ts @@ -1,7 +1,7 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Request } from 'express'; -import { SessionData } from './session.js'; +import { SessionData } from './frontend.controller.js'; @Injectable() export class AuthenticatedGuard implements CanActivate { diff --git a/src/modules/frontend/api/authentication.interceptor.spec.ts b/src/modules/frontend/api/authentication.interceptor.spec.ts index 4f0556503..879927176 100644 --- a/src/modules/frontend/api/authentication.interceptor.spec.ts +++ b/src/modules/frontend/api/authentication.interceptor.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthenticationInterceptor } from './authentication.interceptor.js'; -import { HttpService } from '@nestjs/axios'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; import { CallHandler, ExecutionContext } from '@nestjs/common'; -import { AxiosInstance } from 'axios'; import { HttpArgumentsHost } from '@nestjs/common/interfaces/index.js'; -import { SessionData } from './session.js'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosInstance } from 'axios'; +import { AuthenticationInterceptor } from './authentication.interceptor.js'; +import { SessionData } from './frontend.controller.js'; describe('AuthenticatedGuard', () => { let module: TestingModule; diff --git a/src/modules/frontend/api/authentication.interceptor.ts b/src/modules/frontend/api/authentication.interceptor.ts index d1962c1ed..9cfec9f79 100644 --- a/src/modules/frontend/api/authentication.interceptor.ts +++ b/src/modules/frontend/api/authentication.interceptor.ts @@ -3,7 +3,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes import { HttpArgumentsHost } from '@nestjs/common/interfaces'; import { Observable } from 'rxjs'; -import { SessionData } from './session.js'; +import { SessionData } from './frontend.controller.js'; @Injectable() export class AuthenticationInterceptor implements NestInterceptor { diff --git a/src/modules/frontend/api/frontend.controller.spec.ts b/src/modules/frontend/api/frontend.controller.spec.ts index ec8649993..802b5352d 100644 --- a/src/modules/frontend/api/frontend.controller.spec.ts +++ b/src/modules/frontend/api/frontend.controller.spec.ts @@ -1,18 +1,36 @@ import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; +import { Response } from 'express'; +import { TokenSet } from 'openid-client'; +import { of, throwError } from 'rxjs'; -import { FrontendController } from './frontend.controller.js'; +import { HttpStatus } from '@nestjs/common'; +import { LoginService } from '../outbound/login.service.js'; +import { AuthenticationInterceptor } from './authentication.interceptor.js'; +import { FrontendController, SessionData } from './frontend.controller.js'; +import { LoginParams } from './user.params.js'; describe('FrontendController', () => { let module: TestingModule; let frontendController: FrontendController; + let loginService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ - providers: [FrontendController], - }).compile(); + providers: [FrontendController, { provide: LoginService, useValue: createMock() }], + }) + .overrideInterceptor(AuthenticationInterceptor) + .useValue(createMock()) + .compile(); frontendController = module.get(FrontendController); + loginService = module.get(LoginService); + }); + + afterEach(() => { + jest.resetAllMocks(); }); afterAll(async () => { @@ -24,20 +42,98 @@ describe('FrontendController', () => { }); describe('Login', () => { - it('should not throw', () => { - const loginResponse: string = frontendController.login(); + it('should not throw', async () => { + const loginData: LoginParams = { + username: faker.internet.userName(), + password: faker.internet.password(), + }; + loginService.login.mockReturnValueOnce( + of({ + data: { access_token: faker.string.uuid() }, + } as AxiosResponse), + ); + + const loginResponse: string = await frontendController.login(loginData, createMock()); + + expect(loginResponse).toBe('Logged in.'); + }); - expect(loginResponse).toBe('Login!'); + it('should call login-service with username and password', async () => { + const loginData: LoginParams = { + username: faker.internet.userName(), + password: faker.internet.password(), + }; + loginService.login.mockReturnValueOnce( + of({ + data: { access_token: faker.string.uuid() }, + } as AxiosResponse), + ); + + await frontendController.login(loginData, createMock()); + + expect(loginService.login).toHaveBeenCalledWith(loginData.username, loginData.password); + }); + + it('should set session', async () => { + const loginData: LoginParams = { + username: faker.internet.userName(), + password: faker.internet.password(), + }; + const accessToken: string = faker.string.uuid(); + loginService.login.mockReturnValueOnce( + of({ + data: { access_token: accessToken }, + } as AxiosResponse), + ); + const sessionMock: SessionData = createMock(); + + await frontendController.login(loginData, sessionMock); + + expect(sessionMock.access_token).toEqual(accessToken); + }); + + it('should throw error if backend returns error', async () => { + const loginData: LoginParams = { + username: faker.internet.userName(), + password: faker.internet.password(), + }; + const error: Error = new Error('Some error from backend'); + loginService.login.mockReturnValueOnce(throwError(() => error)); + const sessionMock: SessionData = createMock(); + + const loginPromise: Promise = frontendController.login(loginData, sessionMock); + + await expect(loginPromise).rejects.toEqual(error); }); }); describe('Logout', () => { - it('should not throw', () => { - const userDummy: unknown = { id: faker.string.uuid() }; + it('should set OK-status when the session is destroyed', () => { + const sessionMock: SessionData = createMock({ + destroy(cb: (err?: unknown) => void) { + cb(); + return this; + }, + }); + const responseMock: Response = createMock(); + + frontendController.logout(sessionMock, responseMock); + + expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should set ERROR-status when the session could not be destroyed', () => { + const sessionMock: SessionData = createMock({ + destroy(cb: (err?: unknown) => void) { + cb('some error'); + return this; + }, + }); + const responseMock: Response = createMock(); - const loginResponse: string = frontendController.logout(userDummy); + frontendController.logout(sessionMock, responseMock); - expect(loginResponse).toBe(`Logout! ${JSON.stringify(userDummy)}`); + expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); }); }); }); diff --git a/src/modules/frontend/api/frontend.controller.ts b/src/modules/frontend/api/frontend.controller.ts index 8020a18e1..cd707659a 100644 --- a/src/modules/frontend/api/frontend.controller.ts +++ b/src/modules/frontend/api/frontend.controller.ts @@ -1,21 +1,39 @@ -import { Controller, Post, Res, Session, UseGuards, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, HttpStatus, Post, Res, Session, UseGuards, UseInterceptors } from '@nestjs/common'; import { ApiAcceptedResponse, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { AuthenticatedGuard } from './authentication.guard.js'; import { AuthenticationInterceptor } from './authentication.interceptor.js'; -import { SessionData } from './session.js'; +import { LoginService } from '../outbound/login.service.js'; +import { firstValueFrom } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { TokenSet } from 'openid-client'; +import { LoginParams } from './user.params.js'; + +export type SessionData = Express.Request['session'] & + Partial<{ + user_id: string; + access_token: string; + }>; @ApiTags('frontend') @Controller({ path: 'frontend' }) @UseInterceptors(AuthenticationInterceptor) export class FrontendController { + public constructor(private loginService: LoginService) {} + @Post('login') @ApiAcceptedResponse({ description: 'The person was successfully logged in.' }) - public login(@Session() session: SessionData): string { - session.user_id = ''; + public async login(@Body() loginParams: LoginParams, @Session() session: SessionData): Promise { + const response: AxiosResponse = await firstValueFrom( + this.loginService.login(loginParams.username, loginParams.password), + ); + + if (response.data.access_token) { + session.access_token = response.data.access_token; + } - return 'Login!'; + return 'Logged in.'; } @UseGuards(AuthenticatedGuard) @@ -24,9 +42,9 @@ export class FrontendController { public logout(@Session() session: SessionData, @Res() res: Response): void { session.destroy((err: unknown) => { if (err) { - res.status(400).send('Error while logging out'); + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Error while logging out' }); } else { - res.status(200).send('Logged out'); + res.status(HttpStatus.OK).json({ message: 'Successfully logged out' }); } }); } diff --git a/src/modules/frontend/api/session.ts b/src/modules/frontend/api/session.ts deleted file mode 100644 index cfe548883..000000000 --- a/src/modules/frontend/api/session.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type SessionData = Express.Request['session'] & - Partial<{ - user_id: string; - access_token: string; - }>; diff --git a/src/modules/frontend/api/user.params.ts b/src/modules/frontend/api/user.params.ts new file mode 100644 index 000000000..91a48c5ab --- /dev/null +++ b/src/modules/frontend/api/user.params.ts @@ -0,0 +1,15 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class LoginParams { + @AutoMap() + @IsString() + @ApiProperty({ name: 'username', required: true }) + public readonly username!: string; + + @AutoMap() + @IsString() + @ApiProperty({ name: 'password', required: true }) + public readonly password!: string; +} diff --git a/src/modules/frontend/frontend-api.module.ts b/src/modules/frontend/frontend-api.module.ts index ac5255887..62cb56647 100644 --- a/src/modules/frontend/frontend-api.module.ts +++ b/src/modules/frontend/frontend-api.module.ts @@ -1,10 +1,13 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { AuthenticatedGuard } from './api/authentication.guard.js'; +import { AuthenticationInterceptor } from './api/authentication.interceptor.js'; import { FrontendController } from './api/frontend.controller.js'; -import { HttpModule } from '@nestjs/axios'; +import { LoginService } from './outbound/login.service.js'; @Module({ imports: [HttpModule], - providers: [], + providers: [LoginService, AuthenticationInterceptor, AuthenticatedGuard], controllers: [FrontendController], }) export class FrontendApiModule {} diff --git a/src/modules/frontend/outbound/login.service.spec.ts b/src/modules/frontend/outbound/login.service.spec.ts new file mode 100644 index 000000000..5be2b69a1 --- /dev/null +++ b/src/modules/frontend/outbound/login.service.spec.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ConfigTestModule } from '../../../../test/utils/index.js'; +import { LoginService } from './login.service.js'; + +describe('LoginService', () => { + let module: TestingModule; + let sut: LoginService; + let httpService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule], + providers: [LoginService, { provide: HttpService, useValue: createMock() }], + }).compile(); + + sut = module.get(LoginService); + httpService = module.get(HttpService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('login', () => { + it('should call backend', () => { + const username: string = faker.internet.userName(); + const password: string = faker.internet.password(); + + sut.login(username, password); + + expect(httpService.post).toHaveBeenCalledWith(expect.stringContaining('/api/login'), { + username, + password, + }); + }); + }); +}); diff --git a/src/modules/frontend/outbound/login.service.ts b/src/modules/frontend/outbound/login.service.ts new file mode 100644 index 000000000..0b1b613d1 --- /dev/null +++ b/src/modules/frontend/outbound/login.service.ts @@ -0,0 +1,20 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AxiosResponse } from 'axios'; +import { TokenSet } from 'openid-client'; +import { Observable } from 'rxjs'; +import { FrontendConfig, ServerConfig } from '../../../shared/config/index.js'; + +@Injectable() +export class LoginService { + private backend: string; + + public constructor(private httpService: HttpService, config: ConfigService) { + this.backend = config.getOrThrow('FRONTEND').BACKEND_ADDRESS; + } + + public login(username: string, password: string): Observable> { + return this.httpService.post(`${this.backend}/api/login`, { username, password }); + } +} diff --git a/src/shared/config/config.loader.spec.ts b/src/shared/config/config.loader.spec.ts index 1dd8320b6..1f325c49f 100644 --- a/src/shared/config/config.loader.spec.ts +++ b/src/shared/config/config.loader.spec.ts @@ -31,6 +31,7 @@ describe('configloader', () => { }, FRONTEND: { PORT: 8081, + BACKEND_ADDRESS: 'http://localhost:8080', }, DB: { CLIENT_URL: 'postgres://localhost:5432', diff --git a/src/shared/config/frontend.config.ts b/src/shared/config/frontend.config.ts index b13936125..4cddc4ecf 100644 --- a/src/shared/config/frontend.config.ts +++ b/src/shared/config/frontend.config.ts @@ -9,4 +9,8 @@ export class FrontendConfig { @IsString() @IsNotEmpty() public readonly SESSION_SECRET!: string; + + @IsString() + @IsNotEmpty() + public readonly BACKEND_ADDRESS!: string; }