diff --git a/prisma/migrations/20260830000000_add_email_otp_model/migration.sql b/prisma/migrations/20260830000000_add_email_otp_model/migration.sql new file mode 100644 index 0000000..d97c19a --- /dev/null +++ b/prisma/migrations/20260830000000_add_email_otp_model/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "Merchant" DROP COLUMN "emailOtp", +DROP COLUMN "emailOtpExpiresAt"; + +-- CreateTable +CREATE TABLE "EmailOtp" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailOtp_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EmailOtp_merchantId_createdAt_idx" ON "EmailOtp"("merchantId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "EmailOtp" ADD CONSTRAINT "EmailOtp_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3bf23e8..060f719 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,8 +80,6 @@ model Merchant { verified Boolean @default(false) emailVerified Boolean @default(false) registered Boolean @default(false) - emailOtp String? - emailOtpExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -92,6 +90,23 @@ model Merchant { analytics MerchantAnalytics[] subscriptionPlans SubscriptionPlan[] transactions Transaction[] + emailOtps EmailOtp[] +} + +// One row per generated email verification code, shaped like AuthNonce: the code +// is stored only as a bcrypt hash, and a row is spent by stamping usedAt rather +// than being overwritten. Keeping history (instead of a single mutable field on +// Merchant) is what lets resendEmailOtp rate-limit by querying for a recent row. +model EmailOtp { + id String @id @default(uuid()) + merchantId String + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + codeHash String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([merchantId, createdAt]) } model AuthNonce { diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index 40b5752..94939da 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -8,12 +8,9 @@ import type { MerchantListSortBy, MerchantListSortDir, } from '../utils/merchant.validation.js'; -import { generateOtp, hashOtp } from './otp.services.js'; -import { sendOtp } from './email.service.js'; +import { issueEmailOtp } from './otp.services.js'; import { Keypair } from '@stellar/stellar-sdk'; -const OTP_EXPIRY_MS = 10 * 60 * 1000; - interface MerchantData { merchantId: number; email?: string; @@ -105,10 +102,6 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan throw new AppError(409, 'Email already registered'); } - const code = generateOtp(); - const emailOtp = await hashOtp(code); - const emailOtpExpiresAt = new Date(Date.now() + OTP_EXPIRY_MS); - const updatedMerchant = await prisma.merchant.update({ where: { id: merchantId }, data: { @@ -121,13 +114,15 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan logo: data.logo?.trim() ?? null, emailVerified: false, registered: true, - emailOtp, - emailOtpExpiresAt, }, }); try { - await sendOtp(normalizedEmail, code, data.firstName.trim()); + await issueEmailOtp({ + id: updatedMerchant.id, + email: normalizedEmail, + firstName: data.firstName.trim(), + }); } catch (err) { console.error('Failed to send OTP email after registration', err); } diff --git a/src/services/otp.services.ts b/src/services/otp.services.ts index ebae4c2..cc9de67 100644 --- a/src/services/otp.services.ts +++ b/src/services/otp.services.ts @@ -20,11 +20,10 @@ export const hashOtp = async (code: string): Promise => bcrypt.hash(code export const verifyOtpHash = async (code: string, hash: string): Promise => bcrypt.compare(code, hash); -const getLastOtpSentAt = (expiresAt: Date): Date => new Date(expiresAt.getTime() - OTP_EXPIRY_MS); - /** - * Generates a 6-digit OTP, stores its bcrypt hash with a 10-minute expiry, - * and sends the code to the merchant's email. + * Generates a 6-digit OTP, stores its bcrypt hash as a new EmailOtp row with a + * 10-minute expiry, and sends the code to the merchant's email. Previous codes + * are left untouched; verification always uses the most recent one. */ export const issueEmailOtp = async (merchant: { id: string; @@ -32,50 +31,57 @@ export const issueEmailOtp = async (merchant: { firstName: string | null; }): Promise => { const code = generateOtp(); - const emailOtp = await hashOtp(code); - const emailOtpExpiresAt = new Date(Date.now() + OTP_EXPIRY_MS); + const codeHash = await hashOtp(code); - await prisma.merchant.update({ - where: { id: merchant.id }, - data: { emailOtp, emailOtpExpiresAt }, + await prisma.emailOtp.create({ + data: { + merchantId: merchant.id, + codeHash, + expiresAt: new Date(Date.now() + OTP_EXPIRY_MS), + }, }); await sendOtp(merchant.email, code, merchant.firstName?.trim() || 'there'); }; /** - * Validates the submitted OTP against the stored hash and marks the email verified. + * Validates the submitted OTP against the merchant's most recent unused code and + * marks the email verified. The matched row is stamped usedAt so it cannot be + * replayed. */ export const verifyEmailOtp = async (merchantId: string, code: string) => { - const merchant = await prisma.merchant.findUnique({ - where: { id: merchantId }, + const otp = await prisma.emailOtp.findFirst({ + where: { merchantId, usedAt: null }, + orderBy: { createdAt: 'desc' }, }); - if (!merchant?.emailOtp || !merchant.emailOtpExpiresAt) { + if (!otp) { throw new AppError(400, 'Invalid verification code'); } - if (merchant.emailOtpExpiresAt.getTime() < Date.now()) { + if (otp.expiresAt.getTime() < Date.now()) { throw new AppError(400, 'Code expired'); } - const isValid = await verifyOtpHash(code, merchant.emailOtp); + const isValid = await verifyOtpHash(code, otp.codeHash); if (!isValid) { throw new AppError(400, 'Invalid verification code'); } + await prisma.emailOtp.update({ + where: { id: otp.id }, + data: { usedAt: new Date() }, + }); + return prisma.merchant.update({ where: { id: merchantId }, - data: { - emailVerified: true, - emailOtp: null, - emailOtpExpiresAt: null, - }, + data: { emailVerified: true }, }); }; /** - * Re-generates and re-sends the email OTP, rate-limited to one request per minute. + * Re-generates and re-sends the email OTP, rate-limited to one request per + * minute by checking for an EmailOtp row created within the cooldown window. */ export const resendEmailOtp = async (merchantId: string): Promise => { const merchant = await prisma.merchant.findUnique({ @@ -94,11 +100,15 @@ export const resendEmailOtp = async (merchantId: string): Promise => { throw new AppError(400, 'Email already verified'); } - if (merchant.emailOtpExpiresAt) { - const lastSentAt = getLastOtpSentAt(merchant.emailOtpExpiresAt); - if (Date.now() - lastSentAt.getTime() < OTP_RESEND_COOLDOWN_MS) { - throw new AppError(429, 'Please wait before requesting a new code'); - } + const recentOtp = await prisma.emailOtp.findFirst({ + where: { + merchantId, + createdAt: { gt: new Date(Date.now() - OTP_RESEND_COOLDOWN_MS) }, + }, + }); + + if (recentOtp) { + throw new AppError(429, 'Please wait before requesting a new code'); } await issueEmailOtp({ diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index bb0f591..bc57bab 100644 --- a/tests/integration/admin.merchant.routes.test.ts +++ b/tests/integration/admin.merchant.routes.test.ts @@ -36,8 +36,6 @@ const merchant = { verified: false, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-06-27T12:00:00.000Z'), updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; @@ -75,7 +73,7 @@ describe('GET /api/v1/admin/merchants', () => { expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); expect(response.body.data).toHaveLength(1); expect(response.body.data[0].id).toBe('merchant-1'); - // sanitizeMerchant keeps the OTP columns out of an admin response too. + // sanitizeMerchant is an allow-list, so internal columns never reach an admin response. expect(response.body.data[0]).not.toHaveProperty('emailOtp'); expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ where: {}, diff --git a/tests/integration/api-key.routes.test.ts b/tests/integration/api-key.routes.test.ts index 5ede944..63a608e 100644 --- a/tests/integration/api-key.routes.test.ts +++ b/tests/integration/api-key.routes.test.ts @@ -46,8 +46,6 @@ const merchant = { verified: false, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-06-27T12:00:00.000Z'), updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; diff --git a/tests/integration/auth.email-otp.test.ts b/tests/integration/auth.email-otp.test.ts index 499cb1e..81c0cce 100644 --- a/tests/integration/auth.email-otp.test.ts +++ b/tests/integration/auth.email-otp.test.ts @@ -36,12 +36,20 @@ const registeredMerchant = { verified: false, emailVerified: false, registered: true, - emailOtp: null as string | null, - emailOtpExpiresAt: null as Date | null, createdAt: mockDate, updatedAt: mockDate, }; +const otpRow = (overrides: Record = {}) => ({ + id: 'otp-1', + merchantId: 'uuid-1', + codeHash: 'hash', + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null as Date | null, + createdAt: new Date('2026-06-21T11:55:00.000Z'), + ...overrides, +}); + const authenticateAs = (merchant: Record) => { prismaMock.refreshToken.findUnique.mockResolvedValue({ id: 'session-1', @@ -86,17 +94,13 @@ describe('Email OTP auth routes', () => { test('returns 200 and marks emailVerified true with correct code', async () => { const code = '123456'; - const emailOtp = await bcrypt.hash(code, 10); - const merchantWithOtp = { - ...registeredMerchant, - emailOtp, - emailOtpExpiresAt: new Date('2026-06-21T12:05:00.000Z'), - }; + const codeHash = await bcrypt.hash(code, 10); - authenticateAs(merchantWithOtp); - prismaMock.merchant.findUnique.mockResolvedValue(merchantWithOtp as any); + authenticateAs(registeredMerchant); + prismaMock.emailOtp.findFirst.mockResolvedValue(otpRow({ codeHash }) as any); + prismaMock.emailOtp.update.mockResolvedValue(otpRow({ codeHash, usedAt: mockDate }) as any); prismaMock.merchant.update.mockImplementation(async (args: any) => ({ - ...merchantWithOtp, + ...registeredMerchant, ...args.data, })); @@ -107,13 +111,13 @@ describe('Email OTP auth routes', () => { expect(response.status).toBe(200); expect(response.body.emailVerified).toBe(true); + expect(prismaMock.emailOtp.update).toHaveBeenCalledWith({ + where: { id: 'otp-1' }, + data: { usedAt: expect.any(Date) }, + }); expect(prismaMock.merchant.update).toHaveBeenCalledWith({ where: { id: 'uuid-1' }, - data: { - emailVerified: true, - emailOtp: null, - emailOtpExpiresAt: null, - }, + data: { emailVerified: true }, }); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -127,15 +131,10 @@ describe('Email OTP auth routes', () => { }); test('returns 400 for wrong code', async () => { - const emailOtp = await bcrypt.hash('123456', 10); - const merchantWithOtp = { - ...registeredMerchant, - emailOtp, - emailOtpExpiresAt: new Date('2026-06-21T12:05:00.000Z'), - }; + const codeHash = await bcrypt.hash('123456', 10); - authenticateAs(merchantWithOtp); - prismaMock.merchant.findUnique.mockResolvedValue(merchantWithOtp as any); + authenticateAs(registeredMerchant); + prismaMock.emailOtp.findFirst.mockResolvedValue(otpRow({ codeHash }) as any); const response = await request(app) .post(VERIFY_EMAIL_URL) @@ -144,19 +143,17 @@ describe('Email OTP auth routes', () => { expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Invalid verification code' }); + expect(prismaMock.emailOtp.update).not.toHaveBeenCalled(); }); test('returns 400 with Code expired for expired code', async () => { const code = '123456'; - const emailOtp = await bcrypt.hash(code, 10); - const merchantWithOtp = { - ...registeredMerchant, - emailOtp, - emailOtpExpiresAt: new Date('2026-06-21T11:59:00.000Z'), - }; + const codeHash = await bcrypt.hash(code, 10); - authenticateAs(merchantWithOtp); - prismaMock.merchant.findUnique.mockResolvedValue(merchantWithOtp as any); + authenticateAs(registeredMerchant); + prismaMock.emailOtp.findFirst.mockResolvedValue( + otpRow({ codeHash, expiresAt: new Date('2026-06-21T11:59:00.000Z') }) as any, + ); const response = await request(app) .post(VERIFY_EMAIL_URL) @@ -177,15 +174,10 @@ describe('Email OTP auth routes', () => { }); test('returns 200 and re-sends OTP when cooldown has elapsed', async () => { - const merchantWithOtp = { - ...registeredMerchant, - emailOtp: 'hashed', - emailOtpExpiresAt: new Date('2026-06-21T11:58:00.000Z'), - }; - - authenticateAs(merchantWithOtp); - prismaMock.merchant.findUnique.mockResolvedValue(merchantWithOtp as any); - prismaMock.merchant.update.mockResolvedValue(merchantWithOtp as any); + authenticateAs(registeredMerchant); + prismaMock.merchant.findUnique.mockResolvedValue(registeredMerchant as any); + prismaMock.emailOtp.findFirst.mockResolvedValue(null); + prismaMock.emailOtp.create.mockResolvedValue(otpRow() as any); const response = await request(app) .post(RESEND_OTP_URL) @@ -198,11 +190,11 @@ describe('Email OTP auth routes', () => { expect.stringMatching(/^\d{6}$/), 'Ada', ); - expect(prismaMock.merchant.update).toHaveBeenCalledWith({ - where: { id: 'uuid-1' }, + expect(prismaMock.emailOtp.create).toHaveBeenCalledWith({ data: { - emailOtp: expect.any(String), - emailOtpExpiresAt: expect.any(Date), + merchantId: 'uuid-1', + codeHash: expect.any(String), + expiresAt: expect.any(Date), }, }); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ @@ -215,14 +207,11 @@ describe('Email OTP auth routes', () => { }); test('returns 429 when resend is requested within one minute', async () => { - const merchantWithOtp = { - ...registeredMerchant, - emailOtp: 'hashed', - emailOtpExpiresAt: new Date('2026-06-21T12:09:30.000Z'), - }; - - authenticateAs(merchantWithOtp); - prismaMock.merchant.findUnique.mockResolvedValue(merchantWithOtp as any); + authenticateAs(registeredMerchant); + prismaMock.merchant.findUnique.mockResolvedValue(registeredMerchant as any); + prismaMock.emailOtp.findFirst.mockResolvedValue( + otpRow({ createdAt: new Date('2026-06-21T11:59:30.000Z') }) as any, + ); const response = await request(app) .post(RESEND_OTP_URL) @@ -231,6 +220,7 @@ describe('Email OTP auth routes', () => { expect(response.status).toBe(429); expect(response.body).toEqual({ error: 'Please wait before requesting a new code' }); expect(sendOtpMock).not.toHaveBeenCalled(); + expect(prismaMock.emailOtp.create).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/integration/auth.middleware.test.ts b/tests/integration/auth.middleware.test.ts index 2ade4aa..572e124 100644 --- a/tests/integration/auth.middleware.test.ts +++ b/tests/integration/auth.middleware.test.ts @@ -30,8 +30,6 @@ const merchant = { verified: false, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-06-27T12:00:00.000Z'), updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; diff --git a/tests/integration/merchant.register.test.ts b/tests/integration/merchant.register.test.ts index 45cb95c..a1727ea 100644 --- a/tests/integration/merchant.register.test.ts +++ b/tests/integration/merchant.register.test.ts @@ -3,11 +3,11 @@ import { mockReset } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; import request from 'supertest'; -const sendOtpMock = jest.fn(async () => undefined); +const issueEmailOtpMock = jest.fn(async () => undefined); jest.unstable_mockModule('../../src/services/email.service.js', () => ({ __esModule: true, - sendOtp: sendOtpMock, + sendOtp: jest.fn(async () => undefined), sendInvoiceEmail: jest.fn(async () => undefined), })); @@ -16,7 +16,7 @@ jest.unstable_mockModule('../../src/services/otp.services.js', () => ({ generateOtp: () => '123456', hashOtp: async () => 'hashed-otp', verifyOtpHash: async () => true, - issueEmailOtp: jest.fn(), + issueEmailOtp: issueEmailOtpMock, verifyEmailOtp: jest.fn(), resendEmailOtp: jest.fn(), })); @@ -47,8 +47,6 @@ const baseMerchant = { verified: false, emailVerified: false, registered: false, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -71,7 +69,7 @@ const authHeader = `Bearer ${tokenFor(baseMerchant)}`; describe('POST /api/v1/merchants/register', () => { beforeEach(() => { mockReset(prismaMock); - sendOtpMock.mockClear(); + issueEmailOtpMock.mockClear(); }); test('returns 401 for unauthenticated requests', async () => { @@ -118,7 +116,11 @@ describe('POST /api/v1/merchants/register', () => { emailVerified: false, registered: true, }); - expect(sendOtpMock).toHaveBeenCalledWith('ada@example.com', '123456', 'Ada'); + expect(issueEmailOtpMock).toHaveBeenCalledWith({ + id: 'uuid-1', + email: 'ada@example.com', + firstName: 'Ada', + }); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'merchant.profile_registered', diff --git a/tests/integration/merchant.signing-key.test.ts b/tests/integration/merchant.signing-key.test.ts index 27502cc..65d2d46 100644 --- a/tests/integration/merchant.signing-key.test.ts +++ b/tests/integration/merchant.signing-key.test.ts @@ -40,8 +40,6 @@ const merchant = { verified: false, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-06-27T12:00:00.000Z'), updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; diff --git a/tests/unit/email.service.resend.test.ts b/tests/unit/email.service.resend.test.ts index 5ae540c..b537b40 100644 --- a/tests/unit/email.service.resend.test.ts +++ b/tests/unit/email.service.resend.test.ts @@ -45,8 +45,6 @@ const baseMerchant = { verified: true, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), } as any; diff --git a/tests/unit/email.service.smtp.test.ts b/tests/unit/email.service.smtp.test.ts index 48f0797..6688102 100644 --- a/tests/unit/email.service.smtp.test.ts +++ b/tests/unit/email.service.smtp.test.ts @@ -47,8 +47,6 @@ const baseMerchant = { verified: true, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), } as any; diff --git a/tests/unit/email.service.test.ts b/tests/unit/email.service.test.ts index 406764b..0c791f6 100644 --- a/tests/unit/email.service.test.ts +++ b/tests/unit/email.service.test.ts @@ -34,8 +34,6 @@ const baseMerchant = { verified: true, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), } as any; diff --git a/tests/unit/invoice-pdf.services.test.ts b/tests/unit/invoice-pdf.services.test.ts index 839e37f..d8cde22 100644 --- a/tests/unit/invoice-pdf.services.test.ts +++ b/tests/unit/invoice-pdf.services.test.ts @@ -19,8 +19,6 @@ const baseMerchant = { verified: true, emailVerified: true, registered: true, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), } as any; diff --git a/tests/unit/merchant.register.test.ts b/tests/unit/merchant.register.test.ts index 31b7748..a47d1df 100644 --- a/tests/unit/merchant.register.test.ts +++ b/tests/unit/merchant.register.test.ts @@ -1,19 +1,14 @@ import { jest } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; -const sendOtpMock = jest.fn(async () => undefined); - -jest.unstable_mockModule('../../src/services/email.service.js', () => ({ - __esModule: true, - sendOtp: sendOtpMock, -})); +const issueEmailOtpMock = jest.fn(async () => undefined); jest.unstable_mockModule('../../src/services/otp.services.js', () => ({ __esModule: true, generateOtp: () => '123456', hashOtp: async () => 'hashed-otp', verifyOtpHash: async () => true, - issueEmailOtp: jest.fn(), + issueEmailOtp: issueEmailOtpMock, verifyEmailOtp: jest.fn(), resendEmailOtp: jest.fn(), })); @@ -37,8 +32,6 @@ const baseMerchant = { verified: false, emailVerified: false, registered: false, - emailOtp: null, - emailOtpExpiresAt: null, createdAt: new Date(), updatedAt: new Date(), }; @@ -55,10 +48,10 @@ const validPayload = { describe('registerMerchant service', () => { beforeEach(() => { mockReset(prismaMock); - sendOtpMock.mockClear(); + issueEmailOtpMock.mockClear(); }); - test('completes registration, stores OTP hash and sends email', async () => { + test('completes registration and issues an email OTP', async () => { prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant as any); prismaMock.merchant.findFirst.mockResolvedValue(null); prismaMock.merchant.update.mockImplementation(async (args: any) => ({ @@ -70,7 +63,7 @@ describe('registerMerchant service', () => { expect(prismaMock.merchant.update).toHaveBeenCalledWith({ where: { id: 'uuid-1' }, - data: expect.objectContaining({ + data: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', @@ -80,11 +73,13 @@ describe('registerMerchant service', () => { logo: null, emailVerified: false, registered: true, - emailOtp: 'hashed-otp', - emailOtpExpiresAt: expect.any(Date), - }), + }, + }); + expect(issueEmailOtpMock).toHaveBeenCalledWith({ + id: 'uuid-1', + email: 'ada@example.com', + firstName: 'Ada', }); - expect(sendOtpMock).toHaveBeenCalledWith('ada@example.com', '123456', 'Ada'); expect(result.emailVerified).toBe(false); expect(result.registered).toBe(true); }); @@ -95,7 +90,7 @@ describe('registerMerchant service', () => { await expect(registerMerchant('missing', validPayload)).rejects.toMatchObject({ statusCode: 404, }); - expect(sendOtpMock).not.toHaveBeenCalled(); + expect(issueEmailOtpMock).not.toHaveBeenCalled(); }); test('throws 409 when profile already set up', async () => { diff --git a/tests/unit/otp.services.test.ts b/tests/unit/otp.services.test.ts index c07ea1f..a029098 100644 --- a/tests/unit/otp.services.test.ts +++ b/tests/unit/otp.services.test.ts @@ -29,12 +29,20 @@ const baseMerchant = { verified: false, emailVerified: false, registered: true, - emailOtp: null as string | null, - emailOtpExpiresAt: null as Date | null, createdAt: new Date('2026-06-21T12:00:00Z'), updatedAt: new Date('2026-06-21T12:00:00Z'), }; +const otpRow = (overrides: Record = {}) => ({ + id: 'otp-1', + merchantId: 'uuid-1', + codeHash: 'hash', + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null as Date | null, + createdAt: new Date('2026-06-21T11:55:00.000Z'), + ...overrides, +}); + describe('otp.services', () => { beforeEach(() => { mockReset(prismaMock); @@ -46,53 +54,134 @@ describe('otp.services', () => { jest.useRealTimers(); }); - test('verifyEmailOtp clears OTP hash after successful verification', async () => { - const code = '123456'; - const emailOtp = await bcrypt.hash(code, 10); - const merchant = { - ...baseMerchant, - emailOtp, - emailOtpExpiresAt: new Date('2026-06-21T12:05:00.000Z'), - }; - - prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); - prismaMock.merchant.update.mockResolvedValue({ - ...merchant, - emailVerified: true, - emailOtp: null, - emailOtpExpiresAt: null, - } as any); - - const result = await verifyEmailOtp('uuid-1', code); - - expect(result.emailVerified).toBe(true); - expect(prismaMock.merchant.update).toHaveBeenCalledWith({ - where: { id: 'uuid-1' }, - data: { + describe('verifyEmailOtp', () => { + test('marks the matched row used and sets emailVerified', async () => { + const code = '123456'; + const codeHash = await bcrypt.hash(code, 10); + + prismaMock.emailOtp.findFirst.mockResolvedValue(otpRow({ codeHash }) as any); + prismaMock.emailOtp.update.mockResolvedValue(otpRow({ codeHash, usedAt: new Date() }) as any); + prismaMock.merchant.update.mockResolvedValue({ + ...baseMerchant, emailVerified: true, - emailOtp: null, - emailOtpExpiresAt: null, - }, + } as any); + + const result = await verifyEmailOtp('uuid-1', code); + + expect(result.emailVerified).toBe(true); + expect(prismaMock.emailOtp.findFirst).toHaveBeenCalledWith({ + where: { merchantId: 'uuid-1', usedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + expect(prismaMock.emailOtp.update).toHaveBeenCalledWith({ + where: { id: 'otp-1' }, + data: { usedAt: expect.any(Date) }, + }); + expect(prismaMock.merchant.update).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + data: { emailVerified: true }, + }); + }); + + test('throws 400 when the merchant has no unused code', async () => { + prismaMock.emailOtp.findFirst.mockResolvedValue(null); + + await expect(verifyEmailOtp('uuid-1', '123456')).rejects.toMatchObject({ + statusCode: 400, + message: 'Invalid verification code', + }); + expect(prismaMock.emailOtp.update).not.toHaveBeenCalled(); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + + test('throws 400 Code expired when the newest row is past expiry', async () => { + const code = '123456'; + const codeHash = await bcrypt.hash(code, 10); + + prismaMock.emailOtp.findFirst.mockResolvedValue( + otpRow({ codeHash, expiresAt: new Date('2026-06-21T11:59:00.000Z') }) as any, + ); + + await expect(verifyEmailOtp('uuid-1', code)).rejects.toMatchObject({ + statusCode: 400, + message: 'Code expired', + }); + expect(prismaMock.emailOtp.update).not.toHaveBeenCalled(); + }); + + test('throws 400 for a wrong code without spending the row', async () => { + const codeHash = await bcrypt.hash('123456', 10); + + prismaMock.emailOtp.findFirst.mockResolvedValue(otpRow({ codeHash }) as any); + + await expect(verifyEmailOtp('uuid-1', '654321')).rejects.toMatchObject({ + statusCode: 400, + message: 'Invalid verification code', + }); + expect(prismaMock.emailOtp.update).not.toHaveBeenCalled(); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); }); - test('resendEmailOtp generates a new code and sends email', async () => { - const merchant = { - ...baseMerchant, - emailOtp: 'old-hash', - emailOtpExpiresAt: new Date('2026-06-21T11:58:00.000Z'), - }; + describe('resendEmailOtp', () => { + test('creates a new code row and sends the email when no recent row exists', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant as any); + prismaMock.emailOtp.findFirst.mockResolvedValue(null); + prismaMock.emailOtp.create.mockResolvedValue(otpRow() as any); - prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); - prismaMock.merchant.update.mockResolvedValue(merchant as any); + await resendEmailOtp('uuid-1'); - await resendEmailOtp('uuid-1'); + expect(prismaMock.emailOtp.findFirst).toHaveBeenCalledWith({ + where: { + merchantId: 'uuid-1', + createdAt: { gt: new Date('2026-06-21T11:59:00.000Z') }, + }, + }); + expect(prismaMock.emailOtp.create).toHaveBeenCalledWith({ + data: { + merchantId: 'uuid-1', + codeHash: expect.any(String), + expiresAt: new Date('2026-06-21T12:10:00.000Z'), + }, + }); + expect(sendOtpMock).toHaveBeenCalledWith( + 'ada@example.com', + expect.stringMatching(/^\d{6}$/), + 'Ada', + ); + }); + + test('throws 429 when a code was issued within the cooldown window', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant as any); + prismaMock.emailOtp.findFirst.mockResolvedValue( + otpRow({ createdAt: new Date('2026-06-21T11:59:30.000Z') }) as any, + ); + + await expect(resendEmailOtp('uuid-1')).rejects.toMatchObject({ + statusCode: 429, + message: 'Please wait before requesting a new code', + }); + expect(prismaMock.emailOtp.create).not.toHaveBeenCalled(); + expect(sendOtpMock).not.toHaveBeenCalled(); + }); - expect(sendOtpMock).toHaveBeenCalledWith( - 'ada@example.com', - expect.stringMatching(/^\d{6}$/), - 'Ada', - ); - expect(prismaMock.merchant.update).toHaveBeenCalled(); + test('throws 404 when the merchant does not exist', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + await expect(resendEmailOtp('missing')).rejects.toMatchObject({ statusCode: 404 }); + }); + + test('throws 400 when the email is already verified', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ + ...baseMerchant, + emailVerified: true, + } as any); + + await expect(resendEmailOtp('uuid-1')).rejects.toMatchObject({ + statusCode: 400, + message: 'Email already verified', + }); + expect(prismaMock.emailOtp.findFirst).not.toHaveBeenCalled(); + }); }); });