fix architecture and write new tests
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
import { getCookie, sendForbidden, sendPaymentRequired, sendUnauthorized } from './authHelpers'
|
||||
|
||||
function responseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('extracts and decodes a cookie value by name', () => {
|
||||
const req = {
|
||||
headers: {
|
||||
cookie: 'theme=dark; employee_session=abc%20123%3D; other=value',
|
||||
},
|
||||
} as Request
|
||||
|
||||
expect(getCookie(req, 'employee_session')).toBe('abc 123=')
|
||||
})
|
||||
|
||||
it('returns null when the cookie header or requested cookie is missing', () => {
|
||||
expect(getCookie({ headers: {} } as Request, 'employee_session')).toBeNull()
|
||||
expect(getCookie({ headers: { cookie: 'theme=dark' } } as Request, 'employee_session')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends a uniform unauthorized response', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendUnauthorized(res, 'invalid_token', 'Invalid token')
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
|
||||
})
|
||||
|
||||
it('sends forbidden responses with optional metadata', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendForbidden(res, 'forbidden', 'Nope', { requiredRole: 'OWNER' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'Nope',
|
||||
statusCode: 403,
|
||||
requiredRole: 'OWNER',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends payment-required responses with optional metadata', () => {
|
||||
const res = responseStub()
|
||||
|
||||
sendPaymentRequired(res, 'subscription_suspended', 'Pay up', { billingUrl: '/billing' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'subscription_suspended',
|
||||
message: 'Pay up',
|
||||
statusCode: 402,
|
||||
billingUrl: '/billing',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,19 @@ export function getCookie(req: Request, name: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function getBearerToken(req: Request): string | null {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) return null
|
||||
const token = authHeader.slice(7).trim()
|
||||
return token.length > 0 ? token : null
|
||||
}
|
||||
|
||||
export function getAuthToken(req: Request, cookieName?: string): string | null {
|
||||
// Prefer HttpOnly cookies over bearer tokens so stale script-readable tokens
|
||||
// cannot shadow a valid server-managed session during migration.
|
||||
return (cookieName ? getCookie(req, cookieName) : null) ?? getBearerToken(req)
|
||||
}
|
||||
|
||||
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(403).json({ error, message, statusCode: 403, ...extra })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const createdLimiters: any[] = []
|
||||
|
||||
vi.mock('express-rate-limit', () => ({
|
||||
default: vi.fn((config: any) => {
|
||||
createdLimiters.push(config)
|
||||
return config
|
||||
}),
|
||||
ipKeyGenerator: vi.fn((ip: string) => `ip:${ip}`),
|
||||
}))
|
||||
|
||||
vi.mock('../security/tokens', () => ({
|
||||
verifyAnyActorToken: vi.fn((token: string) => {
|
||||
if (token === 'employee-token') return { type: 'employee', sub: 'employee_1' }
|
||||
if (token === 'renter-token') return { type: 'renter', sub: 'renter_1' }
|
||||
throw new Error('Invalid actor token')
|
||||
}),
|
||||
}))
|
||||
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
|
||||
describe('rateLimiter middleware configuration', () => {
|
||||
beforeEach(() => {
|
||||
createdLimiters.length = 0
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('configures auth limiter to count failed attempts only', async () => {
|
||||
const { authLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(authLimiter.max).toBe(20)
|
||||
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
|
||||
expect(authLimiter.skipSuccessfulRequests).toBe(true)
|
||||
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
|
||||
expect(rateLimit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keys general API limits by verified actor identity before falling back to request context', async () => {
|
||||
const { apiLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { cookie: 'theme=dark; employee_session=employee-token' },
|
||||
companyId: 'company_1',
|
||||
} as any)).toBe('ip:203.0.113.10:employee:employee_1')
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { authorization: 'Bearer renter-token' },
|
||||
renterId: 'legacy_renter_context',
|
||||
} as any)).toBe('ip:203.0.113.10:renter:renter_1')
|
||||
expect(apiLimiter.keyGenerator({
|
||||
ip: '203.0.113.10',
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
companyId: 'company_1',
|
||||
} as any)).toBe('ip:203.0.113.10:company_1')
|
||||
expect(apiLimiter.keyGenerator({ ip: '203.0.113.10', headers: {}, renterId: 'renter_1' } as any)).toBe('ip:203.0.113.10:renter_1')
|
||||
expect(ipKeyGenerator).toHaveBeenCalledWith('203.0.113.10')
|
||||
})
|
||||
|
||||
it('uses tighter public and admin limits with explicit 429 payloads', async () => {
|
||||
const { publicLimiter, adminLimiter } = await import('./rateLimiter')
|
||||
|
||||
expect(publicLimiter.max).toBe(60)
|
||||
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
|
||||
expect(adminLimiter.max).toBe(100)
|
||||
expect(adminLimiter.message.message).toBe('Too many admin requests')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,54 @@
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
import type { Request } from 'express'
|
||||
import { verifyAnyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
getSessionCookieName('admin'),
|
||||
getSessionCookieName('employee'),
|
||||
getSessionCookieName('renter'),
|
||||
]
|
||||
|
||||
function readCookie(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getBearerToken(req: Request): string | null {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) return null
|
||||
const token = authHeader.slice(7).trim()
|
||||
return token || null
|
||||
}
|
||||
|
||||
function getRequestToken(req: Request): string | null {
|
||||
const cookieHeader = req.headers.cookie
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const token = readCookie(cookieHeader, name)
|
||||
if (token) return token
|
||||
}
|
||||
|
||||
return getBearerToken(req)
|
||||
}
|
||||
|
||||
function getAuthenticatedActorKey(req: Request): string | null {
|
||||
const token = getRequestToken(req)
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const payload = verifyAnyActorToken(token)
|
||||
return `${payload.type}:${payload.sub}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||
@@ -25,9 +74,10 @@ export const apiLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
const actorKey = getAuthenticatedActorKey(req)
|
||||
const companyId = (req as any).companyId ?? ''
|
||||
const renterId = (req as any).renterId ?? ''
|
||||
return `${ip}:${companyId || renterId}`
|
||||
return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
@@ -48,6 +98,29 @@ export const adminLimiter = rateLimit({
|
||||
max: 100,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||
})
|
||||
|
||||
|
||||
// Applied after authentication so limits can include actor identity rather than
|
||||
// pretending every employee behind the same NAT is the same organism.
|
||||
export const actorLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 240,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
const actorKey = getAuthenticatedActorKey(req)
|
||||
const companyId = (req as any).companyId ?? ''
|
||||
const employeeId = (req as any).employee?.id ?? ''
|
||||
const renterId = (req as any).renterId ?? ''
|
||||
const adminId = (req as any).admin?.id ?? ''
|
||||
return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const incoming = req.headers['x-request-id']
|
||||
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
|
||||
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
|
||||
res.setHeader('X-Request-Id', req.requestId)
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
adminUser: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireAdminAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects requests without an admin bearer token', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid token signatures', async () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-admin token types', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive admin accounts', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches the admin record for valid admin tokens', async () => {
|
||||
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
|
||||
expect(req.admin).toEqual(admin)
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks non-enrolled admins from privileged routes', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireAdminRole middleware', () => {
|
||||
it('requires requireAdminAuth to run first', () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks admins below the required rank', () => {
|
||||
const req = { admin: { role: 'VIEWER' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('FINANCE' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'This action requires the FINANCE role or higher',
|
||||
statusCode: 403,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows admins at or above the required rank', () => {
|
||||
const req = { admin: { role: 'ADMIN' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('SUPPORT' as any)(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { AdminRole } from '@rentaldrivego/database'
|
||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
import { verifyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
const ROLE_RANK: Record<AdminRole, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
@@ -12,35 +13,48 @@ const ROLE_RANK: Record<AdminRole, number> = {
|
||||
VIEWER: 1,
|
||||
}
|
||||
|
||||
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
|
||||
'/auth/me',
|
||||
'/auth/logout',
|
||||
'/auth/2fa/setup',
|
||||
'/auth/2fa/verify',
|
||||
])
|
||||
|
||||
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
|
||||
|
||||
function is2faEnrollmentExempt(req: Request) {
|
||||
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a valid admin Bearer token.
|
||||
* Requires a valid admin session token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.admin — the full AdminUser record
|
||||
*/
|
||||
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const token = getAuthToken(req, getSessionCookieName('admin'))
|
||||
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
let payload: { sub: string; type: string; last2faAt?: number }
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
payload = verifyActorToken(token, 'admin')
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'admin') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||
}
|
||||
|
||||
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
|
||||
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -63,3 +77,18 @@ export function requireAdminRole(minimumRole: AdminRole) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
|
||||
const admin = req.admin
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
if (!admin.totpEnabled) {
|
||||
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
|
||||
}
|
||||
|
||||
const last2faAt = req.adminAuthLast2faAt
|
||||
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { generateCompanyApiKey } from '../security/apiKeys'
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
companyApiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireApiKey } from './requireApiKey'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireApiKey middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects requests with no x-api-key header', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'missing_api_key',
|
||||
message: 'API key required in x-api-key header',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed API keys before database lookup', async () => {
|
||||
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'invalid_api_key',
|
||||
message: 'Invalid API key',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown API key prefixes', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { prefix: generated.prefix },
|
||||
include: { company: true },
|
||||
})
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'invalid_api_key',
|
||||
message: 'Invalid API key',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects revoked API keys', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: 'company_1',
|
||||
prefix: generated.prefix,
|
||||
keyHash: generated.keyHash,
|
||||
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects API keys whose secret does not match the stored hash', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
const other = generateCompanyApiKey()
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: 'company_1',
|
||||
prefix: generated.prefix,
|
||||
keyHash: other.keyHash,
|
||||
revokedAt: null,
|
||||
company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
|
||||
const generated = generateCompanyApiKey()
|
||||
const company = { id: 'company_1', name: 'Atlas Cars' }
|
||||
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
id: 'key_1',
|
||||
companyId: company.id,
|
||||
prefix: generated.prefix,
|
||||
keyHash: generated.keyHash,
|
||||
revokedAt: null,
|
||||
company,
|
||||
})
|
||||
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
|
||||
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: 'key_1' },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
})
|
||||
expect(req.company).toEqual(company)
|
||||
expect(req.companyId).toBe('company_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.json).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
|
||||
|
||||
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined
|
||||
@@ -8,12 +9,27 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
|
||||
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { apiKey } })
|
||||
if (!company) {
|
||||
const prefix = getApiKeyPrefix(apiKey)
|
||||
if (!prefix) {
|
||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.company = company
|
||||
req.companyId = company.id
|
||||
const keyRecord = await (prisma as any).companyApiKey.findUnique({
|
||||
where: { prefix },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
const hashed = hashApiKey(apiKey)
|
||||
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
|
||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||
}
|
||||
|
||||
await (prisma as any).companyApiKey.update({
|
||||
where: { id: keyRecord.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
|
||||
req.company = keyRecord.company
|
||||
req.companyId = keyRecord.companyId
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireCompanyAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects missing bearer tokens', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid tokens', async () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||
const req = { headers: { authorization: 'Bearer bad' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-employee token types', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive or missing employees', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches employee and company context for active employees', async () => {
|
||||
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
|
||||
expect(req.employee).toEqual(employee)
|
||||
expect(req.company).toEqual(employee.company)
|
||||
expect(req.companyId).toBe('company_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts employee_session cookies for document routes', async () => {
|
||||
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
|
||||
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireCompanyDocumentAuth(req, res, next)
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'rentaldrivego-api',
|
||||
audience: 'employee',
|
||||
})
|
||||
expect(req.companyId).toBe('company_2')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getCookie, sendUnauthorized } from './authHelpers'
|
||||
import { getAuthToken, sendUnauthorized } from './authHelpers'
|
||||
import { verifyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
import { actorLimiter } from './rateLimiter'
|
||||
|
||||
/**
|
||||
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
|
||||
@@ -11,25 +13,18 @@ import { getCookie, sendUnauthorized } from './authHelpers'
|
||||
* req.company — the employee's company
|
||||
* req.companyId — string shorthand for req.company.id
|
||||
*/
|
||||
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
|
||||
const authHeader = req.headers.authorization
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
|
||||
const token = bearerToken ?? cookieToken
|
||||
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction) {
|
||||
const token = getAuthToken(req, getSessionCookieName('employee'))
|
||||
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
payload = verifyActorToken(token, 'employee')
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'employee') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { company: true },
|
||||
@@ -42,7 +37,7 @@ async function authenticateCompanyRequest(req: Request, res: Response, next: Nex
|
||||
req.employee = employee
|
||||
req.company = employee.company
|
||||
req.companyId = employee.companyId
|
||||
next()
|
||||
return actorLimiter(req, res, next)
|
||||
}
|
||||
|
||||
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -50,5 +45,5 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
|
||||
}
|
||||
|
||||
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
|
||||
return authenticateCompanyRequest(req, res, next, true)
|
||||
return authenticateCompanyRequest(req, res, next)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { sendForbidden, sendUnauthorized } from './authHelpers'
|
||||
import type { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { CompanyPolicy, type CompanyPolicyAction } from '../security/policies/companyPolicy'
|
||||
|
||||
export function requireCompanyPolicy(action: CompanyPolicyAction) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const employee = req.employee
|
||||
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
const allowedRoles: readonly EmployeeRole[] = CompanyPolicy[action]
|
||||
if (!allowedRoles.includes(employee.role)) {
|
||||
return sendForbidden(res, 'forbidden', 'You do not have permission to perform this action', {
|
||||
action,
|
||||
allowedRoles,
|
||||
yourRole: employee.role,
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
renter: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth, requireRenterAuth } from './requireRenterAuth'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireRenterAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects missing bearer tokens', async () => {
|
||||
const req = { headers: {} } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireRenterAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid tokens and wrong token types', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
|
||||
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireRenterAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
|
||||
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive renters', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: false } as any)
|
||||
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireRenterAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches renterId for active renters', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
|
||||
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: true } as any)
|
||||
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireRenterAuth(req, res, next)
|
||||
|
||||
expect(req.renterId).toBe('renter_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('optional auth never blocks missing or invalid tokens', async () => {
|
||||
const noTokenReq = { headers: {} } as Request
|
||||
const invalidReq = { headers: { authorization: 'Bearer bad' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
|
||||
|
||||
await optionalRenterAuth(noTokenReq, res, next)
|
||||
await optionalRenterAuth(invalidReq, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('optional auth attaches renterId only for renter tokens', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_2', type: 'renter' } as any)
|
||||
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await optionalRenterAuth(req, res, next)
|
||||
|
||||
expect(req.renterId).toBe('renter_2')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendUnauthorized } from './authHelpers'
|
||||
import { getAuthToken, sendUnauthorized } from './authHelpers'
|
||||
import { verifyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
import { actorLimiter } from './rateLimiter'
|
||||
|
||||
/**
|
||||
* Requires a valid renter Bearer token.
|
||||
@@ -10,29 +12,24 @@ import { sendUnauthorized } from './authHelpers'
|
||||
* req.renterId — the authenticated renter's id
|
||||
*/
|
||||
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const token = getAuthToken(req, getSessionCookieName('renter'))
|
||||
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
payload = verifyActorToken(token, 'renter')
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'renter') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||
if (!renter || !renter.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
|
||||
}
|
||||
|
||||
req.renterId = renter.id
|
||||
next()
|
||||
return actorLimiter(req, res, next)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +40,12 @@ export async function requireRenterAuth(req: Request, res: Response, next: NextF
|
||||
* req.renterId — the authenticated renter's id (if token is valid)
|
||||
*/
|
||||
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const token = getAuthToken(req, getSessionCookieName('renter'))
|
||||
if (!token) return next()
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'renter') req.renterId = payload.sub
|
||||
const payload = verifyActorToken(token, 'renter')
|
||||
req.renterId = payload.sub
|
||||
} catch {
|
||||
// Optional — silently ignore invalid tokens
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { requireRole } from './requireRole'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireRole middleware', () => {
|
||||
it('rejects requests when company auth did not attach an employee', () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireRole('AGENT' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects employees below the required role and includes role context', () => {
|
||||
const req = { employee: { role: 'AGENT' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireRole('MANAGER' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'This action requires the MANAGER role or higher',
|
||||
statusCode: 403,
|
||||
requiredRole: 'MANAGER',
|
||||
yourRole: 'AGENT',
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows employees at or above the required role', () => {
|
||||
const req = { employee: { role: 'OWNER' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireRole('MANAGER' as any)(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { requireSubscription } from './requireSubscription'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireSubscription middleware', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||
})
|
||||
|
||||
it('requires tenant/company context first', () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks suspended companies with a billing URL', () => {
|
||||
const req = { company: { status: 'SUSPENDED' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'subscription_suspended',
|
||||
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||
statusCode: 402,
|
||||
billingUrl: 'https://dashboard.example.test/billing',
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks pending companies with setup guidance', () => {
|
||||
const req = { company: { status: 'PENDING' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'subscription_pending',
|
||||
message: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
}))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows active companies through', () => {
|
||||
const req = { company: { status: 'ACTIVE' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
company: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireTenant } from './requireTenant'
|
||||
|
||||
function responseStub() {
|
||||
const res = { status: vi.fn(), json: vi.fn() }
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
describe('requireTenant middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when company auth did not set companyId', async () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireTenant(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'unauthenticated',
|
||||
message: 'Tenant context missing — requireCompanyAuth must run first',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(prisma.company.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects company ids that no longer exist', async () => {
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
|
||||
const req = { companyId: 'company_missing' } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireTenant(req, res, next)
|
||||
|
||||
expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { id: 'company_missing' } })
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'company_not_found', message: 'Company not found', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches the fresh company record and continues', async () => {
|
||||
const company = { id: 'company_1', status: 'ACTIVE' }
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
|
||||
const req = { companyId: 'company_1' } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireTenant(req, res, next)
|
||||
|
||||
expect(req.company).toEqual(company)
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user