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

@@ -11,6 +11,7 @@ import { LeagueModule } from './domain/league/LeagueModule';
import { LoggingModule } from './domain/logging/LoggingModule';
import { MediaModule } from './domain/media/MediaModule';
import { PaymentsModule } from './domain/payments/PaymentsModule';
import { PolicyModule } from './domain/policy/PolicyModule';
import { ProtestsModule } from './domain/protests/ProtestsModule';
import { RaceModule } from './domain/race/RaceModule';
import { SponsorModule } from './domain/sponsor/SponsorModule';
@@ -33,6 +34,7 @@ import { TeamModule } from './domain/team/TeamModule';
DriverModule,
MediaModule,
PaymentsModule,
PolicyModule,
],
})
export class AppModule {}

View File

@@ -1,3 +1,8 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
@@ -6,6 +11,11 @@ import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
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('AnalyticsController', () => {
let controller: AnalyticsController;
@@ -109,4 +119,83 @@ describe('AnalyticsController', () => {
expect(result).toEqual(dto);
});
});
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: [AnalyticsController],
providers: [
{
provide: AnalyticsService,
useValue: {
recordPageView: vi.fn(async () => ({})),
recordEngagement: vi.fn(async () => ({})),
getDashboardData: vi.fn(async () => ({})),
getAnalyticsMetrics: vi.fn(async () => ({})),
},
},
],
})
.overrideProvider(AnalyticsController)
.useFactory({
factory: (analyticsService: AnalyticsService) => new AnalyticsController(analyticsService),
inject: [AnalyticsService],
})
.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() endpoint without a session', async () => {
await request(app.getHttpServer()).post('/analytics/page-view').send({}).expect(201);
});
it('denies internal dashboard endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/analytics/dashboard').expect(401);
});
it('allows internal dashboard endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/analytics/dashboard').expect(200);
});
});
});

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Res, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Res, HttpStatus, Inject } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger';
import type { Response } from 'express';
import { Public } from '../auth/Public';
import { RecordPageViewInputDTO } from './dtos/RecordPageViewInputDTO';
import { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO';
@@ -16,9 +17,10 @@ type RecordEngagementInput = RecordEngagementInputDTO;
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
@Inject(AnalyticsService) private readonly analyticsService: AnalyticsService,
) {}
@Public()
@Post('page-view')
@ApiOperation({ summary: 'Record a page view' })
@ApiBody({ type: RecordPageViewInputDTO })
@@ -31,6 +33,7 @@ export class AnalyticsController {
res.status(HttpStatus.CREATED).json(dto);
}
@Public()
@Post('engagement')
@ApiOperation({ summary: 'Record an engagement event' })
@ApiBody({ type: RecordEngagementInputDTO })
@@ -43,6 +46,7 @@ export class AnalyticsController {
res.status(HttpStatus.CREATED).json(dto);
}
// Dashboard/metrics are internal and should not be publicly exposed.
@Get('dashboard')
@ApiOperation({ summary: 'Get analytics dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })

View File

@@ -1,8 +1,18 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { Mock, vi } from 'vitest';
import { AuthController } from './AuthController';
import { AuthService } from './AuthService';
import { AuthSessionDTO, LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import type { AuthorizationService } from './AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('AuthController', () => {
let controller: AuthController;
@@ -107,4 +117,72 @@ describe('AuthController', () => {
expect(result).toEqual(dto);
});
});
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: [AuthController],
providers: [
{
provide: AuthService,
useValue: {
getCurrentSession: vi.fn(async () => null),
loginWithEmail: vi.fn(),
signupWithEmail: vi.fn(),
logout: vi.fn(),
startIracingAuth: vi.fn(),
iracingCallback: vi.fn(),
},
},
],
})
.overrideProvider(AuthController)
.useFactory({
factory: (authService: AuthService) => new AuthController(authService),
inject: [AuthService],
})
.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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/auth/session').expect(200);
});
});
});

View File

