diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index fca0f0d8..83bd8ea2 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Validate package.json files + run: | + node -e "const fs=require('fs'); require('child_process').execSync('git ls-files \"*package.json\"',{encoding:'utf8'}).trim().split('\\n').filter(Boolean).forEach(p=>{JSON.parse(fs.readFileSync(p,'utf8')); console.log('OK',p)})" + - name: Install pnpm uses: pnpm/action-setup@v2 with: diff --git a/app/backend/package.json b/app/backend/package.json index f09a435a..0343907a 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -82,6 +82,7 @@ "eslint": "^9.18.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", + "fast-check": "^4.9.0", "globals": "^16.0.0", "ioredis-mock": "^8.13.1", "jest": "^30.4.2", diff --git a/app/backend/test/mocks/prisma-client.mock.ts b/app/backend/test/mocks/prisma-client.mock.ts new file mode 100644 index 00000000..0677700c --- /dev/null +++ b/app/backend/test/mocks/prisma-client.mock.ts @@ -0,0 +1,139 @@ +const createModelMock = () => ({ + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + upsert: jest.fn(), + deleteMany: jest.fn(), + updateMany: jest.fn(), +}); + +export class PrismaClient { + constructor() { + return new Proxy(this, { + get(target, prop) { + if (prop === '$connect') return jest.fn().mockResolvedValue(undefined); + if (prop === '$disconnect') return jest.fn().mockResolvedValue(undefined); + if (prop === '$on') return jest.fn(); + if (prop === '$transaction') { + return jest.fn((cb) => Promise.resolve(typeof cb === 'function' ? cb(this) : cb)); + } + if (typeof prop === 'symbol' || prop === 'constructor' || prop === 'then') { + return (target as any)[prop]; + } + return createModelMock(); + }, + }); + } +} + +export const Prisma = { + defineExtension: jest.fn(x => x), + sql: jest.fn(), +}; + +// Enums +export enum CampaignStatus { + draft = 'draft', + active = 'active', + paused = 'paused', + completed = 'completed', + archived = 'archived', +} + +export enum ClaimStatus { + requested = 'requested', + verified = 'verified', + approved = 'approved', + disbursed = 'disbursed', + archived = 'archived', + cancelled = 'cancelled', +} + +export enum VerificationChannel { + email = 'email', + phone = 'phone', +} + +export enum VerificationSessionStatus { + pending = 'pending', + completed = 'completed', + expired = 'expired', + failed = 'failed', +} + +export enum SessionType { + otp_verification = 'otp_verification', + claim_verification = 'claim_verification', + multi_step_verification = 'multi_step_verification', +} + +export enum SessionStepStatus { + pending = 'pending', + in_progress = 'in_progress', + completed = 'completed', + failed = 'failed', + skipped = 'skipped', +} + +export enum VerificationStatus { + pending = 'pending', + pending_review = 'pending_review', + approved = 'approved', + rejected = 'rejected', + needs_resubmission = 'needs_resubmission', +} + +export enum PurgeStrategy { + soft_delete = 'soft_delete', + hard_delete = 'hard_delete', + anonymize = 'anonymize', +} + +export enum InviteStatus { + pending = 'pending', + accepted = 'accepted', + revoked = 'revoked', + expired = 'expired', +} + +export enum AppRole { + admin = 'admin', + operator = 'operator', + client = 'client', + ngo = 'ngo', +} + +export enum EvidenceStatus { + pending = 'pending', + uploading = 'uploading', + completed = 'completed', + failed = 'failed', +} + +export enum UploadSessionStatus { + active = 'active', + completed = 'completed', + expired = 'expired', + aborted = 'aborted', +} + +export enum NotificationOutboxStatus { + pending = 'pending', + enqueued = 'enqueued', + sent = 'sent', + failed = 'failed', +} + +export enum RegistryEntityType { + individual = 'individual', + household = 'household', +} + +export enum EntityLinkSourceType { + manual = 'manual', + automatic = 'automatic', +} diff --git a/app/backend/test/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index 129bb504..a5835185 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import request from 'supertest'; -import { AppModule } from '../src/app.module'; import { buildCorsOptions, createCorsOriginValidator, @@ -25,12 +24,42 @@ const setEnvValue = (key: string, value: string | undefined) => { } }; +@Controller() +class TestController { + @Get('health') + @HttpCode(200) + getHealth() { + return { status: 'OK' }; + } + + @Get() + @HttpCode(200) + getRoot() { + return { message: 'OK' }; + } +} + const createTestApp = async ({ enableDocs }: TestAppOptions) => { + const mockRedisInstance = new RedisMock(); const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + ], + controllers: [TestController], + providers: [ + { + provide: RedisService, + useValue: { + getOrThrow: () => mockRedisInstance, + }, + }, + ], }).compile(); const app = moduleFixture.createNestApplication(); + app.getHttpAdapter().getInstance().disable('x-powered-by'); app.setGlobalPrefix('api'); app.enableVersioning({ diff --git a/app/backend/test/upload-roundtrip.spec.ts b/app/backend/test/upload-roundtrip.spec.ts new file mode 100644 index 00000000..379bc932 --- /dev/null +++ b/app/backend/test/upload-roundtrip.spec.ts @@ -0,0 +1,75 @@ +import { ConfigService } from '@nestjs/config'; +import { EncryptionService } from '../src/common/encryption/encryption.service'; +import * as fc from 'fast-check'; +import * as crypto from 'crypto'; + +describe('AES Envelope Round-Trip (Property-Based Test)', () => { + let encryptionService: EncryptionService; + + beforeAll(() => { + const configService = new ConfigService({ + ENCRYPTION_MASTER_KEY: 'test-master-key-value-suitable-for-testing-12345', + }); + encryptionService = new EncryptionService(configService); + }); + + it('should preserve checksum equality and correctly decrypt buffers across all evidence sizes (1 KB to 100 MB)', () => { + // We generate a float/double between 0 and 1 using fast-check. + // We map this value to a piecewise log-uniform distribution to bias the sizes towards smaller values (e.g. 90% < 1 MB) + // while still ensuring that large values up to 100 MB are covered. + // This allows us to run 1000 iterations in CI in just a few seconds. + fc.assert( + fc.property( + fc.double({ min: 0, max: 1, noNaN: true, noInfinity: true }), + (d) => { + let size: number; + if (d < 0.1) { + // 10% of tests are large (1 MB to 100 MB) + const logMin = Math.log(1024 * 1024); + const logMax = Math.log(100 * 1024 * 1024); + const logVal = (d / 0.1) * (logMax - logMin) + logMin; + size = Math.floor(Math.exp(logVal)); + } else { + // 90% of tests are small to medium (1 KB to 1 MB) + const logMin = Math.log(1024); + const logMax = Math.log(1024 * 1024); + const logVal = ((d - 0.1) / 0.9) * (logMax - logMin) + logMin; + size = Math.floor(Math.exp(logVal)); + } + + // Construct original buffer of generated size. + // To make this extremely fast and avoid entropy bottlenecks, + // we generate a small random chunk (up to 4KB) and copy it repeatedly. + const patternSize = Math.min(size, 4096); + const pattern = crypto.randomBytes(patternSize); + const originalBuffer = Buffer.alloc(size); + + let offset = 0; + while (offset < size) { + const bytesToWrite = Math.min(patternSize, size - offset); + pattern.copy(originalBuffer, offset, 0, bytesToWrite); + offset += bytesToWrite; + } + + const originalChecksum = crypto + .createHash('sha256') + .update(originalBuffer) + .digest('hex'); + + // Encrypt and decrypt buffer (AES envelope round-trip) + const encrypted = encryptionService.encryptBuffer(originalBuffer); + const decrypted = encryptionService.decryptBuffer(encrypted); + + const decryptedChecksum = crypto + .createHash('sha256') + .update(decrypted) + .digest('hex'); + + expect(decryptedChecksum).toBe(originalChecksum); + expect(decrypted.equals(originalBuffer)).toBe(true); + } + ), + { numRuns: 1000 } + ); + }, 35000); // 35 second timeout to be safe, though it should complete in under 5 seconds +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e393c248..15e5307d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ importers: eslint-plugin-prettier: specifier: ^5.5.5 version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1) + fast-check: + specifier: ^4.9.0 + version: 4.9.0 globals: specifier: ^16.0.0 version: 16.5.0 @@ -5180,6 +5183,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -7386,6 +7393,9 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true @@ -14664,6 +14674,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-copy@4.0.2: {} fast-deep-equal@3.1.3: {} @@ -17639,6 +17653,8 @@ snapshots: pure-rand@7.0.1: {} + pure-rand@8.4.2: {} + qrcode-terminal@0.11.0: {} qs@6.15.0: