authentication authorization
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal file
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal file
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal file
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal file
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal file
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal file
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/domain/auth/Public.ts
Normal file
13
apps/api/src/domain/auth/Public.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal file
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireRoles.ts
Normal file
13
apps/api/src/domain/auth/RequireRoles.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
|
||||
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal file
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
18
apps/api/src/domain/policy/PolicyController.ts
Normal file
18
apps/api/src/domain/policy/PolicyController.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
apps/api/src/domain/policy/PolicyModule.ts
Normal file
11
apps/api/src/domain/policy/PolicyModule.ts
Normal 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 {}
|
||||
229
apps/api/src/domain/policy/PolicyService.ts
Normal file
229
apps/api/src/domain/policy/PolicyService.ts
Normal 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;
|
||||
}
|
||||
24
apps/api/src/domain/policy/RequireCapability.ts
Normal file
24
apps/api/src/domain/policy/RequireCapability.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user