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
23 changes: 21 additions & 2 deletions apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('RegisterUseCase', () => {
generateId,
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
await useCase.execute({ email: 'test@example.com', password: 'pass' });
await useCase.execute({ email: 'test@example.com', password: 'password123' });

expect(generateId).toHaveBeenCalledOnce();
expect(userRepository.create).toHaveBeenCalledWith(
Expand All @@ -76,14 +76,33 @@ describe('RegisterUseCase', () => {
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
const err = await useCase
.execute({ email: 'test@example.com', password: 'pass' })
.execute({ email: 'test@example.com', password: 'password123' })
.catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect((err as { code: string }).code).toBe('CONFLICT');
expect(userRepository.create).not.toHaveBeenCalled();
});

it('throws VALIDATION when the password is too short', async () => {
const userRepository = makeUserRepository({
findByEmail: vi.fn().mockResolvedValue(null),
});

const useCase = new RegisterUseCase({
userRepository,
generateId: vi.fn(),
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
const err = await useCase
.execute({ email: 'test@example.com', password: 'short' })
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(userRepository.findByEmail).not.toHaveBeenCalled();
expect(userRepository.create).not.toHaveBeenCalled();
});

it('sends an email verification for the new user', async () => {
const user = makeUser({ id: 'user-1' });
const userRepository = makeUserRepository({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,81 @@ describe('ConfirmDocumentUseCase', () => {
expect((err as { code: string }).code).toBe('FORBIDDEN');
});

it('throws VALIDATION when the mimeType is not allowed', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(makeApplication()),
});

const useCase = new ConfirmDocumentUseCase({
applicationRepository,
documentRepository: makeDocumentRepository(),
generateId: vi.fn(),
});
const err = await useCase
.execute({
userId: 'user-1',
applicationId: 'app-1',
storageKey: 'key',
name: 'malware.exe',
mimeType: 'application/x-msdownload',
sizeBytes: 1000,
})
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(applicationRepository.findById).not.toHaveBeenCalled();
});

it('throws VALIDATION when sizeBytes is not positive', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(makeApplication()),
});

const useCase = new ConfirmDocumentUseCase({
applicationRepository,
documentRepository: makeDocumentRepository(),
generateId: vi.fn(),
});
const err = await useCase
.execute({
userId: 'user-1',
applicationId: 'app-1',
storageKey: 'key',
name: 'file.pdf',
mimeType: 'application/pdf',
sizeBytes: -1,
})
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(applicationRepository.findById).not.toHaveBeenCalled();
});

it('throws VALIDATION when sizeBytes exceeds the maximum allowed size', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(makeApplication()),
});

const useCase = new ConfirmDocumentUseCase({
applicationRepository,
documentRepository: makeDocumentRepository(),
generateId: vi.fn(),
});
const err = await useCase
.execute({
userId: 'user-1',
applicationId: 'app-1',
storageKey: 'key',
name: 'file.pdf',
mimeType: 'application/pdf',
sizeBytes: 11 * 1024 * 1024,
})
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(applicationRepository.findById).not.toHaveBeenCalled();
});

it('creates the document record with all provided fields and generated id', async () => {
const doc = makeDocument();
const applicationRepository = makeApplicationRepository({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ describe('RequestUploadUrlUseCase', () => {
);
});

it('throws VALIDATION when the mimeType is not allowed', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(makeApplication()),
});

const useCase = new RequestUploadUrlUseCase({
applicationRepository,
storageProvider: makeStorageProvider(),
});
const err = await useCase
.execute({
userId: 'user-1',
applicationId: 'app-1',
filename: 'malware.exe',
mimeType: 'application/x-msdownload',
})
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(applicationRepository.findById).not.toHaveBeenCalled();
});

