cms.c2sgmbh/tests/helpers/access-control-test-utils.ts
Martin Porwoll 47d4825f77 revert: downgrade to Next.js 15.5.9 for Payload compatibility
Payload CMS 3.68.4 doesn't officially support Next.js 16 yet.
Reverting to 15.5.9 restores full compatibility.

Changes:
- Revert next: 16.0.10 → 15.5.9
- Revert eslint-config-next: 16.0.10 → 15.5.9
- Revert proxy.ts → middleware.ts (Next.js 15 convention)
- Restore eslint config in next.config.mjs
- Remove turbopack config (not needed for Next.js 15)

Test fixes (TypeScript errors):
- Fix MockPayloadRequest interface (remove PayloadRequest extension)
- Add Where type imports to access control tests
- Fix headers type casting in rate-limiter tests
- Fix localization type guard in i18n tests
- Add type property to post creation in search tests
- Fix nodemailer mock typing in email tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:07:39 +00:00

352 lines
9.4 KiB
TypeScript

/**
* 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<string, string | string[] | undefined>
payload: {
find: ReturnType<typeof vi.fn>
findByID: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
}
// ============================================================================
// User Factory
// ============================================================================
/**
* Create a mock super admin user
*/
export function createSuperAdmin(overrides: Partial<MockUser> = {}): 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> = {},
): 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> = {},
): 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> = {}): 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<string, unknown>
} = {},
): Promise<AccessResult> {
// 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' }),
}