diff --git a/backend/package-lock.json b/backend/package-lock.json index 4a45dc794..bc7411973 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.12", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.1.5", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.4.0", @@ -3595,6 +3596,19 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.1.tgz", @@ -5264,6 +5278,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", + "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -7811,6 +7831,22 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveM/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11955,6 +11991,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/backend/package.json b/backend/package.json index 33964c027..812f0d98d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,6 +35,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.12", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.1.5", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.4.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e6b31dafd..5db1ef0ab 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { ShipmentsModule } from './shipments/shipments.module'; @@ -56,6 +57,7 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { }, }), EventEmitterModule.forRoot({ wildcard: false, delimiter: '.' }), + ScheduleModule.forRoot(), ThrottlerModule.forRoot({ errorMessage: throttlerErrorMessage, throttlers: [ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 2f228cc12..e55dff808 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt'; import { v4 as uuidv4 } from 'uuid'; import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; import { User } from '../users/entities/user.entity'; import { JwtPayload } from './strategies/jwt.strategy'; @@ -200,7 +201,7 @@ export class AuthService { async updateProfile( userId: string, - dto: { firstName?: string; lastName?: string; walletAddress?: string }, + dto: UpdateProfileDto, ): Promise> { await this.usersService.update(userId, dto); const updated = await this.usersService.findOne(userId); diff --git a/backend/src/auth/dto/update-profile.dto.spec.ts b/backend/src/auth/dto/update-profile.dto.spec.ts new file mode 100644 index 000000000..704ca75e0 --- /dev/null +++ b/backend/src/auth/dto/update-profile.dto.spec.ts @@ -0,0 +1,57 @@ +import { validate } from 'class-validator'; +import { UpdateProfileDto } from './update-profile.dto'; + +const VALID_PUBLIC_KEY = + 'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB'; + +async function getValidationErrors(dto: object): Promise { + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: false, + }); + return errors.flatMap((error) => Object.values(error.constraints ?? {})); +} + +describe('UpdateProfileDto', () => { + it('accepts a valid Stellar Ed25519 public key', async () => { + const dto = new UpdateProfileDto(); + dto.walletAddress = VALID_PUBLIC_KEY; + + expect(await getValidationErrors(dto)).toHaveLength(0); + }); + + it.each([ + 'not-a-stellar-key', + 'SAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB', + 'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNX', + // Same length/prefix as the valid key, but with a bad checksum. + 'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXC', + ])('rejects invalid Stellar public key %s', async (walletAddress) => { + const dto = new UpdateProfileDto(); + dto.walletAddress = walletAddress; + + const messages = await getValidationErrors(dto); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining('valid Stellar Ed25519 public key'), + ]), + ); + }); + + it('keeps the field optional when it is omitted or null', async () => { + const omitted = new UpdateProfileDto(); + const cleared = new UpdateProfileDto(); + cleared.walletAddress = null; + + expect(await getValidationErrors(omitted)).toHaveLength(0); + expect(await getValidationErrors(cleared)).toHaveLength(0); + }); + + it('rejects non-string values when the field is supplied', async () => { + const dto = new UpdateProfileDto(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (dto as any).walletAddress = 123; + + expect(await getValidationErrors(dto)).not.toHaveLength(0); + }); +}); diff --git a/backend/src/auth/dto/update-profile.dto.ts b/backend/src/auth/dto/update-profile.dto.ts index 044fe8ea9..d2ba3cd31 100644 --- a/backend/src/auth/dto/update-profile.dto.ts +++ b/backend/src/auth/dto/update-profile.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsStellarEd25519PublicKey } from '../../common/validators/is-stellar-ed25519-public-key.decorator'; export class UpdateProfileDto { @ApiPropertyOptional({ example: 'John' }) @@ -16,8 +17,12 @@ export class UpdateProfileDto { @MaxLength(50) lastName?: string; - @ApiPropertyOptional({ example: 'GABCDE...XYZ' }) + @ApiPropertyOptional({ + example: 'GAGD6MFNQ5IZQQQW6MOS6JWZALYR5P7ITCZP3NUQPNYLHWYBQVC6NNXB', + description: 'Optional Stellar Ed25519 account public key (StrKey)', + }) @IsOptional() @IsString() - walletAddress?: string; + @IsStellarEd25519PublicKey() + walletAddress?: string | null; } diff --git a/backend/src/carriers/carrier-certification-expiry.scheduler.spec.ts b/backend/src/carriers/carrier-certification-expiry.scheduler.spec.ts new file mode 100644 index 000000000..79ca6b205 --- /dev/null +++ b/backend/src/carriers/carrier-certification-expiry.scheduler.spec.ts @@ -0,0 +1,33 @@ +import { CarrierCertificationExpiryScheduler } from './carrier-certification-expiry.scheduler'; +import { CarrierCertificationsService } from './carrier-certifications.service'; + +describe('CarrierCertificationExpiryScheduler', () => { + it('runs the expiry sweep when the module starts and on schedule', async () => { + const certificationsService = { + revokeExpiredCertifications: jest.fn().mockResolvedValue(2), + } as unknown as CarrierCertificationsService; + const scheduler = new CarrierCertificationExpiryScheduler( + certificationsService, + ); + + await scheduler.onModuleInit(); + await scheduler.sweep(); + + expect( + certificationsService.revokeExpiredCertifications, + ).toHaveBeenCalledTimes(2); + }); + + it('contains sweep failures so the scheduler remains alive', async () => { + const certificationsService = { + revokeExpiredCertifications: jest + .fn() + .mockRejectedValue(new Error('db down')), + } as unknown as CarrierCertificationsService; + const scheduler = new CarrierCertificationExpiryScheduler( + certificationsService, + ); + + await expect(scheduler.sweep()).resolves.toBeUndefined(); + }); +}); diff --git a/backend/src/carriers/carrier-certification-expiry.scheduler.ts b/backend/src/carriers/carrier-certification-expiry.scheduler.ts new file mode 100644 index 000000000..942badddc --- /dev/null +++ b/backend/src/carriers/carrier-certification-expiry.scheduler.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { CarrierCertificationsService } from './carrier-certifications.service'; + +/** + * Runs the expiry cleanup independently of request traffic. The service method + * is atomic, so multiple application instances can safely run this job. + */ +@Injectable() +export class CarrierCertificationExpiryScheduler implements OnModuleInit { + private readonly logger = new Logger( + CarrierCertificationExpiryScheduler.name, + ); + + constructor( + private readonly certificationsService: CarrierCertificationsService, + ) {} + + async onModuleInit(): Promise { + await this.sweep(); + } + + @Cron(CronExpression.EVERY_MINUTE) + async sweep(): Promise { + try { + const revoked = + await this.certificationsService.revokeExpiredCertifications(); + if (revoked > 0) { + this.logger.log(`Revoked ${revoked} expired carrier certification(s)`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Certification expiry sweep failed: ${message}`); + } + } +} diff --git a/backend/src/carriers/carrier-certifications.service.spec.ts b/backend/src/carriers/carrier-certifications.service.spec.ts index 55a514ceb..20aa23975 100644 --- a/backend/src/carriers/carrier-certifications.service.spec.ts +++ b/backend/src/carriers/carrier-certifications.service.spec.ts @@ -1,6 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; import { CarrierCertificationsService } from './carrier-certifications.service'; import { CarrierCertification, @@ -11,6 +15,10 @@ import { UpdateCertificationVerificationDto, } from './dto/carrier-certification.dto'; import { AuditLogService } from '../audit-log/audit-log.service'; +import { DocumentsService } from '../documents/documents.service'; +import { DocumentType } from '../documents/enums/document-type.enum'; + +const DOCUMENT_ID = '4c5e6f70-8b9a-4c1d-9e2f-1234567890ab'; function makeCertification( overrides: Partial = {}, @@ -19,10 +27,14 @@ function makeCertification( id: 'cert-uuid-1', carrierId: 'carrier-uuid-1', documentType: CertificationType.OPERATING_LICENSE, - fileUrl: 'https://example.com/cert.pdf', + fileUrl: null, + document: null, + documentId: DOCUMENT_ID, + isPlatformHosted: true, issuedBy: 'FMCSA', - expiresAt: new Date('2027-12-31'), + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), isVerified: false, + verificationRevokedAt: null, notes: null, createdAt: new Date(), updatedAt: new Date(), @@ -37,12 +49,14 @@ function mockRepo() { create: jest.fn(), save: jest.fn(), remove: jest.fn(), + update: jest.fn(), } as unknown as jest.Mocked<{ findOne: jest.Mock; find: jest.Mock; create: jest.Mock; save: jest.Mock; remove: jest.Mock; + update: jest.Mock; }>; } @@ -50,10 +64,22 @@ describe('CarrierCertificationsService', () => { let service: CarrierCertificationsService; let certificationRepo: ReturnType; let auditLogService: { log: jest.Mock }; + let documentsService: { + getOwnedCertificationDocument: jest.Mock; + }; beforeEach(async () => { certificationRepo = mockRepo(); auditLogService = { log: jest.fn() }; + documentsService = { + getOwnedCertificationDocument: jest.fn().mockResolvedValue({ + id: DOCUMENT_ID, + uploaderId: 'carrier-uuid-1', + documentType: DocumentType.CARRIER_CERTIFICATION, + shipmentId: null, + sha256Hash: 'a'.repeat(64), + }), + }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -66,6 +92,10 @@ describe('CarrierCertificationsService', () => { provide: AuditLogService, useValue: auditLogService, }, + { + provide: DocumentsService, + useValue: documentsService, + }, ], }).compile(); @@ -75,39 +105,89 @@ describe('CarrierCertificationsService', () => { }); describe('create()', () => { - it('creates a certification with isVerified set to false', async () => { + it('creates an unverified certification tied to an owned platform document', async () => { const dto: CreateCarrierCertificationDto = { documentType: CertificationType.OPERATING_LICENSE, - fileUrl: 'https://example.com/license.pdf', + documentId: DOCUMENT_ID, issuedBy: 'FMCSA', - expiresAt: '2027-12-31T23:59:59Z', + expiresAt: new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000, + ).toISOString(), notes: 'Valid license', }; - - const certification = makeCertification(); - certificationRepo.create.mockReturnValue(certification); - certificationRepo.save.mockResolvedValue(certification); + certificationRepo.findOne.mockResolvedValue(undefined); + certificationRepo.create.mockImplementation((value: unknown) => value); + certificationRepo.save.mockImplementation((value: unknown) => value); const result = await service.create('carrier-uuid-1', dto); + expect( + documentsService.getOwnedCertificationDocument, + ).toHaveBeenCalledWith(dto.documentId, 'carrier-uuid-1'); expect(certificationRepo.create).toHaveBeenCalledWith( expect.objectContaining({ carrierId: 'carrier-uuid-1', documentType: CertificationType.OPERATING_LICENSE, + fileUrl: null, + documentId: DOCUMENT_ID, + isPlatformHosted: true, isVerified: false, + verificationRevokedAt: null, + document: expect.objectContaining({ id: DOCUMENT_ID }), }), ); - expect(result).toEqual(certification); + expect(result.documentId).toBe(DOCUMENT_ID); + }); + + it('rejects a legacy external fileUrl even when called directly', async () => { + await expect( + service.create('carrier-uuid-1', { + documentType: CertificationType.OPERATING_LICENSE, + documentId: DOCUMENT_ID, + issuedBy: 'FMCSA', + fileUrl: 'https://attacker.example/cert.pdf', + } as CreateCarrierCertificationDto), + ).rejects.toThrow('fileUrl is not accepted'); + expect(certificationRepo.create).not.toHaveBeenCalled(); + }); + + it('rejects a past expiry even when called outside the HTTP pipe', async () => { + certificationRepo.findOne.mockResolvedValue(undefined); + + await expect( + service.create('carrier-uuid-1', { + documentType: CertificationType.OPERATING_LICENSE, + documentId: DOCUMENT_ID, + issuedBy: 'FMCSA', + expiresAt: new Date(Date.now() - 1000).toISOString(), + }), + ).rejects.toThrow(BadRequestException); + expect(certificationRepo.save).not.toHaveBeenCalled(); + }); + + it('does not create a certification when document ownership fails', async () => { + documentsService.getOwnedCertificationDocument.mockRejectedValueOnce( + new ForbiddenException('not your document'), + ); + + await expect( + service.create('carrier-uuid-1', { + documentType: CertificationType.OPERATING_LICENSE, + documentId: DOCUMENT_ID, + issuedBy: 'FMCSA', + }), + ).rejects.toThrow(ForbiddenException); + expect(certificationRepo.create).not.toHaveBeenCalled(); }); }); - describe('findByCarrierId()', () => { - it('returns all certifications for a carrier ordered by createdAt DESC', async () => { - const certs = [ - makeCertification({ id: 'cert-1', createdAt: new Date('2026-01-01') }), - makeCertification({ id: 'cert-2', createdAt: new Date('2026-02-01') }), - ]; - certificationRepo.find.mockResolvedValue(certs); + describe('reads', () => { + it('returns certifications without mutating or saving stale entities', async () => { + const expired = makeCertification({ + isVerified: true, + expiresAt: new Date(Date.now() - 1000), + }); + certificationRepo.find.mockResolvedValue([expired]); const result = await service.findByCarrierId('carrier-uuid-1'); @@ -115,18 +195,19 @@ describe('CarrierCertificationsService', () => { where: { carrierId: 'carrier-uuid-1' }, order: { createdAt: 'DESC' }, }); - expect(result).toEqual(certs); + expect(result[0].isVerified).toBe(true); + expect(certificationRepo.save).not.toHaveBeenCalled(); }); - }); - - describe('findOne()', () => { - it('returns a certification by id', async () => { - const certification = makeCertification(); - certificationRepo.findOne.mockResolvedValue(certification); - const result = await service.findOne('cert-uuid-1'); + it('returns a certification by id without expiry writes', async () => { + const expired = makeCertification({ + isVerified: true, + expiresAt: new Date(Date.now() - 1000), + }); + certificationRepo.findOne.mockResolvedValue(expired); - expect(result).toEqual(certification); + await expect(service.findOne('cert-uuid-1')).resolves.toBe(expired); + expect(certificationRepo.save).not.toHaveBeenCalled(); }); it('throws NotFoundException when certification does not exist', async () => { @@ -139,19 +220,20 @@ describe('CarrierCertificationsService', () => { }); describe('updateVerification()', () => { - it('updates isVerified status and notes', async () => { + it('uses a conditional update and returns the fresh row', async () => { const certification = makeCertification(); - const dto: UpdateCertificationVerificationDto = { + const fresh = makeCertification({ isVerified: true, notes: 'Verified by admin', - }; - - certificationRepo.findOne.mockResolvedValue(certification); - certificationRepo.save.mockResolvedValue({ - ...certification, + }); + certificationRepo.findOne + .mockResolvedValueOnce(certification) + .mockResolvedValueOnce(fresh); + certificationRepo.update.mockResolvedValue({ affected: 1 }); + const dto: UpdateCertificationVerificationDto = { isVerified: true, notes: 'Verified by admin', - }); + }; const result = await service.updateVerification( 'cert-uuid-1', @@ -159,69 +241,169 @@ describe('CarrierCertificationsService', () => { 'admin-uuid-1', ); - expect(certificationRepo.save).toHaveBeenCalledWith( + expect(certificationRepo.update).toHaveBeenCalledWith( expect.objectContaining({ - isVerified: true, - notes: 'Verified by admin', + id: 'cert-uuid-1', + isPlatformHosted: true, + documentId: expect.objectContaining({ _type: 'not' }), + expiresAt: expect.objectContaining({ _type: 'or' }), + verificationRevokedAt: expect.objectContaining({ _type: 'isNull' }), }), + { isVerified: true, notes: 'Verified by admin' }, ); - expect(result.isVerified).toBe(true); + expect(certificationRepo.save).not.toHaveBeenCalled(); + expect(result).toBe(fresh); }); - it('creates an audit log entry when verifying a certification', async () => { + it('records document provenance in verification audit metadata', async () => { const certification = makeCertification(); - const dto: UpdateCertificationVerificationDto = { - isVerified: true, - notes: 'Verified by admin', - }; + const fresh = makeCertification({ isVerified: true }); + certificationRepo.findOne + .mockResolvedValueOnce(certification) + .mockResolvedValueOnce(fresh); + certificationRepo.update.mockResolvedValue({ affected: 1 }); - certificationRepo.findOne.mockResolvedValue(certification); - certificationRepo.save.mockResolvedValue({ - ...certification, - isVerified: true, - notes: 'Verified by admin', - }); - - await service.updateVerification('cert-uuid-1', dto, 'admin-uuid-1'); + await service.updateVerification( + 'cert-uuid-1', + { isVerified: true }, + 'admin-uuid-1', + ); expect(auditLogService.log).toHaveBeenCalledWith({ adminId: 'admin-uuid-1', action: 'CERTIFICATION_VERIFIED', targetType: 'CarrierCertification', targetId: 'cert-uuid-1', - metadata: { + metadata: expect.objectContaining({ carrierId: 'carrier-uuid-1', - documentType: CertificationType.OPERATING_LICENSE, + documentId: DOCUMENT_ID, + isPlatformHosted: true, + documentUploaderId: 'carrier-uuid-1', + documentStorageType: DocumentType.CARRIER_CERTIFICATION, + documentSha256Hash: 'a'.repeat(64), isVerified: true, - }, + }), }); }); - it('creates an audit log entry when unverified a certification', async () => { - const certification = makeCertification({ isVerified: true }); - const dto: UpdateCertificationVerificationDto = { + it('does not re-enable a row after the expiry sweep wins the race', async () => { + const certification = makeCertification(); + const revoked = makeCertification({ isVerified: false, - }; + verificationRevokedAt: new Date(), + }); + certificationRepo.findOne + .mockResolvedValueOnce(certification) + .mockResolvedValueOnce(revoked); + certificationRepo.update.mockResolvedValue({ affected: 0 }); - certificationRepo.findOne.mockResolvedValue(certification); - certificationRepo.save.mockResolvedValue({ - ...certification, + await expect( + service.updateVerification( + 'cert-uuid-1', + { isVerified: true }, + 'admin-uuid-1', + ), + ).rejects.toThrow('revoked by the expiry sweep'); + expect(certificationRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects verification for a legacy or untrusted document', async () => { + certificationRepo.findOne.mockResolvedValue( + makeCertification({ documentId: null, isPlatformHosted: false }), + ); + + await expect( + service.updateVerification( + 'cert-uuid-1', + { isVerified: true }, + 'admin-uuid-1', + ), + ).rejects.toThrow(BadRequestException); + expect(certificationRepo.update).not.toHaveBeenCalled(); + }); + + it('re-checks platform document ownership before verification', async () => { + certificationRepo.findOne.mockResolvedValue(makeCertification()); + documentsService.getOwnedCertificationDocument.mockRejectedValueOnce( + new ForbiddenException('document is no longer owned'), + ); + + await expect( + service.updateVerification( + 'cert-uuid-1', + { isVerified: true }, + 'admin-uuid-1', + ), + ).rejects.toThrow(ForbiddenException); + expect(certificationRepo.update).not.toHaveBeenCalled(); + }); + + it('rejects verification of an expired certification', async () => { + certificationRepo.findOne.mockResolvedValue( + makeCertification({ expiresAt: new Date(Date.now() - 1000) }), + ); + + await expect( + service.updateVerification( + 'cert-uuid-1', + { isVerified: true }, + 'admin-uuid-1', + ), + ).rejects.toThrow('Expired certifications cannot be verified'); + expect(certificationRepo.update).not.toHaveBeenCalled(); + }); + + it('uses an atomic update for unverification and audits provenance fields', async () => { + const certification = makeCertification({ isVerified: true }); + const fresh = makeCertification({ isVerified: false }); + certificationRepo.findOne + .mockResolvedValueOnce(certification) + .mockResolvedValueOnce(fresh); + certificationRepo.update.mockResolvedValue({ affected: 1 }); + + await service.updateVerification( + 'cert-uuid-1', + { isVerified: false, notes: 'Revoked by admin' }, + 'admin-uuid-1', + ); + + expect(certificationRepo.update).toHaveBeenCalledWith('cert-uuid-1', { isVerified: false, + notes: 'Revoked by admin', }); + expect(certificationRepo.save).not.toHaveBeenCalled(); + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'CERTIFICATION_UNVERIFIED', + metadata: expect.objectContaining({ + documentId: DOCUMENT_ID, + isPlatformHosted: true, + documentUploaderId: null, + documentSha256Hash: null, + }), + }), + ); + }); + }); - await service.updateVerification('cert-uuid-1', dto, 'admin-uuid-1'); + describe('revokeExpiredCertifications()', () => { + it('atomically clears verification and records a revocation marker', async () => { + const now = new Date('2026-09-25T12:00:00.000Z'); + certificationRepo.update.mockResolvedValue({ affected: 3 }); - expect(auditLogService.log).toHaveBeenCalledWith({ - adminId: 'admin-uuid-1', - action: 'CERTIFICATION_UNVERIFIED', - targetType: 'CarrierCertification', - targetId: 'cert-uuid-1', - metadata: { - carrierId: 'carrier-uuid-1', - documentType: CertificationType.OPERATING_LICENSE, - isVerified: false, - }, - }); + const result = await service.revokeExpiredCertifications(now); + + expect(result).toBe(3); + expect(certificationRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + isVerified: true, + expiresAt: expect.objectContaining({ + _type: 'lessThanOrEqual', + _value: now, + }), + }), + { isVerified: false, verificationRevokedAt: now }, + ); }); }); diff --git a/backend/src/carriers/carrier-certifications.service.ts b/backend/src/carriers/carrier-certifications.service.ts index 36e3a4072..c38bb4362 100644 --- a/backend/src/carriers/carrier-certifications.service.ts +++ b/backend/src/carriers/carrier-certifications.service.ts @@ -2,15 +2,25 @@ import { Injectable, NotFoundException, ForbiddenException, + BadRequestException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { + IsNull, + LessThanOrEqual, + MoreThan, + Not, + Or, + Repository, +} from 'typeorm'; import { CarrierCertification } from './entities/carrier-certification.entity'; import { CreateCarrierCertificationDto, UpdateCertificationVerificationDto, } from './dto/carrier-certification.dto'; import { AuditLogService } from '../audit-log/audit-log.service'; +import { DocumentsService } from '../documents/documents.service'; @Injectable() export class CarrierCertificationsService { @@ -18,26 +28,63 @@ export class CarrierCertificationsService { @InjectRepository(CarrierCertification) private readonly certificationRepo: Repository, private readonly auditLogService: AuditLogService, + private readonly documentsService: DocumentsService, ) {} async create( carrierId: string, dto: CreateCarrierCertificationDto, ): Promise { + // Keep this guard at the service boundary as well as in the DTO. It + // prevents an internal caller or a future controller that forgets the + // global whitelist pipe from reintroducing arbitrary external URLs. + if (dto.fileUrl !== undefined) { + throw new BadRequestException( + 'fileUrl is not accepted; upload the certification through the platform document endpoint', + ); + } + + // A document id is only useful when the upload service proves that the + // current carrier owns it. This prevents a carrier from attaching another + // carrier's platform document by guessing/stealing its UUID. + const document = await this.documentsService.getOwnedCertificationDocument( + dto.documentId, + carrierId, + ); + const documentId = document.id ?? dto.documentId; + + const existing = await this.certificationRepo.findOne({ + where: { documentId }, + }); + if (existing) { + throw new ConflictException( + 'This platform document is already attached to a certification', + ); + } + + const expiresAt = this.parseFutureExpiry(dto.expiresAt); const certification = this.certificationRepo.create({ carrierId, documentType: dto.documentType, - fileUrl: dto.fileUrl, + // Never copy a client-supplied URL. The document relation and its + // ownership record are the source of truth for the file. + fileUrl: null, + document, + documentId, + isPlatformHosted: true, issuedBy: dto.issuedBy, - expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + expiresAt, notes: dto.notes ?? null, isVerified: false, + verificationRevokedAt: null, }); return this.certificationRepo.save(certification); } async findByCarrierId(carrierId: string): Promise { + // Reads are intentionally side-effect free. Expiry is handled by the + // atomic scheduled sweep and by conditional verification updates. return this.certificationRepo.find({ where: { carrierId }, order: { createdAt: 'DESC' }, @@ -60,31 +107,129 @@ export class CarrierCertificationsService { adminId: string, ): Promise { const certification = await this.findOne(id); + let verifiedDocument: + | Awaited> + | undefined; + + if (dto.isVerified) { + if (!certification.documentId || !certification.isPlatformHosted) { + throw new BadRequestException( + 'Only certifications backed by a platform-hosted document can be verified', + ); + } + if (certification.verificationRevokedAt) { + throw new BadRequestException( + 'A certification revoked by the expiry sweep cannot be re-verified', + ); + } + if (this.isExpired(certification)) { + throw new BadRequestException( + 'Expired certifications cannot be verified', + ); + } - certification.isVerified = dto.isVerified; - if (dto.notes !== undefined) { - certification.notes = dto.notes; + // Re-check ownership at verification time as well as creation time. This + // protects the admin action if a document is removed or its provenance + // is altered between submission and review. + verifiedDocument = + await this.documentsService.getOwnedCertificationDocument( + certification.documentId, + certification.carrierId, + ); } - const saved = await this.certificationRepo.save(certification); + const now = new Date(); + const update = { + isVerified: dto.isVerified, + ...(dto.notes !== undefined ? { notes: dto.notes } : {}), + }; + + // Verification is a single conditional UPDATE. A stale read/save could + // otherwise re-enable a row after the expiry sweep won the race. + const result = dto.isVerified + ? await this.certificationRepo.update( + { + id, + isPlatformHosted: true, + documentId: Not(IsNull()), + expiresAt: Or(IsNull(), MoreThan(now)), + verificationRevokedAt: IsNull(), + }, + update, + ) + : await this.certificationRepo.update(id, update); + + if ((result?.affected ?? 0) === 0) { + const current = await this.findOne(id); + if (dto.isVerified) { + if (this.isExpired(current, now)) { + throw new BadRequestException( + 'Expired certifications cannot be verified', + ); + } + if (current.verificationRevokedAt) { + throw new BadRequestException( + 'A certification revoked by the expiry sweep cannot be re-verified', + ); + } + if (!current.documentId || !current.isPlatformHosted) { + throw new BadRequestException( + 'Only certifications backed by a platform-hosted document can be verified', + ); + } + throw new BadRequestException( + 'Certification changed while verifying; please retry', + ); + } + throw new BadRequestException('Certification could not be updated'); + } + // Reload only for the response/audit payload; this read never writes. + const saved = await this.findOne(id); await this.auditLogService.log({ adminId, action: dto.isVerified ? 'CERTIFICATION_VERIFIED' : 'CERTIFICATION_UNVERIFIED', targetType: 'CarrierCertification', - targetId: certification.id, + targetId: saved.id, metadata: { - carrierId: certification.carrierId, - documentType: certification.documentType, - isVerified: dto.isVerified, + carrierId: saved.carrierId, + documentType: saved.documentType, + isVerified: saved.isVerified, + documentId: saved.documentId, + isPlatformHosted: saved.isPlatformHosted, + documentUploaderId: verifiedDocument?.uploaderId ?? null, + documentStorageType: verifiedDocument?.documentType ?? null, + documentSha256Hash: verifiedDocument?.sha256Hash ?? null, + expiresAt: this.serializeDate(saved.expiresAt), + verificationRevokedAt: this.serializeDate(saved.verificationRevokedAt), }, }); return saved; } + /** + * Atomically revokes every currently verified certification whose expiry + * has passed. The scheduled job calls this method, and the revocation marker + * prevents a stale admin request from re-enabling the row later. + */ + async revokeExpiredCertifications(now = new Date()): Promise { + const result = await this.certificationRepo.update( + { + isVerified: true, + expiresAt: LessThanOrEqual(now), + }, + { + isVerified: false, + verificationRevokedAt: now, + }, + ); + + return result?.affected ?? 0; + } + async delete(id: string, carrierId: string): Promise { const certification = await this.findOne(id); @@ -96,4 +241,34 @@ export class CarrierCertificationsService { await this.certificationRepo.remove(certification); } + + private parseFutureExpiry(value: string | undefined): Date | null { + if (value === undefined || value === null) return null; + + const expiry = new Date(value); + if (Number.isNaN(expiry.getTime()) || expiry.getTime() <= Date.now()) { + throw new BadRequestException( + 'expiresAt must be a valid date in the future', + ); + } + return expiry; + } + + private isExpired( + certification: CarrierCertification, + now = new Date(), + ): boolean { + if (!certification.expiresAt) return false; + const expiry = + certification.expiresAt instanceof Date + ? certification.expiresAt + : new Date(certification.expiresAt); + return Number.isNaN(expiry.getTime()) || expiry.getTime() <= now.getTime(); + } + + private serializeDate(value: Date | null): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } } diff --git a/backend/src/carriers/carriers.controller.ts b/backend/src/carriers/carriers.controller.ts index 71fd11de8..9fbb6da9c 100644 --- a/backend/src/carriers/carriers.controller.ts +++ b/backend/src/carriers/carriers.controller.ts @@ -42,7 +42,12 @@ export class CarriersController { @Post('me/certifications') @UseGuards(RolesGuard) @Roles(UserRole.CARRIER) - @ApiOperation({ summary: 'Upload a new certification (Carrier only)' }) + @ApiOperation({ + summary: + 'Create a certification from a platform-hosted document (Carrier only)', + description: + 'Upload the file through POST /documents/certification first, then submit the returned documentId.', + }) createCertification( @CurrentUser() user: User, @Body() dto: CreateCarrierCertificationDto, diff --git a/backend/src/carriers/carriers.module.ts b/backend/src/carriers/carriers.module.ts index 36158d286..536c2be72 100644 --- a/backend/src/carriers/carriers.module.ts +++ b/backend/src/carriers/carriers.module.ts @@ -6,14 +6,21 @@ import { CarriersService } from './carriers.service'; import { CarrierCertificationsService } from './carrier-certifications.service'; import { CarriersController } from './carriers.controller'; import { AuditLogModule } from '../audit-log/audit-log.module'; +import { DocumentsModule } from '../documents/documents.module'; +import { CarrierCertificationExpiryScheduler } from './carrier-certification-expiry.scheduler'; @Module({ imports: [ TypeOrmModule.forFeature([Shipment, CarrierCertification]), AuditLogModule, + DocumentsModule, ], controllers: [CarriersController], - providers: [CarriersService, CarrierCertificationsService], + providers: [ + CarriersService, + CarrierCertificationsService, + CarrierCertificationExpiryScheduler, + ], exports: [CarrierCertificationsService], }) export class CarriersModule {} diff --git a/backend/src/carriers/dto/carrier-certification.dto.spec.ts b/backend/src/carriers/dto/carrier-certification.dto.spec.ts new file mode 100644 index 000000000..3fc46f38b --- /dev/null +++ b/backend/src/carriers/dto/carrier-certification.dto.spec.ts @@ -0,0 +1,95 @@ +import { validate } from 'class-validator'; +import { + CreateCarrierCertificationDto, + UpdateCertificationVerificationDto, +} from './carrier-certification.dto'; +import { CertificationType } from '../entities/carrier-certification.entity'; + +const DOCUMENT_ID = '4c5e6f70-8b9a-4c1d-9e2f-1234567890ab'; + +function makeCreateDto( + overrides: Partial = {}, +): CreateCarrierCertificationDto { + const dto = new CreateCarrierCertificationDto(); + dto.documentType = CertificationType.OPERATING_LICENSE; + dto.documentId = DOCUMENT_ID; + dto.issuedBy = 'FMCSA'; + return Object.assign(dto, overrides); +} + +async function errorsFor( + dto: object, + forbidNonWhitelisted = false, +): Promise { + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted, + }); + return errors.flatMap((error) => Object.values(error.constraints ?? {})); +} + +describe('CreateCarrierCertificationDto', () => { + it('requires a platform document id rather than a client URL', async () => { + const dto = makeCreateDto(); + // Simulate the old payload shape reaching the global ValidationPipe. + (dto as unknown as { fileUrl: string }).fileUrl = + 'https://attacker.example/certificate.pdf'; + + const messages = await errorsFor(dto, true); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining('fileUrl')]), + ); + }); + + it('rejects a payload that only contains the legacy fileUrl', async () => { + const dto = makeCreateDto(); + delete (dto as Partial).documentId; + (dto as unknown as { fileUrl: string }).fileUrl = + 'https://attacker.example/certificate.pdf'; + + expect(await errorsFor(dto)).toEqual( + expect.arrayContaining([expect.stringContaining('documentId')]), + ); + }); + + it('rejects a document id that is not a UUID', async () => { + const messages = await errorsFor( + makeCreateDto({ documentId: 'not-a-document-id' }), + ); + expect(messages).not.toHaveLength(0); + }); + + it('accepts a future expiry date', async () => { + const messages = await errorsFor( + makeCreateDto({ + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }), + ); + expect(messages).toHaveLength(0); + }); + + it('rejects a past expiry date', async () => { + const messages = await errorsFor( + makeCreateDto({ + expiresAt: new Date(Date.now() - 86_400_000).toISOString(), + }), + ); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining('date in the future')]), + ); + }); + + it('keeps expiry optional', async () => { + expect(await errorsFor(makeCreateDto())).toHaveLength(0); + }); +}); + +describe('UpdateCertificationVerificationDto', () => { + it('still validates the verification decision', async () => { + const dto = new UpdateCertificationVerificationDto(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (dto as any).isVerified = 'yes'; + + expect(await errorsFor(dto)).not.toHaveLength(0); + }); +}); diff --git a/backend/src/carriers/dto/carrier-certification.dto.ts b/backend/src/carriers/dto/carrier-certification.dto.ts index d8d3f0172..d98277427 100644 --- a/backend/src/carriers/dto/carrier-certification.dto.ts +++ b/backend/src/carriers/dto/carrier-certification.dto.ts @@ -4,21 +4,37 @@ import { IsEnum, IsOptional, IsDateString, - IsUrl, IsBoolean, + IsUUID, + IsDefined, + IsEmpty, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CertificationType } from '../entities/carrier-certification.entity'; +import { IsFutureDate } from '../../common/validators/is-future-date.decorator'; export class CreateCarrierCertificationDto { @ApiProperty({ enum: CertificationType }) @IsEnum(CertificationType) documentType: CertificationType; - @ApiProperty({ example: 'https://storage.example.com/certs/license.pdf' }) - @IsUrl() - @IsNotEmpty() - fileUrl: string; + @ApiProperty({ + description: + 'ID returned by the platform certification-document upload endpoint', + example: '4c5e6f70-8b9a-4c1d-9e2f-1234567890ab', + }) + @IsDefined() + @IsUUID() + documentId: string; + + /** + * @deprecated External URLs are not a valid certification reference. Keep a + * tombstone validator so old clients receive a useful validation error + * instead of silently falling back to an unowned file. + */ + @IsOptional() + @IsEmpty({ message: 'fileUrl is not accepted; use documentId instead' }) + fileUrl?: string | null; @ApiProperty({ example: 'Federal Motor Carrier Safety Administration' }) @IsString() @@ -28,6 +44,7 @@ export class CreateCarrierCertificationDto { @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() + @IsFutureDate() expiresAt?: string; @ApiPropertyOptional({ example: 'Valid for interstate commerce' }) diff --git a/backend/src/carriers/entities/carrier-certification.entity.ts b/backend/src/carriers/entities/carrier-certification.entity.ts index 5445c67dd..201498e97 100644 --- a/backend/src/carriers/entities/carrier-certification.entity.ts +++ b/backend/src/carriers/entities/carrier-certification.entity.ts @@ -5,7 +5,10 @@ import { CreateDateColumn, UpdateDateColumn, Index, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { Document } from '../../documents/entities/document.entity'; export enum CertificationType { OPERATING_LICENSE = 'Operating License', @@ -18,6 +21,8 @@ export enum CertificationType { @Entity('carrier_certifications') @Index(['carrierId']) +@Index(['documentId'], { unique: true }) +@Index(['isVerified', 'expiresAt', 'verificationRevokedAt']) export class CarrierCertification { @PrimaryGeneratedColumn('uuid') id: string; @@ -32,8 +37,27 @@ export class CarrierCertification { }) documentType: CertificationType; - @Column({ name: 'file_url', type: 'text' }) - fileUrl: string; + /** + * Legacy URL retained for records created before platform document + * provenance was introduced. New certifications never populate this field; + * `documentId` is the authoritative reference. + */ + @Column({ name: 'file_url', type: 'text', nullable: true }) + fileUrl: string | null; + + @ManyToOne(() => Document, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'document_id' }) + document: Document | null; + + @Column({ name: 'document_id', type: 'uuid', nullable: true }) + documentId: string | null; + + /** + * Set only after the upload service has verified that the document belongs to + * the carrier. This is an immutable provenance signal for admin review. + */ + @Column({ name: 'is_platform_hosted', default: false }) + isPlatformHosted: boolean; @Column({ name: 'issued_by' }) issuedBy: string; @@ -44,6 +68,14 @@ export class CarrierCertification { @Column({ name: 'is_verified', default: false }) isVerified: boolean; + /** Set atomically by the expiry sweep to prevent stale re-verification. */ + @Column({ + name: 'verification_revoked_at', + type: 'timestamptz', + nullable: true, + }) + verificationRevokedAt: Date | null; + @Column({ nullable: true, type: 'text' }) notes: string | null; diff --git a/backend/src/common/validators/is-future-date.decorator.ts b/backend/src/common/validators/is-future-date.decorator.ts new file mode 100644 index 000000000..ccb4e2928 --- /dev/null +++ b/backend/src/common/validators/is-future-date.decorator.ts @@ -0,0 +1,37 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +/** + * Requires a date value to be strictly in the future at validation time. + * This is intentionally implemented as a validator rather than a module-load + * `MinDate(new Date())`, since a long-running process must not freeze the + * acceptable boundary when the application starts. + */ +export function IsFutureDate( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isFutureDate', + target: object.constructor, + propertyName, + options: { + message: 'expiresAt must be a valid date in the future', + ...validationOptions, + }, + validator: { + validate(value: unknown, _args: ValidationArguments): boolean { + if (typeof value !== 'string' && !(value instanceof Date)) { + return false; + } + + const date = value instanceof Date ? value : new Date(value); + return !Number.isNaN(date.getTime()) && date.getTime() > Date.now(); + }, + }, + }); + }; +} diff --git a/backend/src/common/validators/is-stellar-ed25519-public-key.decorator.ts b/backend/src/common/validators/is-stellar-ed25519-public-key.decorator.ts new file mode 100644 index 000000000..34574eba3 --- /dev/null +++ b/backend/src/common/validators/is-stellar-ed25519-public-key.decorator.ts @@ -0,0 +1,43 @@ +import { StrKey } from '@stellar/stellar-sdk'; +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +/** + * Validates a Stellar account (Ed25519) public key, including its StrKey + * checksum. A regular expression cannot provide the checksum validation, so + * this deliberately delegates to the Stellar SDK rather than only checking the + * `G` prefix and length. + */ +export function IsStellarEd25519PublicKey( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStellarEd25519PublicKey', + target: object.constructor, + propertyName, + options: { + message: + 'walletAddress must be a valid Stellar Ed25519 public key (G...)', + ...validationOptions, + }, + validator: { + validate(value: unknown, _args: ValidationArguments): boolean { + if (typeof value !== 'string') return false; + + try { + return StrKey.isValidEd25519PublicKey(value); + } catch { + // The SDK throws for some malformed values. Validation should + // report an invalid value rather than turn a bad request into a + // server error. + return false; + } + }, + }, + }); + }; +} diff --git a/backend/src/common/validators/is-strong-password.spec.ts b/backend/src/common/validators/is-strong-password.spec.ts index 671c56409..13728e2da 100644 --- a/backend/src/common/validators/is-strong-password.spec.ts +++ b/backend/src/common/validators/is-strong-password.spec.ts @@ -1,5 +1,6 @@ import { validate } from 'class-validator'; import { RegisterDto } from '../../auth/dto/register.dto'; +import { CreateUserDto } from '../../users/dto/create-user.dto'; import { ChangePasswordDto } from '../../auth/dto/change-password.dto'; import { ResetPasswordDto } from '../../auth/dto/reset-password.dto'; import { IsStrongPassword } from './is-strong-password.decorator'; @@ -24,7 +25,7 @@ const WEAK_PASSWORDS: { password: string; missing: string }[] = [ /** Return the list of validation error messages for a given DTO instance. */ async function getValidationErrors(dto: object): Promise { - const errors = await validate(dto as any, { + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: false, }); @@ -102,6 +103,34 @@ describe('RegisterDto – password policy', () => { ); }); +// ── CreateUserDto ───────────────────────────────────────────────────────────── + +describe('CreateUserDto – password policy', () => { + function makeDto(password: string): CreateUserDto { + const dto = new CreateUserDto(); + dto.email = 'test@example.com'; + dto.password = password; + dto.firstName = 'Jane'; + dto.lastName = 'Doe'; + return dto; + } + + it('accepts a valid strong password', async () => { + const messages = await getValidationErrors(makeDto(VALID_PASSWORD)); + expect(messages).toHaveLength(0); + }); + + it.each(WEAK_PASSWORDS)( + 'rejects weak password: $missing', + async ({ password }) => { + const messages = await getValidationErrors(makeDto(password)); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining('uppercase')]), + ); + }, + ); +}); + // ── ChangePasswordDto ──────────────────────────────────────────────────────── describe('ChangePasswordDto – password policy', () => { diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts index b75fdccb5..012e2ce94 100644 --- a/backend/src/data-source.ts +++ b/backend/src/data-source.ts @@ -23,7 +23,9 @@ export const AppDataSource = new DataSource({ database: process.env.DATABASE_NAME ?? 'freightflow', // Glob patterns — CLI resolves these at runtime via ts-node entities: ['src/**/*.entity.ts'], - migrations: ['src/migrations/!(*.spec).ts'], + // Migration filenames begin with a numeric timestamp. Exclude the colocated + // Jest migration specs from the TypeORM CLI migration list. + migrations: ['src/migrations/[0-9]*.ts'], // Never synchronize in migration mode synchronize: false, logging: ['migration'], diff --git a/backend/src/documents/document-file.util.spec.ts b/backend/src/documents/document-file.util.spec.ts new file mode 100644 index 000000000..4f465b199 --- /dev/null +++ b/backend/src/documents/document-file.util.spec.ts @@ -0,0 +1,77 @@ +import { + isSupportedDocumentMimeType, + verifyDocumentFileType, +} from './document-file.util'; + +const signatures = { + pdf: Buffer.from('%PDF-1.7\ncontent'), + png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + jpeg: Buffer.from([0xff, 0xd8, 0xff, 0xdb]), + gif: Buffer.from('GIF89a-content'), + webp: Buffer.concat([ + Buffer.from('RIFF'), + Buffer.from([0, 0, 0, 0]), + Buffer.from('WEBPcontent'), + ]), + zip: Buffer.concat([ + Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0]), + Buffer.from('[Content_Types].xmlword/document.xml'), + ]), + xlsxZip: Buffer.concat([ + Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0]), + Buffer.from('[Content_Types].xmlxl/workbook.xml'), + ]), + ole: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), +}; + +describe('document file provenance', () => { + it.each([ + ['application/pdf', signatures.pdf, '.pdf'], + ['image/png', signatures.png, '.png'], + ['image/jpeg', signatures.jpeg, '.jpg'], + ['image/gif', signatures.gif, '.gif'], + ['image/webp', signatures.webp, '.webp'], + [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + signatures.zip, + '.docx', + ], + [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + signatures.xlsxZip, + '.xlsx', + ], + ['application/msword', signatures.ole, '.doc'], + ['application/vnd.ms-excel', signatures.ole, '.xls'], + ])( + 'verifies %s signatures and returns a canonical extension', + (mimetype, buffer, extension) => { + expect(verifyDocumentFileType(buffer, mimetype)).toEqual({ + mimetype, + extension, + }); + }, + ); + + it('rejects unsupported MIME types', () => { + expect(isSupportedDocumentMimeType('application/x-msdownload')).toBe(false); + }); + + it('rejects a generic ZIP masquerading as an OOXML document', () => { + expect(() => + verifyDocumentFileType( + Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0]), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ), + ).toThrow('does not match'); + }); + + it('rejects content that does not match the claimed MIME type', () => { + expect(() => verifyDocumentFileType(signatures.pdf, 'image/png')).toThrow( + 'does not match', + ); + expect(() => + verifyDocumentFileType(Buffer.from('plain text'), 'application/pdf'), + ).toThrow('does not match'); + }); +}); diff --git a/backend/src/documents/document-file.util.ts b/backend/src/documents/document-file.util.ts new file mode 100644 index 000000000..f6315482c --- /dev/null +++ b/backend/src/documents/document-file.util.ts @@ -0,0 +1,125 @@ +export const SUPPORTED_DOCUMENT_MIME_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]); + +export type VerifiedDocumentType = { + mimetype: string; + extension: string; +}; + +type DocumentTypeDefinition = VerifiedDocumentType & { + matches: (buffer: Buffer) => boolean; +}; + +const startsWith = (buffer: Buffer, bytes: number[]): boolean => + bytes.every((byte, index) => buffer[index] === byte); + +const hasZipSignature = (buffer: Buffer): boolean => + startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]) || + startsWith(buffer, [0x50, 0x4b, 0x05, 0x06]) || + startsWith(buffer, [0x50, 0x4b, 0x07, 0x08]); + +const hasZipEntry = (buffer: Buffer, entry: string): boolean => + buffer.includes(Buffer.from(entry, 'utf8')); + +const definitions: DocumentTypeDefinition[] = [ + { + mimetype: 'application/pdf', + extension: '.pdf', + matches: (buffer) => startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]), + }, + { + mimetype: 'image/png', + extension: '.png', + matches: (buffer) => + startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + }, + { + mimetype: 'image/jpeg', + extension: '.jpg', + matches: (buffer) => startsWith(buffer, [0xff, 0xd8, 0xff]), + }, + { + mimetype: 'image/gif', + extension: '.gif', + matches: (buffer) => + buffer.subarray(0, 6).toString('ascii') === 'GIF87a' || + buffer.subarray(0, 6).toString('ascii') === 'GIF89a', + }, + { + mimetype: 'image/webp', + extension: '.webp', + matches: (buffer) => + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP', + }, + { + mimetype: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + extension: '.docx', + matches: (buffer) => + hasZipSignature(buffer) && + hasZipEntry(buffer, '[Content_Types].xml') && + hasZipEntry(buffer, 'word/'), + }, + { + mimetype: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + extension: '.xlsx', + matches: (buffer) => + hasZipSignature(buffer) && + hasZipEntry(buffer, '[Content_Types].xml') && + hasZipEntry(buffer, 'xl/'), + }, + { + // Legacy .doc/.xls files use the OLE compound-file signature. The + // claimed MIME type still distinguishes the two legacy Office formats. + mimetype: 'application/msword', + extension: '.doc', + matches: (buffer) => + startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), + }, + { + mimetype: 'application/vnd.ms-excel', + extension: '.xls', + matches: (buffer) => + startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), + }, +]; + +export function isSupportedDocumentMimeType(mimetype: string): boolean { + return SUPPORTED_DOCUMENT_MIME_TYPES.has(mimetype); +} + +/** + * Verifies a claimed MIME type against file magic bytes and returns the + * canonical extension to use for storage. Client-provided extensions are never + * trusted for the stored filename. + */ +export function verifyDocumentFileType( + buffer: Buffer, + claimedMimetype: string, +): VerifiedDocumentType { + const definition = definitions.find( + (candidate) => candidate.mimetype === claimedMimetype, + ); + + if (!definition || !Buffer.isBuffer(buffer) || !definition.matches(buffer)) { + throw new Error( + `File content does not match the declared type (${claimedMimetype})`, + ); + } + + return { + mimetype: definition.mimetype, + extension: definition.extension, + }; +} diff --git a/backend/src/documents/documents.controller.spec.ts b/backend/src/documents/documents.controller.spec.ts index 9661c0236..d28ac02b0 100644 --- a/backend/src/documents/documents.controller.spec.ts +++ b/backend/src/documents/documents.controller.spec.ts @@ -8,10 +8,12 @@ describe('DocumentsController', () => { let controller: DocumentsController; let service: { upload: jest.Mock; + uploadCertificationDocument: jest.Mock; listByShipment: jest.Mock; findOne: jest.Mock; getFilePath: jest.Mock; delete: jest.Mock; + cleanupUpload: jest.Mock; }; const user = { id: 'user-1', role: UserRole.SHIPPER } as never; @@ -19,10 +21,12 @@ describe('DocumentsController', () => { beforeEach(async () => { service = { upload: jest.fn(), + uploadCertificationDocument: jest.fn(), listByShipment: jest.fn(), findOne: jest.fn(), getFilePath: jest.fn(), delete: jest.fn(), + cleanupUpload: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -45,6 +49,36 @@ describe('DocumentsController', () => { ).rejects.toThrow(BadRequestException); expect(service.upload).not.toHaveBeenCalled(); + expect(service.cleanupUpload).toHaveBeenCalled(); + }); + + it('cleans up a controller-stage failure after a file was accepted', async () => { + const file = { + mimetype: 'application/pdf', + originalname: 'license.pdf', + path: '/uploads/pending.pdf', + } as Express.Multer.File; + service.upload.mockRejectedValue(new Error('validation failed')); + + await expect( + controller.upload(file, { shipmentId: 'shipment-1' } as never, user), + ).rejects.toThrow('validation failed'); + expect(service.cleanupUpload).toHaveBeenCalledWith(file); + }); + + it('delegates a carrier certification upload to the platform document service', async () => { + const file = { + mimetype: 'application/pdf', + originalname: 'license.pdf', + } as Express.Multer.File; + const carrier = { id: 'carrier-1', role: UserRole.CARRIER } as never; + + await controller.uploadCertification(file, carrier); + + expect(service.uploadCertificationDocument).toHaveBeenCalledWith( + file, + carrier, + ); }); it('delegates valid uploads and reads to the service', async () => { @@ -59,14 +93,10 @@ describe('DocumentsController', () => { }); await controller.upload(file, dto, user); - controller.listByShipment('shipment-1', user); - controller.findOne('doc-1', user); - await controller.download( - 'doc-1', - user, - { download: jest.fn() } as never, - ); - controller.remove('doc-1', user); + await controller.listByShipment('shipment-1', user); + await controller.findOne('doc-1', user); + await controller.download('doc-1', user, { download: jest.fn() } as never); + await controller.remove('doc-1', user); expect(service.upload).toHaveBeenCalledWith(file, dto, user); expect(service.listByShipment).toHaveBeenCalledWith('shipment-1', user); diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index 282c7d3bb..135b0d2de 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -12,6 +12,7 @@ import { HttpStatus, Res, BadRequestException, + UseGuards, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { @@ -27,18 +28,10 @@ import { DocumentsService } from './documents.service'; import { UploadDocumentDto } from './dto/upload-document.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { User } from '../users/entities/user.entity'; - -const ALLOWED_MIMETYPES = new Set([ - 'application/pdf', - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/gif', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -]); +import { UserRole } from '../common/enums/role.enum'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { isSupportedDocumentMimeType } from './document-file.util'; @ApiTags('documents') @ApiBearerAuth() @@ -71,15 +64,60 @@ export class DocumentsController { @Body() dto: UploadDocumentDto, @CurrentUser() user: User, ) { + try { + this.assertAllowedFile(file); + return await this.documentsService.upload(file, dto, user); + } catch (error: unknown) { + this.documentsService.cleanupUpload(file); + throw error; + } + } + + @Post('certification') + @UseGuards(RolesGuard) + @Roles(UserRole.CARRIER) + @ApiOperation({ + summary: 'Upload a platform-owned carrier certification document', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { type: 'string', format: 'binary' }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Certification document uploaded' }) + @ApiResponse({ status: 400, description: 'Invalid file' }) + @ApiResponse({ status: 403, description: 'Carrier role required' }) + @UseInterceptors(FileInterceptor('file')) + async uploadCertification( + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: User, + ) { + try { + this.assertAllowedFile(file); + return await this.documentsService.uploadCertificationDocument( + file, + user, + ); + } catch (error: unknown) { + this.documentsService.cleanupUpload(file); + throw error; + } + } + + private assertAllowedFile(file: Express.Multer.File | undefined): void { if (!file) { throw new BadRequestException('No file provided'); } - if (!ALLOWED_MIMETYPES.has(file.mimetype)) { + if (!isSupportedDocumentMimeType(file.mimetype)) { throw new BadRequestException( `Unsupported file type: ${file.mimetype}. Allowed: PDF, images, Word, Excel`, ); } - return this.documentsService.upload(file, dto, user); } @Get('shipment/:shipmentId') diff --git a/backend/src/documents/documents.module.ts b/backend/src/documents/documents.module.ts index 97b88ef16..03924954e 100644 --- a/backend/src/documents/documents.module.ts +++ b/backend/src/documents/documents.module.ts @@ -1,39 +1,42 @@ -import { Module } from '@nestjs/common'; +import { Module, BadRequestException } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MulterModule } from '@nestjs/platform-express'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { diskStorage } from 'multer'; -import { extname } from 'path'; -import * as fs from 'fs'; -import { v4 as uuidv4 } from 'uuid'; +import { memoryStorage } from 'multer'; import { DocumentsService } from './documents.service'; import { DocumentsController } from './documents.controller'; import { Document } from './entities/document.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; +import { CarrierCertification } from '../carriers/entities/carrier-certification.entity'; +import { isSupportedDocumentMimeType } from './document-file.util'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @Module({ imports: [ - TypeOrmModule.forFeature([Document, Shipment]), + TypeOrmModule.forFeature([Document, Shipment, CarrierCertification]), MulterModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (config: ConfigService) => { - const uploadDir = config.get('UPLOAD_DIR', './uploads'); - fs.mkdirSync(uploadDir, { recursive: true }); - - return { - storage: diskStorage({ - destination: uploadDir, - filename: (_req, file, cb) => { - const unique = `${Date.now()}-${uuidv4()}${extname(file.originalname)}`; - cb(null, unique); - }, - }), - limits: { fileSize: MAX_FILE_SIZE }, - }; - }, + useFactory: (_config: ConfigService) => ({ + // Keep bytes in memory until the service has validated the DTO and + // verified the file signature. This prevents rejected uploads from + // leaving files on disk before controller/ValidationPipe failures. + storage: memoryStorage(), + fileFilter: (_req, file, callback) => { + if (!isSupportedDocumentMimeType(file.mimetype)) { + callback( + new BadRequestException( + `Unsupported file type: ${file.mimetype}`, + ), + false, + ); + return; + } + callback(null, true); + }, + limits: { fileSize: MAX_FILE_SIZE }, + }), }), ], controllers: [DocumentsController], diff --git a/backend/src/documents/documents.service.spec.ts b/backend/src/documents/documents.service.spec.ts index a8c561cfa..e7076c1d7 100644 --- a/backend/src/documents/documents.service.spec.ts +++ b/backend/src/documents/documents.service.spec.ts @@ -1,4 +1,9 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Test, TestingModule } from '@nestjs/testing'; import * as crypto from 'crypto'; @@ -7,10 +12,26 @@ import { ConfigService } from '@nestjs/config'; import { DocumentsService } from './documents.service'; import { Document } from './entities/document.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; +import { CarrierCertification } from '../carriers/entities/carrier-certification.entity'; import { DocumentType } from './enums/document-type.enum'; import { UserRole } from '../common/enums/role.enum'; import { User } from '../users/entities/user.entity'; +const PDF_BYTES = Buffer.from('%PDF-1.7\nvalid test document\n%%EOF'); + +function makeFile( + overrides: Partial = {}, +): Express.Multer.File { + return { + buffer: PDF_BYTES, + originalname: 'invoice.pdf', + filename: 'untrusted-client-name.exe', + mimetype: 'application/pdf', + size: PDF_BYTES.length, + ...overrides, + } as Express.Multer.File; +} + describe('DocumentsService', () => { let service: DocumentsService; let documentRepo: { @@ -21,6 +42,7 @@ describe('DocumentsService', () => { remove: jest.Mock; }; let shipmentRepo: { findOne: jest.Mock }; + let certificationRepo: { findOne: jest.Mock }; const user = { id: 'user-1', @@ -35,69 +57,153 @@ describe('DocumentsService', () => { beforeEach(async () => { documentRepo = { - create: jest.fn((value) => value), + create: jest.fn((value: unknown) => value), save: jest.fn(), find: jest.fn(), findOne: jest.fn(), remove: jest.fn(), }; shipmentRepo = { findOne: jest.fn() }; + certificationRepo = { findOne: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ DocumentsService, { provide: getRepositoryToken(Document), useValue: documentRepo }, { provide: getRepositoryToken(Shipment), useValue: shipmentRepo }, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('/uploads') } }, + { + provide: getRepositoryToken(CarrierCertification), + useValue: certificationRepo, + }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue('/uploads') }, + }, ], }).compile(); service = module.get(DocumentsService); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + jest.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); }); afterEach(() => { jest.restoreAllMocks(); }); - it('uploads and hashes a document for a shipment party', async () => { + it('verifies content, hashes bytes, and derives a safe extension', async () => { shipmentRepo.findOne.mockResolvedValue(shipment); - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('hello')); - const expectedHash = crypto.createHash('sha256').update('hello').digest('hex'); - documentRepo.save.mockResolvedValue({ - id: 'doc-1', - shipmentId: shipment.id, - uploaderId: user.id, - documentType: DocumentType.INVOICE, - originalName: 'invoice.pdf', - storedName: 'stored.pdf', - mimetype: 'application/pdf', - sizeBytes: 5, - sha256Hash: expectedHash, - ipfsCid: null, - onChainDocumentId: null, - notes: null, - }); + const expectedHash = crypto + .createHash('sha256') + .update(PDF_BYTES) + .digest('hex'); + documentRepo.save.mockImplementation((value: unknown) => value); const result = await service.upload( - { - path: '/tmp/file.pdf', - originalname: 'invoice.pdf', - filename: 'stored.pdf', - mimetype: 'application/pdf', - size: 5, - } as Express.Multer.File, + makeFile({ originalname: '../../invoice.exe' }), { shipmentId: shipment.id, documentType: DocumentType.INVOICE }, user, ); - expect(documentRepo.save).toHaveBeenCalledWith( + expect(result.sha256Hash).toBe(expectedHash); + expect(result.mimetype).toBe('application/pdf'); + expect(result.sizeBytes).toBe(PDF_BYTES.length); + expect(result.storedName).toMatch(/^[0-9a-f-]{36}\.pdf$/); + expect(result.originalName).toBe('invoice.exe'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + `/uploads/${result.storedName}`, + PDF_BYTES, + { flag: 'wx' }, + ); + }); + + it('rejects a MIME/content mismatch before writing a file', async () => { + shipmentRepo.findOne.mockResolvedValue(shipment); + + await expect( + service.upload( + makeFile({ buffer: Buffer.from('not a pdf') }), + { shipmentId: shipment.id, documentType: DocumentType.INVOICE }, + user, + ), + ).rejects.toThrow(BadRequestException); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(documentRepo.save).not.toHaveBeenCalled(); + }); + + it('uploads a platform-owned certification document without a shipment', async () => { + const carrier = { id: 'carrier-1', role: UserRole.CARRIER } as User; + documentRepo.save.mockImplementation((value: unknown) => value); + + await service.uploadCertificationDocument(makeFile(), carrier); + + expect(documentRepo.create).toHaveBeenCalledWith( expect.objectContaining({ - shipmentId: shipment.id, - uploaderId: user.id, - sha256Hash: expectedHash, + shipmentId: null, + uploaderId: carrier.id, + documentType: DocumentType.CARRIER_CERTIFICATION, + mimetype: 'application/pdf', }), ); - expect(result.sha256Hash).toBe(expectedHash); + }); + + it('removes a written file when persistence fails', async () => { + shipmentRepo.findOne.mockResolvedValue(shipment); + documentRepo.save.mockRejectedValue(new Error('database unavailable')); + + await expect( + service.upload( + makeFile(), + { shipmentId: shipment.id, documentType: DocumentType.INVOICE }, + user, + ), + ).rejects.toThrow('database unavailable'); + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringMatching(/^\/uploads\/[0-9a-f-]{36}\.pdf$/), + ); + }); + + it('only resolves certification documents owned by the requesting carrier', async () => { + documentRepo.findOne.mockResolvedValue({ + id: 'cert-document-1', + shipmentId: null, + uploaderId: 'carrier-2', + documentType: DocumentType.CARRIER_CERTIFICATION, + }); + + await expect( + service.getOwnedCertificationDocument('cert-document-1', 'carrier-1'), + ).rejects.toThrow(ForbiddenException); + }); + + it('rejects a shipment document as a certification document', async () => { + documentRepo.findOne.mockResolvedValue({ + id: 'shipment-document-1', + shipmentId: shipment.id, + uploaderId: user.id, + documentType: DocumentType.INVOICE, + }); + + await expect( + service.getOwnedCertificationDocument('shipment-document-1', user.id), + ).rejects.toThrow('platform-hosted certification document'); + }); + + it('allows the uploader to access an unbound certification document', async () => { + documentRepo.findOne.mockResolvedValue({ + id: 'cert-document-1', + shipmentId: null, + uploaderId: user.id, + documentType: DocumentType.CARRIER_CERTIFICATION, + storedName: 'stored-certificate.pdf', + originalName: 'certificate.pdf', + }); + + await expect(service.findOne('cert-document-1', user)).resolves.toEqual( + expect.objectContaining({ id: 'cert-document-1' }), + ); + expect(shipmentRepo.findOne).not.toHaveBeenCalled(); }); it('forbids non-parties from accessing a document', async () => { @@ -111,10 +217,28 @@ describe('DocumentsService', () => { }); await expect( - service.findOne('doc-1', { id: 'user-2', role: UserRole.SHIPPER } as never), + service.findOne('doc-1', { + id: 'user-2', + role: UserRole.SHIPPER, + } as never), ).rejects.toThrow(ForbiddenException); }); + it('contains stored paths when returning a file path', async () => { + shipmentRepo.findOne.mockResolvedValue(shipment); + documentRepo.findOne.mockResolvedValue({ + id: 'doc-1', + shipmentId: shipment.id, + uploaderId: user.id, + originalName: 'invoice.pdf', + storedName: '../outside.pdf', + }); + + await expect(service.getFilePath('doc-1', user)).rejects.toThrow( + BadRequestException, + ); + }); + it('returns a file path when the file exists', async () => { shipmentRepo.findOne.mockResolvedValue(shipment); documentRepo.findOne.mockResolvedValue({ @@ -132,7 +256,7 @@ describe('DocumentsService', () => { }); }); - it('removes the document and underlying file for the uploader', async () => { + it('removes the database row before unlinking an unreferenced document', async () => { shipmentRepo.findOne.mockResolvedValue(shipment); documentRepo.findOne.mockResolvedValue({ id: 'doc-1', @@ -141,14 +265,57 @@ describe('DocumentsService', () => { originalName: 'invoice.pdf', storedName: 'stored.pdf', }); - jest.spyOn(fs, 'existsSync').mockReturnValue(true); - const unlinkSpy = jest.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); + certificationRepo.findOne.mockResolvedValue(null); documentRepo.remove.mockResolvedValue(undefined); await service.delete('doc-1', user); - expect(unlinkSpy).toHaveBeenCalledWith('/uploads/stored.pdf'); expect(documentRepo.remove).toHaveBeenCalled(); + expect(fs.unlinkSync).toHaveBeenCalledWith('/uploads/stored.pdf'); + expect(documentRepo.remove.mock.invocationCallOrder[0]).toBeLessThan( + (fs.unlinkSync as unknown as jest.Mock).mock.invocationCallOrder[0], + ); + }); + + it('refuses to delete a document referenced by a certification', async () => { + shipmentRepo.findOne.mockResolvedValue(shipment); + documentRepo.findOne.mockResolvedValue({ + id: 'doc-1', + shipmentId: null, + uploaderId: user.id, + originalName: 'certificate.pdf', + storedName: 'stored-certificate.pdf', + }); + certificationRepo.findOne.mockResolvedValue({ + id: 'cert-1', + isVerified: true, + }); + + await expect(service.delete('doc-1', user)).rejects.toThrow( + ConflictException, + ); + expect(documentRepo.remove).not.toHaveBeenCalled(); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + it('keeps the file when the database FK wins a deletion race', async () => { + shipmentRepo.findOne.mockResolvedValue(shipment); + documentRepo.findOne.mockResolvedValue({ + id: 'doc-1', + shipmentId: shipment.id, + uploaderId: user.id, + originalName: 'invoice.pdf', + storedName: 'stored.pdf', + }); + certificationRepo.findOne.mockResolvedValue(null); + documentRepo.remove.mockRejectedValue({ + driverError: { code: '23503' }, + }); + + await expect(service.delete('doc-1', user)).rejects.toThrow( + ConflictException, + ); + expect(fs.unlinkSync).not.toHaveBeenCalled(); }); it('throws when the file is missing', async () => { diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index c5c83f608..04903b7e0 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException, ForbiddenException, + BadRequestException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -9,11 +11,26 @@ import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; +import { v4 as uuidv4 } from 'uuid'; import { Document } from './entities/document.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; import { User } from '../users/entities/user.entity'; import { UserRole } from '../common/enums/role.enum'; import { UploadDocumentDto } from './dto/upload-document.dto'; +import { DocumentType } from './enums/document-type.enum'; +import { + isSupportedDocumentMimeType, + verifyDocumentFileType, + type VerifiedDocumentType, +} from './document-file.util'; +import { CarrierCertification } from '../carriers/entities/carrier-certification.entity'; + +type PersistedUpload = VerifiedDocumentType & { + storedName: string; + filePath: string; + sha256Hash: string; + sizeBytes: number; +}; @Injectable() export class DocumentsService { @@ -22,11 +39,15 @@ export class DocumentsService { private readonly documentRepo: Repository, @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(CarrierCertification) + private readonly certificationRepo: Repository, private readonly configService: ConfigService, ) {} get uploadDir(): string { - return this.configService.get('UPLOAD_DIR', './uploads'); + return path.resolve( + this.configService.get('UPLOAD_DIR', './uploads'), + ); } // ── Guards ─────────────────────────────────────────────────────────────────── @@ -52,40 +73,240 @@ export class DocumentsService { } } - // ── Hash ───────────────────────────────────────────────────────────────────── + // ── Safe file handling ─────────────────────────────────────────────────────── + + private isPathInsideRoot(candidate: string, root = this.uploadDir): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== '' && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ); + } + + private getFileBuffer(file: Express.Multer.File): Buffer { + if (Buffer.isBuffer(file.buffer)) return file.buffer; + // Kept as a defensive fallback for callers/tests using a disk-backed file. + // The configured Multer storage is memory-backed, so normal requests never + // write a file before validation and signature verification complete. A + // fallback path is accepted only when it is contained by the upload root. + if (file.path && this.isPathInsideRoot(path.resolve(file.path))) { + return fs.readFileSync(file.path); + } + throw new BadRequestException('Uploaded file contents are unavailable'); + } + + private resolveStoredPath(storedName: string): string { + if (!storedName || path.basename(storedName) !== storedName) { + throw new BadRequestException('Invalid stored document path'); + } + + const resolved = path.resolve(this.uploadDir, storedName); + if (!this.isPathInsideRoot(resolved)) { + throw new BadRequestException('Invalid stored document path'); + } + return resolved; + } + + private getErrorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; + } - private computeSha256(filePath: string): string { - const buffer = fs.readFileSync(filePath); - return crypto.createHash('sha256').update(buffer).digest('hex'); + private removeFileIfExists(filePath: string, quiet = false): void { + try { + fs.unlinkSync(filePath); + } catch (error: unknown) { + const code = this.getErrorCode(error); + if (code !== 'ENOENT' && !quiet) throw error; + } + } + + private sanitizeOriginalName(originalName: string): string { + const basename = Array.from(path.basename(originalName || 'upload')) + .filter((character) => { + const code = character.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join('') + .trim(); + return (basename || 'upload').slice(0, 255); + } + + private persistFile(file: Express.Multer.File | undefined): PersistedUpload { + if (!file) { + throw new BadRequestException('No file provided'); + } + if (!isSupportedDocumentMimeType(file.mimetype)) { + throw new BadRequestException(`Unsupported file type: ${file.mimetype}`); + } + + const buffer = this.getFileBuffer(file); + let verifiedType: VerifiedDocumentType; + try { + verifiedType = verifyDocumentFileType(buffer, file.mimetype); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new BadRequestException(message); + } + + const storedName = `${uuidv4()}${verifiedType.extension}`; + const filePath = this.resolveStoredPath(storedName); + const sha256Hash = crypto.createHash('sha256').update(buffer).digest('hex'); + fs.mkdirSync(this.uploadDir, { recursive: true }); + + try { + // The extension comes only from the verified signature, never from the + // client filename. `wx` prevents overwriting an existing object. + fs.writeFileSync(filePath, buffer, { flag: 'wx' }); + } catch (error: unknown) { + // EEXIST means the path was already occupied; never delete a pre-existing + // file in that case. Other write failures may have left a partial file. + if (this.getErrorCode(error) !== 'EEXIST') { + this.removeFileIfExists(filePath, true); + } + throw error; + } + + return { + ...verifiedType, + storedName, + filePath, + sha256Hash, + sizeBytes: buffer.length, + }; + } + + private async persistDocument( + file: Express.Multer.File | undefined, + uploader: User, + shipmentId: string | null, + documentType: DocumentType, + notes: string | null, + ): Promise { + if (!file) { + throw new BadRequestException('No file provided'); + } + const persisted = this.persistFile(file); + + try { + const document = this.documentRepo.create({ + shipmentId, + uploaderId: uploader.id, + documentType, + originalName: this.sanitizeOriginalName(file.originalname), + storedName: persisted.storedName, + mimetype: persisted.mimetype, + sizeBytes: persisted.sizeBytes, + sha256Hash: persisted.sha256Hash, + ipfsCid: null, + onChainDocumentId: null, + notes, + }); + + return await this.documentRepo.save(document); + } catch (error: unknown) { + // Covers repository/create failures after bytes have been accepted. + this.removeFileIfExists(persisted.filePath, true); + throw error; + } + } + + /** + * Removes a file left by a non-memory storage implementation when a + * controller/interceptor fails. With the current memory storage this is a + * no-op, but keeping the guarded cleanup here makes the invariant explicit. + */ + cleanupUpload(file: Express.Multer.File | undefined): void { + if (!file?.path) return; + + try { + const candidate = path.resolve(file.path); + if (this.isPathInsideRoot(candidate)) { + this.removeFileIfExists(candidate, true); + } + } catch { + // Cleanup must never mask the original request error. + } } // ── Upload ─────────────────────────────────────────────────────────────────── async upload( - file: Express.Multer.File, + file: Express.Multer.File | undefined, dto: UploadDocumentDto, uploader: User, ): Promise { const shipment = await this.getShipmentOrThrow(dto.shipmentId); this.assertIsParty(shipment, uploader); - const sha256Hash = this.computeSha256(file.path); + return this.persistDocument( + file, + uploader, + dto.shipmentId, + dto.documentType, + dto.notes ?? null, + ); + } - const doc = this.documentRepo.create({ - shipmentId: dto.shipmentId, - uploaderId: uploader.id, - documentType: dto.documentType, - originalName: file.originalname, - storedName: file.filename, - mimetype: file.mimetype, - sizeBytes: file.size, - sha256Hash, - ipfsCid: null, - onChainDocumentId: null, - notes: dto.notes ?? null, + /** + * Stores a certification file in the same platform-owned upload flow as + * shipment documents. Callers receive a persisted Document id rather than an + * arbitrary URL to submit later. + */ + async uploadCertificationDocument( + file: Express.Multer.File | undefined, + uploader: User, + ): Promise { + if (uploader.role !== UserRole.CARRIER) { + throw new ForbiddenException('Only carriers can upload certifications'); + } + + return this.persistDocument( + file, + uploader, + null, + DocumentType.CARRIER_CERTIFICATION, + null, + ); + } + + /** + * Resolves the document id supplied with a certification and proves that it + * was uploaded by the carrier through this service. A UUID by itself is not + * proof of ownership, so this check happens at the service boundary. + */ + async getOwnedCertificationDocument( + documentId: string, + uploaderId: string, + ): Promise { + const document = await this.documentRepo.findOne({ + where: { id: documentId }, }); - return this.documentRepo.save(doc); + if (!document) { + throw new NotFoundException(`Document ${documentId} not found`); + } + + if (document.uploaderId !== uploaderId) { + throw new ForbiddenException( + 'You can only use certification documents that you uploaded', + ); + } + + if ( + document.documentType !== DocumentType.CARRIER_CERTIFICATION || + (document.shipmentId !== null && document.shipmentId !== undefined) + ) { + throw new BadRequestException( + 'Certification must reference a platform-hosted certification document', + ); + } + + return document; } // ── List ───────────────────────────────────────────────────────────────────── @@ -110,6 +331,15 @@ export class DocumentsService { }); if (!doc) throw new NotFoundException(`Document ${id} not found`); + if (doc.shipmentId === null || doc.shipmentId === undefined) { + if (doc.uploaderId !== user.id && user.role !== UserRole.ADMIN) { + throw new ForbiddenException( + 'Only the uploader or an admin can access this document', + ); + } + return doc; + } + const shipment = await this.getShipmentOrThrow(doc.shipmentId); this.assertIsParty(shipment, user); return doc; @@ -122,17 +352,31 @@ export class DocumentsService { user: User, ): Promise<{ filePath: string; originalName: string }> { const doc = await this.findOne(id, user); - const filePath = path.join(this.uploadDir, doc.storedName); + const filePath = this.resolveStoredPath(doc.storedName); if (!fs.existsSync(filePath)) { throw new NotFoundException('File not found on server'); } - return { filePath, originalName: doc.originalName }; + return { + filePath, + originalName: this.sanitizeOriginalName(doc.originalName), + }; } // ── Delete ─────────────────────────────────────────────────────────────────── + private isForeignKeyViolation(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { + code?: unknown; + driverError?: { code?: unknown }; + }; + return ( + candidate.code === '23503' || candidate.driverError?.code === '23503' + ); + } + async delete(id: string, user: User): Promise { const doc = await this.findOne(id, user); @@ -143,11 +387,32 @@ export class DocumentsService { ); } - const filePath = path.join(this.uploadDir, doc.storedName); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); + // A backing document is immutable provenance for its certification. The + // database FK is RESTRICT as a final race-safe guard; this service check + // turns the normal case into a useful 409 instead of a late FK error. + const certification = await this.certificationRepo.findOne({ + where: { documentId: id }, + }); + if (certification) { + throw new ConflictException( + 'Cannot delete a document referenced by a carrier certification', + ); + } + + const filePath = this.resolveStoredPath(doc.storedName); + try { + // Remove the row first. If a certification is attached concurrently, the + // RESTRICT FK fails and the file remains intact for the certification. + await this.documentRepo.remove(doc); + } catch (error: unknown) { + if (this.isForeignKeyViolation(error)) { + throw new ConflictException( + 'Cannot delete a document referenced by a carrier certification', + ); + } + throw error; } - await this.documentRepo.remove(doc); + this.removeFileIfExists(filePath); } } diff --git a/backend/src/documents/entities/document.entity.ts b/backend/src/documents/entities/document.entity.ts index 5ca5f6ee5..7fb415881 100644 --- a/backend/src/documents/entities/document.entity.ts +++ b/backend/src/documents/entities/document.entity.ts @@ -17,12 +17,12 @@ export class Document { id: string; @Index() - @ManyToOne(() => Shipment, { nullable: false, onDelete: 'CASCADE' }) + @ManyToOne(() => Shipment, { nullable: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'shipment_id' }) - shipment: Shipment; + shipment: Shipment | null; - @Column({ name: 'shipment_id' }) - shipmentId: string; + @Column({ name: 'shipment_id', type: 'uuid', nullable: true }) + shipmentId: string | null; @ManyToOne(() => User, { nullable: false }) @JoinColumn({ name: 'uploader_id' }) diff --git a/backend/src/documents/enums/document-type.enum.ts b/backend/src/documents/enums/document-type.enum.ts index 05b596e08..a6fb1a6cd 100644 --- a/backend/src/documents/enums/document-type.enum.ts +++ b/backend/src/documents/enums/document-type.enum.ts @@ -4,6 +4,7 @@ export enum DocumentType { INVOICE = 'invoice', CUSTOMS_DECLARATION = 'customs_declaration', INSURANCE_CERTIFICATE = 'insurance_certificate', + CARRIER_CERTIFICATION = 'carrier_certification', PHOTO = 'photo', OTHER = 'other', } diff --git a/backend/src/migrations/1724313600000-AddCertificationDocumentProvenance.ts b/backend/src/migrations/1724313600000-AddCertificationDocumentProvenance.ts new file mode 100644 index 000000000..3e685ba57 --- /dev/null +++ b/backend/src/migrations/1724313600000-AddCertificationDocumentProvenance.ts @@ -0,0 +1,108 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds platform-owned document provenance to carrier certifications. + * + * The migration is intentionally forward-safe: rerunning the statements is + * harmless, and the down migration preserves rows and provenance columns + * rather than deleting files or manufacturing legacy URLs. + */ +export class AddCertificationDocumentProvenance1724313600000 + implements MigrationInterface +{ + name = 'AddCertificationDocumentProvenance1724313600000'; + + public async up(queryRunner: QueryRunner): Promise { + // Certification documents are not attached to a shipment, so the shared + // document record must permit a null shipment reference. + await queryRunner.query( + `ALTER TABLE "documents" ALTER COLUMN "shipment_id" DROP NOT NULL`, + ); + + // TypeORM derives enum names as __enum. For the documents + // table/document_type column this is documents_document_type_enum. + await queryRunner.query( + `ALTER TYPE "public"."documents_document_type_enum" ADD VALUE IF NOT EXISTS 'carrier_certification'`, + ); + + await queryRunner.query(` + ALTER TABLE "carrier_certifications" + ADD COLUMN IF NOT EXISTS "document_id" uuid, + ADD COLUMN IF NOT EXISTS "is_platform_hosted" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "verification_revoked_at" timestamptz + `); + + // Existing rows predate platform upload provenance. Revoke any old + // verification rather than allowing an arbitrary legacy URL to remain + // trusted after this migration. This changes only verification state; it + // does not delete user data or files. + await queryRunner.query(` + UPDATE "carrier_certifications" + SET "is_verified" = false, + "verification_revoked_at" = CURRENT_TIMESTAMP + WHERE "document_id" IS NULL AND "is_verified" = true + `); + await queryRunner.query(` + UPDATE "carrier_certifications" + SET "is_platform_hosted" = false + WHERE "document_id" IS NULL + `); + + await queryRunner.query( + `ALTER TABLE "carrier_certifications" ALTER COLUMN "file_url" DROP NOT NULL`, + ); + + // RESTRICT is intentional: deleting a backing document must not silently + // turn a verified certification into an unverifiable dangling reference. + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_carrier_certifications_document' + AND conrelid = 'carrier_certifications'::regclass + ) THEN + ALTER TABLE "carrier_certifications" + DROP CONSTRAINT "FK_carrier_certifications_document"; + END IF; + ALTER TABLE "carrier_certifications" + ADD CONSTRAINT "FK_carrier_certifications_document" + FOREIGN KEY ("document_id") REFERENCES "documents"("id") + ON DELETE RESTRICT; + END $$; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_carrier_certifications_document_id" + ON "carrier_certifications" ("document_id") + WHERE "document_id" IS NOT NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_carrier_certifications_verified_expiry" + ON "carrier_certifications" + ("is_verified", "expires_at", "verification_revoked_at") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_carrier_certifications_document_id"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_carrier_certifications_verified_expiry"`, + ); + await queryRunner.query( + `ALTER TABLE "carrier_certifications" DROP CONSTRAINT IF EXISTS "FK_carrier_certifications_document"`, + ); + + // Do not synthesize replacement URLs, delete unbound documents, or restore + // NOT NULL constraints here. Those operations would be destructive and can + // fail once platform documents exist. Keeping the nullable provenance + // columns is a safe rollback; a subsequent up is idempotent. + // + // PostgreSQL also cannot remove an enum value without rebuilding the type, + // so carrier_certification intentionally remains in the enum. + } +} diff --git a/backend/src/migrations/migrations.spec.ts b/backend/src/migrations/migrations.spec.ts index 3f94df72e..2770ff01b 100644 --- a/backend/src/migrations/migrations.spec.ts +++ b/backend/src/migrations/migrations.spec.ts @@ -1,5 +1,6 @@ import { CreatePaymentsTable1724140800000 } from './1724140800000-CreatePaymentsTable'; import { AddPaymentFundingFields1724227200000 } from './1724227200000-AddPaymentFundingFields'; +import { AddCertificationDocumentProvenance1724313600000 } from './1724313600000-AddCertificationDocumentProvenance'; import { CreateTwoFactorOtpTable1724313600000 } from './1724313600000-CreateTwoFactorOtpTable'; import { AppDataSource } from '../data-source'; @@ -38,12 +39,45 @@ describe('migrations', () => { ); }); - it('excludes migration specs from the TypeORM CLI glob', () => { + it('uses a CLI migration glob that excludes colocated specs', () => { expect(AppDataSource.options.migrations).toEqual([ - 'src/migrations/!(*.spec).ts', + 'src/migrations/[0-9]*.ts', ]); }); + it('adds platform document provenance and expiry-safe certification fields', async () => { + const queryRunner = mockQueryRunner(); + const migration = new AddCertificationDocumentProvenance1724313600000(); + + await migration.up(queryRunner as never); + await migration.down(queryRunner as never); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('ADD COLUMN IF NOT EXISTS "document_id"'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('is_platform_hosted'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('verification_revoked_at'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('"documents_document_type_enum"'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('ON DELETE RESTRICT'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('SET "is_verified" = false'), + ); + expect(queryRunner.query).not.toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "documents"'), + ); + expect(queryRunner.query).not.toHaveBeenCalledWith( + expect.stringContaining('platform://document/'), + ); + }); + it('creates and removes the persistent two-factor table', async () => { const queryRunner = mockQueryRunner(); const migration = new CreateTwoFactorOtpTable1724313600000(); diff --git a/backend/src/users/dto/create-user.dto.ts b/backend/src/users/dto/create-user.dto.ts index 89db9a466..f4f70f847 100644 --- a/backend/src/users/dto/create-user.dto.ts +++ b/backend/src/users/dto/create-user.dto.ts @@ -4,10 +4,10 @@ import { IsNotEmpty, IsOptional, IsString, - MinLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { UserRole } from '../../common/enums/role.enum'; +import { IsStrongPassword } from '../../common/validators/is-strong-password.decorator'; export class CreateUserDto { @ApiProperty({ example: 'user@example.com' }) @@ -16,7 +16,7 @@ export class CreateUserDto { @ApiProperty({ example: 'SecurePass123!', minLength: 8 }) @IsString() - @MinLength(8) + @IsStrongPassword() password: string; @ApiProperty({ example: 'John' }) diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8ae863fb4..db76e9dc3 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -68,7 +68,7 @@ export class UsersService { async update( id: string, - updateUserDto: UpdateUserDto & { walletAddress?: string }, + updateUserDto: UpdateUserDto & { walletAddress?: string | null }, ): Promise { const user = await this.findOne(id); if (updateUserDto.password) {