feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,678 @@
/**
* LiveComponents Security E2E Tests
*
* Tests security features and protections:
* - CSRF token validation for state-changing actions
* - Rate limiting for action calls
* - Idempotency for critical operations
* - Input sanitization and XSS prevention
* - Authorization checks for protected actions
* - Session security and token rotation
*
* Run with: npx playwright test security.spec.js
*/
import { test, expect } from '@playwright/test';
test.describe('LiveComponents Security Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost/livecomponents/test/security');
await page.waitForFunction(() => window.LiveComponents !== undefined);
});
test.describe('CSRF Protection', () => {
test('should include CSRF token in action requests', async ({ page }) => {
// Monitor network requests
const actionRequests = [];
page.on('request', request => {
if (request.url().includes('/live-component/')) {
actionRequests.push({
url: request.url(),
headers: request.headers(),
postData: request.postData()
});
}
});
// Trigger action
await page.click('button#trigger-action');
// Wait for request to complete
await page.waitForTimeout(1000);
// Verify at least one action request was made
expect(actionRequests.length).toBeGreaterThan(0);
// Verify CSRF token present in request
const actionRequest = actionRequests[0];
// Check for CSRF token in headers or POST data
const hasCsrfHeader = 'x-csrf-token' in actionRequest.headers ||
'x-xsrf-token' in actionRequest.headers;
const hasCsrfInBody = actionRequest.postData &&
actionRequest.postData.includes('_csrf_token');
expect(hasCsrfHeader || hasCsrfInBody).toBe(true);
});
test('should reject action without CSRF token', async ({ page }) => {
// Intercept and remove CSRF token
await page.route('**/live-component/**', async (route) => {
const request = route.request();
// Remove CSRF token from headers
const headers = { ...request.headers() };
delete headers['x-csrf-token'];
delete headers['x-xsrf-token'];
// Remove from POST data if present
let postData = request.postData();
if (postData) {
postData = postData.replace(/_csrf_token=[^&]+&?/, '');
}
await route.continue({
headers,
postData
});
});
// Trigger action
await page.click('button#trigger-action');
// Wait for error
await page.waitForTimeout(1000);
// Verify error response
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('CSRF');
});
test('should reject action with invalid CSRF token', async ({ page }) => {
// Intercept and replace CSRF token with invalid one
await page.route('**/live-component/**', async (route) => {
const request = route.request();
const headers = {
...request.headers(),
'x-csrf-token': 'invalid-token-12345'
};
await route.continue({ headers });
});
// Trigger action
await page.click('button#trigger-action');
// Wait for error
await page.waitForTimeout(1000);
// Verify error response
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('CSRF');
});
test('should rotate CSRF token after usage', async ({ page }) => {
// Get initial CSRF token
const initialToken = await page.evaluate(() => {
return document.querySelector('meta[name="csrf-token"]')?.content;
});
expect(initialToken).toBeTruthy();
// Trigger action
await page.click('button#trigger-action');
// Wait for action to complete
await page.waitForTimeout(1000);
// Get new CSRF token
const newToken = await page.evaluate(() => {
return document.querySelector('meta[name="csrf-token"]')?.content;
});
// Tokens should be different after action
// (depending on framework configuration)
// For now, just verify new token exists
expect(newToken).toBeTruthy();
});
});
test.describe('Rate Limiting', () => {
test('should enforce rate limit on rapid action calls', async ({ page }) => {
// Trigger action rapidly 20 times
const results = [];
for (let i = 0; i < 20; i++) {
try {
await page.click('button#trigger-action');
await page.waitForTimeout(50); // Small delay
const success = await page.locator('.action-success').isVisible();
results.push({ success, attempt: i + 1 });
} catch (error) {
results.push({ success: false, attempt: i + 1, error: error.message });
}
}
// Some requests should be rate limited
const successCount = results.filter(r => r.success).length;
// Expect rate limiting to kick in (not all 20 succeed)
expect(successCount).toBeLessThan(20);
// Verify rate limit error message
const rateLimitError = await page.locator('.rate-limit-error').isVisible();
expect(rateLimitError).toBe(true);
});
test('should provide retry-after information when rate limited', async ({ page }) => {
// Trigger rapid requests to hit rate limit
for (let i = 0; i < 15; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// Wait for rate limit error
await page.waitForSelector('.rate-limit-error', { timeout: 5000 });
// Verify retry-after information
const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after');
expect(retryAfter).toBeTruthy();
expect(parseInt(retryAfter)).toBeGreaterThan(0);
console.log(`Rate limit retry-after: ${retryAfter} seconds`);
});
test('should reset rate limit after cooldown period', async ({ page }) => {
// Hit rate limit
for (let i = 0; i < 15; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// Verify rate limited
await expect(page.locator('.rate-limit-error')).toBeVisible();
// Get retry-after duration
const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after');
const waitTime = parseInt(retryAfter) * 1000 + 1000; // Add 1 second buffer
console.log(`Waiting ${waitTime}ms for rate limit reset...`);
// Wait for cooldown
await page.waitForTimeout(waitTime);
// Try action again
await page.click('button#trigger-action');
// Should succeed now
await expect(page.locator('.action-success')).toBeVisible();
});
test('should have separate rate limits per action type', async ({ page }) => {
// Hit rate limit on action A
for (let i = 0; i < 15; i++) {
await page.click('button#action-a');
await page.waitForTimeout(20);
}
// Verify action A is rate limited
await expect(page.locator('.rate-limit-error-a')).toBeVisible();
// Action B should still work
await page.click('button#action-b');
await expect(page.locator('.action-success-b')).toBeVisible();
});
test('should apply IP-based rate limiting', async ({ page, context }) => {
// Create multiple browser contexts (simulate multiple users from same IP)
const context2 = await page.context().browser().newContext();
const page2 = await context2.newPage();
await page2.goto('https://localhost/livecomponents/test/security');
await page2.waitForFunction(() => window.LiveComponents !== undefined);
// User 1: Trigger many requests
for (let i = 0; i < 10; i++) {
await page.click('button#trigger-action');
await page.waitForTimeout(20);
}
// User 2: Should also be affected by IP-based limit
for (let i = 0; i < 10; i++) {
await page2.click('button#trigger-action');
await page2.waitForTimeout(20);
}
// Both should eventually be rate limited (shared IP limit)
const user1Limited = await page.locator('.rate-limit-error').isVisible();
const user2Limited = await page2.locator('.rate-limit-error').isVisible();
expect(user1Limited || user2Limited).toBe(true);
await context2.close();
});
});
test.describe('Idempotency', () => {
test('should handle duplicate action calls idempotently', async ({ page }) => {
// Get initial state
const initialValue = await page.locator('#counter-value').textContent();
const initialCount = parseInt(initialValue);
// Trigger increment action twice with same idempotency key
const idempotencyKey = 'test-key-12345';
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Same idempotency key - should be ignored
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Value should only increment once
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(initialCount + 1);
});
test('should allow different idempotency keys', async ({ page }) => {
const initialValue = await page.locator('#counter-value').textContent();
const initialCount = parseInt(initialValue);
// First action with idempotency key 1
await page.evaluate(() => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: 'key-1'
});
});
await page.waitForTimeout(500);
// Second action with idempotency key 2 (different key)
await page.evaluate(() => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: 'key-2'
});
});
await page.waitForTimeout(500);
// Both should execute (different keys)
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(initialCount + 2);
});
test('should expire idempotency keys after TTL', async ({ page }) => {
const idempotencyKey = 'expiring-key';
// First action
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
const afterFirstValue = await page.locator('#counter-value').textContent();
const afterFirstCount = parseInt(afterFirstValue);
// Wait for idempotency key to expire (assume 5 second TTL)
await page.waitForTimeout(6000);
// Same key should work again after expiration
await page.evaluate((key) => {
window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
const finalValue = await page.locator('#counter-value').textContent();
const finalCount = parseInt(finalValue);
expect(finalCount).toBe(afterFirstCount + 1);
});
test('should return cached result for duplicate idempotent action', async ({ page }) => {
const idempotencyKey = 'cached-result-key';
// First action
const result1 = await page.evaluate((key) => {
return window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
await page.waitForTimeout(500);
// Duplicate action (same key)
const result2 = await page.evaluate((key) => {
return window.LiveComponents.get('counter:test').call('increment', {}, {
idempotencyKey: key
});
}, idempotencyKey);
// Results should be identical (cached)
expect(result2).toEqual(result1);
});
});
test.describe('Input Sanitization & XSS Prevention', () => {
test('should sanitize HTML in action parameters', async ({ page }) => {
// Attempt XSS injection via action parameter
const xssPayload = '<script>alert("XSS")</script>';
await page.evaluate((payload) => {
window.LiveComponents.get('text:test').call('setText', {
text: payload
});
}, xssPayload);
await page.waitForTimeout(500);
// Verify script tag is sanitized/escaped
const displayedText = await page.locator('#text-display').textContent();
// Should display as text, not execute
expect(displayedText).toContain('script');
expect(displayedText).not.toContain('<script>');
// Verify no script execution
const alertShown = await page.evaluate(() => {
return window.__alertCalled === true;
});
expect(alertShown).toBeFalsy();
});
test('should escape HTML entities in rendered content', async ({ page }) => {
const maliciousInput = '<img src=x onerror=alert("XSS")>';
await page.evaluate((input) => {
window.LiveComponents.get('text:test').call('setText', {
text: input
});
}, maliciousInput);
await page.waitForTimeout(500);
// Check that img tag is escaped
const innerHTML = await page.locator('#text-display').innerHTML();
// Should be escaped HTML entities
expect(innerHTML).toContain('&lt;img');
expect(innerHTML).not.toContain('<img');
});
test('should prevent JavaScript injection in fragment updates', async ({ page }) => {
const jsPayload = 'javascript:alert("XSS")';
await page.evaluate((payload) => {
window.LiveComponents.get('link:test').call('setLink', {
url: payload
});
}, jsPayload);
await page.waitForTimeout(500);
// Verify link is sanitized
const linkHref = await page.locator('#link-display').getAttribute('href');
// Should not contain javascript: protocol
expect(linkHref).not.toContain('javascript:');
});
test('should validate and sanitize file paths', async ({ page }) => {
// Path traversal attempt
const maliciousPath = '../../../etc/passwd';
await page.evaluate((path) => {
window.LiveComponents.get('file:test').call('loadFile', {
path: path
});
}, maliciousPath);
await page.waitForTimeout(500);
// Should reject or sanitize path
const errorMessage = await page.locator('.error-message').textContent();
expect(errorMessage).toContain('Invalid path');
});
test('should prevent SQL injection in search parameters', async ({ page }) => {
const sqlInjection = "'; DROP TABLE users; --";
await page.evaluate((query) => {
window.LiveComponents.get('search:test').call('search', {
query: query
});
}, sqlInjection);
await page.waitForTimeout(500);
// Search should handle safely (parameterized queries)
// No error should occur, but SQL shouldn't execute
const results = await page.locator('.search-results').isVisible();
expect(results).toBe(true); // Search completed without error
});
});
test.describe('Authorization', () => {
test('should reject unauthorized action calls', async ({ page }) => {
// Attempt to call admin-only action without auth
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('deleteAllData', {});
});
await page.waitForTimeout(500);
// Should be rejected
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Unauthorized');
});
test('should allow authorized action calls', async ({ page }) => {
// Login first
await page.fill('#username', 'admin');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Now try admin action
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('performAdminAction', {});
});
await page.waitForTimeout(500);
// Should succeed
const successMessage = await page.locator('.action-success').isVisible();
expect(successMessage).toBe(true);
});
test('should respect role-based authorization', async ({ page }) => {
// Login as regular user
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Try admin action - should fail
await page.evaluate(() => {
window.LiveComponents.get('admin:test').call('performAdminAction', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Insufficient permissions');
});
});
test.describe('Session Security', () => {
test('should invalidate session after logout', async ({ page }) => {
// Login
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Perform authenticated action
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const beforeLogout = await page.locator('.user-data').isVisible();
expect(beforeLogout).toBe(true);
// Logout
await page.click('#logout-btn');
await page.waitForSelector('.logged-out-indicator');
// Try same action - should fail
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.authorization-error').textContent();
expect(errorMessage).toContain('Unauthorized');
});
test('should detect session hijacking attempts', async ({ page, context }) => {
// Login in first tab
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Get session cookie
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'session_id');
expect(sessionCookie).toBeTruthy();
// Create new context with different IP/user agent
const newContext = await page.context().browser().newContext({
userAgent: 'Mozilla/5.0 (Different Browser)'
});
const newPage = await newContext.newPage();
// Set stolen cookie
await newContext.addCookies([sessionCookie]);
await newPage.goto('https://localhost/livecomponents/test/security');
// Try to use session from different context
await newPage.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await newPage.waitForTimeout(500);
// Should detect session hijacking and reject
const errorMessage = await newPage.locator('.security-error').textContent();
expect(errorMessage).toContain('Session invalid');
await newContext.close();
});
test('should enforce session timeout', async ({ page }) => {
// Login
await page.fill('#username', 'user');
await page.fill('#password', 'password');
await page.click('#login-btn');
await page.waitForSelector('.logged-in-indicator');
// Wait for session timeout (assume 5 minute timeout in test environment)
console.log('Waiting for session timeout (5 minutes)...');
await page.waitForTimeout(5 * 60 * 1000 + 5000); // 5 min + buffer
// Try action - should fail due to timeout
await page.evaluate(() => {
window.LiveComponents.get('user:test').call('getUserData', {});
});
await page.waitForTimeout(500);
const errorMessage = await page.locator('.session-expired-error').textContent();
expect(errorMessage).toContain('Session expired');
});
});
test.describe('Content Security Policy', () => {
test('should have CSP headers set', async ({ page }) => {
const response = await page.goto('https://localhost/livecomponents/test/security');
const cspHeader = response.headers()['content-security-policy'];
expect(cspHeader).toBeTruthy();
console.log('CSP Header:', cspHeader);
});
test('should block inline scripts via CSP', async ({ page }) => {
// Attempt to inject inline script
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'alert("Inline script executed")';
document.body.appendChild(script);
});
await page.waitForTimeout(500);
// CSP should block inline script
const hasCspError = consoleErrors.some(err =>
err.includes('Content Security Policy') || err.includes('CSP')
);
expect(hasCspError).toBe(true);
});
});
});