authentication authorization

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

View File

@@ -1,7 +1,16 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { PaymentsController } from './PaymentsController';
import { PaymentsService } from './PaymentsService';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import {
GetPaymentsQuery,
CreatePaymentInput,
@@ -366,4 +375,107 @@ describe('PaymentsController', () => {
expect(response).toEqual(result);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [PaymentsController],
providers: [
{
provide: PaymentsService,
useValue: {
getPayments: vi.fn(async () => ({ payments: [] })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('denies endpoint when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/payments').expect(401);
});
it('returns 403 when authenticated but missing required role', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/payments').expect(403);
});
it('returns 404 when role is satisfied but capability is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'disabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(404);
});
it('allows access when role is satisfied and capability is enabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(200);
});
});
});

View File

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