- 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.
679 lines
24 KiB
JavaScript
679 lines
24 KiB
JavaScript
/**
|
|
* 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('<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);
|
|
});
|
|
});
|
|
});
|