Skip to content
Merged
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
24 changes: 10 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,29 +93,25 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Unit tests
run: npm run test
- name: Unit tests with coverage
run: npm run test:coverage

- name: Upload frontend coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: frontend/coverage/

- name: Accessibility tests (jest-axe)
run: npm run test:a11y -- --passWithNoTests

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium firefox webkit

- name: E2E tests
run: npm run test:e2e

# TODO: Remove --passWithNoTests and switch to test:coverage once real test suites exist
# - name: Unit tests with coverage
# run: npm run test:coverage
#
# - name: Upload coverage artifact
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: frontend-coverage
# path: frontend/coverage/

# ─────────────────────────────────────────────
# SMART CONTRACTS — Rust / Cargo
# ─────────────────────────────────────────────
Expand Down
8 changes: 7 additions & 1 deletion backend/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "contracts/**/*.json",
"watchAssets": true
}
]
}
}
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"build": "node ../contracts/sync.mjs && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:dev": "node ../contracts/sync.mjs && nest start --watch",
"start:debug": "node ../contracts/sync.mjs && nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
Expand Down
4 changes: 3 additions & 1 deletion backend/src/auth/dto/login-auth.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { apiContracts } from '../../common/api-contracts';

export class LoginAuthDto {
@IsEmail()
email: string;

@IsNotEmpty()
@MinLength(apiContracts.login.password.minLength)
password: string;
}
4 changes: 3 additions & 1 deletion backend/src/auth/dto/register-auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@
MinLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { apiContracts } from '../../common/api-contracts';

export class RegisterAuthDto {
@IsEmail()
@Transform(({ value }) => value?.trim().toLowerCase())
email: string;

@IsString()
@MinLength(8)
@MinLength(apiContracts.register.password.minLength)
@Matches(/[A-Z]/, { message: 'passwordUppercase' })
@Matches(/[a-z]/, { message: 'passwordLowercase' })
@Matches(/[0-9]/, { message: 'passwordNumber' })
@Matches(/[^A-Za-z0-9]/, { message: 'passwordSpecial' })
password: string;

@IsNotEmpty()
@MinLength(apiContracts.register.fullName.minLength)
@Transform(({ value }) => value?.trim())
fullName: string;
}
48 changes: 48 additions & 0 deletions backend/src/common/api-contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
type StringContract = {
format?: 'email' | 'uuid';
minLength?: number;
};

type ApiContracts = {
api: {
version: string;
};
register: {
email: StringContract;
password: { minLength: number };
fullName: { minLength: number };
};
login: {
email: StringContract;
password: { minLength: number };
};
dispute: {
documentId: StringContract;
description: { minLength: number };
statuses: string[];
};
documentUpload: {
fileSize: { max: number };
mimeTypes: string[];
};
};

export const apiContracts = require('../contracts/api-contracts.json') as ApiContracts;

Check failure on line 31 in backend/src/common/api-contracts.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

A `require()` style import is forbidden
const contractFormats: Array<[string | undefined, string]> = [
[apiContracts.register.email.format, 'email'],
[apiContracts.login.email.format, 'email'],
[apiContracts.dispute.documentId.format, 'uuid'],
];
for (const [format, expected] of contractFormats) {
if (format !== expected) {
throw new Error(`Unsupported API contract format: ${format}`);
}
}

export const DISPUTE_STATUS_VALUES = apiContracts.dispute.statuses;

export const DOCUMENT_MAX_FILE_SIZE_BYTES =
apiContracts.documentUpload.fileSize.max;
export const DOCUMENT_ALLOWED_MIME_TYPES =
apiContracts.documentUpload.mimeTypes;
48 changes: 48 additions & 0 deletions backend/src/contracts/api-contracts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"api": {
"version": "1"
},
"register": {
"email": {
"format": "email"
},
"password": {
"minLength": 8
},
"fullName": {
"minLength": 2
}
},
"login": {
"email": {
"format": "email"
},
"password": {
"minLength": 1
}
},
"dispute": {
"documentId": {
"format": "uuid"
},
"description": {
"minLength": 1
},
"statuses": [
"open",
"in_review",
"resolved",
"dismissed"
]
},
"documentUpload": {
"fileSize": {
"max": 20971520
},
"mimeTypes": [
"application/pdf",
"image/png",
"image/jpeg"
]
}
}
18 changes: 5 additions & 13 deletions backend/src/dispute/dispute-status.state-machine.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
/**
* Explicit state machine guarding dispute status transitions, preventing
* arbitrary moves such as resolved -> open with no guard rails.
*/
export type DisputeStatus = 'open' | 'under_review' | 'resolved' | 'closed';

