.agents/skills/playwright-best-practices/testing-patterns/security-testing.md
test('input is properly escaped', async ({page}) => {
const xssPayloads = [
'<script>alert("xss")</script>',
'',
'"><script>alert(1)</script>',
'javascript:alert(1)',
'<svg onload="alert(1)">',
]
for (const payload of xssPayloads) {
await page.goto(`/search?q=${encodeURIComponent(payload)}`)
// Verify script didn't execute
const alertTriggered = await page.evaluate(() => {
return (window as any).__xssTriggered === true
})
expect(alertTriggered).toBe(false)
// Verify payload is escaped in HTML
const content = await page.content()
expect(content).not.toContain('<script>alert')
expect(content).not.toContain('onerror=')
}
})
test('user content is sanitized', async ({page}) => {
await page.goto('/create-post')
// Try to inject script via form
await page.getByLabel('Content').fill('<script>alert("xss")</script>Hello')
await page.getByRole('button', {name: 'Submit'}).click()
// View the post
await page.goto('/posts/latest')
// Script should not be in page
const scripts = await page.locator('script').count()
const pageContent = await page.content()
// The script tag should be escaped or removed
expect(pageContent).not.toContain('<script>alert')
// Text should still be visible (just sanitized)
await expect(page.getByText('Hello')).toBeVisible()
})
test('no XSS execution', async ({page}) => {
// Set up XSS detection
await page.addInitScript(() => {
;(window as any).__xssDetected = false
// Override alert/confirm/prompt
window.alert = () => {
;(window as any).__xssDetected = true
}
window.confirm = () => {
;(window as any).__xssDetected = true
return false
}
window.prompt = () => {
;(window as any).__xssDetected = true
return null
}
})
// Perform test actions
await page.goto('/vulnerable-page')
await page.getByLabel('Search').fill('">')
await page.getByLabel('Search').press('Enter')
// Check if XSS triggered
const xssDetected = await page.evaluate(() => (window as any).__xssDetected)
expect(xssDetected).toBe(false)
})
test('forms include CSRF token', async ({page}) => {
await page.goto('/settings')
// Check form has CSRF token
const csrfInput = page.locator('input[name="_csrf"], input[name="csrf_token"]')
await expect(csrfInput).toBeAttached()
const csrfValue = await csrfInput.getAttribute('value')
expect(csrfValue).toBeTruthy()
expect(csrfValue!.length).toBeGreaterThan(20)
})
test('rejects requests without CSRF token', async ({page, request}) => {
await page.goto('/settings')
// Try to submit without CSRF token
const response = await request.post('/api/settings', {
data: {theme: 'dark'},
headers: {
'Content-Type': 'application/json',
},
})
// Should be rejected
expect(response.status()).toBe(403)
})
test('rejects requests with invalid CSRF token', async ({page, request}) => {
await page.goto('/settings')
const response = await request.post('/api/settings', {
data: {theme: 'dark'},
headers: {
'X-CSRF-Token': 'invalid-token',
},
})
expect(response.status()).toBe(403)
})
test('accepts requests with valid CSRF token', async ({page}) => {
await page.goto('/settings')
// Get CSRF token from page
const csrfToken = await page.locator('meta[name="csrf-token"]').getAttribute('content')
// Submit form normally
await page.getByLabel('Theme').selectOption('dark')
await page.getByRole('button', {name: 'Save'}).click()
// Should succeed
await expect(page.getByText('Settings saved')).toBeVisible()
})
test('session expires after timeout', async ({page, context}) => {
await page.goto('/login')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('password')
await page.getByRole('button', {name: 'Sign in'}).click()
await expect(page).toHaveURL('/dashboard')
// Simulate time passing (if using clock mocking)
await page.clock.fastForward('02:00:00') // 2 hours
// Try to access protected page
await page.goto('/profile')
// Should redirect to login
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText('Session expired')).toBeVisible()
})
test('handles concurrent session limit', async ({browser}) => {
// Login from first browser
const context1 = await browser.newContext()
const page1 = await context1.newPage()
await page1.goto('/login')
await page1.getByLabel('Email').fill('[email protected]')
await page1.getByLabel('Password').fill('password')
await page1.getByRole('button', {name: 'Sign in'}).click()
await expect(page1).toHaveURL('/dashboard')
// Login from second browser (same user)
const context2 = await browser.newContext()
const page2 = await context2.newPage()
await page2.goto('/login')
await page2.getByLabel('Email').fill('[email protected]')
await page2.getByLabel('Password').fill('password')
await page2.getByRole('button', {name: 'Sign in'}).click()
// First session should be invalidated (or warning shown)
await page1.reload()
await expect(page1.getByText(/session.*another device|logged out/i)).toBeVisible()
await context1.close()
await context2.close()
})
test('password reset token is single-use', async ({page, request}) => {
// Request password reset
await page.goto('/forgot-password')
await page.getByLabel('Email').fill('[email protected]')
await page.getByRole('button', {name: 'Reset'}).click()
// Get token (in test env, might be exposed or use email mock)
const resetToken = 'mock-reset-token'
// Use token first time
await page.goto(`/reset-password?token=${resetToken}`)
await page.getByLabel('New Password').fill('NewPassword123')
await page.getByRole('button', {name: 'Reset'}).click()
await expect(page.getByText('Password updated')).toBeVisible()
// Try to use same token again
await page.goto(`/reset-password?token=${resetToken}`)
await expect(page.getByText('Invalid or expired token')).toBeVisible()
})
test.describe('authorization', () => {
test('cannot access admin routes as user', async ({browser}) => {
const context = await browser.newContext({
storageState: '.auth/user.json', // Regular user
})
const page = await context.newPage()
// Try to access admin page
await page.goto('/admin/users')
// Should be denied
await expect(page).not.toHaveURL('/admin/users')
expect(
(await page.getByText('Access denied').isVisible()) ||
(await page.url()).includes('/login') ||
(await page.url()).includes('/403'),
).toBe(true)
await context.close()
})
test("cannot access other user's data", async ({page}) => {
// Logged in as user 1, try to access user 2's profile
await page.goto('/users/other-user-id/settings')
await expect(page.getByText('Access denied')).toBeVisible()
})
})
test('cannot access other user resources by changing ID', async ({page, request}) => {
// Get current user's order
await page.goto('/orders/my-order-123')
await expect(page.getByText('Order #my-order-123')).toBeVisible()
// Try to access another user's order
const response = await request.get('/api/orders/other-user-order-456')
// Should be forbidden
expect(response.status()).toBe(403)
})
test('SQL injection is prevented', async ({page}) => {
const sqlPayloads = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
'1; DELETE FROM orders',
"' UNION SELECT * FROM users --",
]
for (const payload of sqlPayloads) {
await page.goto('/search')
await page.getByLabel('Search').fill(payload)
await page.getByRole('button', {name: 'Search'}).click()
// Should not error (injection blocked/escaped)
await expect(page.getByText('Error')).not.toBeVisible()
// Should show no results or escaped text
const hasError = await page.getByText(/database error|sql|syntax/i).isVisible()
expect(hasError).toBe(false)
}
})
test('enforces input length limits', async ({page}) => {
await page.goto('/profile')
// Try to submit very long input
const longString = 'a'.repeat(10000)
await page.getByLabel('Bio').fill(longString)
await page.getByRole('button', {name: 'Save'}).click()
// Should show validation error or truncate
const bioValue = await page.getByLabel('Bio').inputValue()
expect(bioValue.length).toBeLessThanOrEqual(500) // Expected max
})
test('response includes security headers', async ({page}) => {
const response = await page.goto('/')
const headers = response!.headers()
// Content Security Policy
expect(headers['content-security-policy']).toBeTruthy()
// Prevent clickjacking
expect(headers['x-frame-options']).toMatch(/DENY|SAMEORIGIN/)
// Prevent MIME type sniffing
expect(headers['x-content-type-options']).toBe('nosniff')
// XSS Protection (legacy but good to have)
expect(headers['x-xss-protection']).toBeTruthy()
// HTTPS enforcement
if (!page.url().includes('localhost')) {
expect(headers['strict-transport-security']).toBeTruthy()
}
})
test('CSP blocks inline scripts', async ({page}) => {
const cspViolations: string[] = []
// Listen for CSP violations via console
page.on('console', (msg) => {
if (msg.text().includes('Content Security Policy')) {
cspViolations.push(msg.text())
}
})
await page.goto('/')
// Try to inject inline script - CSP should block it
await page.evaluate(() => {
const script = document.createElement('script')
script.textContent = 'console.log("injected")'
document.body.appendChild(script)
})
expect(cspViolations.length).toBeGreaterThan(0)
})
For comprehensive console monitoring (fixtures, allowed patterns, fail on errors), see console-errors.md.
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Testing only happy path | Misses security holes | Test malicious inputs |
| Hardcoded test credentials | Security risk | Use environment variables |
| Skipping auth tests in dev | Bugs reach production | Test auth in all environments |
| Not testing authorization | Access control bugs | Test all role combinations |