From e8532b388d6b35030b1dbe64273ce1b3b1a69256 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 12 Dec 2025 22:32:55 +0000 Subject: [PATCH] test: add E2E tests for critical flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive E2E test suite covering: - Authentication flow (login, CSRF, admin access) - News API (tenant isolation, filtering, pagination) - Newsletter Double Opt-In (subscribe, confirm, unsubscribe) - Form submission flow - Multi-tenant data isolation Tests validate: - Tenant parameter is required on public APIs - Cross-tenant data access is prevented - Rate limiting headers are present - API responses have correct structure - Error handling returns proper formats Updated Playwright config with: - CI-specific reporters (github, list) - Screenshot/video on failure - Improved timeouts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- playwright.config.ts | 25 +- tests/e2e/auth.e2e.spec.ts | 168 ++++++++++ tests/e2e/forms.e2e.spec.ts | 94 ++++++ tests/e2e/news-api.e2e.spec.ts | 431 +++++++++++++++++++++++++ tests/e2e/newsletter.e2e.spec.ts | 303 +++++++++++++++++ tests/e2e/tenant-isolation.e2e.spec.ts | 301 +++++++++++++++++ 6 files changed, 1321 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/auth.e2e.spec.ts create mode 100644 tests/e2e/forms.e2e.spec.ts create mode 100644 tests/e2e/news-api.e2e.spec.ts create mode 100644 tests/e2e/newsletter.e2e.spec.ts create mode 100644 tests/e2e/tenant-isolation.e2e.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 48962b2..929ebd6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,10 +11,25 @@ const TEST_PORT = process.env.TEST_PORT || '3001' const TEST_URL = `http://localhost:${TEST_PORT}` /** + * Playwright E2E Test Configuration + * + * Tests critical flows: + * - Authentication (login, CSRF) + * - News API (tenant isolation, filtering) + * - Newsletter (Double Opt-In flow) + * - Form submissions + * - Tenant data isolation + * * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './tests/e2e', + /* Global test timeout */ + timeout: 30000, + /* Expect timeout for assertions */ + expect: { + timeout: 10000, + }, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -22,7 +37,9 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: process.env.CI + ? [['html', { open: 'never' }], ['github'], ['list']] + : [['html', { open: 'on-failure' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -30,6 +47,12 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + /* Screenshot on failure */ + screenshot: 'only-on-failure', + + /* Video recording on failure */ + video: 'retain-on-failure', }, projects: [ { diff --git a/tests/e2e/auth.e2e.spec.ts b/tests/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..5de9899 --- /dev/null +++ b/tests/e2e/auth.e2e.spec.ts @@ -0,0 +1,168 @@ +/** + * E2E Tests für Authentication Flow + * + * Testet den kritischen Login-Flow mit Rate-Limiting und Security-Features + */ + +import { test, expect } from '@playwright/test' + +test.describe('Authentication API', () => { + const loginEndpoint = '/api/users/login' + + test('POST /api/users/login returns 400 for missing credentials', async ({ request }) => { + const response = await request.post(loginEndpoint, { + data: {}, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data).toHaveProperty('errors') + expect(Array.isArray(data.errors)).toBe(true) + expect(data.errors[0].message).toContain('erforderlich') + }) + + test('POST /api/users/login returns 401 for invalid credentials', async ({ request }) => { + const response = await request.post(loginEndpoint, { + data: { + email: 'nonexistent@example.com', + password: 'wrongpassword123', + }, + }) + + expect(response.status()).toBe(401) + + const data = await response.json() + expect(data).toHaveProperty('errors') + expect(data.errors[0].message).toContain('incorrect') + }) + + test('POST /api/users/login returns 400 for invalid email format', async ({ request }) => { + const response = await request.post(loginEndpoint, { + data: { + email: 'not-an-email', + password: 'password123', + }, + }) + + // Either 400 for validation or 401 for failed login + expect([400, 401]).toContain(response.status()) + }) + + test('POST /api/users/login accepts JSON content type', async ({ request }) => { + const response = await request.post(loginEndpoint, { + headers: { + 'Content-Type': 'application/json', + }, + data: { + email: 'test@example.com', + password: 'testpassword', + }, + }) + + // Should process the request (even if credentials are wrong) + expect([401, 400, 500]).toContain(response.status()) + const data = await response.json() + expect(data).toHaveProperty('errors') + }) + + test('POST /api/users/login accepts form data content type', async ({ request }) => { + const response = await request.post(loginEndpoint, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + form: { + email: 'test@example.com', + password: 'testpassword', + }, + }) + + // Should process the request + expect([401, 400, 500]).toContain(response.status()) + }) + + test('POST /api/users/login response has correct structure on failure', async ({ request }) => { + const response = await request.post(loginEndpoint, { + data: { + email: 'invalid@test.com', + password: 'invalid', + }, + }) + + const data = await response.json() + + // Payload-compatible error format + expect(data).toHaveProperty('errors') + expect(Array.isArray(data.errors)).toBe(true) + expect(data.errors.length).toBeGreaterThan(0) + + for (const error of data.errors) { + expect(error).toHaveProperty('message') + expect(typeof error.message).toBe('string') + } + }) +}) + +test.describe('Admin Panel Access', () => { + test('Admin panel redirects to login when unauthenticated', async ({ page }) => { + const response = await page.goto('/admin') + + // Should redirect to login or return the admin page with login form + expect(response?.status()).toBeLessThan(500) + + // Check if we're on the login page or redirected + await page.waitForLoadState('networkidle') + + // Should see login form or be on login route + const url = page.url() + const hasLoginForm = await page.locator('input[type="password"]').count() + + expect(url.includes('login') || hasLoginForm > 0).toBe(true) + }) + + test('Admin collections require authentication', async ({ request }) => { + // Try to access users collection without auth + const response = await request.get('/api/users') + + // Should return 401 or 403 for unauthenticated access + expect([401, 403]).toContain(response.status()) + }) + + test('Protected API routes return auth error', async ({ request }) => { + // Try to create a post without auth + const response = await request.post('/api/posts', { + data: { + title: 'Test Post', + slug: 'test-post', + }, + }) + + // Should require authentication + expect([401, 403]).toContain(response.status()) + }) +}) + +test.describe('CSRF Protection', () => { + test('GET /api/csrf-token returns valid token', async ({ request }) => { + const response = await request.get('/api/csrf-token') + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data).toHaveProperty('token') + expect(typeof data.token).toBe('string') + expect(data.token.length).toBeGreaterThan(0) + }) + + test('CSRF token endpoint sets cookie', async ({ request }) => { + const response = await request.get('/api/csrf-token') + + expect(response.ok()).toBe(true) + + // Check for csrf-related cookie in response + const cookies = response.headers()['set-cookie'] + // Cookie may or may not be set depending on implementation + // The important thing is the endpoint works + expect(response.status()).toBe(200) + }) +}) diff --git a/tests/e2e/forms.e2e.spec.ts b/tests/e2e/forms.e2e.spec.ts new file mode 100644 index 0000000..acf7104 --- /dev/null +++ b/tests/e2e/forms.e2e.spec.ts @@ -0,0 +1,94 @@ +/** + * E2E Tests für Form Submission Flow + * + * Testet das Form-Builder Plugin mit E-Mail-Benachrichtigungen + */ + +import { test, expect } from '@playwright/test' + +test.describe('Forms API', () => { + test('GET /api/forms returns form list', async ({ request }) => { + const response = await request.get('/api/forms') + + // Forms require authentication, should return 401/403 + expect([200, 401, 403]).toContain(response.status()) + + if (response.ok()) { + const data = await response.json() + expect(data).toHaveProperty('docs') + expect(Array.isArray(data.docs)).toBe(true) + } + }) + + test('GET /api/form-submissions requires authentication', async ({ request }) => { + const response = await request.get('/api/form-submissions') + + expect([401, 403]).toContain(response.status()) + }) +}) + +test.describe('Form Submission Flow', () => { + test('POST /api/form-submissions requires form reference', async ({ request }) => { + const response = await request.post('/api/form-submissions', { + data: { + submissionData: [{ field: 'email', value: 'test@example.com' }], + }, + }) + + // Should fail without proper form reference or auth + expect([400, 401, 403]).toContain(response.status()) + }) + + test('Form submission validates required fields', async ({ request }) => { + const response = await request.post('/api/form-submissions', { + data: { + form: 1, // Assuming form ID + submissionData: [], // Empty submission data + }, + }) + + // Should reject incomplete submission + expect([400, 401, 403, 404]).toContain(response.status()) + }) +}) + +test.describe('Form Builder Features', () => { + test('Forms collection is accessible', async ({ request }) => { + // Even without auth, endpoint should exist + const response = await request.get('/api/forms') + + // Should not return 404 (endpoint exists) + expect(response.status()).not.toBe(404) + }) + + test('Form submissions collection is accessible', async ({ request }) => { + const response = await request.get('/api/form-submissions') + + // Should not return 404 (endpoint exists) + expect(response.status()).not.toBe(404) + }) +}) + +test.describe('Form API Structure', () => { + test('Forms API returns proper error format', async ({ request }) => { + const response = await request.get('/api/forms') + + if (!response.ok()) { + const data = await response.json() + // Payload error format + expect(data).toHaveProperty('errors') + } + }) + + test('Form submission API returns proper error format', async ({ request }) => { + const response = await request.post('/api/form-submissions', { + data: {}, + }) + + if (!response.ok()) { + const data = await response.json() + // Payload error format + expect(data).toHaveProperty('errors') + } + }) +}) diff --git a/tests/e2e/news-api.e2e.spec.ts b/tests/e2e/news-api.e2e.spec.ts new file mode 100644 index 0000000..feb0de0 --- /dev/null +++ b/tests/e2e/news-api.e2e.spec.ts @@ -0,0 +1,431 @@ +/** + * E2E Tests für News API + * + * Testet die dedizierte News-API mit Tenant-Isolation und Filtering + */ + +import { test, expect } from '@playwright/test' + +// Test tenant ID (porwoll.de) +const TEST_TENANT_ID = 1 + +test.describe('News API - List Endpoint', () => { + const newsEndpoint = '/api/news' + + test('GET /api/news requires tenant parameter', async ({ request }) => { + const response = await request.get(newsEndpoint) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data).toHaveProperty('error') + expect(data.error).toContain('Tenant ID is required') + }) + + test('GET /api/news returns 400 for invalid tenant ID', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=invalid`) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data).toHaveProperty('error') + expect(data.error).toContain('Invalid tenant ID') + }) + + test('GET /api/news returns valid response structure with tenant', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + // Validate response structure + expect(data).toHaveProperty('docs') + expect(data).toHaveProperty('pagination') + expect(data).toHaveProperty('filters') + + expect(Array.isArray(data.docs)).toBe(true) + expect(data.pagination).toHaveProperty('page') + expect(data.pagination).toHaveProperty('limit') + expect(data.pagination).toHaveProperty('totalPages') + expect(data.pagination).toHaveProperty('totalDocs') + expect(data.pagination).toHaveProperty('hasNextPage') + expect(data.pagination).toHaveProperty('hasPrevPage') + + // Filters should contain tenant + expect(data.filters).toHaveProperty('tenant') + expect(data.filters.tenant).toBe(TEST_TENANT_ID) + }) + + test('GET /api/news validates type parameter', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&type=invalid`) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data).toHaveProperty('error') + expect(data.error).toContain('Invalid type') + }) + + test('GET /api/news accepts valid type parameter', async ({ request }) => { + const validTypes = ['news', 'press', 'announcement', 'blog'] + + for (const type of validTypes) { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&type=${type}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data.filters.type).toBe(type) + + // All returned docs should match the type + for (const doc of data.docs) { + expect(doc.type).toBe(type) + } + } + }) + + test('GET /api/news supports multiple types', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&types=news,blog`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + // All docs should be either news or blog + for (const doc of data.docs) { + expect(['news', 'blog']).toContain(doc.type) + } + }) + + test('GET /api/news respects pagination parameters', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&page=1&limit=5`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data.pagination.page).toBe(1) + expect(data.pagination.limit).toBe(5) + expect(data.docs.length).toBeLessThanOrEqual(5) + }) + + test('GET /api/news enforces max limit', async ({ request }) => { + // Request more than max limit (50) + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&limit=100`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + // Should cap at max limit + expect(data.pagination.limit).toBeLessThanOrEqual(50) + }) + + test('GET /api/news includes categories when requested', async ({ request }) => { + const response = await request.get( + `${newsEndpoint}?tenant=${TEST_TENANT_ID}&includeCategories=true` + ) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data).toHaveProperty('categories') + expect(Array.isArray(data.categories)).toBe(true) + + // Each category should have id, name, slug + for (const cat of data.categories) { + expect(cat).toHaveProperty('id') + expect(cat).toHaveProperty('name') + expect(cat).toHaveProperty('slug') + } + }) + + test('GET /api/news includes archive when requested', async ({ request }) => { + const response = await request.get( + `${newsEndpoint}?tenant=${TEST_TENANT_ID}&includeArchive=true` + ) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data).toHaveProperty('archive') + expect(Array.isArray(data.archive)).toBe(true) + + // Each archive entry should have year, months, total + for (const entry of data.archive) { + expect(entry).toHaveProperty('year') + expect(entry).toHaveProperty('months') + expect(entry).toHaveProperty('total') + expect(Array.isArray(entry.months)).toBe(true) + } + }) + + test('GET /api/news filters by featured', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&featured=true`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data.filters.featured).toBe(true) + + // All docs should be featured + for (const doc of data.docs) { + expect(doc.isFeatured).toBe(true) + } + }) + + test('GET /api/news validates year parameter', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&year=1900`) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.error).toContain('Invalid year') + }) + + test('GET /api/news validates month parameter', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&month=13`) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.error).toContain('Invalid month') + }) + + test('GET /api/news accepts valid locale parameter', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}&locale=en`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + expect(data.filters.locale).toBe('en') + }) + + test('GET /api/news doc items have correct structure', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + for (const doc of data.docs) { + expect(doc).toHaveProperty('id') + expect(doc).toHaveProperty('title') + expect(doc).toHaveProperty('slug') + expect(doc).toHaveProperty('type') + expect(doc).toHaveProperty('excerpt') + expect(doc).toHaveProperty('publishedAt') + expect(doc).toHaveProperty('isFeatured') + expect(doc).toHaveProperty('featuredImage') + expect(doc).toHaveProperty('categories') + expect(doc).toHaveProperty('seo') + + // Categories should be an array + expect(Array.isArray(doc.categories)).toBe(true) + } + }) + + test('GET /api/news includes rate limit headers', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const headers = response.headers() + expect(headers['x-ratelimit-remaining']).toBeDefined() + }) + + test('GET /api/news includes cache headers', async ({ request }) => { + const response = await request.get(`${newsEndpoint}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const headers = response.headers() + expect(headers['cache-control']).toBeDefined() + expect(headers['cache-control']).toContain('max-age') + }) +}) + +test.describe('News API - Detail Endpoint', () => { + test('GET /api/news/[slug] requires tenant parameter', async ({ request }) => { + const response = await request.get('/api/news/some-article') + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data).toHaveProperty('error') + expect(data.error).toContain('Tenant ID is required') + }) + + test('GET /api/news/[slug] returns 404 for non-existent article', async ({ request }) => { + const response = await request.get( + `/api/news/non-existent-article-slug-12345?tenant=${TEST_TENANT_ID}` + ) + + expect(response.status()).toBe(404) + + const data = await response.json() + expect(data).toHaveProperty('error') + expect(data.error).toContain('not found') + }) + + test('GET /api/news/[slug] returns valid article structure', async ({ request }) => { + // First get a list to find a valid slug + const listResponse = await request.get(`/api/news?tenant=${TEST_TENANT_ID}&limit=1`) + + if (!listResponse.ok()) { + test.skip() + return + } + + const listData = await listResponse.json() + + if (listData.docs.length === 0) { + test.skip() // No articles to test with + return + } + + const slug = listData.docs[0].slug + const response = await request.get(`/api/news/${slug}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + expect(data).toHaveProperty('article') + expect(data).toHaveProperty('locale') + + const article = data.article + expect(article).toHaveProperty('id') + expect(article).toHaveProperty('title') + expect(article).toHaveProperty('slug') + expect(article).toHaveProperty('type') + expect(article).toHaveProperty('content') + expect(article).toHaveProperty('seo') + }) + + test('GET /api/news/[slug] includes related posts by default', async ({ request }) => { + // First get a list to find a valid slug + const listResponse = await request.get(`/api/news?tenant=${TEST_TENANT_ID}&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 + const response = await request.get(`/api/news/${slug}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + // Related posts may or may not exist, but structure should be valid + if (data.relatedPosts) { + expect(Array.isArray(data.relatedPosts)).toBe(true) + + for (const related of data.relatedPosts) { + expect(related).toHaveProperty('id') + expect(related).toHaveProperty('title') + expect(related).toHaveProperty('slug') + expect(related).toHaveProperty('type') + } + } + }) + + test('GET /api/news/[slug] includes navigation', async ({ request }) => { + // First get a list to find a valid slug + const listResponse = await request.get(`/api/news?tenant=${TEST_TENANT_ID}&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 + const response = await request.get(`/api/news/${slug}?tenant=${TEST_TENANT_ID}`) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + expect(data).toHaveProperty('navigation') + expect(data.navigation).toHaveProperty('previous') + expect(data.navigation).toHaveProperty('next') + }) + + test('GET /api/news/[slug] can exclude related posts', async ({ request }) => { + const listResponse = await request.get(`/api/news?tenant=${TEST_TENANT_ID}&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 + const response = await request.get( + `/api/news/${slug}?tenant=${TEST_TENANT_ID}&includeRelated=false` + ) + + expect(response.ok()).toBe(true) + + const data = await response.json() + + // Related posts should not be included + expect(data.relatedPosts).toBeUndefined() + }) +}) + +test.describe('News API - Tenant Isolation', () => { + test('Different tenants return isolated data', async ({ request }) => { + // Get news for tenant 1 + const response1 = await request.get('/api/news?tenant=1') + // Get news for tenant 4 (C2S) + const response4 = await request.get('/api/news?tenant=4') + + expect(response1.ok()).toBe(true) + expect(response4.ok()).toBe(true) + + const data1 = await response1.json() + const data4 = await response4.json() + + // Both should have valid structure + expect(data1).toHaveProperty('docs') + expect(data4).toHaveProperty('docs') + + // Filter responses should reflect tenant + expect(data1.filters.tenant).toBe(1) + expect(data4.filters.tenant).toBe(4) + }) + + test('Cannot access news from non-existent tenant', 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 error + expect(data.docs).toEqual([]) + expect(data.pagination.totalDocs).toBe(0) + }) +}) diff --git a/tests/e2e/newsletter.e2e.spec.ts b/tests/e2e/newsletter.e2e.spec.ts new file mode 100644 index 0000000..9413cad --- /dev/null +++ b/tests/e2e/newsletter.e2e.spec.ts @@ -0,0 +1,303 @@ +/** + * E2E Tests für Newsletter Double Opt-In Flow + * + * Testet den kompletten Newsletter-Anmeldeprozess inkl. Rate-Limiting + */ + +import { test, expect } from '@playwright/test' + +// Test tenant ID (porwoll.de) +const TEST_TENANT_ID = 1 + +test.describe('Newsletter Subscribe API', () => { + const subscribeEndpoint = '/api/newsletter/subscribe' + + test('POST /api/newsletter/subscribe requires email', async ({ request }) => { + const response = await request.post(subscribeEndpoint, { + data: { + tenantId: TEST_TENANT_ID, + }, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + expect(data.message).toContain('E-Mail') + }) + + test('POST /api/newsletter/subscribe validates email format', async ({ request }) => { + const response = await request.post(subscribeEndpoint, { + data: { + email: 'not-an-email', + tenantId: TEST_TENANT_ID, + }, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + expect(data.message).toContain('gültige E-Mail') + }) + + test('POST /api/newsletter/subscribe requires tenant', async ({ request }) => { + const response = await request.post(subscribeEndpoint, { + data: { + email: 'test@example.com', + }, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + expect(data.message).toContain('Tenant') + }) + + test('POST /api/newsletter/subscribe accepts valid subscription', async ({ request }) => { + // Use a unique email to avoid conflicts with existing data + const uniqueEmail = `test-${Date.now()}@e2e-test.example` + + const response = await request.post(subscribeEndpoint, { + data: { + email: uniqueEmail, + tenantId: TEST_TENANT_ID, + firstName: 'E2E', + lastName: 'Test', + source: 'e2e-test', + }, + }) + + // Should succeed or indicate already subscribed + expect([200, 400]).toContain(response.status()) + + const data = await response.json() + expect(data).toHaveProperty('success') + expect(data).toHaveProperty('message') + + if (data.success) { + // New subscription + expect(data.message).toContain('Bestätigungs') + } + }) + + test('POST /api/newsletter/subscribe normalizes email to lowercase', async ({ request }) => { + const uniqueEmail = `Test-Upper-${Date.now()}@E2E-TEST.Example` + + const response = await request.post(subscribeEndpoint, { + data: { + email: uniqueEmail, + tenantId: TEST_TENANT_ID, + }, + }) + + // Request should be processed (email normalized internally) + expect([200, 400]).toContain(response.status()) + }) + + test('POST /api/newsletter/subscribe handles optional fields', async ({ request }) => { + const uniqueEmail = `test-optional-${Date.now()}@e2e-test.example` + + const response = await request.post(subscribeEndpoint, { + data: { + email: uniqueEmail, + tenantId: TEST_TENANT_ID, + // Only required fields, no optional ones + }, + }) + + expect([200, 400]).toContain(response.status()) + + const data = await response.json() + expect(data).toHaveProperty('success') + expect(data).toHaveProperty('message') + }) + + test('POST /api/newsletter/subscribe accepts source parameter', async ({ request }) => { + const uniqueEmail = `test-source-${Date.now()}@e2e-test.example` + + const response = await request.post(subscribeEndpoint, { + data: { + email: uniqueEmail, + tenantId: TEST_TENANT_ID, + source: 'footer-form', + }, + }) + + expect([200, 400]).toContain(response.status()) + }) +}) + +test.describe('Newsletter Confirm API', () => { + const confirmEndpoint = '/api/newsletter/confirm' + + test('GET /api/newsletter/confirm requires token', async ({ request }) => { + const response = await request.get(confirmEndpoint) + + // Returns HTML page with error + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain('Ungültiger') + }) + + test('GET /api/newsletter/confirm rejects invalid token', async ({ request }) => { + const response = await request.get(`${confirmEndpoint}?token=invalid-token-12345`) + + expect(response.ok()).toBe(true) + + const html = await response.text() + // Should show error page + expect(html).toContain('Fehler') + }) + + test('POST /api/newsletter/confirm requires token', async ({ request }) => { + const response = await request.post(confirmEndpoint, { + data: {}, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + expect(data.message).toContain('Token') + }) + + test('POST /api/newsletter/confirm rejects invalid token', async ({ request }) => { + const response = await request.post(confirmEndpoint, { + data: { + token: 'invalid-token-67890', + }, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + }) + + test('GET /api/newsletter/confirm returns HTML response', async ({ request }) => { + const response = await request.get(`${confirmEndpoint}?token=test`) + + expect(response.ok()).toBe(true) + + const contentType = response.headers()['content-type'] + expect(contentType).toContain('text/html') + }) +}) + +test.describe('Newsletter Unsubscribe API', () => { + const unsubscribeEndpoint = '/api/newsletter/unsubscribe' + + test('GET /api/newsletter/unsubscribe requires token', async ({ request }) => { + const response = await request.get(unsubscribeEndpoint) + + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain('Ungültiger') + }) + + test('GET /api/newsletter/unsubscribe returns HTML response', async ({ request }) => { + const response = await request.get(`${unsubscribeEndpoint}?token=test`) + + expect(response.ok()).toBe(true) + + const contentType = response.headers()['content-type'] + expect(contentType).toContain('text/html') + }) + + test('POST /api/newsletter/unsubscribe requires token', async ({ request }) => { + const response = await request.post(unsubscribeEndpoint, { + data: {}, + }) + + expect(response.status()).toBe(400) + + const data = await response.json() + expect(data.success).toBe(false) + }) +}) + +test.describe('Newsletter Rate Limiting', () => { + test('Newsletter subscribe has rate limiting headers', async ({ request }) => { + const response = await request.post('/api/newsletter/subscribe', { + data: { + email: 'ratelimit-test@example.com', + tenantId: TEST_TENANT_ID, + }, + }) + + // Rate limit headers should be present on rate limited response + if (response.status() === 429) { + expect(response.headers()['retry-after']).toBeDefined() + } + }) +}) + +test.describe('Newsletter Flow Integration', () => { + test('Complete subscription flow structure', async ({ request }) => { + // 1. Subscribe + const uniqueEmail = `integration-${Date.now()}@e2e-test.example` + + const subscribeResponse = await request.post('/api/newsletter/subscribe', { + data: { + email: uniqueEmail, + tenantId: TEST_TENANT_ID, + firstName: 'Integration', + lastName: 'Test', + source: 'e2e-integration-test', + }, + }) + + expect([200, 400, 429]).toContain(subscribeResponse.status()) + + const subscribeData = await subscribeResponse.json() + + // If successful, should indicate confirmation email sent + if (subscribeData.success) { + expect(subscribeData.message).toContain('Bestätigungs') + } + + // 2. Confirm endpoint should be accessible + const confirmResponse = await request.get('/api/newsletter/confirm?token=dummy') + expect(confirmResponse.ok()).toBe(true) + + // 3. Unsubscribe endpoint should be accessible + const unsubscribeResponse = await request.get('/api/newsletter/unsubscribe?token=dummy') + expect(unsubscribeResponse.ok()).toBe(true) + }) + + test('Re-subscription handling', async ({ request }) => { + const email = `resub-test-${Date.now()}@e2e-test.example` + + // First subscription + const firstResponse = await request.post('/api/newsletter/subscribe', { + data: { + email: email, + tenantId: TEST_TENANT_ID, + }, + }) + + if (firstResponse.status() === 429) { + test.skip() // Rate limited, skip test + return + } + + // Second subscription with same email + const secondResponse = await request.post('/api/newsletter/subscribe', { + data: { + email: email, + tenantId: TEST_TENANT_ID, + }, + }) + + // Should handle gracefully (either success or already subscribed message) + expect([200, 400, 429]).toContain(secondResponse.status()) + + const data = await secondResponse.json() + expect(data).toHaveProperty('success') + expect(data).toHaveProperty('message') + }) +}) diff --git a/tests/e2e/tenant-isolation.e2e.spec.ts b/tests/e2e/tenant-isolation.e2e.spec.ts new file mode 100644 index 0000000..d8ba55d --- /dev/null +++ b/tests/e2e/tenant-isolation.e2e.spec.ts @@ -0,0 +1,301 @@ +/** + * 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('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()) + }) +})