diff --git a/test/integration/api/auth/login.test.ts b/test/integration/api/auth/login.test.ts new file mode 100644 index 0000000..91ae677 --- /dev/null +++ b/test/integration/api/auth/login.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '../../../__mocks__/prisma' + +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/auth/login/route' +import { prisma } from '@/lib/prisma' +import { mockUser } from '../../../fixtures/users' + +vi.mock('@/lib/auth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + verifyPassword: vi.fn(), + createSession: vi.fn().mockResolvedValue('mock-session-token'), + setSessionCookie: vi.fn(), + } +}) + +import { verifyPassword, createSession, setSessionCookie } from '@/lib/auth' + +function makeRequest(body: unknown, ip = '1.2.3.4') { + return new NextRequest( + new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-forwarded-for': ip }, + body: JSON.stringify(body), + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0) + vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() }) + vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 }) +}) + +describe('POST /api/auth/login', () => { + it('returns 429 when rate limit exceeded', async () => { + vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10) + + const res = await POST(makeRequest({ email: 'user@example.com', password: 'pass' })) + expect(res.status).toBe(429) + const data = await res.json() + expect(data.error).toMatch(/Too many/) + }) + + it('returns 400 for invalid email format', async () => { + const res = await POST(makeRequest({ email: 'not-an-email', password: 'pass' })) + expect(res.status).toBe(400) + }) + + it('returns 400 for empty password', async () => { + const res = await POST(makeRequest({ email: 'user@example.com', password: '' })) + expect(res.status).toBe(400) + }) + + it('returns 400 for malformed JSON body', async () => { + const req = new NextRequest( + new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' }, + body: 'not-json', + }) + ) + const res = await POST(req) + expect(res.status).toBe(400) + }) + + it('returns 401 when user not found', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null) + + const res = await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' })) + expect(res.status).toBe(401) + const data = await res.json() + expect(data.error).toBe('Invalid email or password') + }) + + it('records a failed attempt when user not found', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null) + + await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' })) + + expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } }) + }) + + it('returns 401 when password is wrong', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any) + vi.mocked(verifyPassword).mockResolvedValue(false) + + const res = await POST(makeRequest({ email: mockUser.email, password: 'WrongPass1!' })) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('Invalid email or password') + }) + + it('returns 200 with user data on successful login', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any) + vi.mocked(verifyPassword).mockResolvedValue(true) + vi.mocked(prisma.session.create).mockResolvedValue({} as any) + + const res = await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' })) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.user.email).toBe(mockUser.email) + expect(data.user.role).toBe(mockUser.role) + expect(data.user).not.toHaveProperty('passwordHash') + }) + + it('creates a session and sets cookie on successful login', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any) + vi.mocked(verifyPassword).mockResolvedValue(true) + vi.mocked(prisma.session.create).mockResolvedValue({} as any) + + await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' })) + + expect(createSession).toHaveBeenCalledWith(mockUser.id) + expect(setSessionCookie).toHaveBeenCalledWith('mock-session-token') + }) +}) diff --git a/test/integration/api/auth/register.test.ts b/test/integration/api/auth/register.test.ts new file mode 100644 index 0000000..56646ca --- /dev/null +++ b/test/integration/api/auth/register.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '../../../__mocks__/prisma' + +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/auth/register/route' +import { prisma } from '@/lib/prisma' +import { mockUser } from '../../../fixtures/users' + +vi.mock('@/lib/auth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + hashPassword: vi.fn().mockResolvedValue('$2a$12$hashedpassword'), + createSession: vi.fn().mockResolvedValue('mock-token'), + setSessionCookie: vi.fn(), + } +}) + +import { hashPassword, createSession, setSessionCookie } from '@/lib/auth' + +function makeRequest(body: unknown) { + return new NextRequest( + new Request('http://localhost/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const validBody = { + email: 'newuser@example.com', + password: 'ValidPass123!', + name: 'Nuovo Utente', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.user.findUnique).mockResolvedValue(null) + vi.mocked(prisma.user.create).mockResolvedValue({ ...mockUser, email: validBody.email, name: validBody.name } as any) + vi.mocked(prisma.session.create).mockResolvedValue({} as any) +}) + +describe('POST /api/auth/register', () => { + it('returns 201 with user data on successful registration', async () => { + const res = await POST(makeRequest(validBody)) + expect(res.status).toBe(201) + const data = await res.json() + expect(data.user.email).toBe(validBody.email) + expect(data.user).not.toHaveProperty('passwordHash') + }) + + it('hashes the password before storing', async () => { + await POST(makeRequest(validBody)) + expect(hashPassword).toHaveBeenCalledWith(validBody.password) + const createCall = vi.mocked(prisma.user.create).mock.calls[0][0] + expect(createCall.data.passwordHash).toBe('$2a$12$hashedpassword') + }) + + it('creates a session and sets cookie after registration', async () => { + await POST(makeRequest(validBody)) + expect(createSession).toHaveBeenCalled() + expect(setSessionCookie).toHaveBeenCalledWith('mock-token') + }) + + it('sets role to CUSTOMER', async () => { + await POST(makeRequest(validBody)) + const createCall = vi.mocked(prisma.user.create).mock.calls[0][0] + expect(createCall.data.role).toBe('CUSTOMER') + }) + + it('returns 409 when email is already in use', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any) + + const res = await POST(makeRequest(validBody)) + expect(res.status).toBe(409) + const data = await res.json() + expect(data.error).toMatch(/already/) + }) + + it('returns 400 for invalid email', async () => { + const res = await POST(makeRequest({ ...validBody, email: 'not-an-email' })) + expect(res.status).toBe(400) + }) + + it('returns 400 for weak password (too short)', async () => { + const res = await POST(makeRequest({ ...validBody, password: 'Short1!' })) + expect(res.status).toBe(400) + }) + + it('returns 400 for empty name', async () => { + const res = await POST(makeRequest({ ...validBody, name: '' })) + expect(res.status).toBe(400) + }) + + it('returns 400 for malformed JSON', async () => { + const req = new NextRequest( + new Request('http://localhost/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'bad-json', + }) + ) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) diff --git a/test/integration/api/webhooks/stripe.test.ts b/test/integration/api/webhooks/stripe.test.ts new file mode 100644 index 0000000..41835cc --- /dev/null +++ b/test/integration/api/webhooks/stripe.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '../../../__mocks__/prisma' + +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/webhooks/stripe/route' +import { prisma } from '@/lib/prisma' +import { + mockOrder, + mockPayment, + mockStripeCheckoutEvent, + mockStripePaymentSucceededEvent, + mockStripePaymentFailedEvent, +} from '../../../fixtures/orders' + +vi.mock('@/lib/stripe', () => ({ + constructWebhookEvent: vi.fn(), +})) + +vi.mock('@/lib/email', () => ({ + sendOrderConfirmationEmail: vi.fn().mockResolvedValue(undefined), +})) + +import { constructWebhookEvent } from '@/lib/stripe' +import { sendOrderConfirmationEmail } from '@/lib/email' + +function makeRequest(body: string, signature = 'valid-sig') { + return new NextRequest( + new Request('http://localhost/api/webhooks/stripe', { + method: 'POST', + headers: { 'stripe-signature': signature, 'Content-Type': 'text/plain' }, + body, + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.order.update).mockResolvedValue(mockOrder as any) + vi.mocked(prisma.payment.updateMany).mockResolvedValue({ count: 1 }) + vi.mocked(prisma.payment.update).mockResolvedValue(mockPayment as any) + vi.mocked(prisma.payment.findFirst).mockResolvedValue(mockPayment as any) + vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: mockOrder.user } as any) +}) + +describe('POST /api/webhooks/stripe', () => { + it('returns 400 when stripe-signature header is missing', async () => { + const req = new NextRequest( + new Request('http://localhost/api/webhooks/stripe', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: '{}', + }) + ) + const res = await POST(req) + expect(res.status).toBe(400) + expect((await res.json()).error).toMatch(/signature/) + }) + + it('returns 400 when signature verification fails', async () => { + vi.mocked(constructWebhookEvent).mockImplementation(() => { + throw new Error('stripe_signature_mismatch') + }) + + const res = await POST(makeRequest('{}', 'bad-sig')) + expect(res.status).toBe(400) + expect((await res.json()).error).toBe('Invalid signature') + }) + + it('processes checkout.session.completed: updates order to PAID', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) + + const res = await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) + expect(res.status).toBe(200) + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: mockOrder.id }, data: { status: 'PAID' } }) + ) + }) + + it('processes checkout.session.completed: updates payment record', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) + + await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) + + expect(prisma.payment.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orderId: mockOrder.id }, + data: expect.objectContaining({ status: 'paid' }), + }) + ) + }) + + it('processes checkout.session.completed: sends confirmation email', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) + + await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) + + expect(sendOrderConfirmationEmail).toHaveBeenCalledWith( + mockOrder.user.email, + expect.objectContaining({ orderId: mockOrder.id }) + ) + }) + + it('skips email send if order user has no email', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) + vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: null } as any) + + await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) + + expect(sendOrderConfirmationEmail).not.toHaveBeenCalled() + }) + + it('processes checkout.session.completed without orderId: no DB update', async () => { + const eventWithoutOrderId = { + type: 'checkout.session.completed', + data: { object: { metadata: {}, payment_intent: 'pi_test' } }, + } + vi.mocked(constructWebhookEvent).mockReturnValue(eventWithoutOrderId as any) + + const res = await POST(makeRequest('{}')) + expect(res.status).toBe(200) + expect(prisma.order.update).not.toHaveBeenCalled() + }) + + it('processes payment_intent.succeeded: updates payment and order', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any) + + const res = await POST(makeRequest(JSON.stringify(mockStripePaymentSucceededEvent))) + expect(res.status).toBe(200) + expect(prisma.payment.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) }) + ) + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'PAID' } }) + ) + }) + + it('processes payment_intent.succeeded: no-op when payment not found', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any) + vi.mocked(prisma.payment.findFirst).mockResolvedValue(null) + + const res = await POST(makeRequest('{}')) + expect(res.status).toBe(200) + expect(prisma.payment.update).not.toHaveBeenCalled() + }) + + it('processes payment_intent.payment_failed: sets status to failed and CANCELLED', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentFailedEvent as any) + + const res = await POST(makeRequest(JSON.stringify(mockStripePaymentFailedEvent))) + expect(res.status).toBe(200) + expect(prisma.payment.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) }) + ) + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'CANCELLED' } }) + ) + }) + + it('returns 200 for unhandled event types', async () => { + vi.mocked(constructWebhookEvent).mockReturnValue({ type: 'customer.created', data: { object: {} } } as any) + + const res = await POST(makeRequest('{}')) + expect(res.status).toBe(200) + expect((await res.json()).received).toBe(true) + }) +})