fix: enforce expired trial subscription menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m48s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s

This commit is contained in:
root
2026-07-02 02:32:37 -04:00
parent 0dcbe18c54
commit 75a1d39050
6 changed files with 156 additions and 22 deletions
@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
vi.mock('../lib/prisma', () => ({
prisma: {
subscription: {
findUnique: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { requireSubscription } from './requireSubscription' import { requireSubscription } from './requireSubscription'
function responseStub() { function responseStub() {
@@ -12,27 +21,28 @@ function responseStub() {
describe('requireSubscription middleware', () => { describe('requireSubscription middleware', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test' process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
}) })
it('requires tenant/company context first', () => { it('requires tenant/company context first', async () => {
const req = {} as Request const req = {} as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401) expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 }) expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
it('blocks suspended companies with a subscription recovery URL', () => { it('blocks suspended companies with a subscription recovery URL', async () => {
const req = { company: { status: 'SUSPENDED' } } as Request const req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402) expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
@@ -42,14 +52,15 @@ describe('requireSubscription middleware', () => {
billingUrl: 'https://dashboard.example.test/subscription', billingUrl: 'https://dashboard.example.test/subscription',
}) })
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
expect(prisma.subscription.findUnique).not.toHaveBeenCalled()
}) })
it('blocks pending companies with setup guidance', () => { it('blocks pending companies with setup guidance', async () => {
const req = { company: { status: 'PENDING' } } as Request const req = { company: { status: 'PENDING' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402) expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
@@ -59,12 +70,39 @@ describe('requireSubscription middleware', () => {
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
it('allows active companies through', () => { it('blocks expired trial subscriptions with a subscription recovery URL', async () => {
const req = { company: { status: 'ACTIVE' } } as Request vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never)
const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
select: { status: true },
})
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_required',
message: 'Your subscription has ended. Please reactivate to continue.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/subscription',
subscriptionStatus: 'EXPIRED',
accessLevel: 'none',
})
expect(next).not.toHaveBeenCalled()
})
it('allows active companies with active subscriptions through', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } 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).toHaveBeenCalledTimes(1) expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled() expect(res.status).not.toHaveBeenCalled()
+23 -2
View File
@@ -1,4 +1,6 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers' import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
@@ -8,9 +10,9 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
* Must be applied after `requireTenant`. * Must be applied after `requireTenant`.
* *
* Guarantees on success: * Guarantees on success:
* req.company.status is not SUSPENDED or PENDING * req.company.status is not SUSPENDED or PENDING, and subscription access is not none
*/ */
export function requireSubscription(req: Request, res: Response, next: NextFunction) { export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
const company = req.company const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
@@ -25,5 +27,24 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct
) )
} }
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() next()
} }
@@ -268,4 +268,45 @@ describe('menu.service', () => {
'settings', 'settings',
]) ])
}) })
it('returns no feature menu for expired trial subscriptions', async () => {
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
id: 'employee_1',
role: 'OWNER',
isActive: true,
companyId: 'company_1',
} as never)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'TRIALING',
subscription: { plan: 'STARTER', status: 'EXPIRED' },
} as never)
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
{
id: 'item_dashboard',
systemKey: 'dashboard',
label: 'Dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/',
icon: 'LayoutDashboard',
parentId: null,
openInNewTab: false,
isRequired: true,
isActive: true,
displayOrder: 10,
roleVisibilities: [{ role: 'OWNER' }],
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }],
companyAssignments: [],
},
] as never)
const result = await getEmployeeMenu('employee_1')
expect(result.subscriptionStatus).toBe('EXPIRED')
expect(result.subscriptionAccessLevel).toBe('none')
expect(result.items).toEqual([])
})
}) })
+15 -4
View File
@@ -1,5 +1,6 @@
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO'
@@ -466,23 +467,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
}) })
const currentPlan = company.subscription?.plan ?? null const currentPlan = company.subscription?.plan ?? null
const currentPlanName = currentPlan as Plan | null const subscriptionStatus = company.subscription?.status ?? 'EXPIRED'
const subscriptionAccessLevel = getAccessLevel(subscriptionStatus)
const subscriptionHasFullAccess = hasFullAccess(subscriptionStatus)
const currentPlanName = subscriptionHasFullAccess ? currentPlan as Plan | null : null
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => { const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0 const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
const subscriptionAssignment = item.subscriptionAssignments.find( const subscriptionAssignment = item.subscriptionAssignments.find(
(assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan), (assignment: any) => assignment.isActive && (!currentPlanName || assignment.plan === currentPlanName),
) )
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive) const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role) const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan)) const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName)))
const entitled = Boolean(subscriptionEntitled || companyAssignment) const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment))
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive) const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
const reasons: string[] = [] const reasons: string[] = []
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.') if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.') if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
if (!subscriptionHasFullAccess) reasons.push(`Hidden because subscription status ${subscriptionStatus} does not allow dashboard feature access.`)
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false)) if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
if (entitled) { if (entitled) {
reasons.push( reasons.push(
@@ -518,6 +523,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
company, company,
role, role,
currentPlan: currentPlanName, currentPlan: currentPlanName,
subscriptionStatus,
subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel,
items: sortByDisplayOrder(evaluated), items: sortByDisplayOrder(evaluated),
} }
} }
@@ -578,6 +585,8 @@ export async function previewCompanyMenu(input: MenuPreviewInput) {
company: context.company, company: context.company,
role: context.role, role: context.role,
subscriptionPlan: context.currentPlan, subscriptionPlan: context.currentPlan,
subscriptionStatus: context.subscriptionStatus,
subscriptionAccessLevel: context.subscriptionAccessLevel,
items: context.items, items: context.items,
finalMenu: buildMenuTree( finalMenu: buildMenuTree(
context.items context.items
@@ -615,6 +624,8 @@ export async function getEmployeeMenu(employeeId: string) {
companyId: employee.companyId, companyId: employee.companyId,
role: employee.role, role: employee.role,
subscriptionPlan: context.currentPlan, subscriptionPlan: context.currentPlan,
subscriptionStatus: context.subscriptionStatus,
subscriptionAccessLevel: context.subscriptionAccessLevel,
items: buildMenuTree( items: buildMenuTree(
visibleItems.map((item) => ({ visibleItems.map((item) => ({
id: item.id, id: item.id,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems } from './Sidebar' import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar'
describe('Sidebar menu rendering helpers', () => { describe('Sidebar menu rendering helpers', () => {
it('keeps the safe fallback menu to the approved seven baseline items in order', () => { it('keeps the safe fallback menu to the approved seven baseline items in order', () => {
@@ -17,6 +17,9 @@ describe('Sidebar menu rendering helpers', () => {
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => { it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false) expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false) expect(hasRenderableMenuItems([])).toBe(false)
expect(shouldUseGeneratedMenu('loaded', [])).toBe(false)
expect(shouldUseGeneratedMenu('loaded', [], 'full')).toBe(false)
expect(shouldUseGeneratedMenu('loaded', [], 'none')).toBe(true)
}) })
it('ignores structural-only menu responses without clickable entries', () => { it('ignores structural-only menu responses without clickable entries', () => {
@@ -49,12 +52,12 @@ describe('Sidebar menu rendering helpers', () => {
}) })
it('detects nested clickable menu entries', () => { it('detects nested clickable menu entries', () => {
expect(hasRenderableMenuItems([ const menuItems = [
{ {
id: 'parent', id: 'parent',
systemKey: null, systemKey: null,
label: 'Operations', label: 'Operations',
itemType: 'PARENT_MENU', itemType: 'PARENT_MENU' as const,
routeOrUrl: null, routeOrUrl: null,
icon: null, icon: null,
parentId: null, parentId: null,
@@ -65,7 +68,7 @@ describe('Sidebar menu rendering helpers', () => {
id: 'fleet', id: 'fleet',
systemKey: 'fleet', systemKey: 'fleet',
label: 'Fleet', label: 'Fleet',
itemType: 'INTERNAL_PAGE', itemType: 'INTERNAL_PAGE' as const,
routeOrUrl: '/fleet', routeOrUrl: '/fleet',
icon: 'Car', icon: 'Car',
parentId: 'parent', parentId: 'parent',
@@ -75,6 +78,10 @@ describe('Sidebar menu rendering helpers', () => {
}, },
], ],
}, },
])).toBe(true) ]
expect(hasRenderableMenuItems(menuItems)).toBe(true)
expect(shouldUseGeneratedMenu('loaded', menuItems)).toBe(true)
expect(shouldUseGeneratedMenu('failed', menuItems)).toBe(false)
}) })
}) })
@@ -68,6 +68,8 @@ interface GeneratedMenuItem {
children: GeneratedMenuItem[] children: GeneratedMenuItem[]
} }
type SubscriptionAccessLevel = 'full' | 'limited' | 'read_only' | 'none'
function computeInitials(name: string): string { function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean) const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U' if (parts.length === 0) return 'U'
@@ -136,6 +138,16 @@ export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boole
}) })
} }
export function shouldUseGeneratedMenu(
menuLoadState: 'loading' | 'loaded' | 'failed',
items: GeneratedMenuItem[] | null,
subscriptionAccessLevel: SubscriptionAccessLevel | null = null,
): boolean {
if (menuLoadState !== 'loaded') return false
if (subscriptionAccessLevel === 'none') return true
return hasRenderableMenuItems(items)
}
function notifyParent(message: Record<string, unknown>) { function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*') window.parent.postMessage(message, '*')
@@ -154,6 +166,7 @@ export default function Sidebar() {
}) })
const [role, setRole] = useState<string>('AGENT') const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null) const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(null)
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading') const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@@ -221,7 +234,7 @@ export default function Sidebar() {
Promise.all([ Promise.all([
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'), apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'),
apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null), apiFetch<{ items: GeneratedMenuItem[]; subscriptionAccessLevel?: SubscriptionAccessLevel }>('/auth/employee/menu').catch(() => null),
]) ])
.then(([{ employee }, menu]) => { .then(([{ employee }, menu]) => {
if (cancelled) return if (cancelled) return
@@ -230,8 +243,10 @@ export default function Sidebar() {
if (employee.role) setRole(employee.role) if (employee.role) setRole(employee.role)
if (menu) { if (menu) {
setMenuItems(menu.items) setMenuItems(menu.items)
setMenuAccessLevel(menu.subscriptionAccessLevel ?? null)
setMenuLoadState('loaded') setMenuLoadState('loaded')
} else { } else {
setMenuAccessLevel(null)
setMenuLoadState('failed') setMenuLoadState('failed')
} }
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
@@ -247,6 +262,7 @@ export default function Sidebar() {
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth)) if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
setMenuAccessLevel(null)
setMenuLoadState('failed') setMenuLoadState('failed')
}) })
@@ -283,7 +299,7 @@ export default function Sidebar() {
children: [], children: [],
})) }))
const useGeneratedMenu = menuLoadState === 'loaded' const useGeneratedMenu = shouldUseGeneratedMenu(menuLoadState, menuItems, menuAccessLevel)
const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS
.filter((item) => !mounted || hasMinRole(role, item.minRole)) .filter((item) => !mounted || hasMinRole(role, item.minRole))
.map((item) => ({ .map((item) => ({