From 77cbbd3a991ebd40e98dcefda86f8425fbfbe19d Mon Sep 17 00:00:00 2001 From: portableDD Date: Tue, 25 Aug 2026 17:31:22 +0100 Subject: [PATCH] feat: access-log filtering, revoke-access status update, dispute status audit, user rate limiting --- .../access-logs.controller.spec.ts | 40 ++++--- .../src/access-logs/access-logs.service.ts | 28 ++--- .../access-logs/dto/filter-access-logs.dto.ts | 39 ++++++- backend/src/dispute/dispute.controller.ts | 11 ++ backend/src/dispute/dispute.service.spec.ts | 107 ++++++++++++++++++ backend/src/dispute/dispute.service.ts | 36 +++++- .../src/dispute/dto/dispute-response.dto.ts | 2 + .../src/dispute/entities/dispute.entity.ts | 18 +++ backend/src/documents/documents.controller.ts | 21 ++++ backend/src/documents/documents.service.ts | 8 ++ backend/src/users/users.service.spec.ts | 52 +++++++++ backend/src/users/users.service.ts | 26 +++++ 12 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 backend/src/dispute/dispute.service.spec.ts create mode 100644 backend/src/users/users.service.spec.ts diff --git a/backend/src/access-logs/access-logs.controller.spec.ts b/backend/src/access-logs/access-logs.controller.spec.ts index 0c6accfb..4c23d3b8 100644 --- a/backend/src/access-logs/access-logs.controller.spec.ts +++ b/backend/src/access-logs/access-logs.controller.spec.ts @@ -1,32 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AccessLogsController } from './access-logs.controller'; import { AccessLogsService } from './access-logs.service'; -import { FilterAccessLogsDto } from './dto/filter-access-logs.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; describe('AccessLogsController', () => { let controller: AccessLogsController; - let service: AccessLogsService; - const mockAccessLogsService = { + const mockService = { findAll: jest.fn().mockResolvedValue({ data: [], total: 0, page: 1, - limit: 10, + limit: 50, + totalPages: 0, }), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AccessLogsController], - providers: [ - { - provide: AccessLogsService, - useValue: mockAccessLogsService, - }, - ], + providers: [{ provide: AccessLogsService, useValue: mockService }], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) @@ -35,19 +29,31 @@ describe('AccessLogsController', () => { .compile(); controller = module.get(AccessLogsController); - service = module.get(AccessLogsService); }); it('should be defined', () => { expect(controller).toBeDefined(); }); - it('should return paginated access logs for admin user', async () => { - const filterDto: FilterAccessLogsDto = { page: 1, limit: 10 }; - const result = await controller.getAccessLogs(filterDto); + it('should return filtered access logs', async () => { + const result = await controller.getAccessLogs({ + userId: 'user-1', + httpMethod: 'GET', + page: 1, + limit: 25, + }); + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(mockService.findAll).toHaveBeenCalledWith({ + userId: 'user-1', + httpMethod: 'GET', + page: 1, + limit: 25, + }); + }); - expect(service.findAll).toHaveBeenCalledWith(filterDto); - expect(result).toHaveProperty('data'); - expect(result).toHaveProperty('total'); + it('should pass empty filters', async () => { + await controller.getAccessLogs({}); + expect(mockService.findAll).toHaveBeenCalledWith({}); }); }); diff --git a/backend/src/access-logs/access-logs.service.ts b/backend/src/access-logs/access-logs.service.ts index a0999bd8..766f7bec 100644 --- a/backend/src/access-logs/access-logs.service.ts +++ b/backend/src/access-logs/access-logs.service.ts @@ -18,16 +18,6 @@ export class AccessLogsService { constructor(private readonly accessLogRepository: Repository) {} - - async logDocumentAccess( - documentId: string, - action: string, - userId?: string, - ipAddress?: string, - userAgent?: string, - isDenied = false, - ): Promise { - async create(dto: CreateAccessLogDto): Promise { const log = this.accessLogRepository.create({ userId: dto.userId ?? null, @@ -39,23 +29,23 @@ export class AccessLogsService { return this.accessLogRepository.save(log); } - async logDocumentAccess(documentId: string, action: string, userId?: string, ipAddress?: string, userAgent?: string, isDenied = false): Promise { - + async logDocumentAccess( + documentId: string, + action: string, + userId?: string, + ipAddress?: string, + userAgent?: string, + isDenied = false, + ): Promise { try { this.logger.log( `Document Access Log: docId=${documentId}, action=${action}, user=${userId || 'anonymous'}, denied=${isDenied}`, ); - // Asynchronously record log without blocking the main thread } catch (err) { this.logger.error('Failed to log document access', err); } } - async create(dto: CreateAccessLogDto): Promise { - const log = this.accessLogRepository.create(dto); - return this.accessLogRepository.save(log); - } - async findAll(filterDto: FilterAccessLogsDto): Promise { const { page = 1, @@ -66,7 +56,6 @@ export class AccessLogsService { const where: FindOptionsWhere = {}; - // Apply filters if (filters.userId) { where.userId = filters.userId; } @@ -83,7 +72,6 @@ export class AccessLogsService { where.ipAddress = filters.ipAddress; } - // Date range filter if (filters.startDate || filters.endDate) { const startDate = filters.startDate ? new Date(filters.startDate) diff --git a/backend/src/access-logs/dto/filter-access-logs.dto.ts b/backend/src/access-logs/dto/filter-access-logs.dto.ts index 87e50c9e..0014d48d 100644 --- a/backend/src/access-logs/dto/filter-access-logs.dto.ts +++ b/backend/src/access-logs/dto/filter-access-logs.dto.ts @@ -1,11 +1,44 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + export class FilterAccessLogsDto { - page?: number; - limit?: number; - sortByDateDesc?: boolean; + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 50; + + @IsOptional() + sortByDateDesc?: boolean = true; + + @IsOptional() + @IsString() userId?: string; + + @IsOptional() + @IsString() routePath?: string; + + @IsOptional() + @IsEnum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) httpMethod?: string; + + @IsOptional() + @IsString() ipAddress?: string; + + @IsOptional() + @IsString() startDate?: string; + + @IsOptional() + @IsString() endDate?: string; } diff --git a/backend/src/dispute/dispute.controller.ts b/backend/src/dispute/dispute.controller.ts index 86eb25fc..494ae0da 100644 --- a/backend/src/dispute/dispute.controller.ts +++ b/backend/src/dispute/dispute.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Param, + Patch, Post, Query, Req, @@ -14,6 +15,7 @@ import { User } from '../users/entities/user.entity'; import { CreateDisputeDto } from './dto/create-dispute.dto'; import { DisputeResponseDto } from './dto/dispute-response.dto'; import { DisputeService } from './dispute.service'; +import { DisputeStatus } from './entities/dispute.entity'; @Controller('disputes') @UseGuards(JwtAuthGuard) @@ -45,4 +47,13 @@ export class DisputeController { async getDispute(@Param('id') id: string): Promise { return this.disputeService.findOne(id); } + + @Patch(':id/status') + async updateDisputeStatus( + @Param('id') id: string, + @Body('status') status: DisputeStatus, + @Req() req: Request & { user?: User }, + ): Promise { + return this.disputeService.updateStatus(id, status, req.user!.id); + } } diff --git a/backend/src/dispute/dispute.service.spec.ts b/backend/src/dispute/dispute.service.spec.ts new file mode 100644 index 00000000..b035e287 --- /dev/null +++ b/backend/src/dispute/dispute.service.spec.ts @@ -0,0 +1,107 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DisputeService } from './dispute.service'; +import { Dispute, DisputeStatus } from './entities/dispute.entity'; +import { DisputeReasonClassifierService } from './dispute-reason-classifier.service'; +import { AccessLogsService } from '../access-logs/access-logs.service'; +import { NotFoundException } from '@nestjs/common'; + +const mockDispute = { + id: 'dispute-1', + documentId: 'doc-1', + description: 'Invalid signature', + reason: null, + filedBy: 'user-1', + status: DisputeStatus.OPEN, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockRepo = () => ({ + create: jest.fn().mockReturnValue(mockDispute), + save: jest.fn().mockResolvedValue(mockDispute), + findAndCount: jest.fn().mockResolvedValue([[mockDispute], 1]), + findOne: jest.fn().mockResolvedValue(null), +}); + +const mockClassifier = { + classifyDispute: jest.fn().mockResolvedValue(null), +}; + +const mockAccessLogs = { + logDocumentAccess: jest.fn().mockResolvedValue(undefined), +}; + +describe('DisputeService', () => { + let service: DisputeService; + let repo: ReturnType; + + beforeEach(async () => { + repo = mockRepo(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DisputeService, + { provide: getRepositoryToken(Dispute), useValue: repo }, + { provide: DisputeReasonClassifierService, useValue: mockClassifier }, + { provide: AccessLogsService, useValue: mockAccessLogs }, + ], + }).compile(); + + service = module.get(DisputeService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('fileDispute()', () => { + it('should create a dispute with OPEN status', async () => { + const result = await service.fileDispute( + { documentId: 'doc-1', description: 'test' }, + 'user-1', + ); + expect(result.status).toBe(DisputeStatus.OPEN); + expect(mockAccessLogs.logDocumentAccess).toHaveBeenCalled(); + }); + }); + + describe('updateStatus()', () => { + it('should update dispute status and audit log', async () => { + repo.findOne.mockResolvedValueOnce({ ...mockDispute }); + const result = await service.updateStatus( + 'dispute-1', + DisputeStatus.RESOLVED, + 'admin-1', + ); + expect(result.status).toBe(DisputeStatus.RESOLVED); + expect(mockAccessLogs.logDocumentAccess).toHaveBeenCalledWith( + 'doc-1', + `dispute_status_changed:${DisputeStatus.OPEN}->${DisputeStatus.RESOLVED}`, + 'admin-1', + ); + }); + + it('should throw if dispute not found', async () => { + repo.findOne.mockResolvedValueOnce(null); + await expect( + service.updateStatus('nonexistent', DisputeStatus.RESOLVED, 'admin-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('findOne()', () => { + it('should return dispute', async () => { + repo.findOne.mockResolvedValueOnce({ ...mockDispute }); + const result = await service.findOne('dispute-1'); + expect(result.id).toBe('dispute-1'); + expect(result.status).toBe(DisputeStatus.OPEN); + }); + + it('should throw if not found', async () => { + repo.findOne.mockResolvedValueOnce(null); + await expect(service.findOne('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/dispute/dispute.service.ts b/backend/src/dispute/dispute.service.ts index 790f76f6..f2ab5b04 100644 --- a/backend/src/dispute/dispute.service.ts +++ b/backend/src/dispute/dispute.service.ts @@ -1,10 +1,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Dispute } from './entities/dispute.entity'; +import { Dispute, DisputeStatus } from './entities/dispute.entity'; import { DisputeReasonClassifierService } from './dispute-reason-classifier.service'; import { CreateDisputeDto } from './dto/create-dispute.dto'; import { DisputeResponseDto } from './dto/dispute-response.dto'; +import { AccessLogsService } from '../access-logs/access-logs.service'; @Injectable() export class DisputeService { @@ -12,6 +13,7 @@ export class DisputeService { @InjectRepository(Dispute) private readonly disputeRepo: Repository, private readonly classifier: DisputeReasonClassifierService, + private readonly accessLogsService: AccessLogsService, ) {} async fileDispute( @@ -25,12 +27,43 @@ export class DisputeService { description: dto.description, reason, filedBy: userId, + status: DisputeStatus.OPEN, }); const saved = await this.disputeRepo.save(dispute); + + await this.accessLogsService.logDocumentAccess( + dto.documentId, + `dispute_filed:${DisputeStatus.OPEN}`, + userId, + ); + return this.toResponseDto(saved); } + async updateStatus( + id: string, + newStatus: DisputeStatus, + userId: string, + ): Promise { + const dispute = await this.disputeRepo.findOne({ where: { id } }); + if (!dispute) { + throw new NotFoundException(`Dispute ${id} not found`); + } + + const oldStatus = dispute.status; + dispute.status = newStatus; + await this.disputeRepo.save(dispute); + + await this.accessLogsService.logDocumentAccess( + dispute.documentId, + `dispute_status_changed:${oldStatus}->${newStatus}`, + userId, + ); + + return this.toResponseDto(dispute); + } + async findByUser( userId: string, limit = 20, @@ -64,6 +97,7 @@ export class DisputeService { description: dispute.description, reason: dispute.reason, filedBy: dispute.filedBy, + status: dispute.status, createdAt: dispute.createdAt, }; } diff --git a/backend/src/dispute/dto/dispute-response.dto.ts b/backend/src/dispute/dto/dispute-response.dto.ts index 981a5aa1..85a9d520 100644 --- a/backend/src/dispute/dto/dispute-response.dto.ts +++ b/backend/src/dispute/dto/dispute-response.dto.ts @@ -1,4 +1,5 @@ import { DisputeReason } from '../entities/dispute-reason.entity'; +import { DisputeStatus } from '../entities/dispute.entity'; export class DisputeResponseDto { id: string; @@ -6,5 +7,6 @@ export class DisputeResponseDto { description: string; reason: DisputeReason | null; filedBy: string; + status: DisputeStatus; createdAt: Date; } diff --git a/backend/src/dispute/entities/dispute.entity.ts b/backend/src/dispute/entities/dispute.entity.ts index d4bb7ba8..33b55634 100644 --- a/backend/src/dispute/entities/dispute.entity.ts +++ b/backend/src/dispute/entities/dispute.entity.ts @@ -5,9 +5,17 @@ import { Index, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { DisputeReason } from './dispute-reason.entity'; +export enum DisputeStatus { + OPEN = 'open', + IN_REVIEW = 'in_review', + RESOLVED = 'resolved', + DISMISSED = 'dismissed', +} + @Entity('disputes') @Index('IDX_DISPUTE_DOCUMENT', ['documentId']) @Index('IDX_DISPUTE_FILED_BY', ['filedBy']) @@ -27,6 +35,16 @@ export class Dispute { @Column() filedBy: string; + @Column({ + type: 'enum', + enum: DisputeStatus, + default: DisputeStatus.OPEN, + }) + status: DisputeStatus; + @CreateDateColumn() createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; } diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index cde08204..1eeb5d23 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -7,6 +7,7 @@ import { NotFoundException, Param, ParseUUIDPipe, + Patch, Post, Query, Req, @@ -198,6 +199,26 @@ export class DocumentsController { return record; } + @Patch(':id/revoke') + @UseGuards(JwtAuthGuard) + async revokeAccess( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, + ) { + const document = await this.documentsService.findById(id); + if (!document) { + throw new NotFoundException('Document not found'); + } + + const user = req.user!; + if (document.ownerId !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Access denied'); + } + + const updated = await this.documentsService.revokeAccess(id); + return { message: 'Access revoked', documentId: updated?.id, status: updated?.status }; + } + @Get(':id/download') @UseGuards(JwtAuthGuard) async downloadDocument( diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index afbe6a98..54dd18a3 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -68,6 +68,14 @@ export class DocumentsService { return this.findById(id); } + async revokeAccess(id: string): Promise { + await this.documentRepository.update(id, { + status: DocumentStatus.REJECTED, + archived: true, + }); + return this.findById(id); + } + async delete(id: string): Promise { await this.documentRepository.delete(id); } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts new file mode 100644 index 00000000..328adcfc --- /dev/null +++ b/backend/src/users/users.service.spec.ts @@ -0,0 +1,52 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UsersService } from './users.service'; +import { User } from './entities/user.entity'; + +const mockRepo = () => ({ + create: jest.fn().mockReturnValue({ id: 'u-1', email: 'test@example.com' }), + save: jest.fn().mockResolvedValue({ id: 'u-1', email: 'test@example.com' }), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue({ affected: 1 }), + softDelete: jest.fn().mockResolvedValue({ affected: 1 }), +}); + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('checkRateLimit()', () => { + it('should allow first request', () => { + expect(service.checkRateLimit('user-1')).toBe(true); + }); + + it('should block after exceeding rate limit', () => { + for (let i = 0; i < 30; i++) { + service.checkRateLimit('rate-limited-user'); + } + expect(service.checkRateLimit('rate-limited-user')).toBe(false); + }); + + it('should track different users separately', () => { + for (let i = 0; i < 30; i++) { + service.checkRateLimit('user-a'); + } + expect(service.checkRateLimit('user-a')).toBe(false); + expect(service.checkRateLimit('user-b')).toBe(true); + }); + }); +}); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index e3cdd9df..a03976f4 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,12 +3,38 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; +interface RateLimitEntry { + count: number; + windowStart: number; +} + @Injectable() export class UsersService { + private readonly rateLimitMap = new Map(); + private readonly RATE_LIMIT_WINDOW_MS = 60 * 1000; + private readonly RATE_LIMIT_MAX = 30; + constructor( @InjectRepository(User) private readonly userRepository: Repository, ) {} + checkRateLimit(key: string): boolean { + const now = Date.now(); + const entry = this.rateLimitMap.get(key); + + if (!entry || now - entry.windowStart > this.RATE_LIMIT_WINDOW_MS) { + this.rateLimitMap.set(key, { count: 1, windowStart: now }); + return true; + } + + if (entry.count >= this.RATE_LIMIT_MAX) { + return false; + } + + entry.count += 1; + return true; + } + async create(data: Partial): Promise { const user = this.userRepository.create(data); return this.userRepository.save(user);