/** * Access Control Test Utilities * * Helper functions for testing access control logic in Payload CMS collections. * Provides mock request builders, user factories, and tenant resolution mocks. */ import type { PayloadRequest, Access, Where } from 'payload' import { vi } from 'vitest' // ============================================================================ // Types // ============================================================================ export interface MockUser { id: number email: string isSuperAdmin?: boolean tenants?: Array<{ tenant: { id: number } | number }> } export interface MockTenant { id: number name: string slug: string domains?: Array<{ domain: string }> } // Note: Not extending PayloadRequest to allow flexible mock types for testing export interface MockPayloadRequest { user?: MockUser | null // Allow both Headers and plain object for testing different header formats headers: Headers | Record payload: { find: ReturnType findByID: ReturnType create: ReturnType update: ReturnType delete: ReturnType } } // ============================================================================ // User Factory // ============================================================================ /** * Create a mock super admin user */ export function createSuperAdmin(overrides: Partial = {}): MockUser { return { id: 1, email: 'superadmin@example.com', isSuperAdmin: true, tenants: [], ...overrides, } } /** * Create a mock regular user with tenant assignment */ export function createTenantUser( tenantIds: number[], overrides: Partial = {}, ): MockUser { return { id: 2, email: 'user@example.com', isSuperAdmin: false, tenants: tenantIds.map((id) => ({ tenant: { id } })), ...overrides, } } /** * Create a mock user with tenant as primitive (alternative format) */ export function createTenantUserPrimitive( tenantIds: number[], overrides: Partial = {}, ): MockUser { return { id: 3, email: 'user-primitive@example.com', isSuperAdmin: false, tenants: tenantIds.map((id) => ({ tenant: id })), ...overrides, } } /** * Create an anonymous user (null) */ export function createAnonymousUser(): null { return null } // ============================================================================ // Tenant Factory // ============================================================================ /** * Create a mock tenant */ export function createMockTenant(overrides: Partial = {}): MockTenant { const id = overrides.id ?? 1 return { id, name: `Tenant ${id}`, slug: `tenant-${id}`, domains: [{ domain: `tenant${id}.example.com` }], ...overrides, } } // ============================================================================ // Request Factory // ============================================================================ /** * Create a mock PayloadRequest with user */ export function createMockPayloadRequest( user: MockUser | null, options: { host?: string tenants?: MockTenant[] } = {}, ): MockPayloadRequest { const headers = new Headers() if (options.host) { headers.set('host', options.host) } // Mock payload.find to resolve tenant from host const mockFind = vi.fn().mockImplementation(async (args: { collection: string; where?: Where }) => { if (args.collection === 'tenants' && options.tenants) { // Extract domain from where clause const domainQuery = args.where?.['domains.domain'] as { equals?: string } | undefined const domain = domainQuery?.equals if (domain) { const tenant = options.tenants.find((t) => t.domains?.some((d) => d.domain === domain), ) return { docs: tenant ? [tenant] : [], totalDocs: tenant ? 1 : 0, page: 1, totalPages: tenant ? 1 : 0, } } } return { docs: [], totalDocs: 0, page: 1, totalPages: 0 } }) return { user, headers, payload: { find: mockFind, findByID: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, } as MockPayloadRequest } /** * Create a mock request for authenticated user */ export function createAuthenticatedRequest( user: MockUser, host?: string, ): MockPayloadRequest { return createMockPayloadRequest(user, { host }) } /** * Create a mock request for anonymous user with host */ export function createAnonymousRequest( host: string, tenants: MockTenant[] = [], ): MockPayloadRequest { return createMockPayloadRequest(null, { host, tenants }) } // ============================================================================ // Access Control Result Helpers // ============================================================================ export type AccessResult = boolean | Where /** * Check if access result is a boolean true (full access) */ export function hasFullAccess(result: AccessResult): boolean { return result === true } /** * Check if access result is a boolean false (no access) */ export function hasNoAccess(result: AccessResult): boolean { return result === false } /** * Check if access result is a Where constraint (filtered access) */ export function hasFilteredAccess(result: AccessResult): result is Where { return typeof result === 'object' && result !== null } /** * Extract tenant ID from filtered access result */ export function getTenantIdFromFilter(result: Where): number | null { const tenantFilter = result.tenant as { equals?: number } | undefined if (tenantFilter?.equals !== undefined) { return tenantFilter.equals } return null } /** * Extract tenant IDs from "in" filter */ export function getTenantIdsFromInFilter(result: Where): number[] { const tenantFilter = result.tenant as { in?: number[] } | undefined if (tenantFilter?.in) { return tenantFilter.in } return [] } // ============================================================================ // Assertion Helpers // ============================================================================ /** * Assert that access is granted (true) */ export function assertAccessGranted(result: AccessResult): void { if (result !== true) { throw new Error(`Expected full access (true), got: ${JSON.stringify(result)}`) } } /** * Assert that access is denied (false) */ export function assertAccessDenied(result: AccessResult): void { if (result !== false) { throw new Error(`Expected access denied (false), got: ${JSON.stringify(result)}`) } } /** * Assert that access is filtered by tenant */ export function assertTenantFiltered(result: AccessResult, expectedTenantId: number): void { if (!hasFilteredAccess(result)) { throw new Error(`Expected tenant filter, got: ${JSON.stringify(result)}`) } const tenantId = getTenantIdFromFilter(result) if (tenantId !== expectedTenantId) { throw new Error(`Expected tenant ID ${expectedTenantId}, got: ${tenantId}`) } } /** * Assert that access is filtered by multiple tenants (IN clause) */ export function assertTenantsFiltered(result: AccessResult, expectedTenantIds: number[]): void { if (!hasFilteredAccess(result)) { throw new Error(`Expected tenant filter, got: ${JSON.stringify(result)}`) } const tenantIds = getTenantIdsFromInFilter(result) const sortedExpected = [...expectedTenantIds].sort() const sortedActual = [...tenantIds].sort() if (JSON.stringify(sortedExpected) !== JSON.stringify(sortedActual)) { throw new Error( `Expected tenant IDs [${sortedExpected.join(', ')}], got: [${sortedActual.join(', ')}]`, ) } } // ============================================================================ // Access Function Wrapper // ============================================================================ /** * Execute an access function with mock context */ export async function executeAccess( accessFn: Access, request: MockPayloadRequest, options: { id?: string | number data?: Record } = {}, ): Promise { // Convert string ID to number if needed (Payload access functions expect number | undefined) const numericId = typeof options.id === 'string' ? parseInt(options.id, 10) : options.id const result = await accessFn({ req: request as unknown as PayloadRequest, id: numericId, data: options.data, }) return result } // ============================================================================ // Test Data // ============================================================================ export const TEST_TENANTS = { porwoll: createMockTenant({ id: 1, name: 'porwoll.de', slug: 'porwoll', domains: [{ domain: 'porwoll.de' }], }), c2s: createMockTenant({ id: 4, name: 'Complex Care Solutions GmbH', slug: 'c2s', domains: [{ domain: 'complexcaresolutions.de' }], }), gunshin: createMockTenant({ id: 5, name: 'Gunshin', slug: 'gunshin', domains: [{ domain: 'gunshin.de' }], }), } export const TEST_USERS = { superAdmin: createSuperAdmin({ id: 1, email: 'admin@c2sgmbh.de' }), porwollUser: createTenantUser([1], { id: 2, email: 'user@porwoll.de' }), c2sUser: createTenantUser([4], { id: 3, email: 'user@c2s.de' }), multiTenantUser: createTenantUser([1, 4, 5], { id: 4, email: 'multi@example.com' }), }