const ALLOWED_TRANSITIONS: Record<DisputeStatus, DisputeStatus[]> = {
open: ['under_review', 'closed'],
under_review: ['resolved', 'open'],
resolved: ['closed'],
closed: [],
};
import {
ALLOWED_DISPUTE_TRANSITIONS,
DisputeStatus,
} from './entities/dispute.entity';

export function canTransition(from: DisputeStatus, to: DisputeStatus): boolean {
return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
return ALLOWED_DISPUTE_TRANSITIONS[from]?.includes(to) ?? false;
}
6 changes: 3 additions & 3 deletions backend/src/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { User } from '../users/entities/user.entity';
import { DocumentsService } from '../documents/documents.service';
import { CreateDisputeDto } from './dto/create-dispute.dto';
import { DisputeResponseDto } from './dto/dispute-response.dto';
import { UpdateDisputeStatusDto } from './dto/update-dispute-status.dto';
import { DisputeService } from './dispute.service';
import { DisputeStatus } from './entities/dispute.entity';

@Controller('disputes')
@UseGuards(JwtAuthGuard)
Expand Down Expand Up @@ -79,7 +79,7 @@ export class DisputeController {
@Patch(':id/status')
async updateDisputeStatus(
@Param('id') id: string,
@Body('status') status: DisputeStatus,
@Body() dto: UpdateDisputeStatusDto,
@Req() req: Request & { user?: User },
): Promise<DisputeResponseDto> {
const user = req.user!;
Expand All @@ -89,6 +89,6 @@ export class DisputeController {
);
}

return this.disputeService.updateStatus(id, status, user.id);
return this.disputeService.updateStatus(id, dto.status, user.id);
}
}
4 changes: 3 additions & 1 deletion backend/src/dispute/dto/create-dispute.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsString, IsUUID, MinLength } from 'class-validator';
import { apiContracts } from '../../common/api-contracts';

