diff --git a/apps/api/src/middleware/requireSubscription.test.ts b/apps/api/src/middleware/requireSubscription.test.ts index 3dcc8f3..9ab1636 100644 --- a/apps/api/src/middleware/requireSubscription.test.ts +++ b/apps/api/src/middleware/requireSubscription.test.ts @@ -107,4 +107,18 @@ describe('requireSubscription middleware', () => { expect(next).toHaveBeenCalledTimes(1) expect(res.status).not.toHaveBeenCalled() }) + + it('passes subscription lookup failures to Express error handling', async () => { + const error = new Error('database unavailable') + vi.mocked(prisma.subscription.findUnique).mockRejectedValue(error as never) + + const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request + const res = responseStub() + const next = vi.fn() as NextFunction + + await requireSubscription(req, res, next) + + expect(next).toHaveBeenCalledWith(error) + expect(res.status).not.toHaveBeenCalled() + }) }) diff --git a/apps/api/src/middleware/requireSubscription.ts b/apps/api/src/middleware/requireSubscription.ts index f2a85e2..b5efbd8 100644 --- a/apps/api/src/middleware/requireSubscription.ts +++ b/apps/api/src/middleware/requireSubscription.ts @@ -13,38 +13,42 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] * req.company.status is not SUSPENDED or PENDING, and subscription access is not none */ export async function requireSubscription(req: Request, res: Response, next: NextFunction) { - const company = req.company - if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') + try { + const company = req.company + if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') - if (BLOCKED_STATUSES.includes(company.status)) { - return sendPaymentRequired( - res, - `subscription_${company.status.toLowerCase()}`, - company.status === 'SUSPENDED' - ? 'Your account has been suspended. Please contact support or renew your subscription.' - : 'Your account is pending activation. Please complete your subscription setup.', - { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` }, - ) + if (BLOCKED_STATUSES.includes(company.status)) { + return sendPaymentRequired( + res, + `subscription_${company.status.toLowerCase()}`, + company.status === 'SUSPENDED' + ? 'Your account has been suspended. Please contact support or renew your subscription.' + : 'Your account is pending activation. Please complete your subscription setup.', + { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` }, + ) + } + + const subscription = await prisma.subscription.findUnique({ + where: { companyId: company.id }, + select: { status: true }, + }) + const subscriptionStatus = subscription?.status ?? 'EXPIRED' + + if (!hasAnyAccess(subscriptionStatus)) { + return sendPaymentRequired( + res, + 'subscription_required', + 'Your subscription has ended. Please reactivate to continue.', + { + billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`, + subscriptionStatus, + accessLevel: getAccessLevel(subscriptionStatus), + }, + ) + } + + next() + } catch (error) { + next(error) } - - const subscription = await prisma.subscription.findUnique({ - where: { companyId: company.id }, - select: { status: true }, - }) - const subscriptionStatus = subscription?.status ?? 'EXPIRED' - - if (!hasAnyAccess(subscriptionStatus)) { - return sendPaymentRequired( - res, - 'subscription_required', - 'Your subscription has ended. Please reactivate to continue.', - { - billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`, - subscriptionStatus, - accessLevel: getAccessLevel(subscriptionStatus), - }, - ) - } - - next() } diff --git a/apps/api/src/tests/api/auth-middleware.api.test.ts b/apps/api/src/tests/api/auth-middleware.api.test.ts index 42ddf62..550fcca 100644 --- a/apps/api/src/tests/api/auth-middleware.api.test.ts +++ b/apps/api/src/tests/api/auth-middleware.api.test.ts @@ -12,6 +12,7 @@ vi.mock('../../lib/prisma', () => ({ prisma: { employee: { findUnique: vi.fn() }, company: { findUnique: vi.fn() }, + subscription: { findUnique: vi.fn() }, adminUser: { findUnique: vi.fn() }, renter: { findUnique: vi.fn() }, }, @@ -53,6 +54,7 @@ describe('auth middleware API boundaries', () => { vi.clearAllMocks() process.env.JWT_SECRET = 'test-secret' process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test' + vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never) }) it('rejects protected company routes before service execution when no token is present', async () => { diff --git a/apps/api/src/tests/api/vehicle-pricing.api.test.ts b/apps/api/src/tests/api/vehicle-pricing.api.test.ts index e990ff1..118bd26 100644 --- a/apps/api/src/tests/api/vehicle-pricing.api.test.ts +++ b/apps/api/src/tests/api/vehicle-pricing.api.test.ts @@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({ prisma: { employee: { findUnique: vi.fn() }, company: { findUnique: vi.fn() }, + subscription: { findUnique: vi.fn() }, }, })) vi.mock('../../modules/vehicles/vehicle.service', () => ({ @@ -57,6 +58,7 @@ beforeEach(() => { vi.mocked(jwt.verify).mockReturnValue({ sub: 'employee_1', type: 'employee' } as never) vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee() as never) vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never) + vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never) }) describe('vehicle pricing API contracts', () => { diff --git a/apps/api/src/tests/e2e/vehicle-pricing-boundaries.e2e.test.ts b/apps/api/src/tests/e2e/vehicle-pricing-boundaries.e2e.test.ts index 909e2a1..41c62ac 100644 --- a/apps/api/src/tests/e2e/vehicle-pricing-boundaries.e2e.test.ts +++ b/apps/api/src/tests/e2e/vehicle-pricing-boundaries.e2e.test.ts @@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({ prisma: { employee: { findUnique: vi.fn().mockResolvedValue({ id: 'employee_1', role: 'MANAGER', isActive: true, companyId: 'company_1', company: { id: 'company_1', status: 'ACTIVE' } }) }, company: { findUnique: vi.fn().mockResolvedValue({ id: 'company_1', status: 'ACTIVE' }) }, + subscription: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) }, }, })) vi.mock('../../modules/vehicles/vehicle.service', () => ({ diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts index 357439a..596542a 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveDashboardRoutePolicy, roleCanAccessPolicy } from '@/lib/dashboardRoutePolicies' -import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard' +import { flattenInternalRoutes, getBaselineInternalRoutes, isAllowedRoute, resolveAccessRedirect, resolveAllowedRoutes } from './DashboardAccessGuard' describe('DashboardAccessGuard route helpers', () => { it('normalizes dashboard-prefixed menu routes before checking access', () => { @@ -35,6 +35,46 @@ describe('DashboardAccessGuard route helpers', () => { expect(resolveAccessRedirect('/fleet', [])).toBeNull() }) + it('uses the approved baseline routes when active subscription menu data is empty or root-only', () => { + expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'full' }, 'OWNER')).toEqual([ + '/', + '/reservations', + '/fleet', + '/customers', + '/reports', + '/billing', + '/settings', + ]) + + expect(resolveAllowedRoutes({ + subscriptionAccessLevel: 'full', + items: [ + { + id: 'dashboard', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/', + children: [], + }, + ], + }, 'MANAGER')).toEqual([ + '/', + '/reservations', + '/fleet', + '/customers', + '/reports', + '/billing', + ]) + }) + + it('does not apply baseline fallback when subscription access is none', () => { + expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'none' }, 'OWNER')).toEqual([]) + }) + + it('filters baseline routes by role', () => { + expect(getBaselineInternalRoutes('AGENT')).toEqual(['/', '/reservations', '/fleet', '/customers']) + expect(getBaselineInternalRoutes('MANAGER')).toEqual(['/', '/reservations', '/fleet', '/customers', '/reports', '/billing']) + }) + it('redirects disallowed routes to the first visible internal route', () => { expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/') expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull() diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx index f24a259..ddf05ea 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx @@ -19,6 +19,7 @@ type GeneratedMenuItem = { type EmployeeMenuResponse = { items: GeneratedMenuItem[] + subscriptionAccessLevel?: 'full' | 'limited' | 'read_only' | 'none' } type EmployeeProfileResponse = { @@ -27,6 +28,27 @@ type EmployeeProfileResponse = { } } +const ROLE_RANK: Record = { OWNER: 3, MANAGER: 2, AGENT: 1 } +const BASELINE_MENU_ROUTES = [ + { route: '/', minRole: 'AGENT' }, + { route: '/reservations', minRole: 'AGENT' }, + { route: '/fleet', minRole: 'AGENT' }, + { route: '/customers', minRole: 'AGENT' }, + { route: '/reports', minRole: 'MANAGER' }, + { route: '/billing', minRole: 'MANAGER' }, + { route: '/settings', minRole: 'OWNER' }, +] as const + +function hasMinRole(employeeRole: string, minRole: string): boolean { + return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0) +} + +export function getBaselineInternalRoutes(role: string): string[] { + return BASELINE_MENU_ROUTES + .filter((item) => hasMinRole(role, item.minRole)) + .map((item) => item.route) +} + export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { const routes: string[] = [] @@ -41,6 +63,15 @@ export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { return Array.from(new Set(routes)) } +export function resolveAllowedRoutes(menu: EmployeeMenuResponse, role: string): string[] { + const routes = flattenInternalRoutes(menu.items) + const shouldUseBaselineFallback = + menu.subscriptionAccessLevel !== 'none' && + (routes.length === 0 || routes.every((route) => route === '/')) + + return shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes +} + export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) { return allowedRoutes.some((route) => { if (route === '/') return currentPath === '/' @@ -93,7 +124,12 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea const menu = await apiFetch('/auth/employee/menu') if (cancelled) return - const allowedRoutes = flattenInternalRoutes(menu.items) + if (menu.subscriptionAccessLevel === 'none') { + router.replace('/subscription') + return + } + + const allowedRoutes = resolveAllowedRoutes(menu, employee.role) const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes) if (redirectPath) {