fix: align dashboard guard with fallback menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m56s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 1m1s

This commit is contained in:
root
2026-07-02 03:06:45 -04:00
parent 13b54f07de
commit ea2bd215f6
7 changed files with 133 additions and 34 deletions
@@ -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()
})
})
@@ -13,6 +13,7 @@ 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) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
@@ -47,4 +48,7 @@ export async function requireSubscription(req: Request, res: Response, next: Nex
}
next()
} catch (error) {
next(error)
}
}
@@ -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 () => {
@@ -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', () => {
@@ -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', () => ({
@@ -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()
@@ -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<string, number> = { 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<EmployeeMenuResponse>('/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) {