export class CreateDisputeDto {
@IsUUID()
Expand All @@ -7,5 +8,6 @@ export class CreateDisputeDto {

@IsString()
@IsNotEmpty()
@MinLength(apiContracts.dispute.description.minLength)
description: string;
}
7 changes: 7 additions & 0 deletions backend/src/dispute/dto/update-dispute-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { DisputeStatus } from '../entities/dispute.entity';

export class UpdateDisputeStatusDto {
@IsEnum(DisputeStatus)
status: DisputeStatus;
}
10 changes: 10 additions & 0 deletions backend/src/dispute/entities/dispute.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { DisputeReason } from './dispute-reason.entity';
import { DISPUTE_STATUS_VALUES } from '../../common/api-contracts';

export enum DisputeStatus {
OPEN = 'open',
Expand All @@ -16,6 +17,15 @@ export enum DisputeStatus {
DISMISSED = 'dismissed',
}

const backendStatuses = new Set<string>(Object.values(DisputeStatus));
const contractStatuses = new Set<string>(DISPUTE_STATUS_VALUES);
if (
backendStatuses.size !== contractStatuses.size ||
[...backendStatuses].some((status) => !contractStatuses.has(status))
) {
throw new Error('Dispute statuses do not match the API contract');
}

export const ALLOWED_DISPUTE_TRANSITIONS: Record<DisputeStatus, DisputeStatus[]> = {
[DisputeStatus.OPEN]: [DisputeStatus.IN_REVIEW, DisputeStatus.DISMISSED],
[DisputeStatus.IN_REVIEW]: [DisputeStatus.RESOLVED, DisputeStatus.DISMISSED],
Expand Down
8 changes: 6 additions & 2 deletions backend/src/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ import { AccessLogsService } from '../access-logs/access-logs.service';
import { FileValidationPipe } from './pipes/file-validation.pipe';
import { ListDocumentsDto } from './dto/list-documents.dto';
import { DocumentResponseDto } from './dto/document-response.dto';
import {
DOCUMENT_ALLOWED_MIME_TYPES,
DOCUMENT_MAX_FILE_SIZE_BYTES,
} from '../common/api-contracts';

const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME_TYPES = DOCUMENT_ALLOWED_MIME_TYPES;
const MAX_FILE_SIZE_BYTES = DOCUMENT_MAX_FILE_SIZE_BYTES;
const DEFAULT_USER_QUOTA = 20;

const multerStorage = multer.memoryStorage();
Expand Down
3 changes: 2 additions & 1 deletion backend/src/documents/pipes/file-size-validation.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { DOCUMENT_MAX_FILE_SIZE_BYTES } from '../../common/api-contracts';

/**
* Enforces a maximum upload size on documents to prevent very large
* files from exhausting server memory or disk.
*/
@Injectable()
export class FileSizeValidationPipe implements PipeTransform {
private readonly maxSizeBytes = 25 * 1024 * 1024;
private readonly maxSizeBytes = DOCUMENT_MAX_FILE_SIZE_BYTES;

transform(file: { size: number }) {
if (file.size > this.maxSizeBytes) {
Expand Down
8 changes: 6 additions & 2 deletions backend/src/documents/pipes/file-validation.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import {
PipeTransform,
} from '@nestjs/common';
import { PDFDocument, PDFName } from 'pdf-lib';
import {
DOCUMENT_ALLOWED_MIME_TYPES,
DOCUMENT_MAX_FILE_SIZE_BYTES,
} from '../../common/api-contracts';
const sharp = require('sharp') as (...args: any[]) => any;

const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME_TYPES = DOCUMENT_ALLOWED_MIME_TYPES;
const MAX_FILE_SIZE_BYTES = DOCUMENT_MAX_FILE_SIZE_BYTES;

interface DetectedType {
mime: string;
Expand Down
7 changes: 2 additions & 5 deletions backend/src/documents/pipes/mime-type-validation.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { DOCUMENT_ALLOWED_MIME_TYPES } from '../../common/api-contracts';

/**
* Restricts document uploads to an explicit MIME-type allowlist so
* arbitrary file types can't be uploaded as "documents".
*/
@Injectable()
export class MimeTypeValidationPipe implements PipeTransform {
private readonly allowedMimeTypes = [
'application/pdf',
'image/png',
'image/jpeg',
];
private readonly allowedMimeTypes = DOCUMENT_ALLOWED_MIME_TYPES;

transform(file: { mimetype: string }) {
if (!this.allowedMimeTypes.includes(file.mimetype)) {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { WinstonModule } from 'nest-winston';
import { buildWinstonOptions } from './common/logger.config';
import { buildCorsOptions } from './common/cors.config';
import { apiContracts } from './common/api-contracts';
import { config as loadEnv } from 'dotenv';

loadEnv({ path: '.env' });
Expand All @@ -31,7 +32,7 @@ async function bootstrap() {
app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
defaultVersion: apiContracts.api.version,
});

app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
Expand Down
7 changes: 7 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# API validation contracts

`api-contracts.json` is the repository-level source of truth for the API version, dispute status vocabulary, and request validation constraints that are enforced by both NestJS and the browser.

Backend DTO decorators and upload validation import the contract through `backend/src/common/api-contracts.ts`. Frontend Zod schemas and active forms import it through `frontend/lib/api-contracts.ts`. The checked-in `backend/src/contracts/api-contracts.json` and `frontend/contracts/api-contracts.json` files are generated copies; `build` and development scripts refresh them, and `npm run sync:contracts` can be run after editing the canonical file. Do not copy numeric limits or MIME allowlists into feature code.

Frontend schemas are user-experience checks only. NestJS `ValidationPipe`, DTOs, upload pipes, and server authorization remain authoritative. Form-only rules, such as a richer dispute description hint, stay in the frontend.
Loading
Loading