mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
test: add E2E tests for critical flows
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 <noreply@anthropic.com>
This commit is contained in:
parent
aa1f8b1054
commit
e8532b388d
6 changed files with 1321 additions and 1 deletions
|
|
@ -11,10 +11,25 @@ const TEST_PORT = process.env.TEST_PORT || '3001'
|
||||||
const TEST_URL = `http://localhost:${TEST_PORT}`
|
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.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
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. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
|
|
@ -22,7 +37,9 @@ export default defineConfig({
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* 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. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* 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 */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
/* Screenshot on failure */
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
/* Video recording on failure */
|
||||||
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
168
tests/e2e/auth.e2e.spec.ts
Normal file
168
tests/e2e/auth.e2e.spec.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
94
tests/e2e/forms.e2e.spec.ts
Normal file
94
tests/e2e/forms.e2e.spec.ts
Normal file
|
|
@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
431
tests/e2e/news-api.e2e.spec.ts
Normal file
431
tests/e2e/news-api.e2e.spec.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
303
tests/e2e/newsletter.e2e.spec.ts
Normal file
303
tests/e2e/newsletter.e2e.spec.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
301
tests/e2e/tenant-isolation.e2e.spec.ts
Normal file
301
tests/e2e/tenant-isolation.e2e.spec.ts
Normal file
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue