From ae8f6d4435c7cd6bd54bf1d1dbc2528678037f0c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 08:22:16 +0200 Subject: [PATCH] feat(backend): Auth/Users/Audit-Modul (Modul 1) AuthModule: Login/Refresh/Logout mit JWT-Access-Token + rotierendem, gehashtem Refresh-Token (RefreshToken-Tabelle). JwtAuthGuard + RolesGuard global via APP_GUARD registriert (gilt fuer jeden kuenftigen Endpunkt), @Public()-Decorator als Ausnahme (Auth-Endpunkte, /health). UsersModule: CRUD + Deactivate, nur ADMIN-Rolle, Passwoerter mit bcrypt gehasht, nie im Response enthalten. AuditModule: AuditLogService (Grundstruktur), wird von UsersService bei create/update/deactivate injiziert und geschrieben. Geteilte App-Konfiguration (Prefix/Versioning/Pipes/Helmet) aus main.ts in src/app.config.ts extrahiert; e2e-Tests nutzten das vorher nicht und bekamen dadurch 404 auf jede /api/v1/... Route - jetzt ueber test/utils/create-test-app.ts geteilt, betrifft alle kuenftigen e2e-Tests gleichermassen. prisma/seed.ts legt einen Admin-User fuer lokale Entwicklung an. 26 Unit-Tests + 11 e2e-Tests (gegen echte Postgres-Testdatenbank) gruen, Lint/Typecheck/Build gruen, Login/Refresh/Logout/Rollenpruefung manuell gegen laufenden Server verifiziert. --- ApiCore-Backend/.gitignore | 3 + ApiCore-Backend/prisma.config.ts | 1 + ApiCore-Backend/prisma/seed.ts | 35 ++++ ApiCore-Backend/src/app.config.ts | 27 ++++ ApiCore-Backend/src/app.controller.spec.ts | 22 --- ApiCore-Backend/src/app.controller.ts | 12 -- ApiCore-Backend/src/app.module.ts | 18 ++- ApiCore-Backend/src/app.service.ts | 8 - .../src/audit/audit-log.service.ts | 30 ++++ ApiCore-Backend/src/audit/audit.module.ts | 9 ++ ApiCore-Backend/src/auth/auth.controller.ts | 34 ++++ ApiCore-Backend/src/auth/auth.module.ts | 14 ++ ApiCore-Backend/src/auth/auth.service.spec.ts | 150 ++++++++++++++++++ ApiCore-Backend/src/auth/auth.service.ts | 106 +++++++++++++ .../auth/decorators/current-user.decorator.ts | 13 ++ .../src/auth/decorators/public.decorator.ts | 4 + .../src/auth/decorators/roles.decorator.ts | 5 + .../src/auth/dto/auth-tokens.dto.ts | 9 ++ ApiCore-Backend/src/auth/dto/login.dto.ts | 13 ++ .../src/auth/dto/refresh-token.dto.ts | 8 + .../src/auth/guards/jwt-auth.guard.ts | 22 +++ .../src/auth/guards/roles.guard.spec.ts | 48 ++++++ .../src/auth/guards/roles.guard.ts | 27 ++++ .../src/auth/strategies/jwt.strategy.ts | 25 +++ .../src/auth/types/authenticated-user.ts | 7 + .../src/auth/util/duration.spec.ts | 18 +++ ApiCore-Backend/src/auth/util/duration.ts | 15 ++ .../src/health/health.controller.ts | 2 + ApiCore-Backend/src/main.ts | 21 +-- ApiCore-Backend/src/prisma/prisma.service.ts | 4 +- .../src/users/dto/create-user.dto.ts | 22 +++ .../src/users/dto/update-user.dto.ts | 15 ++ ApiCore-Backend/src/users/users.controller.ts | 46 ++++++ ApiCore-Backend/src/users/users.module.ts | 10 ++ .../src/users/users.service.spec.ts | 125 +++++++++++++++ ApiCore-Backend/src/users/users.service.ts | 119 ++++++++++++++ ApiCore-Backend/test/app.e2e-spec.ts | 29 ---- ApiCore-Backend/test/auth.e2e-spec.ts | 91 +++++++++++ ApiCore-Backend/test/health.e2e-spec.ts | 30 ++++ ApiCore-Backend/test/jest-e2e.json | 1 + ApiCore-Backend/test/setup-env.ts | 4 + ApiCore-Backend/test/users.e2e-spec.ts | 102 ++++++++++++ ApiCore-Backend/test/utils/api.ts | 30 ++++ ApiCore-Backend/test/utils/create-test-app.ts | 16 ++ ApiCore-Backend/tsconfig.build.json | 2 +- TASKS.md | 4 +- 46 files changed, 1258 insertions(+), 98 deletions(-) create mode 100644 ApiCore-Backend/prisma/seed.ts create mode 100644 ApiCore-Backend/src/app.config.ts delete mode 100644 ApiCore-Backend/src/app.controller.spec.ts delete mode 100644 ApiCore-Backend/src/app.controller.ts delete mode 100644 ApiCore-Backend/src/app.service.ts create mode 100644 ApiCore-Backend/src/audit/audit-log.service.ts create mode 100644 ApiCore-Backend/src/audit/audit.module.ts create mode 100644 ApiCore-Backend/src/auth/auth.controller.ts create mode 100644 ApiCore-Backend/src/auth/auth.module.ts create mode 100644 ApiCore-Backend/src/auth/auth.service.spec.ts create mode 100644 ApiCore-Backend/src/auth/auth.service.ts create mode 100644 ApiCore-Backend/src/auth/decorators/current-user.decorator.ts create mode 100644 ApiCore-Backend/src/auth/decorators/public.decorator.ts create mode 100644 ApiCore-Backend/src/auth/decorators/roles.decorator.ts create mode 100644 ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts create mode 100644 ApiCore-Backend/src/auth/dto/login.dto.ts create mode 100644 ApiCore-Backend/src/auth/dto/refresh-token.dto.ts create mode 100644 ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 ApiCore-Backend/src/auth/guards/roles.guard.spec.ts create mode 100644 ApiCore-Backend/src/auth/guards/roles.guard.ts create mode 100644 ApiCore-Backend/src/auth/strategies/jwt.strategy.ts create mode 100644 ApiCore-Backend/src/auth/types/authenticated-user.ts create mode 100644 ApiCore-Backend/src/auth/util/duration.spec.ts create mode 100644 ApiCore-Backend/src/auth/util/duration.ts create mode 100644 ApiCore-Backend/src/users/dto/create-user.dto.ts create mode 100644 ApiCore-Backend/src/users/dto/update-user.dto.ts create mode 100644 ApiCore-Backend/src/users/users.controller.ts create mode 100644 ApiCore-Backend/src/users/users.module.ts create mode 100644 ApiCore-Backend/src/users/users.service.spec.ts create mode 100644 ApiCore-Backend/src/users/users.service.ts delete mode 100644 ApiCore-Backend/test/app.e2e-spec.ts create mode 100644 ApiCore-Backend/test/auth.e2e-spec.ts create mode 100644 ApiCore-Backend/test/health.e2e-spec.ts create mode 100644 ApiCore-Backend/test/setup-env.ts create mode 100644 ApiCore-Backend/test/users.e2e-spec.ts create mode 100644 ApiCore-Backend/test/utils/api.ts create mode 100644 ApiCore-Backend/test/utils/create-test-app.ts diff --git a/ApiCore-Backend/.gitignore b/ApiCore-Backend/.gitignore index 4ce8b12..5663bbb 100644 --- a/ApiCore-Backend/.gitignore +++ b/ApiCore-Backend/.gitignore @@ -2,6 +2,9 @@ node_modules dist coverage *.tsbuildinfo +prisma/seed.js +prisma/seed.js.map +prisma/seed.d.ts # Keep environment variables out of version control .env diff --git a/ApiCore-Backend/prisma.config.ts b/ApiCore-Backend/prisma.config.ts index 831a20f..b912ece 100644 --- a/ApiCore-Backend/prisma.config.ts +++ b/ApiCore-Backend/prisma.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", + seed: "ts-node prisma/seed.ts", }, datasource: { url: process.env["DATABASE_URL"], diff --git a/ApiCore-Backend/prisma/seed.ts b/ApiCore-Backend/prisma/seed.ts new file mode 100644 index 0000000..0d80a9d --- /dev/null +++ b/ApiCore-Backend/prisma/seed.ts @@ -0,0 +1,35 @@ +import 'dotenv/config'; +import * as bcrypt from 'bcryptjs'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma'; + +const BCRYPT_SALT_ROUNDS = 12; +const DEFAULT_ADMIN_EMAIL = 'admin@apicore.local'; +const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!'; + +async function main(): Promise { + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); + + const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL; + const password = process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD; + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + console.log(`Seed: admin user ${email} already exists, skipping.`); + await prisma.$disconnect(); + return; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); + await prisma.user.create({ + data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' }, + }); + console.log(`Seed: created admin user ${email}.`); + await prisma.$disconnect(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts new file mode 100644 index 0000000..dd35f37 --- /dev/null +++ b/ApiCore-Backend/src/app.config.ts @@ -0,0 +1,27 @@ +import { + INestApplication, + RequestMethod, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import helmet from 'helmet'; + +export function configureApp(app: INestApplication): void { + app.use(helmet()); + + app.setGlobalPrefix('api', { + exclude: [ + { path: 'health', method: RequestMethod.GET }, + { path: 'health/ready', method: RequestMethod.GET }, + ], + }); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); +} diff --git a/ApiCore-Backend/src/app.controller.spec.ts b/ApiCore-Backend/src/app.controller.spec.ts deleted file mode 100644 index d22f389..0000000 --- a/ApiCore-Backend/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/ApiCore-Backend/src/app.controller.ts b/ApiCore-Backend/src/app.controller.ts deleted file mode 100644 index cce879e..0000000 --- a/ApiCore-Backend/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 7c9e705..64a61c3 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -1,17 +1,27 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { APP_GUARD } from '@nestjs/core'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; +import { AuditModule } from './audit/audit.module'; +import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { RolesGuard } from './auth/guards/roles.guard'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + AuditModule, HealthModule, + UsersModule, + AuthModule, + ], + controllers: [], + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, ], - controllers: [AppController], - providers: [AppService], }) export class AppModule {} diff --git a/ApiCore-Backend/src/app.service.ts b/ApiCore-Backend/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/ApiCore-Backend/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/ApiCore-Backend/src/audit/audit-log.service.ts b/ApiCore-Backend/src/audit/audit-log.service.ts new file mode 100644 index 0000000..d3fcdf2 --- /dev/null +++ b/ApiCore-Backend/src/audit/audit-log.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditAction, Prisma } from '../../generated/prisma'; + +interface RecordAuditLogInput { + entityType: string; + entityId: string; + action: AuditAction; + userId?: string; + beforeData?: unknown; + afterData?: unknown; +} + +@Injectable() +export class AuditLogService { + constructor(private readonly prisma: PrismaService) {} + + async record(input: RecordAuditLogInput): Promise { + await this.prisma.auditLog.create({ + data: { + entityType: input.entityType, + entityId: input.entityId, + action: input.action, + userId: input.userId, + beforeData: input.beforeData as Prisma.InputJsonValue, + afterData: input.afterData as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/ApiCore-Backend/src/audit/audit.module.ts b/ApiCore-Backend/src/audit/audit.module.ts new file mode 100644 index 0000000..a91777c --- /dev/null +++ b/ApiCore-Backend/src/audit/audit.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditLogService } from './audit-log.service'; + +@Global() +@Module({ + providers: [AuditLogService], + exports: [AuditLogService], +}) +export class AuditModule {} diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..f83ea85 --- /dev/null +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from './decorators/public.decorator'; +import { LoginDto } from './dto/login.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { AuthTokensDto } from './dto/auth-tokens.dto'; +import { AuthService } from './auth.service'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('login') + @HttpCode(HttpStatus.OK) + login(@Body() dto: LoginDto): Promise { + return this.authService.login(dto.email, dto.password); + } + + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + refresh(@Body() dto: RefreshTokenDto): Promise { + return this.authService.refresh(dto.refreshToken); + } + + @Public() + @Post('logout') + @HttpCode(HttpStatus.NO_CONTENT) + async logout(@Body() dto: RefreshTokenDto): Promise { + await this.authService.logout(dto.refreshToken); + } +} diff --git a/ApiCore-Backend/src/auth/auth.module.ts b/ApiCore-Backend/src/auth/auth.module.ts new file mode 100644 index 0000000..b3d9048 --- /dev/null +++ b/ApiCore-Backend/src/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [PassportModule, JwtModule.register({}), UsersModule], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], +}) +export class AuthModule {} diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..ad37990 --- /dev/null +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -0,0 +1,150 @@ +import { UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { UsersService } from '../users/users.service'; + +describe('AuthService', () => { + let authService: AuthService; + let usersService: { findByEmail: jest.Mock; findById: jest.Mock }; + let prisma: { + refreshToken: { + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; + }; + let jwtService: { sign: jest.Mock }; + + const activeUser = { + id: 'user-1', + email: 'user@example.com', + role: 'STAFF', + isActive: true, + passwordHash: '', + }; + + beforeEach(async () => { + activeUser.passwordHash = await bcrypt.hash('correct-password', 4); + usersService = { findByEmail: jest.fn(), findById: jest.fn() }; + prisma = { + refreshToken: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + }; + jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; + + authService = new AuthService( + usersService as unknown as UsersService, + prisma as unknown as PrismaService, + jwtService as never, + ); + }); + + describe('login', () => { + it('throws when the user does not exist', async () => { + usersService.findByEmail.mockResolvedValue(null); + await expect( + authService.login('missing@example.com', 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('throws when the user is deactivated', async () => { + usersService.findByEmail.mockResolvedValue({ + ...activeUser, + isActive: false, + }); + await expect( + authService.login(activeUser.email, 'correct-password'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('throws when the password does not match', async () => { + usersService.findByEmail.mockResolvedValue(activeUser); + await expect( + authService.login(activeUser.email, 'wrong-password'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns tokens and stores a hashed refresh token on success', async () => { + usersService.findByEmail.mockResolvedValue(activeUser); + prisma.refreshToken.create.mockResolvedValue({}); + + const result = await authService.login( + activeUser.email, + 'correct-password', + ); + + expect(result.accessToken).toBe('signed.jwt.token'); + expect(result.refreshToken).toEqual(expect.any(String)); + const [[createArg]] = prisma.refreshToken.create.mock.calls as [ + [{ data: { userId: string } }], + ]; + expect(createArg.data.userId).toBe(activeUser.id); + }); + }); + + describe('refresh', () => { + it('throws when the refresh token is unknown', async () => { + prisma.refreshToken.findUnique.mockResolvedValue(null); + await expect(authService.refresh('unknown-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('throws when the refresh token is revoked', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: new Date(), + expiresAt: new Date(Date.now() + 1000), + }); + await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('throws when the refresh token is expired', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: null, + expiresAt: new Date(Date.now() - 1000), + }); + await expect(authService.refresh('expired-token')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rotates the refresh token on success', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: null, + expiresAt: new Date(Date.now() + 1000), + }); + usersService.findById.mockResolvedValue(activeUser); + prisma.refreshToken.update.mockResolvedValue({}); + prisma.refreshToken.create.mockResolvedValue({}); + + const result = await authService.refresh('valid-token'); + + expect(result.accessToken).toBe('signed.jwt.token'); + expect(prisma.refreshToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'rt-1' } }), + ); + }); + }); + + describe('logout', () => { + it('revokes the matching refresh token', async () => { + prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); + await authService.logout('some-token'); + expect(prisma.refreshToken.updateMany).toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts new file mode 100644 index 0000000..a8f1062 --- /dev/null +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -0,0 +1,106 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import * as crypto from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { UsersService } from '../users/users.service'; +import { AuthenticatedUser } from './types/authenticated-user'; +import { parseDurationToMs } from './util/duration'; + +interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +const REFRESH_TOKEN_BYTES = 40; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + ) {} + + private hashRefreshToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private signAccessToken(user: AuthenticatedUser): string { + const expiresInSeconds = Math.floor( + parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000, + ); + return this.jwtService.sign( + { sub: user.id, email: user.email, role: user.role }, + { + secret: process.env.JWT_ACCESS_SECRET, + expiresIn: expiresInSeconds, + }, + ); + } + + private async issueRefreshToken(userId: string): Promise { + const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex'); + const expiresAt = new Date( + Date.now() + + parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), + ); + await this.prisma.refreshToken.create({ + data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt }, + }); + return token; + } + + async login(email: string, password: string): Promise { + const user = await this.usersService.findByEmail(email); + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid credentials'); + } + const passwordMatches = await bcrypt.compare(password, user.passwordHash); + if (!passwordMatches) { + throw new UnauthorizedException('Invalid credentials'); + } + const authenticatedUser: AuthenticatedUser = { + id: user.id, + email: user.email, + role: user.role, + }; + const accessToken = this.signAccessToken(authenticatedUser); + const refreshToken = await this.issueRefreshToken(user.id); + return { accessToken, refreshToken }; + } + + async refresh(refreshToken: string): Promise { + const tokenHash = this.hashRefreshToken(refreshToken); + const stored = await this.prisma.refreshToken.findUnique({ + where: { tokenHash }, + }); + if (!stored || stored.revokedAt || stored.expiresAt < new Date()) { + throw new UnauthorizedException('Invalid refresh token'); + } + const user = await this.usersService.findById(stored.userId); + if (!user.isActive) { + throw new UnauthorizedException('Invalid refresh token'); + } + await this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { revokedAt: new Date() }, + }); + const authenticatedUser: AuthenticatedUser = { + id: user.id, + email: user.email, + role: user.role, + }; + const accessToken = this.signAccessToken(authenticatedUser); + const newRefreshToken = await this.issueRefreshToken(user.id); + return { accessToken, refreshToken: newRefreshToken }; + } + + async logout(refreshToken: string): Promise { + const tokenHash = this.hashRefreshToken(refreshToken); + await this.prisma.refreshToken.updateMany({ + where: { tokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } +} diff --git a/ApiCore-Backend/src/auth/decorators/current-user.decorator.ts b/ApiCore-Backend/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..3f682fe --- /dev/null +++ b/ApiCore-Backend/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,13 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthenticatedUser } from '../types/authenticated-user'; + +interface RequestWithUser { + user: AuthenticatedUser; +} + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthenticatedUser => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/ApiCore-Backend/src/auth/decorators/public.decorator.ts b/ApiCore-Backend/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/ApiCore-Backend/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/ApiCore-Backend/src/auth/decorators/roles.decorator.ts b/ApiCore-Backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..244ca5b --- /dev/null +++ b/ApiCore-Backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../../../generated/prisma'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts b/ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts new file mode 100644 index 0000000..5ffba97 --- /dev/null +++ b/ApiCore-Backend/src/auth/dto/auth-tokens.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthTokensDto { + @ApiProperty() + accessToken!: string; + + @ApiProperty() + refreshToken!: string; +} diff --git a/ApiCore-Backend/src/auth/dto/login.dto.ts b/ApiCore-Backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..72222dd --- /dev/null +++ b/ApiCore-Backend/src/auth/dto/login.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(8) + password!: string; +} diff --git a/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts b/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..e6c64d3 --- /dev/null +++ b/ApiCore-Backend/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @ApiProperty() + @IsString() + refreshToken!: string; +} diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..345c4ad --- /dev/null +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,22 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts b/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts new file mode 100644 index 0000000..c9cf8a9 --- /dev/null +++ b/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts @@ -0,0 +1,48 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; +import { Role } from '../../../generated/prisma'; + +function createContext(user: { role: Role } | undefined): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; +} + +describe('RolesGuard', () => { + it('allows access when no roles are required', () => { + const reflector = { + getAllAndOverride: () => undefined, + } as unknown as Reflector; + const guard = new RolesGuard(reflector); + expect(guard.canActivate(createContext(undefined))).toBe(true); + }); + + it('denies access when the user role does not match', () => { + const reflector = { + getAllAndOverride: () => [Role.ADMIN], + } as unknown as Reflector; + const guard = new RolesGuard(reflector); + expect(guard.canActivate(createContext({ role: Role.STAFF }))).toBe(false); + }); + + it('allows access when the user role matches', () => { + const reflector = { + getAllAndOverride: () => [Role.ADMIN], + } as unknown as Reflector; + const guard = new RolesGuard(reflector); + expect(guard.canActivate(createContext({ role: Role.ADMIN }))).toBe(true); + }); + + it('denies access when no user is attached to the request', () => { + const reflector = { + getAllAndOverride: () => [Role.ADMIN], + } as unknown as Reflector; + const guard = new RolesGuard(reflector); + expect(guard.canActivate(createContext(undefined))).toBe(false); + }); +}); diff --git a/ApiCore-Backend/src/auth/guards/roles.guard.ts b/ApiCore-Backend/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..4f14b09 --- /dev/null +++ b/ApiCore-Backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '../../../generated/prisma'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { AuthenticatedUser } from '../types/authenticated-user'; + +interface RequestWithUser { + user?: AuthenticatedUser; +} + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + const request = context.switchToHttp().getRequest(); + const user = request.user; + return !!user && requiredRoles.includes(user.role); + } +} diff --git a/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..9d4e92f --- /dev/null +++ b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthenticatedUser } from '../types/authenticated-user'; + +interface AccessTokenPayload { + sub: string; + email: string; + role: AuthenticatedUser['role']; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_ACCESS_SECRET as string, + }); + } + + validate(payload: AccessTokenPayload): AuthenticatedUser { + return { id: payload.sub, email: payload.email, role: payload.role }; + } +} diff --git a/ApiCore-Backend/src/auth/types/authenticated-user.ts b/ApiCore-Backend/src/auth/types/authenticated-user.ts new file mode 100644 index 0000000..595a438 --- /dev/null +++ b/ApiCore-Backend/src/auth/types/authenticated-user.ts @@ -0,0 +1,7 @@ +import { Role } from '../../../generated/prisma'; + +export interface AuthenticatedUser { + id: string; + email: string; + role: Role; +} diff --git a/ApiCore-Backend/src/auth/util/duration.spec.ts b/ApiCore-Backend/src/auth/util/duration.spec.ts new file mode 100644 index 0000000..9b96be5 --- /dev/null +++ b/ApiCore-Backend/src/auth/util/duration.spec.ts @@ -0,0 +1,18 @@ +import { parseDurationToMs } from './duration'; + +describe('parseDurationToMs', () => { + it.each([ + ['15s', 15_000], + ['15m', 900_000], + ['2h', 7_200_000], + ['7d', 604_800_000], + ])('parses %s to %i ms', (spec, expected) => { + expect(parseDurationToMs(spec)).toBe(expected); + }); + + it('throws on an invalid spec', () => { + expect(() => parseDurationToMs('not-a-duration')).toThrow( + 'Invalid duration spec', + ); + }); +}); diff --git a/ApiCore-Backend/src/auth/util/duration.ts b/ApiCore-Backend/src/auth/util/duration.ts new file mode 100644 index 0000000..e7ae513 --- /dev/null +++ b/ApiCore-Backend/src/auth/util/duration.ts @@ -0,0 +1,15 @@ +const UNIT_MS: Record = { + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +export function parseDurationToMs(spec: string): number { + const match = /^(\d+)(s|m|h|d)$/.exec(spec.trim()); + if (!match) { + throw new Error(`Invalid duration spec: ${spec}`); + } + const [, amount, unit] = match; + return Number(amount) * UNIT_MS[unit]; +} diff --git a/ApiCore-Backend/src/health/health.controller.ts b/ApiCore-Backend/src/health/health.controller.ts index 1d06f23..2c9d664 100644 --- a/ApiCore-Backend/src/health/health.controller.ts +++ b/ApiCore-Backend/src/health/health.controller.ts @@ -6,8 +6,10 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { PrismaService } from '../prisma/prisma.service'; +import { Public } from '../auth/decorators/public.decorator'; @ApiTags('health') +@Public() @Controller({ path: 'health', version: VERSION_NEUTRAL }) export class HealthController { constructor(private readonly prisma: PrismaService) {} diff --git a/ApiCore-Backend/src/main.ts b/ApiCore-Backend/src/main.ts index a982da2..51f260e 100644 --- a/ApiCore-Backend/src/main.ts +++ b/ApiCore-Backend/src/main.ts @@ -1,29 +1,12 @@ import { NestFactory } from '@nestjs/core'; -import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import helmet from 'helmet'; import { AppModule } from './app.module'; +import { configureApp } from './app.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.use(helmet()); - - app.setGlobalPrefix('api', { - exclude: [ - { path: 'health', method: RequestMethod.GET }, - { path: 'health/ready', method: RequestMethod.GET }, - ], - }); - app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); + configureApp(app); const swaggerConfig = new DocumentBuilder() .setTitle('ApiCore API') diff --git a/ApiCore-Backend/src/prisma/prisma.service.ts b/ApiCore-Backend/src/prisma/prisma.service.ts index 234458f..ec6d742 100644 --- a/ApiCore-Backend/src/prisma/prisma.service.ts +++ b/ApiCore-Backend/src/prisma/prisma.service.ts @@ -8,7 +8,9 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { constructor() { - super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }); + super({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); } async onModuleInit(): Promise { diff --git a/ApiCore-Backend/src/users/dto/create-user.dto.ts b/ApiCore-Backend/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..f19efa4 --- /dev/null +++ b/ApiCore-Backend/src/users/dto/create-user.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; +import { Role } from '../../../generated/prisma'; + +export class CreateUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(8) + password!: string; + + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty({ enum: Role }) + @IsEnum(Role) + role!: Role; +} diff --git a/ApiCore-Backend/src/users/dto/update-user.dto.ts b/ApiCore-Backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..9c122d4 --- /dev/null +++ b/ApiCore-Backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { Role } from '../../../generated/prisma'; + +export class UpdateUserDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ enum: Role }) + @IsOptional() + @IsEnum(Role) + role?: Role; +} diff --git a/ApiCore-Backend/src/users/users.controller.ts b/ApiCore-Backend/src/users/users.controller.ts new file mode 100644 index 0000000..4f70815 --- /dev/null +++ b/ApiCore-Backend/src/users/users.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; + +@ApiTags('users') +@ApiBearerAuth() +@Controller('users') +@Roles(Role.ADMIN) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + findAll() { + return this.usersService.findAll(); + } + + @Post() + create(@Body() dto: CreateUserDto, @CurrentUser() actor: AuthenticatedUser) { + return this.usersService.create(dto, actor.id); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findSummaryById(id); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateUserDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.usersService.update(id, dto, actor.id); + } + + @Post(':id/deactivate') + deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.usersService.deactivate(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/users/users.module.ts b/ApiCore-Backend/src/users/users.module.ts new file mode 100644 index 0000000..513776d --- /dev/null +++ b/ApiCore-Backend/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/ApiCore-Backend/src/users/users.service.spec.ts b/ApiCore-Backend/src/users/users.service.spec.ts new file mode 100644 index 0000000..5b508fc --- /dev/null +++ b/ApiCore-Backend/src/users/users.service.spec.ts @@ -0,0 +1,125 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('UsersService', () => { + let usersService: UsersService; + let prisma: { + user: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const existingUser = { + id: 'user-1', + email: 'existing@example.com', + passwordHash: 'hashed', + name: 'Existing User', + role: 'STAFF', + isActive: true, + createdAt: new Date(), + deactivatedAt: null, + }; + + beforeEach(() => { + prisma = { + user: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + usersService = new UsersService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findAll returns summaries without the password hash', async () => { + prisma.user.findMany.mockResolvedValue([existingUser]); + + const result = await usersService.findAll(); + + expect(result).toEqual([ + { + id: existingUser.id, + email: existingUser.email, + name: existingUser.name, + role: existingUser.role, + isActive: existingUser.isActive, + createdAt: existingUser.createdAt, + deactivatedAt: existingUser.deactivatedAt, + }, + ]); + expect(result[0]).not.toHaveProperty('passwordHash'); + }); + + it('findById throws NotFoundException when the user is missing', async () => { + prisma.user.findUnique.mockResolvedValue(null); + await expect(usersService.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create throws ConflictException when the email is already in use', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + await expect( + usersService.create({ + email: existingUser.email, + password: 'password123', + name: 'New', + role: 'STAFF', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('create hashes the password, persists the user, and records an audit entry', async () => { + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ + ...existingUser, + id: 'user-2', + email: 'new@example.com', + }); + + const result = await usersService.create({ + email: 'new@example.com', + password: 'password123', + name: 'New User', + role: 'STAFF', + }); + + expect(result.id).toBe('user-2'); + const [[createArg]] = prisma.user.create.mock.calls as [ + [{ data: { email: string; passwordHash: string } }], + ]; + expect(createArg.data.email).toBe('new@example.com'); + expect(createArg.data.passwordHash).not.toBe('password123'); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'User', action: 'CREATE' }), + ); + }); + + it('deactivate sets isActive to false and records an audit entry', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + prisma.user.update.mockResolvedValue({ + ...existingUser, + isActive: false, + deactivatedAt: new Date(), + }); + + const result = await usersService.deactivate(existingUser.id); + + expect(result.isActive).toBe(false); + expect(result.deactivatedAt).not.toBeNull(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'User', action: 'DEACTIVATE' }), + ); + }); +}); diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts new file mode 100644 index 0000000..19b2e28 --- /dev/null +++ b/ApiCore-Backend/src/users/users.service.ts @@ -0,0 +1,119 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { User } from '../../generated/prisma'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +const BCRYPT_SALT_ROUNDS = 12; + +export interface UserSummary { + id: string; + email: string; + name: string; + role: string; + isActive: boolean; + createdAt: Date; + deactivatedAt: Date | null; +} + +@Injectable() +export class UsersService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + private toSummary(user: User): UserSummary { + const { id, email, name, role, isActive, createdAt, deactivatedAt } = user; + return { id, email, name, role, isActive, createdAt, deactivatedAt }; + } + + async findAll(): Promise { + const users = await this.prisma.user.findMany({ + orderBy: { createdAt: 'asc' }, + }); + return users.map((user) => this.toSummary(user)); + } + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + async findSummaryById(id: string): Promise { + return this.toSummary(await this.findById(id)); + } + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + + async create( + dto: CreateUserDto, + actingUserId?: string, + ): Promise { + const existing = await this.findByEmail(dto.email); + if (existing) { + throw new ConflictException('Email already in use'); + } + const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); + const user = await this.prisma.user.create({ + data: { email: dto.email, passwordHash, name: dto.name, role: dto.role }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: user.id, + action: 'CREATE', + userId: actingUserId, + afterData: this.toSummary(user), + }); + return this.toSummary(user); + } + + async update( + id: string, + dto: UpdateUserDto, + actingUserId?: string, + ): Promise { + const before = await this.findById(id); + const user = await this.prisma.user.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: this.toSummary(before), + afterData: this.toSummary(user), + }); + return this.toSummary(user); + } + + async deactivate(id: string, actingUserId?: string): Promise { + const before = await this.findById(id); + const user = await this.prisma.user.update({ + where: { id }, + data: { isActive: false, deactivatedAt: new Date() }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: id, + action: 'DEACTIVATE', + userId: actingUserId, + beforeData: this.toSummary(before), + afterData: this.toSummary(user), + }); + return this.toSummary(user); + } +} diff --git a/ApiCore-Backend/test/app.e2e-spec.ts b/ApiCore-Backend/test/app.e2e-spec.ts deleted file mode 100644 index a767839..0000000 --- a/ApiCore-Backend/test/app.e2e-spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); - - afterEach(async () => { - await app.close(); - }); -}); diff --git a/ApiCore-Backend/test/auth.e2e-spec.ts b/ApiCore-Backend/test/auth.e2e-spec.ts new file mode 100644 index 0000000..77cf708 --- /dev/null +++ b/ApiCore-Backend/test/auth.e2e-spec.ts @@ -0,0 +1,91 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { AuthTokensBody, login } from './utils/api'; + +describe('Auth (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const email = `auth-e2e-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let userId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { email, passwordHash, name: 'Auth E2E User', role: 'ADMIN' }, + }); + userId = user.id; + }); + + afterAll(async () => { + await prisma.refreshToken.deleteMany({ where: { userId } }); + await prisma.auditLog.deleteMany({ where: { userId } }); + await prisma.user.delete({ where: { id: userId } }); + await app.close(); + }); + + it('rejects login with wrong password', () => { + return request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email, password: 'wrong-password' }) + .expect(401); + }); + + it('logs in and returns access + refresh tokens', async () => { + const tokens = await login(app, email, password); + + expect(tokens.accessToken).toEqual(expect.any(String)); + expect(tokens.refreshToken).toEqual(expect.any(String)); + }); + + it('rejects a protected route without a token', () => { + return request(app.getHttpServer()).get('/api/v1/users').expect(401); + }); + + it('accepts a protected route with a valid access token', async () => { + const tokens = await login(app, email, password); + + await request(app.getHttpServer()) + .get('/api/v1/users') + .set('Authorization', `Bearer ${tokens.accessToken}`) + .expect(200); + }); + + it('rotates the refresh token and rejects reuse of the old one', async () => { + const tokens = await login(app, email, password); + + const refreshResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .send({ refreshToken: tokens.refreshToken }) + .expect(200); + const refreshed = refreshResponse.body as AuthTokensBody; + + expect(refreshed.refreshToken).not.toEqual(tokens.refreshToken); + + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .send({ refreshToken: tokens.refreshToken }) + .expect(401); + }); + + it('logs out and invalidates the refresh token', async () => { + const tokens = await login(app, email, password); + + await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .send({ refreshToken: tokens.refreshToken }) + .expect(204); + + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .send({ refreshToken: tokens.refreshToken }) + .expect(401); + }); +}); diff --git a/ApiCore-Backend/test/health.e2e-spec.ts b/ApiCore-Backend/test/health.e2e-spec.ts new file mode 100644 index 0000000..c8e07b3 --- /dev/null +++ b/ApiCore-Backend/test/health.e2e-spec.ts @@ -0,0 +1,30 @@ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { createTestApp } from './utils/create-test-app'; + +describe('Health (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /health returns ok without auth', () => { + return request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect({ status: 'ok' }); + }); + + it('GET /health/ready returns ok when the database is reachable', () => { + return request(app.getHttpServer()) + .get('/health/ready') + .expect(200) + .expect({ status: 'ok' }); + }); +}); diff --git a/ApiCore-Backend/test/jest-e2e.json b/ApiCore-Backend/test/jest-e2e.json index e9d912f..d4ffa05 100644 --- a/ApiCore-Backend/test/jest-e2e.json +++ b/ApiCore-Backend/test/jest-e2e.json @@ -3,6 +3,7 @@ "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "setupFiles": ["/setup-env.ts"], "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/ApiCore-Backend/test/setup-env.ts b/ApiCore-Backend/test/setup-env.ts new file mode 100644 index 0000000..ec97629 --- /dev/null +++ b/ApiCore-Backend/test/setup-env.ts @@ -0,0 +1,4 @@ +import { config } from 'dotenv'; +import path from 'node:path'; + +config({ path: path.resolve(__dirname, '../.env.test') }); diff --git a/ApiCore-Backend/test/users.e2e-spec.ts b/ApiCore-Backend/test/users.e2e-spec.ts new file mode 100644 index 0000000..a5e7b3f --- /dev/null +++ b/ApiCore-Backend/test/users.e2e-spec.ts @@ -0,0 +1,102 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { UserBody, login } from './utils/api'; + +describe('Users (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const adminEmail = `users-e2e-admin-${Date.now()}@example.com`; + const staffEmail = `users-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let adminId: string; + let staffId: string; + let adminAccessToken: string; + let staffAccessToken: string; + let createdUserId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Users E2E Admin', + role: 'ADMIN', + }, + }); + adminId = admin.id; + const staff = await prisma.user.create({ + data: { + email: staffEmail, + passwordHash, + name: 'Users E2E Staff', + role: 'STAFF', + }, + }); + staffId = staff.id; + + adminAccessToken = (await login(app, adminEmail, password)).accessToken; + staffAccessToken = (await login(app, staffEmail, password)).accessToken; + }); + + afterAll(async () => { + const userIds = [adminId, staffId, createdUserId].filter(Boolean); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + if (createdUserId) { + await prisma.user.delete({ where: { id: createdUserId } }); + } + await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); + await app.close(); + }); + + it('rejects non-admin roles from listing users', () => { + return request(app.getHttpServer()) + .get('/api/v1/users') + .set('Authorization', `Bearer ${staffAccessToken}`) + .expect(403); + }); + + it('lets an admin create a user and writes an audit log entry', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/users') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + email: `users-e2e-created-${Date.now()}@example.com`, + password: 'AnotherSecret123!', + name: 'Created via e2e', + role: 'BEEKEEPER', + }) + .expect(201); + const created = response.body as UserBody & { passwordHash?: string }; + + expect(created.id).toEqual(expect.any(String)); + expect(created.passwordHash).toBeUndefined(); + createdUserId = created.id; + + const auditEntry = await prisma.auditLog.findFirst({ + where: { entityType: 'User', entityId: createdUserId, action: 'CREATE' }, + }); + expect(auditEntry).not.toBeNull(); + }); + + it('lets an admin deactivate a user', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/users/${createdUserId}/deactivate`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(201); + const deactivated = response.body as UserBody; + + expect(deactivated.isActive).toBe(false); + expect(deactivated.deactivatedAt).not.toBeNull(); + }); +}); diff --git a/ApiCore-Backend/test/utils/api.ts b/ApiCore-Backend/test/utils/api.ts new file mode 100644 index 0000000..e837feb --- /dev/null +++ b/ApiCore-Backend/test/utils/api.ts @@ -0,0 +1,30 @@ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; + +export interface AuthTokensBody { + accessToken: string; + refreshToken: string; +} + +export interface UserBody { + id: string; + email: string; + name: string; + role: string; + isActive: boolean; + createdAt: string; + deactivatedAt: string | null; +} + +export async function login( + app: INestApplication, + email: string, + password: string, +): Promise { + const response = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email, password }) + .expect(200); + return response.body as AuthTokensBody; +} diff --git a/ApiCore-Backend/test/utils/create-test-app.ts b/ApiCore-Backend/test/utils/create-test-app.ts new file mode 100644 index 0000000..bdbab74 --- /dev/null +++ b/ApiCore-Backend/test/utils/create-test-app.ts @@ -0,0 +1,16 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { App } from 'supertest/types'; +import { AppModule } from '../../src/app.module'; +import { configureApp } from '../../src/app.config'; + +export async function createTestApp(): Promise> { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestApplication>(); + configureApp(app); + await app.init(); + return app; +} diff --git a/ApiCore-Backend/tsconfig.build.json b/ApiCore-Backend/tsconfig.build.json index a65ffd0..eba2f6b 100644 --- a/ApiCore-Backend/tsconfig.build.json +++ b/ApiCore-Backend/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "rootDir": "./src" }, - "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts"] + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts", "prisma/seed.ts"] } diff --git a/TASKS.md b/TASKS.md index 5299a52..aef344b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -17,8 +17,8 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `AuthModule`, `UsersModule`, `AuditModule` (Grundstruktur) - Endpunkte: `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/users`, `POST /api/v1/users`, `GET/PATCH /api/v1/users/:id`, `POST /api/v1/users/:id/deactivate` - Frontend: `/login`, `/settings/users` (nur Administrator) -- [ ] Rollen-Enum + Guards (`JwtAuthGuard`, `RolesGuard`, `@Roles()`) implementiert und in jedem künftigen Modul verpflichtend -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Rollen-Enum + Guards (`JwtAuthGuard`, `RolesGuard`, `@Roles()`) implementiert und in jedem künftigen Modul verpflichtend +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 2. Bienenstände