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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" DATETIME;

CREATE TABLE "EmailVerificationToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE UNIQUE INDEX "EmailVerificationToken_tokenHash_key" ON "EmailVerificationToken"("tokenHash");
CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId");
34 changes: 24 additions & 10 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,30 @@ datasource db {
}

model User {
id String @id
email String @unique
passwordHash String
name String?
timezone String?
targetRole String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
applications JobApplication[]
apiTokens ApiToken[]
id String @id
email String @unique
passwordHash String
name String?
timezone String?
targetRole String?
emailVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
applications JobApplication[]
apiTokens ApiToken[]
emailVerificationTokens EmailVerificationToken[]
}

model EmailVerificationToken {
id String @id
userId String
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
}

model ApiToken {
Expand Down
67 changes: 64 additions & 3 deletions apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RegisterUseCase } from '@/use-cases/auth/RegisterUseCase.js';
import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js';
import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js';

vi.mock('bcryptjs', () => ({
default: {
Expand All @@ -9,6 +10,13 @@ vi.mock('bcryptjs', () => ({
},
}));

const makeSendEmailVerificationUseCase = (
overrides?: Partial<ISendEmailVerificationUseCase>,
): ISendEmailVerificationUseCase => ({
execute: vi.fn().mockResolvedValue(undefined),
...overrides,
});

describe('RegisterUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -22,7 +30,11 @@ describe('RegisterUseCase', () => {
});
const generateId = vi.fn().mockReturnValue('user-1');

const useCase = new RegisterUseCase({ userRepository, generateId });
const useCase = new RegisterUseCase({
userRepository,
generateId,
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
const result = await useCase.execute({ email: 'test@example.com', password: 'password123' });

expect(result).toEqual({ userId: 'user-1', email: 'test@example.com' });
Expand All @@ -40,7 +52,11 @@ describe('RegisterUseCase', () => {
});
const generateId = vi.fn().mockReturnValue('custom-id');

const useCase = new RegisterUseCase({ userRepository, generateId });
const useCase = new RegisterUseCase({
userRepository,
generateId,
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
await useCase.execute({ email: 'test@example.com', password: 'pass' });

expect(generateId).toHaveBeenCalledOnce();
Expand All @@ -54,7 +70,11 @@ describe('RegisterUseCase', () => {
findByEmail: vi.fn().mockResolvedValue(makeUser()),
});

const useCase = new RegisterUseCase({ userRepository, generateId: vi.fn() });
const useCase = new RegisterUseCase({
userRepository,
generateId: vi.fn(),
sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(),
});
const err = await useCase
.execute({ email: 'test@example.com', password: 'pass' })
.catch((e) => e);
Expand All @@ -63,4 +83,45 @@ describe('RegisterUseCase', () => {
expect((err as { code: string }).code).toBe('CONFLICT');
expect(userRepository.create).not.toHaveBeenCalled();
});

it('sends an email verification for the new user', async () => {
const user = makeUser({ id: 'user-1' });
const userRepository = makeUserRepository({
findByEmail: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(user),
});
const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase();

const useCase = new RegisterUseCase({
userRepository,
generateId: vi.fn().mockReturnValue('user-1'),
sendEmailVerificationUseCase,
});
await useCase.execute({ email: 'test@example.com', password: 'password123' });

expect(sendEmailVerificationUseCase.execute).toHaveBeenCalledWith('user-1');
});

it('still returns successfully when sending the verification email fails', async () => {
const user = makeUser({ id: 'user-1' });
const userRepository = makeUserRepository({
findByEmail: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(user),
});
const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase({
execute: vi.fn().mockRejectedValue(new Error('Brevo is down')),
});

const useCase = new RegisterUseCase({
userRepository,
generateId: vi.fn().mockReturnValue('user-1'),
sendEmailVerificationUseCase,
});
const result = await useCase.execute({
email: 'test@example.com',
password: 'password123',
});

expect(result).toEqual({ userId: 'user-1', email: 'test@example.com' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SendEmailVerificationUseCase } from '@/use-cases/auth/SendEmailVerificationUseCase.js';
import {
makeUserRepository,
makeEmailVerificationTokenRepository,
makeUser,
} from '@/__tests__/helpers/mocks.js';
import type { IEmailService } from '@/use-cases/ports/IEmailService.js';

const makeEmailService = (overrides?: Partial<IEmailService>): IEmailService => ({
sendFollowUpReminder: vi.fn().mockResolvedValue(undefined),
sendWeeklyDigest: vi.fn().mockResolvedValue(undefined),
sendEmailVerification: vi.fn().mockResolvedValue(undefined),
...overrides,
});

const makeDeps = (overrides?: object) => ({
userRepository: makeUserRepository(),
emailVerificationTokenRepository: makeEmailVerificationTokenRepository(),
emailService: makeEmailService(),
generateId: vi.fn().mockReturnValue('generated-id'),
webAppOrigin: 'http://localhost:3000',
...overrides,
});

describe('SendEmailVerificationUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('throws NOT_FOUND when the user does not exist', async () => {
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) });

const err = await new SendEmailVerificationUseCase(makeDeps({ userRepository }))
.execute('missing')
.catch((e) => e);

expect((err as { code: string }).code).toBe('NOT_FOUND');
});

it('deletes existing tokens, creates a new one, and emails a verification link', async () => {
const user = makeUser({ id: 'user-1', email: 'test@example.com' });
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) });
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository();
const emailService = makeEmailService();

await new SendEmailVerificationUseCase(
makeDeps({
userRepository,
emailVerificationTokenRepository,
emailService,
webAppOrigin: 'https://app.jobfinder.com',
}),
).execute('user-1');

expect(emailVerificationTokenRepository.deleteAllForUser).toHaveBeenCalledWith('user-1');
expect(emailVerificationTokenRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ id: 'generated-id', userId: 'user-1' }),
);
expect(emailService.sendEmailVerification).toHaveBeenCalledWith(
'test@example.com',
expect.stringMatching(/^https:\/\/app\.jobfinder\.com\/verify-email\?token=[a-f0-9]+$/),
);
});

