cms.c2sgmbh/tests/e2e/tenant-isolation.e2e.spec.ts
Martin Porwoll 3f61050fb3 feat: add Timeline Collection for complex chronological events
Add dedicated Timeline Collection for managing complex timeline events:

- Collection: Multiple types (history, milestones, releases, career, events, process)
- Events: Flexible date handling (year, month+year, full date, ranges, custom text)
- Categories: milestone, founding, product, team, award, partnership, expansion, technology
- Importance levels: highlight, normal, minor
- Display options: layouts (vertical, alternating, horizontal, compact), sorting, year grouping
- Media: Image and gallery support per event
- Localization: Full support for DE/EN
- SEO: Meta fields for each timeline

API Features:
- Public endpoint at /api/timelines with tenant isolation
- Rate limiting and IP blocking
- Filter by type, slug, category, importance
- Locale parameter support
- Date formatting and sorting
- Optional grouping by year

Database: 8 tables created via migration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 10:22:56 +00:00

417 lines
13 KiB
TypeScript

/**
* E2E Tests für Multi-Tenant Isolation
*
* Kritische Tests um sicherzustellen, dass Daten zwischen Tenants isoliert sind
*/
import { test, expect } from '@playwright/test'
// Known tenant IDs
const TENANT_PORWOLL = 1
const TENANT_C2S = 4
const TENANT_GUNSHIN = 5
test.describe('Tenant Isolation - Public APIs', () => {
test.describe('News API Tenant Isolation', () => {
test('News API requires tenant parameter', async ({ request }) => {
const response = await request.get('/api/news')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Tenant ID is required')
})
test('News API returns different data for different tenants', async ({ request }) => {
const [response1, response4, response5] = await Promise.all([
request.get(`/api/news?tenant=${TENANT_PORWOLL}`),
request.get(`/api/news?tenant=${TENANT_C2S}`),
request.get(`/api/news?tenant=${TENANT_GUNSHIN}`),
])
expect(response1.ok()).toBe(true)
expect(response4.ok()).toBe(true)
expect(response5.ok()).toBe(true)
const data1 = await response1.json()
const data4 = await response4.json()
const data5 = await response5.json()
// Each response should reflect its tenant filter
expect(data1.filters.tenant).toBe(TENANT_PORWOLL)
expect(data4.filters.tenant).toBe(TENANT_C2S)
expect(data5.filters.tenant).toBe(TENANT_GUNSHIN)
})
test('News detail API enforces tenant isolation', async ({ request }) => {
// Get a news item from tenant 1
const listResponse = await request.get(`/api/news?tenant=${TENANT_PORWOLL}&limit=1`)
if (!listResponse.ok()) {
test.skip()
return
}
const listData = await listResponse.json()
if (listData.docs.length === 0) {
test.skip()
return
}
const slug = listData.docs[0].slug
// Try to access with wrong tenant - should return 404
const wrongTenantResponse = await request.get(`/api/news/${slug}?tenant=${TENANT_C2S}`)
// Should not find the article (different tenant)
expect(wrongTenantResponse.status()).toBe(404)
// Same tenant should work
const correctTenantResponse = await request.get(`/api/news/${slug}?tenant=${TENANT_PORWOLL}`)
expect(correctTenantResponse.ok()).toBe(true)
})
})
test.describe('Posts API Tenant Isolation', () => {
test('Posts API filters by tenant when specified', async ({ request }) => {
const response = await request.get(`/api/posts?tenant=${TENANT_PORWOLL}`)
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data.filters.tenant).toBe(TENANT_PORWOLL)
})
test('Different tenants can have independent post counts', async ({ request }) => {
const [response1, response4] = await Promise.all([
request.get(`/api/posts?tenant=${TENANT_PORWOLL}&limit=1`),
request.get(`/api/posts?tenant=${TENANT_C2S}&limit=1`),
])
expect(response1.ok()).toBe(true)
expect(response4.ok()).toBe(true)
const data1 = await response1.json()
const data4 = await response4.json()
// Both should have valid pagination info
expect(data1.pagination).toHaveProperty('totalDocs')
expect(data4.pagination).toHaveProperty('totalDocs')
// Counts can be different (independent data)
expect(typeof data1.pagination.totalDocs).toBe('number')
expect(typeof data4.pagination.totalDocs).toBe('number')
})
})
test.describe('Newsletter Tenant Isolation', () => {
test('Newsletter subscription requires tenant', async ({ request }) => {
const response = await request.post('/api/newsletter/subscribe', {
data: {
email: 'isolation-test@example.com',
},
})
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.message).toContain('Tenant')
})
test('Newsletter subscriptions are tenant-specific', async ({ request }) => {
const uniqueEmail = `tenant-isolation-${Date.now()}@e2e-test.example`
// Subscribe to tenant 1
const response1 = await request.post('/api/newsletter/subscribe', {
data: {
email: uniqueEmail,
tenantId: TENANT_PORWOLL,
},
})
// Should succeed or be rate limited
expect([200, 400, 429]).toContain(response1.status())
// Subscribe same email to tenant 4 (should be separate)
const response4 = await request.post('/api/newsletter/subscribe', {
data: {
email: uniqueEmail,
tenantId: TENANT_C2S,
},
})
// Both should be able to subscribe (separate per tenant)
expect([200, 400, 429]).toContain(response4.status())
})
})
})
test.describe('Tenant Isolation - Protected APIs', () => {
test('Tenants API requires authentication', async ({ request }) => {
const response = await request.get('/api/tenants')
expect([401, 403]).toContain(response.status())
})
test('Users API requires authentication', async ({ request }) => {
const response = await request.get('/api/users')
expect([401, 403]).toContain(response.status())
})
test('Media API requires authentication', async ({ request }) => {
const response = await request.get('/api/media')
expect([401, 403]).toContain(response.status())
})
test('Pages API requires authentication', async ({ request }) => {
const response = await request.get('/api/pages')
expect([401, 403]).toContain(response.status())
})
test('Categories API requires authentication', async ({ request }) => {
const response = await request.get('/api/categories')
expect([401, 403]).toContain(response.status())
})
})
test.describe('Tenant Data Leakage Prevention', () => {
test('Cannot enumerate tenants without auth', async ({ request }) => {
const response = await request.get('/api/tenants')
// Should not expose tenant list without authentication
expect([401, 403]).toContain(response.status())
})
test('Cannot access other tenant media without auth', async ({ request }) => {
const response = await request.get('/api/media')
expect([401, 403]).toContain(response.status())
})
test('Public endpoints do not leak tenant information', async ({ request }) => {
const response = await request.get(`/api/news?tenant=${TENANT_PORWOLL}`)
expect(response.ok()).toBe(true)
const data = await response.json()
// Response should not contain sensitive tenant data
for (const doc of data.docs) {
// Should not expose full tenant object, only ID reference at most
if (doc.tenant) {
expect(typeof doc.tenant).not.toBe('object')
}
}
})
test('Error messages do not leak tenant information', async ({ request }) => {
const response = await request.get('/api/news?tenant=99999')
expect(response.ok()).toBe(true)
const data = await response.json()
// Should return empty results, not expose info about non-existent tenant
expect(data.docs).toEqual([])
// Error message should not reveal tenant existence
})
})
test.describe('Cross-Tenant Access Prevention', () => {
test('Cannot access data from wrong tenant via news API', async ({ request }) => {
// Assuming tenant 1 has some posts but tenant 99999 doesn't exist
const validResponse = await request.get(`/api/news?tenant=${TENANT_PORWOLL}`)
const invalidResponse = await request.get('/api/news?tenant=99999')
expect(validResponse.ok()).toBe(true)
expect(invalidResponse.ok()).toBe(true) // Returns empty, not error
const validData = await validResponse.json()
const invalidData = await invalidResponse.json()
// Invalid tenant should return empty results
expect(invalidData.docs).toEqual([])
expect(invalidData.pagination.totalDocs).toBe(0)
})
test('Archive data is tenant-scoped', async ({ request }) => {
const response = await request.get(`/api/news?tenant=${TENANT_PORWOLL}&includeArchive=true`)
expect(response.ok()).toBe(true)
const data = await response.json()
// Archive should be present and scoped to tenant
expect(data).toHaveProperty('archive')
expect(data.filters.tenant).toBe(TENANT_PORWOLL)
})
test('Categories are tenant-scoped', async ({ request }) => {
const response = await request.get(`/api/news?tenant=${TENANT_PORWOLL}&includeCategories=true`)
expect(response.ok()).toBe(true)
const data = await response.json()
// Categories should be present and scoped to tenant
expect(data).toHaveProperty('categories')
expect(Array.isArray(data.categories)).toBe(true)
})
})
test.describe('Timeline API Tenant Isolation', () => {
test('Timeline API requires tenant parameter', async ({ request }) => {
const response = await request.get('/api/timelines')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Tenant ID is required')
})
test('Timeline API returns different data for different tenants', async ({ request }) => {
const [response1, response4, response5] = await Promise.all([
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}`),
request.get(`/api/timelines?tenant=${TENANT_C2S}`),
request.get(`/api/timelines?tenant=${TENANT_GUNSHIN}`),
])
expect(response1.ok()).toBe(true)
expect(response4.ok()).toBe(true)
expect(response5.ok()).toBe(true)
const data1 = await response1.json()
const data4 = await response4.json()
const data5 = await response5.json()
// Each response should reflect its tenant filter
expect(data1.filters.tenant).toBe(TENANT_PORWOLL)
expect(data4.filters.tenant).toBe(TENANT_C2S)
expect(data5.filters.tenant).toBe(TENANT_GUNSHIN)
})
test('Timeline API validates tenant ID format', async ({ request }) => {
const response = await request.get('/api/timelines?tenant=invalid')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid tenant ID')
})
test('Timeline API returns empty for non-existent tenant', async ({ request }) => {
const response = await request.get('/api/timelines?tenant=99999')
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data.docs).toEqual([])
expect(data.total).toBe(0)
})
test('Timeline API supports type filtering', async ({ request }) => {
const response = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&type=history`)
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data.filters.type).toBe('history')
})
test('Timeline API rejects invalid type', async ({ request }) => {
const response = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&type=invalid`)
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid type')
})
test('Timeline API supports locale parameter', async ({ request }) => {
const [responseDE, responseEN] = await Promise.all([
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&locale=de`),
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&locale=en`),
])
expect(responseDE.ok()).toBe(true)
expect(responseEN.ok()).toBe(true)
const dataDE = await responseDE.json()
const dataEN = await responseEN.json()
expect(dataDE.filters.locale).toBe('de')
expect(dataEN.filters.locale).toBe('en')
})
test('Timeline detail API enforces tenant isolation', async ({ request }) => {
// Get a timeline from tenant 1
const listResponse = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}`)
if (!listResponse.ok()) {
test.skip()
return
}
const listData = await listResponse.json()
if (listData.docs.length === 0) {
test.skip()
return
}
const slug = listData.docs[0].slug
// Try to access with wrong tenant - should return 404
const wrongTenantResponse = await request.get(`/api/timelines?tenant=${TENANT_C2S}&slug=${slug}`)
// Should not find the timeline (different tenant)
expect(wrongTenantResponse.status()).toBe(404)
// Same tenant should work
const correctTenantResponse = await request.get(
`/api/timelines?tenant=${TENANT_PORWOLL}&slug=${slug}`
)
expect(correctTenantResponse.ok()).toBe(true)
})
})
test.describe('Tenant Validation', () => {
test('Rejects invalid tenant ID format', async ({ request }) => {
const response = await request.get('/api/news?tenant=invalid')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid tenant ID')
})
test('Rejects negative tenant ID', async ({ request }) => {
const response = await request.get('/api/news?tenant=-1')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid tenant ID')
})
test('Rejects zero tenant ID', async ({ request }) => {
const response = await request.get('/api/news?tenant=0')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid tenant ID')
})
test('Rejects floating point tenant ID', async ({ request }) => {
const response = await request.get('/api/news?tenant=1.5')
// Should either reject or truncate to integer
expect([200, 400]).toContain(response.status())
})
})