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,3 +1,8 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
@@ -6,6 +11,11 @@ import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('AnalyticsController', () => {
let controller: AnalyticsController;
@@ -109,4 +119,83 @@ describe('AnalyticsController', () => {
expect(result).toEqual(dto);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AnalyticsController],
providers: [
{
provide: AnalyticsService,
useValue: {
recordPageView: vi.fn(async () => ({})),
recordEngagement: vi.fn(async () => ({})),
getDashboardData: vi.fn(async () => ({})),
getAnalyticsMetrics: vi.fn(async () => ({})),
},
},
],
})
.overrideProvider(AnalyticsController)
.useFactory({
factory: (analyticsService: AnalyticsService) => new AnalyticsController(analyticsService),
inject: [AnalyticsService],
})
.compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).post('/analytics/page-view').send({}).expect(201);
});
it('denies internal dashboard endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/analytics/dashboard').expect(401);
});
it('allows internal dashboard endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/analytics/dashboard').expect(200);
});
});
});

View File

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