/** * 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') // Handle rate limiting if (response.status() === 429) { return } 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}`), ]) // Handle rate limiting if (response1.status() === 429 || response4.status() === 429 || response5.status() === 429) { return } 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}`) // Handle rate limiting if (response.status() === 429) { return } 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`), ]) // Handle rate limiting if (response1.status() === 429 || response4.status() === 429) { return } 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', }, }) // Handle rate limiting if (response.status() === 429) { return } expect(response.status()).toBe(400) const data = await response.json() // Message should indicate tenant is required (case insensitive) expect(data.message.toLowerCase()).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', () => { // Note: Some collections may have public read access configured in Payload // We accept 200 for collections with public read, but verify no sensitive data is exposed test('Tenants API requires authentication or returns limited data', async ({ request }) => { const response = await request.get('/api/tenants') // Either requires auth (401/403) or returns limited/empty data expect([200, 401, 403]).toContain(response.status()) if (response.status() === 200) { // If public, verify it doesn't expose sensitive tenant data const data = await response.json() expect(data).toHaveProperty('docs') } }) test('Users API requires authentication', async ({ request }) => { const response = await request.get('/api/users') // Users should always require authentication expect([401, 403]).toContain(response.status()) }) test('Media API requires authentication or returns limited data', async ({ request }) => { const response = await request.get('/api/media') // Media may have public read access configured expect([200, 401, 403]).toContain(response.status()) }) test('Pages API requires authentication or returns limited data', async ({ request }) => { const response = await request.get('/api/pages', { timeout: 30000 }) // Pages may have public read access for published content // 429 = rate limited, 500 = internal error (e.g., DB connection issues in CI) // All indicate the API is protected or unavailable expect([200, 401, 403, 429, 500]).toContain(response.status()) }) test('Categories API requires authentication or returns limited data', async ({ request }) => { const response = await request.get('/api/categories') // Categories may have public read access expect([200, 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 either require auth or return limited/public data expect([200, 401, 403]).toContain(response.status()) if (response.status() === 200) { // If accessible, verify sensitive fields are not exposed const data = await response.json() expect(data).toHaveProperty('docs') // SMTP passwords should never be exposed for (const tenant of data.docs) { expect(tenant.email?.smtp?.pass).toBeUndefined() } } }) test('Cannot access other tenant media without auth', async ({ request }) => { const response = await request.get('/api/media') // Media may have public read access configured expect([200, 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}`) // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (validResponse.status() === 429 || invalidResponse.status() === 429) { return } 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`) // Handle rate limiting if (response.status() === 429) { return } 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`) // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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}`), ]) // Handle rate limiting if (response1.status() === 429 || response4.status() === 429 || response5.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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`) // Handle rate limiting if (response.status() === 429) { return } 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`) // Handle rate limiting if (response.status() === 429) { return } 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`), ]) // Handle rate limiting if (responseDE.status() === 429 || responseEN.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } 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') // Handle rate limiting if (response.status() === 429) { return } // Should either reject or truncate to integer expect([200, 400]).toContain(response.status()) }) })