authentication authorization

This commit is contained in:
2025-12-26 15:32:22 +01:00
parent 68ae9da22a
commit 64377de548
54 changed files with 2833 additions and 95 deletions

View File

@@ -1,7 +1,16 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { RaceController } from './RaceController';
import { RaceService } from './RaceService';
import { vi, Mocked } from 'vitest';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('RaceController', () => {
let controller: RaceController;
@@ -70,4 +79,75 @@ describe('RaceController', () => {
expect(result).toEqual(mockPresenter.viewModel);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [RaceController],
providers: [
{
provide: RaceService,
useValue: {
getAllRaces: vi.fn(async () => ({ viewModel: { races: [], filters: { statuses: [], leagues: [] } } })),
registerForRace: vi.fn(async () => ({ viewModel: { success: true } })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() read without a session', async () => {
await request(app.getHttpServer()).get('/races/all').expect(200);
});
it('denies mutation by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(401);
});
it('allows mutation when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query, Inject } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
@@ -24,6 +25,7 @@ import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
export class RaceController {
constructor(@Inject(RaceService) private readonly raceService: RaceService) {}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all races' })
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO })
@@ -32,6 +34,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('total-races')
@ApiOperation({ summary: 'Get the total number of races' })
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO })
@@ -40,6 +43,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('page-data')
@ApiOperation({ summary: 'Get races page data' })
@ApiQuery({ name: 'leagueId', description: 'League ID' })
@@ -49,6 +53,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('all/page-data')
@ApiOperation({ summary: 'Get all races page data' })
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
@@ -57,6 +62,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get(':raceId')
@ApiOperation({ summary: 'Get race detail' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -64,12 +70,14 @@ export class RaceController {
@ApiResponse({ status: 200, description: 'Race detail', type: RaceDetailDTO })
async getRaceDetail(
@Param('raceId') raceId: string,
@Query('driverId') driverId: string,
@Query('driverId') driverId?: string,
): Promise<RaceDetailDTO> {
const presenter = await this.raceService.getRaceDetail({ raceId, driverId });
const params = driverId ? { raceId, driverId } : { raceId };
const presenter = await this.raceService.getRaceDetail(params);
return await presenter.viewModel;
}
@Public()
@Get(':raceId/results')
@ApiOperation({ summary: 'Get race results detail' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -79,6 +87,7 @@ export class RaceController {
return await presenter.viewModel;
}
@Public()
@Get(':raceId/sof')
@ApiOperation({ summary: 'Get race with strength of field' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -88,6 +97,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get(':raceId/protests')
@ApiOperation({ summary: 'Get race protests' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -97,6 +107,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('reference/penalty-types')
@ApiOperation({ summary: 'Get allowed penalty types and semantics' })
@ApiResponse({ status: 200, description: 'Penalty types reference', type: PenaltyTypesReferenceDTO })
@@ -104,6 +115,7 @@ export class RaceController {
return this.raceService.getPenaltyTypesReference();
}
@Public()
@Get(':raceId/penalties')
@ApiOperation({ summary: 'Get race penalties' })
@ApiParam({ name: 'raceId', description: 'Race ID' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class GetRaceDetailParamsDTO {
@ApiProperty()
@@ -7,8 +7,8 @@ export class GetRaceDetailParamsDTO {
@IsNotEmpty()
raceId!: string;
@ApiProperty()
@ApiPropertyOptional()
@IsString()
@IsNotEmpty()
driverId!: string;
@IsOptional()
driverId?: string;
}