canina/backend/test/app-audit-verification.e2e-spec.ts
2026-08-06 22:27:56 +03:30

176 lines
6.3 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { JwtService } from '@nestjs/jwt';
import { Prisma } from '@prisma/client';
import { CustomHttpExceptionFilter } from '../src/common/filters/http-exception.filter';
import { PrismaExceptionFilter } from '../src/common/filters/prisma-exception.filter';
import { DecimalInterceptor } from '../src/common/interceptors/decimal.interceptor';
import * as fs from 'fs';
import * as path from 'path';
describe('Phase 4 Master Backlog E2E Verification Matrix (e2e)', () => {
let app: INestApplication;
let jwtService: JwtService;
let adminToken: string;
let userToken: string;
beforeAll(async () => {
process.env.JWT_ACCESS_SECRET =
'test_access_secret_32_characters_minimum_entropy';
process.env.JWT_REFRESH_SECRET =
'test_refresh_secret_32_characters_minimum_entropy';
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(
new CustomHttpExceptionFilter(),
new PrismaExceptionFilter(),
);
app.useGlobalInterceptors(new DecimalInterceptor());
await app.init();
jwtService = new JwtService();
adminToken = jwtService.sign(
{
sub: '12345678-1234-1234-1234-123456789012',
email: 'admin@canino.ir',
role: 'Admin',
},
{ secret: process.env.JWT_ACCESS_SECRET },
);
userToken = jwtService.sign(
{
sub: '12345678-1234-1234-1234-123456789012',
email: 'user@canino.ir',
role: 'User_PetOwner',
},
{ secret: process.env.JWT_ACCESS_SECRET },
);
});
afterAll(async () => {
if (app) {
await app.close();
}
});
describe('TASK-SEC-001: Mandatory Dual-Secret Startup Enforcement', () => {
it('should reject startup logic if JWT_ACCESS_SECRET is missing or under 32 characters', () => {
const validateStartup = (accessSec?: string, refreshSec?: string) => {
if (!accessSec || accessSec.trim().length < 32) {
throw new Error('FATAL: JWT_ACCESS_SECRET missing or short');
}
if (!refreshSec || refreshSec.trim().length < 32) {
throw new Error('FATAL: JWT_REFRESH_SECRET missing or short');
}
};
expect(() => validateStartup('short', process.env.JWT_REFRESH_SECRET)).toThrow(
'FATAL: JWT_ACCESS_SECRET missing or short',
);
expect(() => validateStartup(process.env.JWT_ACCESS_SECRET, 'short')).toThrow(
'FATAL: JWT_REFRESH_SECRET missing or short',
);
expect(() =>
validateStartup(
process.env.JWT_ACCESS_SECRET,
process.env.JWT_REFRESH_SECRET,
),
).not.toThrow();
});
});
describe('TASK-SEC-002: Cryptographically Secure OTP Generation & Response Payload Hardening', () => {
it('should NOT disclose OTP plaintext code in POST /api/auth/send-otp response', async () => {
const httpServer = app.getHttpServer() as unknown as App;
const response = await request(httpServer)
.post('/api/auth/send-otp')
.send({ phoneNumber: '09123456789' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('message');
expect(response.body).not.toHaveProperty('code');
});
});
describe('TASK-SEC-003: Role-Based Access Control (RBAC) Enforcement on Settings API', () => {
it('should forbid non-admin users (User_PetOwner) with HTTP 403 when accessing /api/settings/ui-texts', async () => {
const httpServer = app.getHttpServer() as unknown as App;
const response = await request(httpServer)
.get('/api/settings/ui-texts')
.set('Authorization', `Bearer ${userToken}`);
expect(response.status).toBe(403);
});
it('should allow admin users (Admin) with HTTP 200 when accessing /api/settings/ui-texts', async () => {
const httpServer = app.getHttpServer() as unknown as App;
const response = await request(httpServer)
.get('/api/settings/ui-texts')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
});
});
describe('TASK-FIN-001: Arbitrary-Precision Decimal Accounting & Prisma.Decimal Serialization', () => {
it('should perform exact arbitrary-precision arithmetic (19.99 * 3 + 5.01 = 64.98)', () => {
const item1Price = new Prisma.Decimal('19.99');
const item1Qty = 3;
const item2Price = new Prisma.Decimal('5.01');
const item2Qty = 1;
const subtotal1 = item1Price.mul(item1Qty);
const subtotal2 = item2Price.mul(item2Qty);
const total = subtotal1.add(subtotal2);
expect(total.toString()).toBe('64.98');
expect(Number(total)).toBe(64.98);
});
it('should transform Prisma.Decimal instances into formatted strings via DecimalInterceptor', () => {
const interceptor = new DecimalInterceptor();
const mockDecimal = new Prisma.Decimal('149.50');
const testPayload = {
id: 'ord-1',
totalAmount: mockDecimal,
items: [{ price: new Prisma.Decimal('49.99') }],
};
const decimalTransform = interceptor as unknown as {
transform: (data: unknown) => {
totalAmount: string;
items: { price: string }[];
};
};
const transformed = decimalTransform.transform(testPayload);
expect(transformed.totalAmount).toBe('149.5');
expect(transformed.items[0].price).toBe('49.99');
});
});
describe('TASK-DOC-001: OpenAPI Documentation Synchronization', () => {
it('should confirm root swagger.yml file exists and is synchronized', () => {
const rootSwaggerPath = path.resolve(__dirname, '../../swagger.yml');
expect(fs.existsSync(rootSwaggerPath)).toBe(true);
const content = fs.readFileSync(rootSwaggerPath, 'utf8');
expect(content).toContain('openapi: 3.0.0');
expect(content).toContain('/api/auth/send-otp');
expect(content).toContain('/api/settings');
});
});
});