authentication authorization
This commit is contained in:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user