cms.c2sgmbh/tests/helpers/access-control-test-utils.ts
Martin Porwoll da735cab46 feat: add Products and ProductCategories collections with CI/CD pipeline
- Add Products collection with comprehensive fields (pricing, inventory, SEO, CTA)
- Add ProductCategories collection with hierarchical structure
- Implement CI/CD pipeline with GitHub Actions (lint, typecheck, test, build, e2e)
- Add access control test utilities and unit tests
- Fix Posts API to include category field for backwards compatibility
- Update ESLint config with ignores for migrations and admin components
- Add centralized access control functions in src/lib/access
- Add db-direct.sh utility script for database access

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 21:36:26 +00:00

348 lines
9.1 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 }>
}
export interface MockPayloadRequest extends Partial<PayloadRequest> {
user?: MockUser | null
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: Record<string, string | string[] | undefined> = {}
if (options.host) {
headers['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> {
const result = await accessFn({
req: request as unknown as PayloadRequest,
id: options.id,
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' }),
}