cms.c2sgmbh/tests/unit/access-control/tenant-access.unit.spec.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

323 lines
11 KiB
TypeScript

/**
* Tenant Access Control Unit Tests
*
* Tests for the tenant access control functions in src/lib/tenantAccess.ts
* Covers: getTenantIdFromHost, tenantScopedPublicRead, authenticatedOnly
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { PayloadRequest, Where } from 'payload'
import {
createSuperAdmin,
createTenantUser,
createTenantUserPrimitive,
createMockPayloadRequest,
createAnonymousRequest,
createMockTenant,
executeAccess,
hasFullAccess,
hasNoAccess,
hasFilteredAccess,
getTenantIdFromFilter,
TEST_TENANTS,
TEST_USERS,
} from '../../helpers/access-control-test-utils'
// ============================================================================
// Import the actual functions to test
// ============================================================================
import {
getTenantIdFromHost,
tenantScopedPublicRead,
authenticatedOnly,
} from '@/lib/tenantAccess'
// ============================================================================
// getTenantIdFromHost Tests
// ============================================================================
describe('getTenantIdFromHost', () => {
describe('Host Header Extraction', () => {
it('extracts tenant ID from valid domain', async () => {
const request = createAnonymousRequest('porwoll.de', [TEST_TENANTS.porwoll])
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBe(1)
})
it('extracts tenant ID with port in host', async () => {
const request = createAnonymousRequest('porwoll.de:3000', [TEST_TENANTS.porwoll])
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBe(1)
})
it('extracts tenant ID with www prefix', async () => {
const tenant = createMockTenant({
id: 1,
domains: [{ domain: 'porwoll.de' }],
})
const request = createAnonymousRequest('www.porwoll.de', [tenant])
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBe(1)
})
it('handles uppercase domain', async () => {
const request = createAnonymousRequest('PORWOLL.DE', [TEST_TENANTS.porwoll])
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBe(1)
})
it('returns null for missing host header', async () => {
const request = createMockPayloadRequest(null, { tenants: [TEST_TENANTS.porwoll] })
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBeNull()
})
it('returns null for unknown domain', async () => {
const request = createAnonymousRequest('unknown-domain.com', [TEST_TENANTS.porwoll])
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBeNull()
})
it('returns null for empty host header', async () => {
const request = createMockPayloadRequest(null, { host: '', tenants: [TEST_TENANTS.porwoll] })
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBeNull()
})
})
describe('Multiple Tenants', () => {
const allTenants = [TEST_TENANTS.porwoll, TEST_TENANTS.c2s, TEST_TENANTS.gunshin]
it('resolves correct tenant from multiple options', async () => {
const request = createAnonymousRequest('complexcaresolutions.de', allTenants)
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBe(4) // c2s tenant ID
})
it('resolves each tenant correctly', async () => {
const porwollReq = createAnonymousRequest('porwoll.de', allTenants)
const gunshinReq = createAnonymousRequest('gunshin.de', allTenants)
const porwollId = await getTenantIdFromHost(porwollReq as unknown as PayloadRequest)
const gunshinId = await getTenantIdFromHost(gunshinReq as unknown as PayloadRequest)
expect(porwollId).toBe(1)
expect(gunshinId).toBe(5)
})
})
describe('Error Handling', () => {
it('returns null when payload.find throws', async () => {
const request = createMockPayloadRequest(null, { host: 'test.com' })
request.payload.find = vi.fn().mockRejectedValue(new Error('Database error'))
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBeNull()
})
it('returns null for tenant without ID', async () => {
const request = createMockPayloadRequest(null, { host: 'test.com' })
request.payload.find = vi.fn().mockResolvedValue({
docs: [{ name: 'Test Tenant' }], // Missing ID
totalDocs: 1,
})
const tenantId = await getTenantIdFromHost(request as unknown as PayloadRequest)
expect(tenantId).toBeNull()
})
})
})
// ============================================================================
// tenantScopedPublicRead Tests
// ============================================================================
describe('tenantScopedPublicRead', () => {
describe('Authenticated Users', () => {
it('grants full access to super admin', async () => {
const request = createMockPayloadRequest(TEST_USERS.superAdmin)
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFullAccess(result)).toBe(true)
})
it('grants full access to regular authenticated user', async () => {
const request = createMockPayloadRequest(TEST_USERS.porwollUser)
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFullAccess(result)).toBe(true)
})
it('grants full access to multi-tenant user', async () => {
const request = createMockPayloadRequest(TEST_USERS.multiTenantUser)
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFullAccess(result)).toBe(true)
})
})
describe('Anonymous Users', () => {
it('returns tenant filter for valid domain', async () => {
const request = createAnonymousRequest('porwoll.de', [TEST_TENANTS.porwoll])
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Where)).toBe(1)
})
it('returns different tenant filter for different domain', async () => {
const request = createAnonymousRequest('complexcaresolutions.de', [TEST_TENANTS.c2s])
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Where)).toBe(4)
})
it('denies access for unknown domain', async () => {
const request = createAnonymousRequest('malicious-site.com', [TEST_TENANTS.porwoll])
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasNoAccess(result)).toBe(true)
})
it('denies access when no host header', async () => {
const request = createMockPayloadRequest(null)
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasNoAccess(result)).toBe(true)
})
})
describe('Filter Structure', () => {
it('returns correct where clause structure', async () => {
const request = createAnonymousRequest('gunshin.de', [TEST_TENANTS.gunshin])
const result = await executeAccess(tenantScopedPublicRead, request)
expect(result).toEqual({
tenant: {
equals: 5,
},
})
})
})
})
// ============================================================================
// authenticatedOnly Tests
// ============================================================================
describe('authenticatedOnly', () => {
describe('Grants Access', () => {
it('grants access to super admin', async () => {
const request = createMockPayloadRequest(TEST_USERS.superAdmin)
const result = await executeAccess(authenticatedOnly, request)
expect(result).toBe(true)
})
it('grants access to regular user', async () => {
const request = createMockPayloadRequest(TEST_USERS.porwollUser)
const result = await executeAccess(authenticatedOnly, request)
expect(result).toBe(true)
})
it('grants access to user with minimal data', async () => {
const minimalUser = { id: 99, email: 'minimal@test.com' }
const request = createMockPayloadRequest(minimalUser)
const result = await executeAccess(authenticatedOnly, request)
expect(result).toBe(true)
})
})
describe('Denies Access', () => {
it('denies access to anonymous user', async () => {
const request = createMockPayloadRequest(null)
const result = await executeAccess(authenticatedOnly, request)
expect(result).toBe(false)
})
it('denies access when user is undefined', async () => {
const request = createMockPayloadRequest(undefined as unknown as null)
const result = await executeAccess(authenticatedOnly, request)
expect(result).toBe(false)
})
})
})
// ============================================================================
// Edge Cases & Integration Scenarios
// ============================================================================
describe('Access Control Integration Scenarios', () => {
describe('Tenant Assignment Formats', () => {
it('handles tenant object format { tenant: { id } }', () => {
const user = createTenantUser([1, 4])
expect(user.tenants).toEqual([{ tenant: { id: 1 } }, { tenant: { id: 4 } }])
})
it('handles tenant primitive format { tenant: number }', () => {
const user = createTenantUserPrimitive([1, 4])
expect(user.tenants).toEqual([{ tenant: 1 }, { tenant: 4 }])
})
})
describe('Real-World Scenarios', () => {
it('public blog post access from tenant domain', async () => {
// Anonymous user visiting porwoll.de/blog
const request = createAnonymousRequest('porwoll.de', [TEST_TENANTS.porwoll])
const result = await executeAccess(tenantScopedPublicRead, request)
// Should only see porwoll.de posts
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Where)).toBe(1)
})
it('admin editing posts from any tenant', async () => {
// Super admin in admin panel
const request = createMockPayloadRequest(TEST_USERS.superAdmin)
const result = await executeAccess(tenantScopedPublicRead, request)
// Should see all posts
expect(hasFullAccess(result)).toBe(true)
})
it('tenant user creating content', async () => {
// User assigned to c2s tenant creating a post
const request = createMockPayloadRequest(TEST_USERS.c2sUser)
const result = await executeAccess(authenticatedOnly, request)
// Should be allowed to create
expect(result).toBe(true)
})
it('cross-origin attack prevention', async () => {
// Request from malicious site
const request = createAnonymousRequest('evil-site.com', [
TEST_TENANTS.porwoll,
TEST_TENANTS.c2s,
TEST_TENANTS.gunshin,
])
const result = await executeAccess(tenantScopedPublicRead, request)
// Should be denied
expect(hasNoAccess(result)).toBe(true)
})
})
})