@@ -1,11 +1,13 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, Inject } from '@nestjs/common';
import { Public } from './Public';
import { AuthService } from './AuthService';
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
@Public()
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('signup')
async signup(@Body() params: SignupParamsDTO): Promise<AuthSessionDTO> {

View File

@@ -0,0 +1,164 @@
import 'reflect-metadata';
import { Controller, Get } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import { AuthorizationService } from './AuthorizationService';
import { Public } from './Public';
import { RequireRoles } from './RequireRoles';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import { PolicyService, type PolicySnapshot } from '../policy/PolicyService';
import { RequireCapability } from '../policy/RequireCapability';
@Controller('authz-test')
class AuthzTestController {
@Public()
@Get('public')
publicRoute() {
return { ok: true };
}
@Get('protected')
protectedRoute() {
return { ok: true };
}
@RequireRoles('admin')
@Get('admin')
adminOnlyRoute() {
return { ok: true };
}
@RequireCapability('demo.feature', 'view')
@Get('feature')
featureGatedRoute() {
return { ok: true };
}
}
type SessionPort = {
getCurrentSession: () => Promise<null | { token: string; user: { id: string } }>;
};
describe('Auth guards (HTTP)', () => {
let app: any;
const sessionPort: SessionPort = {
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: { 'demo.feature': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AuthzTestController],
}).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()` routes without a session', async () => {
await request(app.getHttpServer()).get('/authz-test/public').expect(200).expect({ ok: true });
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
});
it('denies non-public routes by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/authz-test/protected').expect(401);
});
it('allows non-public routes when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/authz-test/protected').expect(200).expect({ ok: true });
});
it('returns 403 when route requires a role and the user does not have it', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/authz-test/admin').expect(403);
});
it('allows access when route requires a role and the user has it', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
await request(app.getHttpServer()).get('/authz-test/admin').expect(200).expect({ ok: true });
});
it('returns 404 when a `@RequireCapability()` feature is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'demo.feature': 'disabled' },
loadedFrom: 'env',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/authz-test/feature').expect(404);
});
it('returns 503 during maintenance when capability is not allowlisted', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'maintenance',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'demo.feature': 'enabled' },
loadedFrom: 'env',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/authz-test/feature').expect(503);
});
});

View File

@@ -2,10 +2,19 @@ import { Module } from '@nestjs/common';
import { AuthService } from './AuthService';
import { AuthController } from './AuthController';
import { AuthProviders } from './AuthProviders';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import { AuthorizationService } from './AuthorizationService';
@Module({
controllers: [AuthController],
providers: [AuthService, ...AuthProviders],
exports: [AuthService],
providers: [
AuthService,
...AuthProviders,
AuthenticationGuard,
AuthorizationService,
AuthorizationGuard,
],
exports: [AuthService, AuthenticationGuard, AuthorizationService, AuthorizationGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { AuthenticationGuard } from './AuthenticationGuard';
function createExecutionContext(request: Record<string, unknown>) {
return {
switchToHttp: () => ({
getRequest: () => request,
}),
};
}
describe('AuthenticationGuard', () => {
it('attaches request.user.userId from session when missing', async () => {
const request: any = {};
const sessionPort = {
getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(request.user).toEqual({ userId: 'user-1' });
});
it('does not override request.user.userId if already present', async () => {
const request: any = { user: { userId: 'already-set' } };
const sessionPort = {
getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).not.toHaveBeenCalled();
expect(request.user).toEqual({ userId: 'already-set' });
});
it('leaves request.user undefined when no session exists', async () => {
const request: any = {};
const sessionPort = {
getCurrentSession: vi.fn(async () => null),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(request.user).toBeUndefined();
});
});

View File

@@ -0,0 +1,30 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { IDENTITY_SESSION_PORT_TOKEN } from './AuthProviders';
type AuthenticatedRequest = {
user?: { userId: string };
};
@Injectable()
export class AuthenticationGuard implements CanActivate {
constructor(
@Inject(IDENTITY_SESSION_PORT_TOKEN)
private readonly sessionPort: IdentitySessionPort,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (request.user?.userId) {
return true;
}
const session = await this.sessionPort.getCurrentSession();
if (session?.user?.id) {
request.user = { userId: session.user.id };
}
return true;
}
}

View File

@@ -0,0 +1,89 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { describe, expect, it, vi } from 'vitest';
import { AuthorizationGuard } from './AuthorizationGuard';
import { Public } from './Public';
import { RequireRoles } from './RequireRoles';
class DummyController {
@Public()
publicHandler(): void {}
protectedHandler(): void {}
@RequireRoles('admin')
adminHandler(): void {}
}
function createExecutionContext(options: { handler: Function; userId?: string }) {
const request = options.userId ? { user: { userId: options.userId } } : {};
return {
getHandler: () => options.handler,
getClass: () => DummyController,
switchToHttp: () => ({
getRequest: () => request,
}),
};
}
describe('AuthorizationGuard', () => {
it('allows public routes without a user session', () => {
const authorizationService = { getRolesForUser: vi.fn() };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.publicHandler,
});
expect(guard.canActivate(ctx as any)).toBe(true);
expect(authorizationService.getRolesForUser).not.toHaveBeenCalled();
});
it('denies non-public routes by default when not authenticated', () => {
const authorizationService = { getRolesForUser: vi.fn() };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.protectedHandler,
});
expect(() => guard.canActivate(ctx as any)).toThrow(UnauthorizedException);
});
it('allows non-public routes when authenticated', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue([]) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.protectedHandler,
userId: 'user-1',
});
expect(guard.canActivate(ctx as any)).toBe(true);
});
it('denies routes requiring roles when user does not have any required role', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['user']) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.adminHandler,
userId: 'user-1',
});
expect(() => guard.canActivate(ctx as any)).toThrow(ForbiddenException);
});
it('allows routes requiring roles when user has a required role', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['admin']) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.adminHandler,
userId: 'user-1',
});
expect(guard.canActivate(ctx as any)).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthorizationService } from './AuthorizationService';
import { PUBLIC_ROUTE_METADATA_KEY } from './Public';
import { REQUIRE_AUTHENTICATED_USER_METADATA_KEY } from './RequireAuthenticatedUser';
import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles';
type AuthenticatedRequest = {
user?: { userId: string };
};
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authorizationService: AuthorizationService,
) {}
canActivate(context: ExecutionContext): boolean {
const handler = context.getHandler();
const controllerClass = context.getClass();
const isPublic =
this.reflector.getAllAndOverride<{ public: true } | undefined>(
PUBLIC_ROUTE_METADATA_KEY,
[handler, controllerClass],
)?.public ?? false;
if (isPublic) {
return true;
}
const requiresAuth =
this.reflector.getAllAndOverride<{ required: true } | undefined>(
REQUIRE_AUTHENTICATED_USER_METADATA_KEY,
[handler, controllerClass],
)?.required ?? false;
const rolesMetadata =
this.reflector.getAllAndOverride<RequireRolesMetadata | undefined>(
REQUIRE_ROLES_METADATA_KEY,
[handler, controllerClass],
) ?? null;
// Protect all endpoints by default:
// - if a route is not marked public, it requires an authenticated user.
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const userId = request.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
// If the route explicitly requires auth, that's already satisfied by userId.
void requiresAuth;
if (rolesMetadata && rolesMetadata.anyOf.length > 0) {
const userRoles = this.authorizationService.getRolesForUser(userId);
const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r));
if (!hasAnyRole) {
throw new ForbiddenException('Forbidden');
}
}
return true;
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
export type SystemRole = string;
@Injectable()
export class AuthorizationService {
private cache:
| {
rolesByUserId: Readonly<Record<string, readonly SystemRole[]>>;
expiresAtMs: number;
}
| null = null;
getRolesForUser(userId: string): readonly SystemRole[] {
const now = Date.now();
if (this.cache && now < this.cache.expiresAtMs) {
return this.cache.rolesByUserId[userId] ?? [];
}
const cacheMs = parseCacheMs(process.env.GRIDPILOT_AUTHZ_CACHE_MS);
const rolesByUserId = parseUserRolesJson(process.env.GRIDPILOT_USER_ROLES_JSON);
this.cache = {
rolesByUserId,
expiresAtMs: now + cacheMs,
};
return rolesByUserId[userId] ?? [];
}
}
function parseCacheMs(raw: string | undefined): number {
if (!raw) {
return 5_000;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) {
return 5_000;
}
return parsed;
}
function parseUserRolesJson(raw: string | undefined): Readonly<Record<string, readonly SystemRole[]>> {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
const record = parsed as Record<string, unknown>;
const normalized: Record<string, readonly SystemRole[]> = {};
for (const [userId, roles] of Object.entries(record)) {
if (!userId || !Array.isArray(roles)) {
continue;
}
const cleaned = roles
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
if (cleaned.length > 0) {
normalized[userId] = cleaned;
}
}
return normalized;
} catch {
return {};
}
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const PUBLIC_ROUTE_METADATA_KEY = 'gridpilot:publicRoute';
export type PublicRouteMetadata = {
readonly public: true;
};
export function Public(): MethodDecorator & ClassDecorator {
return SetMetadata(PUBLIC_ROUTE_METADATA_KEY, {
public: true,
} satisfies PublicRouteMetadata);
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_AUTHENTICATED_USER_METADATA_KEY = 'gridpilot:requireAuthenticatedUser';
export type RequireAuthenticatedUserMetadata = {
readonly required: true;
};
export function RequireAuthenticatedUser(): MethodDecorator & ClassDecorator {
return SetMetadata(REQUIRE_AUTHENTICATED_USER_METADATA_KEY, {
required: true,
} satisfies RequireAuthenticatedUserMetadata);
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_ROLES_METADATA_KEY = 'gridpilot:requireRoles';
export type RequireRolesMetadata = {
readonly anyOf: readonly string[];
};
export function RequireRoles(...anyOf: readonly string[]): MethodDecorator & ClassDecorator {
return SetMetadata(REQUIRE_ROLES_METADATA_KEY, {
anyOf,
} satisfies RequireRolesMetadata);
}

View File

@@ -1,6 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { DashboardController } from './DashboardController';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
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';
import { DashboardService } from './DashboardService';
describe('DashboardController', () => {
let controller: DashboardController;
@@ -34,10 +45,93 @@ describe('DashboardController', () => {
};
mockService.getDashboardOverview.mockResolvedValue(overview);
const result = await controller.getDashboardOverview(driverId);
const result = await controller.getDashboardOverview(driverId, { user: { userId: driverId } });
expect(mockService.getDashboardOverview).toHaveBeenCalledWith(driverId);
expect(result).toEqual(overview);
});
});
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: [DashboardController],
providers: [
{
provide: DashboardService,
useValue: {
getDashboardOverview: vi.fn(async () => ({
currentDriver: null,
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 0,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: { notificationCount: 0, items: [] },
friends: [],
})),
},
},
],
})
.overrideProvider(DashboardController)
.useFactory({
factory: (dashboardService: DashboardService) => new DashboardController(dashboardService),
inject: [DashboardService],
})
.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('denies endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(401);
});
it('allows endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(200);
});
});
});

View File

@@ -1,18 +1,29 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Controller, Get, Query, Req, UnauthorizedException, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { DashboardService } from './DashboardService';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('dashboard')
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
constructor(@Inject(DashboardService) private readonly dashboardService: DashboardService) {}
@Get('overview')
@ApiOperation({ summary: 'Get dashboard overview' })
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
return this.dashboardService.getDashboardOverview(driverId);
async getDashboardOverview(
@Query('driverId') _driverId: string,
@Req() req: AuthenticatedRequest,
): Promise<DashboardOverviewDTO> {
const userId = req.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
return this.dashboardService.getDashboardOverview(userId);
}
}

View File

@@ -1,8 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { DriverController } from './DriverController';
import { DriverService } from './DriverService';
import type { Request } from 'express';
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';
interface AuthenticatedRequest extends Request {
user?: { userId: string };
@@ -160,4 +169,75 @@ describe('DriverController', () => {
expect(result).toEqual(updated);
});
});
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: [DriverController],
providers: [
{
provide: DriverService,
useValue: {
getDriversLeaderboard: vi.fn(async () => ({ drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 })),
getCurrentDriver: vi.fn(async () => ({ id: 'd1' })),
},
},
],
}).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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/drivers/leaderboard').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/drivers/current').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/drivers/current').expect(200);
});
});
});

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { DriverService } from './DriverService';
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
@@ -20,6 +21,7 @@ import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
export class DriverController {
constructor(@Inject(DriverService) private readonly driverService: DriverService) {}
@Public()
@Get('leaderboard')
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO })
@@ -27,6 +29,7 @@ export class DriverController {
return await this.driverService.getDriversLeaderboard();
}
@Public()
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO })
@@ -61,6 +64,7 @@ export class DriverController {
return await this.driverService.completeOnboarding(userId, input);
}
@Public()
@Get(':driverId/races/:raceId/registration-status')
@ApiOperation({ summary: 'Get driver registration status for a specific race' })
@ApiResponse({ status: 200, description: 'Driver registration status', type: DriverRegistrationStatusDTO })
@@ -71,6 +75,7 @@ export class DriverController {
return await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
}
@Public()
@Get(':driverId')
@ApiOperation({ summary: 'Get driver by ID' })
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@@ -79,6 +84,7 @@ export class DriverController {
return await this.driverService.getDriver(driverId);
}
@Public()
@Get(':driverId/profile')
@ApiOperation({ summary: 'Get driver profile with full details' })
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })

View File

@@ -1,8 +1,10 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/Public';
import { HelloService } from './HelloService';
@Public()
@Controller()
export class HelloController {
constructor(private readonly helloService: HelloService) {}

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 { vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
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('LeagueController', () => {
let controller: LeagueController;
@@ -55,4 +64,75 @@ describe('LeagueController', () => {
expect(result).toEqual(mockResult);
expect(leagueService.getLeagueStandings).toHaveBeenCalledWith('league-1');
});
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: [LeagueController],
providers: [
{
provide: LeagueService,
useValue: {
getTotalLeagues: vi.fn(async () => ({ totalLeagues: 0 })),
getLeagueAdmin: vi.fn(async () => ({}) ),
},
},
],
}).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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/leagues/total-leagues').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/leagues/l1/admin').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/leagues/l1/admin').expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
@@ -40,6 +41,7 @@ import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO';
export class LeagueController {
constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {}
@Public()
@Get('all-with-capacity')
@ApiOperation({ summary: 'Get all leagues with their capacity information' })
@ApiResponse({ status: 200, description: 'List of leagues with capacity', type: AllLeaguesWithCapacityDTO })
@@ -47,6 +49,7 @@ export class LeagueController {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('total-leagues')
@ApiOperation({ summary: 'Get the total number of leagues' })
@ApiResponse({ status: 200, description: 'Total number of leagues', type: TotalLeaguesDTO })
@@ -126,6 +129,7 @@ export class LeagueController {
return this.leagueService.updateLeagueMemberRole({ leagueId, performerDriverId, targetDriverId, newRole: input.newRole });
}
@Public()
@Get(':leagueId/owner-summary/:ownerId')
@ApiOperation({ summary: 'Get owner summary for a league' })
@ApiResponse({ status: 200, description: 'League owner summary', type: LeagueOwnerSummaryDTO })
@@ -187,6 +191,7 @@ export class LeagueController {
};
}
@Public()
@Get(':leagueId/seasons')
@ApiOperation({ summary: 'Get seasons for a league' })
@ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] })
@@ -195,6 +200,7 @@ export class LeagueController {
return this.leagueService.getLeagueSeasons(query);
}
@Public()
@Get(':leagueId/memberships')
@ApiOperation({ summary: 'Get league memberships' })
@ApiResponse({ status: 200, description: 'List of league members', type: LeagueMembershipsDTO })
@@ -202,6 +208,7 @@ export class LeagueController {
return this.leagueService.getLeagueMemberships(leagueId);
}
@Public()
@Get(':leagueId/standings')
@ApiOperation({ summary: 'Get league standings' })
@ApiResponse({ status: 200, description: 'League standings', type: LeagueStandingsDTO })
@@ -209,6 +216,7 @@ export class LeagueController {
return this.leagueService.getLeagueStandings(leagueId);
}
@Public()
@Get(':leagueId/schedule')
@ApiOperation({ summary: 'Get league schedule' })
@ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleDTO })
@@ -216,6 +224,7 @@ export class LeagueController {
return this.leagueService.getLeagueSchedule(leagueId);
}
@Public()
@Get(':leagueId/stats')
@ApiOperation({ summary: 'Get league stats' })
@ApiResponse({ status: 200, description: 'League stats', type: LeagueStatsDTO })
@@ -238,6 +247,7 @@ export class LeagueController {
return this.leagueService.createLeague(input);
}
@Public()
@Get('scoring-presets')
@ApiOperation({ summary: 'Get league scoring presets' })
@ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO })
@@ -245,6 +255,7 @@ export class LeagueController {
return this.leagueService.listLeagueScoringPresets();
}
@Public()
@Get(':leagueId/scoring-config')
@ApiOperation({ summary: 'Get league scoring config' })
@ApiResponse({ status: 200, description: 'League scoring config' })
@@ -266,6 +277,7 @@ export class LeagueController {
return this.leagueService.transferLeagueOwnership(leagueId, body.currentOwnerId, body.newOwnerId);
}
@Public()
@Get('seasons/:seasonId/sponsorships')
@ApiOperation({ summary: 'Get season sponsorships' })
@ApiResponse({ status: 200, description: 'Season sponsorships', type: GetSeasonSponsorshipsOutputDTO })
@@ -273,6 +285,7 @@ export class LeagueController {
return this.leagueService.getSeasonSponsorships(seasonId);
}
@Public()
@Get(':leagueId/races')
@ApiOperation({ summary: 'Get league races' })
@ApiResponse({ status: 200, description: 'League races', type: GetLeagueRacesOutputDTO })

View File

@@ -1,8 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { MediaController } from './MediaController';
import { MediaService } from './MediaService';
import type { Response } from 'express';
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';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
@@ -248,4 +257,75 @@ describe('MediaController', () => {
expect(res.json).toHaveBeenCalledWith(dto);
});
});
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: [MediaController],
providers: [
{
provide: MediaService,
useValue: {
getMedia: vi.fn(async () => ({ id: 'm1' })),
deleteMedia: vi.fn(async () => ({ 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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/media/m1').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).delete('/media/m1').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).delete('/media/m1').expect(200);
});
});
});

View File

@@ -2,6 +2,7 @@ import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseIn
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { Public } from '../auth/Public';
import { MediaService } from './MediaService';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
@@ -60,6 +61,7 @@ export class MediaController {
}
}
@Public()
@Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' })
@ApiParam({ name: 'mediaId', description: 'Media ID' })
@@ -90,6 +92,7 @@ export class MediaController {
res.status(HttpStatus.OK).json(dto);
}
@Public()
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })

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 { vi } from 'vitest';
import { PaymentsController } from './PaymentsController';
import { PaymentsService } from './PaymentsService';
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';
import {
GetPaymentsQuery,
CreatePaymentInput,
@@ -366,4 +375,107 @@ describe('PaymentsController', () => {
expect(response).toEqual(result);
});
});
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: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [PaymentsController],
providers: [
{
provide: PaymentsService,
useValue: {
getPayments: vi.fn(async () => ({ payments: [] })),
},
},
],
}).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('denies endpoint when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/payments').expect(401);
});
it('returns 403 when authenticated but missing required role', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/payments').expect(403);
});
it('returns 404 when role is satisfied but capability is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'disabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(404);
});
it('allows access when role is satisfied and capability is enabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(200);
});
});
});

View File

@@ -1,14 +1,20 @@
import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { RequireCapability } from '../policy/RequireCapability';
import { PaymentsService } from './PaymentsService';
import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dtos/PaymentsDto';
@ApiTags('payments')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@Controller('payments')
export class PaymentsController {
constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {}
@Get()
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
@@ -16,6 +22,7 @@ export class PaymentsController {
}
@Post()
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
@@ -24,6 +31,7 @@ export class PaymentsController {
}
@Patch('status')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
@@ -31,6 +39,7 @@ export class PaymentsController {
}
@Get('membership-fees')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get membership fees and member payments' })
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
@@ -38,6 +47,7 @@ export class PaymentsController {
}
@Post('membership-fees')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create or update membership fee configuration' })
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
@@ -46,12 +56,14 @@ export class PaymentsController {
}
@Patch('membership-fees/member-payment')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Record or update a member payment' })
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
return this.paymentsService.updateMemberPayment(input);
}
@Get('prizes')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get prizes for a league or season' })
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
async getPrizes(@Query() query: GetPrizesQuery): Promise<GetPrizesOutput> {
@@ -59,6 +71,7 @@ export class PaymentsController {
}
@Post('prizes')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new prize' })
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
@@ -67,6 +80,7 @@ export class PaymentsController {
}
@Patch('prizes/award')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Award a prize to a driver' })
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
async awardPrize(@Body() input: AwardPrizeInput): Promise<AwardPrizeOutput> {
@@ -74,12 +88,14 @@ export class PaymentsController {
}
@Delete('prizes')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Delete a prize' })
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
async deletePrize(@Query() query: DeletePrizeInput): Promise<DeletePrizeOutput> {
return this.paymentsService.deletePrize(query);
}
@Get('wallets')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get wallet information and transactions' })
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
async getWallet(@Query() query: GetWalletQuery): Promise<GetWalletOutput> {
@@ -87,6 +103,7 @@ export class PaymentsController {
}
@Post('wallets/transactions')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })

View File

@@ -0,0 +1,91 @@
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PolicyService, ActionType, FeatureState } from './PolicyService';
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
@Injectable()
export class FeatureAvailabilityGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly policyService: PolicyService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controllerClass = context.getClass();
const metadata =
this.reflector.getAllAndOverride<FeatureAvailabilityMetadata | undefined>(
FEATURE_AVAILABILITY_METADATA_KEY,
[handler, controllerClass],
) ?? null;
if (!metadata) {
return true;
}
const snapshot = await this.policyService.getSnapshot();
const decision = evaluate(snapshot, metadata);
if (decision.allow) {
return true;
}
if (decision.reason === 'maintenance') {
throw new ServiceUnavailableException('Service temporarily unavailable');
}
throw new NotFoundException('Not Found');
}
}
function evaluate(
snapshot: Awaited<ReturnType<PolicyService['getSnapshot']>>,
metadata: FeatureAvailabilityMetadata,
): Evaluation {
if (snapshot.operationalMode === 'maintenance') {
const allowlist = metadata.actionType === 'mutate'
? snapshot.maintenanceAllowlist.mutate
: snapshot.maintenanceAllowlist.view;
if (!allowlist.includes(metadata.capabilityKey)) {
return { allow: false, reason: 'maintenance' };
}
}
const state = snapshot.capabilities[metadata.capabilityKey] ?? 'hidden';
if (state === 'enabled') {
return { allow: true };
}
// Coming soon is treated as "not found" on the public API for now (no disclosure).
if (state === 'coming_soon') {
return { allow: false, reason: 'coming_soon' };
}
if (state === 'disabled' || state === 'hidden') {
return { allow: false, reason: state };
}
return { allow: false, reason: 'not_configured' };
}
export function inferActionTypeFromHttpMethod(method: string): ActionType {
switch (method.toUpperCase()) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
return 'view';
default:
return 'mutate';
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/Public';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { PolicyService, PolicySnapshot } from './PolicyService';
@ApiTags('policy')
@Public()
@Controller('policy')
export class PolicyController {
constructor(private readonly policyService: PolicyService) {}
@Get('snapshot')
@ApiOperation({ summary: 'Get current feature availability policy snapshot (read-only)' })
@ApiResponse({ status: 200, description: 'Policy snapshot', type: Object })
async getSnapshot(): Promise<PolicySnapshot> {
return await this.policyService.getSnapshot();
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PolicyController } from './PolicyController';
import { PolicyService } from './PolicyService';
import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard';
@Module({
controllers: [PolicyController],
providers: [PolicyService, FeatureAvailabilityGuard],
exports: [PolicyService, FeatureAvailabilityGuard],
})
export class PolicyModule {}

View File

@@ -0,0 +1,229 @@
import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
export type OperationalMode = 'normal' | 'maintenance' | 'test';
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
export type ActionType = 'view' | 'mutate';
export type PolicySnapshot = {
readonly policyVersion: number;
readonly operationalMode: OperationalMode;
readonly maintenanceAllowlist: {
readonly view: readonly string[];
readonly mutate: readonly string[];
};
readonly capabilities: Readonly<Record<string, FeatureState>>;
readonly loadedFrom: 'env' | 'file' | 'defaults';
readonly loadedAtIso: string;
};
const DEFAULT_POLICY_VERSION = 1;
const DEFAULT_CACHE_MS = 5_000;
type RawPolicySnapshot = Partial<{
policyVersion: number;
operationalMode: string;
maintenanceAllowlist: Partial<{
view: unknown;
mutate: unknown;
}>;
capabilities: unknown;
}>;
@Injectable()
export class PolicyService {
private cache:
| {
snapshot: PolicySnapshot;
expiresAtMs: number;
}
| null = null;
async getSnapshot(): Promise<PolicySnapshot> {
const now = Date.now();
if (this.cache && now < this.cache.expiresAtMs) {
return this.cache.snapshot;
}
const cacheMs = parseCacheMs(process.env.GRIDPILOT_POLICY_CACHE_MS);
const loadedAtIso = new Date(now).toISOString();
const { raw, loadedFrom } = await this.loadRawSnapshot();
const snapshot = normalizeSnapshot(raw, loadedFrom, loadedAtIso);
this.cache = {
snapshot,
expiresAtMs: now + cacheMs,
};
return snapshot;
}
private async loadRawSnapshot(): Promise<{
raw: RawPolicySnapshot;
loadedFrom: PolicySnapshot['loadedFrom'];
}> {
const policyPath = process.env.GRIDPILOT_POLICY_PATH;
if (policyPath) {
const rawJson = await readFile(policyPath, 'utf8');
return {
raw: JSON.parse(rawJson) as RawPolicySnapshot,
loadedFrom: 'file',
};
}
const anyEnvConfigured =
Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) ||
Boolean(process.env.GRIDPILOT_FEATURES_JSON) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
const raw: RawPolicySnapshot = {};
const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE;
if (operationalMode) {
raw.operationalMode = operationalMode;
}
const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON);
if (capabilities) {
raw.capabilities = capabilities;
}
const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW);
const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
if (maintenanceAllowView || maintenanceAllowMutate) {
raw.maintenanceAllowlist = {
...(maintenanceAllowView ? { view: maintenanceAllowView } : {}),
...(maintenanceAllowMutate ? { mutate: maintenanceAllowMutate } : {}),
};
}
return {
raw,
loadedFrom: anyEnvConfigured ? 'env' : 'defaults',
};
}
}
function normalizeSnapshot(
raw: RawPolicySnapshot,
loadedFrom: PolicySnapshot['loadedFrom'],
loadedAtIso: string,
): PolicySnapshot {
const operationalMode = parseOperationalMode(raw.operationalMode);
const maintenanceAllowlistView = normalizeStringArray(raw.maintenanceAllowlist?.view);
const maintenanceAllowlistMutate = normalizeStringArray(raw.maintenanceAllowlist?.mutate);
const capabilities = normalizeCapabilities(raw.capabilities);
return {
policyVersion:
typeof raw.policyVersion === 'number' ? raw.policyVersion : DEFAULT_POLICY_VERSION,
operationalMode,
maintenanceAllowlist: {
view: maintenanceAllowlistView,
mutate: maintenanceAllowlistMutate,
},
capabilities,
loadedFrom,
loadedAtIso,
};
}
function parseCacheMs(cacheMsRaw: string | undefined): number {
if (!cacheMsRaw) {
return DEFAULT_CACHE_MS;
}
const parsed = Number(cacheMsRaw);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_CACHE_MS;
}
return parsed;
}
function parseOperationalMode(raw: string | undefined): OperationalMode {
switch (raw) {
case 'normal':
case 'maintenance':
case 'test':
return raw;
default:
return 'normal';
}
}
function normalizeCapabilities(input: unknown): Readonly<Record<string, FeatureState>> {
if (!input || typeof input !== 'object') {
return {};
}
const record = input as Record<string, unknown>;
const normalized: Record<string, FeatureState> = {};
for (const [key, value] of Object.entries(record)) {
if (!key) {
continue;
}
const state = parseFeatureState(value);
if (state) {
normalized[key] = state;
}
}
return normalized;
}
function parseFeatureState(value: unknown): FeatureState | null {
if (typeof value !== 'string') {
return null;
}
switch (value) {
case 'enabled':
case 'disabled':
case 'coming_soon':
case 'hidden':
return value;
default:
return null;
}
}
function parseFeaturesJson(raw: string | undefined): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function parseCsvList(raw: string | undefined): readonly string[] | undefined {
if (!raw) {
return undefined;
}
const items = raw
.split(',')
.map((v) => v.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
function normalizeStringArray(value: unknown): readonly string[] {
if (!Array.isArray(value)) {
return [];
}
const normalized = value
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
return normalized;
}

View File

@@ -0,0 +1,24 @@
import { SetMetadata } from '@nestjs/common';
import type { ActionType } from './PolicyService';
export const FEATURE_AVAILABILITY_METADATA_KEY = 'gridpilot:featureAvailability';
export type FeatureAvailabilityMetadata = {
readonly capabilityKey: string;
readonly actionType: ActionType;
};
/**
* Attach feature availability metadata to a controller or route handler.
*
* The backend Guard is authoritative enforcement (not UX).
*/
export function RequireCapability(
capabilityKey: string,
actionType: ActionType,
): MethodDecorator & ClassDecorator {
return SetMetadata(FEATURE_AVAILABILITY_METADATA_KEY, {
capabilityKey,
actionType,
} satisfies FeatureAvailabilityMetadata);
}

View File

@@ -31,10 +31,12 @@ describe('ProtestsController', () => {
const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto;
describe('reviewProtest', () => {
const stewardUserId = 'steward-1';
const req = { user: { userId: stewardUserId } };
it('should call service and not throw on success', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -43,20 +45,19 @@ describe('ProtestsController', () => {
successDto({
success: true,
protestId,
stewardId: body.stewardId,
stewardId: stewardUserId,
decision: body.decision,
}),
);
await controller.reviewProtest(protestId, body);
await controller.reviewProtest(protestId, body, req);
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body });
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, stewardId: stewardUserId, ...body });
});
it('should throw NotFoundException when protest is not found', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -69,13 +70,12 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(NotFoundException);
});
it('should throw ForbiddenException when steward is not league admin', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -88,13 +88,12 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(ForbiddenException);
});
it('should throw InternalServerErrorException for unexpected error codes', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -107,7 +106,7 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
});

View File

@@ -1,9 +1,13 @@
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post, Req, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('protests')
@Controller('protests')
export class ProtestsController {
@@ -16,9 +20,19 @@ export class ProtestsController {
@ApiResponse({ status: 200, description: 'Protest reviewed successfully' })
async reviewProtest(
@Param('protestId') protestId: string,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'>,
@Req() req: AuthenticatedRequest,
): Promise<void> {
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body });
const userId = req.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({
protestId,
stewardId: userId,
...body,
});
if (!result.success) {
switch (result.errorCode) {

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;
}

View File

@@ -1,14 +1,23 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { SponsorController } from './SponsorController';
import { SponsorService } from './SponsorService';
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('SponsorController', () => {
let controller: SponsorController;
let sponsorService: ReturnType<typeof vi.mocked<SponsorService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
const moduleBuilder = Test.createTestingModule({
controllers: [SponsorController],
providers: [
{
@@ -31,7 +40,15 @@ describe('SponsorController', () => {
},
},
],
}).compile();
})
.overrideGuard(AuthenticationGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) })
.overrideGuard(AuthorizationGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) })
.overrideGuard(FeatureAvailabilityGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) });
const module: TestingModule = await moduleBuilder.compile();
controller = module.get<SponsorController>(SponsorController);
sponsorService = vi.mocked(module.get(SponsorService));
@@ -309,4 +326,112 @@ describe('SponsorController', () => {
expect(sponsorService.updateSponsorSettings).toHaveBeenCalledWith(sponsorId, input);
});
});
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: { 'sponsors.portal': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [SponsorController],
providers: [
{
provide: SponsorService,
useValue: {
getEntitySponsorshipPricing: vi.fn(async () => ({ entityType: 'season', entityId: 's1', pricing: [] })),
getSponsors: vi.fn(async () => ({ sponsors: [] })),
},
},
],
}).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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/sponsors/pricing').expect(200);
});
it('denies protected endpoint when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/sponsors').expect(401);
});
it('returns 403 when authenticated but missing required role', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/sponsors').expect(403);
});
it('returns 404 when role is satisfied but capability is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'sponsors.portal': 'disabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/sponsors').expect(404);
});
it('allows access when role is satisfied and capability is enabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'sponsors.portal': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/sponsors').expect(200);
});
});
});

View File

@@ -1,5 +1,9 @@
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { RequireCapability } from '../policy/RequireCapability';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
@@ -31,6 +35,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us
export class SponsorController {
constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {}
@Public()
@Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
@ApiResponse({
@@ -43,6 +48,9 @@ export class SponsorController {
}
@Get()
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get all sponsors' })
@ApiResponse({
status: 200,
@@ -54,6 +62,9 @@ export class SponsorController {
}
@Post()
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new sponsor' })
@ApiResponse({
@@ -66,6 +77,9 @@ export class SponsorController {
}
@Get('dashboard/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
@ApiResponse({
status: 200,
@@ -82,6 +96,9 @@ export class SponsorController {
}
@Get(':sponsorId/sponsorships')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({
summary: 'Get all sponsorships for a given sponsor',
})
@@ -99,19 +116,10 @@ export class SponsorController {
} as GetSponsorSponsorshipsQueryParamsDTO);
}
@Get(':sponsorId')
@ApiOperation({ summary: 'Get a sponsor by ID' })
@ApiResponse({
status: 200,
description: 'Sponsor data',
type: GetSponsorOutputDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO> {
return await this.sponsorService.getSponsor(sponsorId);
}
@Get('requests')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get pending sponsorship requests' })
@ApiResponse({
status: 200,
@@ -129,7 +137,25 @@ export class SponsorController {
);
}
@Get(':sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get a sponsor by ID' })
@ApiResponse({
status: 200,
description: 'Sponsor data',
type: GetSponsorOutputDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO> {
return await this.sponsorService.getSponsor(sponsorId);
}
@Post('requests/:requestId/accept')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@@ -146,6 +172,9 @@ export class SponsorController {
}
@Post('requests/:requestId/reject')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@@ -163,6 +192,9 @@ export class SponsorController {
}
@Get('billing/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(
@@ -176,6 +208,9 @@ export class SponsorController {
}
@Get('leagues/available')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({
status: 200,
@@ -188,6 +223,9 @@ export class SponsorController {
}
@Get('leagues/:leagueId/detail')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(
@@ -202,6 +240,9 @@ export class SponsorController {
}
@Get('settings/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(
@@ -216,6 +257,9 @@ export class SponsorController {
}
@Put('settings/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' })

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/AuthModule';
import { PolicyModule } from '../policy/PolicyModule';
import { SponsorService } from './SponsorService';
import { SponsorController } from './SponsorController';
import { SponsorProviders } from './SponsorProviders';
@Module({
imports: [AuthModule, PolicyModule],
controllers: [SponsorController],
providers: SponsorProviders,
exports: [SponsorService],

View File

@@ -1,9 +1,18 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { TeamController } from './TeamController';
import { TeamService } from './TeamService';
import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO';
import { UpdateTeamInput } from './dtos/TeamDto';
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('TeamController', () => {
let controller: TeamController;
@@ -146,4 +155,75 @@ describe('TeamController', () => {
expect(response).toEqual(result);
});
});
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: [TeamController],
providers: [
{
provide: TeamService,
useValue: {
getAll: vi.fn(async () => ({ teams: [], totalCount: 0 })),
getJoinRequests: vi.fn(async () => ({ requests: [], pendingCount: 0, totalCount: 0 })),
},
},
],
}).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() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/teams/all').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
type RequestWithUser = Record<string, unknown> & {
user?: {
@@ -23,6 +24,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO';
export class TeamController {
constructor(@Inject(TeamService) private readonly teamService: TeamService) {}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all teams' })
@ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO })
@@ -30,6 +32,7 @@ export class TeamController {
return await this.teamService.getAll();
}
@Public()
@Get(':teamId')
@ApiOperation({ summary: 'Get team details' })
@ApiResponse({ status: 200, description: 'Team details', type: GetTeamDetailsOutputDTO })
@@ -39,6 +42,7 @@ export class TeamController {
return await this.teamService.getDetails(teamId, userId);
}
@Public()
@Get(':teamId/members')
@ApiOperation({ summary: 'Get team members' })
@ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO })
@@ -69,6 +73,7 @@ export class TeamController {
return await this.teamService.update(teamId, input, userId);
}
@Public()
@Get('driver/:driverId')
@ApiOperation({ summary: 'Get driver\'s team' })
@ApiResponse({ status: 200, description: 'Driver\'s team', type: GetDriverTeamOutputDTO })
@@ -77,6 +82,7 @@ export class TeamController {
return await this.teamService.getDriverTeam(driverId);
}
@Public()
@Get(':teamId/members/:driverId')
@ApiOperation({ summary: 'Get team membership for a driver' })
@ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO })

View File

@@ -7,6 +7,9 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { AppModule } from './app.module';
import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
async function bootstrap() {
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined);
@@ -19,6 +22,12 @@ async function bootstrap() {
}),
);
app.useGlobalGuards(
app.get(AuthenticationGuard),
app.get(AuthorizationGuard),
app.get(FeatureAvailabilityGuard),
);
// Swagger/OpenAPI configuration
const config = new DocumentBuilder()
.setTitle('GridPilot API')