Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions app/backend/test/mocks/prisma-client.mock.ts
Original file line number Diff line number Diff line change
@@ -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(async (cb) => (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',
}
33 changes: 31 additions & 2 deletions app/backend/test/security.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down
75 changes: 75 additions & 0 deletions app/backend/test/upload-roundtrip.spec.ts
Original file line number Diff line number Diff line change
@@ -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)', async () => {
// 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.
await fc.assert(
fc.asyncProperty(
fc.double({ min: 0, max: 1, noNaN: true, noInfinity: true }),
async (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
});
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading