diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 194fc83..8595a74 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -65,14 +65,19 @@ export const apiKeyAuth = async ( try { const token = extractBearerToken(req); - if (!token || !isApiKeyToken(token)) { - res.status(401).json({ error: 'Unauthorized' }); + if (!token) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!isApiKeyToken(token)) { + res.status(401).json({ error: 'Invalid or expired token' }); return; } const merchant = await authenticateApiKey(token); if (!merchant) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } @@ -96,12 +101,12 @@ export const authenticateSessionOnly = async ( const token = extractBearerToken(req); if (!token) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Authentication required' }); return; } if (isApiKeyToken(token)) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } @@ -111,7 +116,7 @@ export const authenticateSessionOnly = async ( : await authenticateRefreshToken(token); if (!merchant) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } @@ -123,7 +128,15 @@ export const authenticateSessionOnly = async ( }; /** - * Authenticates a merchant using refresh tokens, JWT access tokens, or API keys. + * Authenticates a merchant from a bearer token. + * + * Accepts JWT access tokens (signed with `JWT_SECRET`), refresh session tokens, + * or API keys. The resolved Merchant is attached to `req.merchant` on success. + * + * Responds with 401 when the `Authorization: Bearer ` header is missing + * or malformed (`Authentication required`), or when the token is invalid, + * expired, or references a merchant that no longer exists + * (`Invalid or expired token`). */ export const authenticateMerchant = async ( req: Request, @@ -134,13 +147,13 @@ export const authenticateMerchant = async ( const token = extractBearerToken(req); if (!token) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Authentication required' }); return; } const merchant = await resolveMerchantFromToken(token); if (!merchant) { - res.status(401).json({ error: 'Unauthorized' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } diff --git a/tests/integration/auth.email-otp.test.ts b/tests/integration/auth.email-otp.test.ts index e110e4a..16fb8c1 100644 --- a/tests/integration/auth.email-otp.test.ts +++ b/tests/integration/auth.email-otp.test.ts @@ -68,7 +68,7 @@ describe('Email OTP auth routes', () => { const response = await request(app).post(VERIFY_EMAIL_URL).send({ code: '123456' }); expect(response.status).toBe(401); - expect(response.body).toEqual({ error: 'Unauthorized' }); + expect(response.body).toEqual({ error: 'Authentication required' }); }); test('returns 400 when code is missing', async () => { @@ -163,7 +163,7 @@ describe('Email OTP auth routes', () => { const response = await request(app).post(RESEND_OTP_URL); expect(response.status).toBe(401); - expect(response.body).toEqual({ error: 'Unauthorized' }); + expect(response.body).toEqual({ error: 'Authentication required' }); }); test('returns 200 and re-sends OTP when cooldown has elapsed', async () => { diff --git a/tests/integration/invoice.routes.test.ts b/tests/integration/invoice.routes.test.ts index 943675e..f80e280 100644 --- a/tests/integration/invoice.routes.test.ts +++ b/tests/integration/invoice.routes.test.ts @@ -1,7 +1,9 @@ import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); const { default: app } = await import('../../src/app.js'); const MERCHANT_ID = 'merchant-1'; @@ -47,17 +49,14 @@ const baseInvoice = { }; const authenticate = () => { - prismaMock.refreshToken.findUnique.mockResolvedValue({ - id: 'session-1', - merchantId: MERCHANT_ID, - token: 'valid-token', - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - merchant, - } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); }; -const auth = { Authorization: 'Bearer valid-token' }; +const accessToken = jwt.sign( + { sub: MERCHANT_ID, address: merchant.address }, + environment.jwtSecret, +); +const auth = { Authorization: `Bearer ${accessToken}` }; describe('Invoice routes', () => { beforeEach(() => { diff --git a/tests/integration/merchant.register.test.ts b/tests/integration/merchant.register.test.ts index 4eea7e4..bbd93f6 100644 --- a/tests/integration/merchant.register.test.ts +++ b/tests/integration/merchant.register.test.ts @@ -1,5 +1,6 @@ import { jest } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; const sendOtpMock = jest.fn(async () => undefined); @@ -20,8 +21,12 @@ jest.unstable_mockModule('../../src/services/otp.services.js', () => ({ })); const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); const { default: app } = await import('../../src/app.js'); +const tokenFor = (merchant: Record) => + jwt.sign({ sub: merchant.id as string }, environment.jwtSecret); + const REGISTER_URL = '/api/v1/merchants/register'; const baseMerchant = { @@ -57,16 +62,11 @@ const validPayload = { }; const authenticateAs = (merchant: Record) => { - prismaMock.refreshToken.findUnique.mockResolvedValue({ - id: 'session-1', - merchantId: merchant.id, - token: 'valid-token', - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - merchant, - } as any); + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); }; +const authHeader = `Bearer ${tokenFor(baseMerchant)}`; + describe('POST /api/v1/merchants/register', () => { beforeEach(() => { mockReset(prismaMock); @@ -77,11 +77,11 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app).post(REGISTER_URL).send(validPayload); expect(response.status).toBe(401); - expect(response.body).toEqual({ error: 'Unauthorized' }); + expect(response.body).toEqual({ error: 'Authentication required' }); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); - test('returns 401 when the session token is invalid', async () => { + test('returns 401 when the token is invalid', async () => { prismaMock.refreshToken.findUnique.mockResolvedValue(null); const response = await request(app) @@ -90,6 +90,7 @@ describe('POST /api/v1/merchants/register', () => { .send(validPayload); expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid or expired token' }); }); test('returns 200 with the merchant profile on valid payload', async () => { @@ -103,7 +104,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(200); @@ -129,7 +130,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(409); @@ -143,7 +144,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send(validPayload); expect(response.status).toBe(409); @@ -155,7 +156,7 @@ describe('POST /api/v1/merchants/register', () => { const response = await request(app) .post(REGISTER_URL) - .set('Authorization', 'Bearer valid-token') + .set('Authorization', authHeader) .send({ email: 'not-an-email' }); expect(response.status).toBe(400); diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts new file mode 100644 index 0000000..370d904 --- /dev/null +++ b/tests/unit/auth.middleware.test.ts @@ -0,0 +1,121 @@ +import { jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; +import type { Request, Response, NextFunction } from 'express'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { authenticateMerchant } = await import('../../src/middlewares/auth.middleware.js'); + +const MERCHANT_ID = 'merchant-1'; + +const merchant = { + id: MERCHANT_ID, + merchantId: 1, + address: '0x123', + registered: true, +}; + +const buildReq = (authorization?: string): Request => + ({ headers: authorization ? { authorization } : {} }) as unknown as Request; + +const buildRes = () => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + return res; +}; + +const validToken = () => + jwt.sign({ sub: MERCHANT_ID, address: merchant.address }, environment.jwtSecret); + +describe('authenticateMerchant', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('attaches the merchant and calls next() for a valid JWT', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant as any); + const req = buildReq(`Bearer ${validToken()}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ where: { id: MERCHANT_ID } }); + expect(req.merchant).toEqual(merchant); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('returns 401 "Authentication required" when the Authorization header is missing', async () => { + const req = buildReq(); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' }); + expect(next).not.toHaveBeenCalled(); + }); + + test('returns 401 "Authentication required" when the scheme is not Bearer', async () => { + const req = buildReq('Basic abc123'); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + test('returns 401 "Invalid or expired token" for a malformed token', async () => { + const req = buildReq('Bearer not-a-real-jwt'); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + expect(prismaMock.merchant.findUnique).not.toHaveBeenCalled(); + }); + + test('returns 401 "Invalid or expired token" for an expired token', async () => { + const expired = jwt.sign({ sub: MERCHANT_ID }, environment.jwtSecret, { expiresIn: '-1s' }); + const req = buildReq(`Bearer ${expired}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + test('returns 401 "Invalid or expired token" when the token is signed with the wrong secret', async () => { + const forged = jwt.sign({ sub: MERCHANT_ID }, 'a-different-secret'); + const req = buildReq(`Bearer ${forged}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + test('returns 401 when the merchant no longer exists in the database', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + const req = buildReq(`Bearer ${validToken()}`); + const res = buildRes(); + const next = jest.fn() as unknown as NextFunction; + + await authenticateMerchant(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + expect(next).not.toHaveBeenCalled(); + }); +});