mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +00:00
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>
417 lines
13 KiB
TypeScript
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())
|
|
})
|
|
})
|