it('sanitizes the filename — replaces spaces and strips special characters', async () => {
const applicationRepository = makeApplicationRepository({
findById: vi.fn().mockResolvedValue(makeApplication()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,16 @@ describe('UpdatePasswordUseCase', () => {
expect(userRepository.update).not.toHaveBeenCalled();
expect(bcrypt.hash).not.toHaveBeenCalled();
});

it('throws VALIDATION when the new password is too short', async () => {
const userRepository = makeUserRepository();

const err = await new UpdatePasswordUseCase({ userRepository })
.execute({ ...input, newPassword: 'short1' })
.catch((e) => e);

expect((err as { code: string }).code).toBe('VALIDATION');
expect(userRepository.findById).not.toHaveBeenCalled();
expect(userRepository.update).not.toHaveBeenCalled();
});
});
4 changes: 2 additions & 2 deletions apps/api/src/__tests__/security/authorizationGuards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('Authorization guards', () => {

it('UpdatePasswordUseCase', async () => {
const err = await new UpdatePasswordUseCase({ userRepository: notFound })
.execute({ userId: 'x', currentPassword: 'p', newPassword: 'np' })
.execute({ userId: 'x', currentPassword: 'p', newPassword: 'newpassword1' })
.catch((e) => e);
expect((err as { code: string }).code).toBe('NOT_FOUND');
});
Expand Down Expand Up @@ -82,7 +82,7 @@ describe('Authorization guards', () => {

it('UpdatePasswordUseCase does not hash or update on wrong password', async () => {
const err = await new UpdatePasswordUseCase({ userRepository: repo })
.execute({ userId: 'user-1', currentPassword: 'wrong', newPassword: 'new' })
.execute({ userId: 'user-1', currentPassword: 'wrong', newPassword: 'newpassword1' })
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,16 @@ export const PAGINATION = {
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100,
} as const;

/** MIME types accepted for document uploads (resumes, cover letters, portfolios). */
export const ALLOWED_DOCUMENT_MIME_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'image/png',
'image/jpeg',
] as const;

/** Max accepted document upload size, in bytes. */
export const MAX_DOCUMENT_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
3 changes: 3 additions & 0 deletions apps/api/src/use-cases/auth/RegisterUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs';
import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js';
import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js';
import { ERROR_CODES } from '@/constants.js';
import { assertValidPassword } from '@/use-cases/auth/passwordValidation.js';
import type {
IRegisterUseCase,
RegisterInput,
Expand All @@ -18,6 +19,8 @@ export class RegisterUseCase implements IRegisterUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: RegisterInput): Promise<RegisterOutput> {
assertValidPassword(input.password);

const existing = await this.deps.userRepository.findByEmail(input.email);
if (existing) {
throw Object.assign(new Error('Email already registered'), { code: ERROR_CODES.CONFLICT });
Expand Down
10 changes: 3 additions & 7 deletions apps/api/src/use-cases/auth/ResetPasswordUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import bcrypt from 'bcryptjs';
import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js';
import type { IPasswordResetTokenRepository } from '@/use-cases/ports/IPasswordResetTokenRepository.js';
import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js';
import { ERROR_CODES, PASSWORD_MIN_LENGTH } from '@/constants.js';
import { ERROR_CODES } from '@/constants.js';
import { assertValidPassword } from '@/use-cases/auth/passwordValidation.js';
import type {
IResetPasswordUseCase,
ResetPasswordInput,
Expand All @@ -19,12 +20,7 @@ export class ResetPasswordUseCase implements IResetPasswordUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: ResetPasswordInput): Promise<void> {
if (input.newPassword.length < PASSWORD_MIN_LENGTH) {
throw Object.assign(
new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`),
{ code: ERROR_CODES.VALIDATION },
);
}
assertValidPassword(input.newPassword);

const tokenHash = createHash('sha256').update(input.token).digest('hex');
const resetToken = await this.deps.passwordResetTokenRepository.findByTokenHash(tokenHash);
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/use-cases/auth/passwordValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ERROR_CODES, PASSWORD_MIN_LENGTH } from '@/constants.js';

export function assertValidPassword(password: string): void {
if (password.length < PASSWORD_MIN_LENGTH) {
throw Object.assign(new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`), {
code: ERROR_CODES.VALIDATION,
});
}
}
7 changes: 7 additions & 0 deletions apps/api/src/use-cases/documents/ConfirmDocumentUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { IApplicationRepository } from '@/use-cases/ports/IApplicationRepos
import type { IDocumentRepository } from '@/use-cases/ports/IDocumentRepository.js';
import type { IActivityLogRepository } from '@/use-cases/ports/IActivityLogRepository.js';
import { ERROR_CODES } from '@/constants.js';
import {
assertAllowedMimeType,
assertValidSizeBytes,
} from '@/use-cases/documents/documentValidation.js';
import type {
IConfirmDocumentUseCase,
ConfirmDocumentInput,
Expand All @@ -19,6 +23,9 @@ export class ConfirmDocumentUseCase implements IConfirmDocumentUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: ConfirmDocumentInput): Promise<ConfirmDocumentOutput> {
assertAllowedMimeType(input.mimeType);
assertValidSizeBytes(input.sizeBytes);

const app = await this.deps.applicationRepository.findById(input.applicationId);
if (!app) {
throw Object.assign(new Error('Application not found'), { code: ERROR_CODES.NOT_FOUND });
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/use-cases/documents/RequestUploadUrlUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { nanoid } from 'nanoid';
import type { IApplicationRepository } from '@/use-cases/ports/IApplicationRepository.js';
import type { IStorageProvider } from '@/use-cases/ports/IStorageProvider.js';
import { ERROR_CODES } from '@/constants.js';
import { assertAllowedMimeType } from '@/use-cases/documents/documentValidation.js';
import type {
IRequestUploadUrlUseCase,
RequestUploadUrlInput,
Expand All @@ -24,6 +25,8 @@ export class RequestUploadUrlUseCase implements IRequestUploadUrlUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: RequestUploadUrlInput): Promise<RequestUploadUrlOutput> {
assertAllowedMimeType(input.mimeType);

const app = await this.deps.applicationRepository.findById(input.applicationId);
if (!app) {
throw Object.assign(new Error('Application not found'), { code: ERROR_CODES.NOT_FOUND });
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/use-cases/documents/documentValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ERROR_CODES, ALLOWED_DOCUMENT_MIME_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/constants.js';

export function assertAllowedMimeType(mimeType: string): void {
if (!(ALLOWED_DOCUMENT_MIME_TYPES as readonly string[]).includes(mimeType)) {
throw Object.assign(new Error(`Unsupported file type: ${mimeType}`), {
code: ERROR_CODES.VALIDATION,
});
}
}

export function assertValidSizeBytes(sizeBytes: number): void {
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) {
throw Object.assign(new Error('File size must be greater than 0 bytes'), {
code: ERROR_CODES.VALIDATION,
});
}
if (sizeBytes > MAX_DOCUMENT_SIZE_BYTES) {
throw Object.assign(
new Error(`File exceeds the maximum allowed size of ${MAX_DOCUMENT_SIZE_BYTES} bytes`),
{ code: ERROR_CODES.VALIDATION },
);
}
}
3 changes: 3 additions & 0 deletions apps/api/src/use-cases/user/UpdatePasswordUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs';
import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js';
import { ERROR_CODES } from '@/constants.js';
import { assertValidPassword } from '@/use-cases/auth/passwordValidation.js';
import type {
IUpdatePasswordUseCase,
UpdatePasswordInput,
Expand All @@ -14,6 +15,8 @@ export class UpdatePasswordUseCase implements IUpdatePasswordUseCase {
constructor(private readonly deps: Deps) {}

async execute(input: UpdatePasswordInput): Promise<void> {
assertValidPassword(input.newPassword);

const user = await this.deps.userRepository.findById(input.userId);
if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND });

Expand Down
Loading