it('sets an expiry roughly 24 hours in the future', async () => {
const user = makeUser({ id: 'user-1' });
const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) });
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository();

const before = Date.now();
await new SendEmailVerificationUseCase(
makeDeps({ userRepository, emailVerificationTokenRepository }),
).execute('user-1');
const after = Date.now();

const createCall = vi.mocked(emailVerificationTokenRepository.create).mock.calls[0][0];
const expiresAtMs = createCall.expiresAt.getTime();
const dayMs = 24 * 60 * 60 * 1000;
expect(expiresAtMs).toBeGreaterThanOrEqual(before + dayMs - 1000);
expect(expiresAtMs).toBeLessThanOrEqual(after + dayMs + 1000);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createHash } from 'crypto';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { VerifyEmailUseCase } from '@/use-cases/auth/VerifyEmailUseCase.js';
import {
makeUserRepository,
makeEmailVerificationTokenRepository,
makeEmailVerificationToken,
} from '@/__tests__/helpers/mocks.js';

const RAW_TOKEN = 'raw-verify-token';
const TOKEN_HASH = createHash('sha256').update(RAW_TOKEN).digest('hex');

describe('VerifyEmailUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('throws UNAUTHORIZED when no token matches the hash', async () => {
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({
findByTokenHash: vi.fn().mockResolvedValue(null),
});
const userRepository = makeUserRepository();

const err = await new VerifyEmailUseCase({
userRepository,
emailVerificationTokenRepository,
})
.execute({ token: RAW_TOKEN })
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
expect(userRepository.update).not.toHaveBeenCalled();
});

it('throws UNAUTHORIZED when the token was already used', async () => {
const verificationToken = makeEmailVerificationToken({
tokenHash: TOKEN_HASH,
usedAt: new Date('2024-01-01T00:30:00.000Z'),
});
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({
findByTokenHash: vi.fn().mockResolvedValue(verificationToken),
});
const userRepository = makeUserRepository();

const err = await new VerifyEmailUseCase({
userRepository,
emailVerificationTokenRepository,
})
.execute({ token: RAW_TOKEN })
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
expect(userRepository.update).not.toHaveBeenCalled();
});

it('throws UNAUTHORIZED when the token has expired', async () => {
const verificationToken = makeEmailVerificationToken({
tokenHash: TOKEN_HASH,
expiresAt: new Date(Date.now() - 1000),
});
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({
findByTokenHash: vi.fn().mockResolvedValue(verificationToken),
});
const userRepository = makeUserRepository();

const err = await new VerifyEmailUseCase({
userRepository,
emailVerificationTokenRepository,
})
.execute({ token: RAW_TOKEN })
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
expect(userRepository.update).not.toHaveBeenCalled();
});

it('marks the user verified and the token used for a valid token', async () => {
const verificationToken = makeEmailVerificationToken({
id: 'verify-1',
userId: 'user-1',
tokenHash: TOKEN_HASH,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({
findByTokenHash: vi.fn().mockResolvedValue(verificationToken),
});
const userRepository = makeUserRepository();

await new VerifyEmailUseCase({ userRepository, emailVerificationTokenRepository }).execute({
token: RAW_TOKEN,
});

expect(userRepository.update).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ emailVerifiedAt: expect.any(Date) }),
);
expect(emailVerificationTokenRepository.markUsed).toHaveBeenCalledWith('verify-1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { IEmailService } from '@/use-cases/ports/IEmailService.js';
const makeEmailService = (overrides?: Partial<IEmailService>): IEmailService => ({
sendFollowUpReminder: vi.fn().mockResolvedValue(undefined),
sendWeeklyDigest: vi.fn().mockResolvedValue(undefined),
sendEmailVerification: vi.fn().mockResolvedValue(undefined),
...overrides,
});

Expand Down
Loading
Loading