mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
- 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>
348 lines
9.1 KiB
TypeScript
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' }),
|
|
}
|