- Add XSS protection with DOMPurify sanitization in rich text editor - Implement comprehensive file upload security validation - Enhance server-side content sanitization with wp_kses - Add comprehensive security test suite with 194+ test cases - Create security remediation plan documentation Security fixes address: - CRITICAL: XSS vulnerability in event description editor - HIGH: File upload security bypass for malicious files - HIGH: Enhanced CSRF protection verification - MEDIUM: Input validation and error handling improvements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
553 lines
No EOL
23 KiB
JavaScript
553 lines
No EOL
23 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
const { HVACTestBase } = require('./page-objects/HVACTestBase');
|
|
|
|
/**
|
|
* Searchable Selector Components Test Suite
|
|
*
|
|
* Tests the advanced selector components including:
|
|
* - Multi-select organizers (max 3) with autocomplete search
|
|
* - Multi-select categories (max 3) with autocomplete search
|
|
* - Single-select venue with autocomplete search
|
|
* - "Create New" modal integration
|
|
* - Search functionality and filtering
|
|
* - Selection limits and validation
|
|
* - Keyboard navigation and accessibility
|
|
*/
|
|
test.describe('Searchable Selector Components', () => {
|
|
let hvacTest;
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
hvacTest = new HVACTestBase(page);
|
|
await hvacTest.loginAsTrainer();
|
|
await hvacTest.navigateToCreateEvent();
|
|
|
|
// Wait for selectors to be ready
|
|
await expect(page.locator('.organizer-selector')).toBeVisible();
|
|
await expect(page.locator('.category-selector')).toBeVisible();
|
|
await expect(page.locator('.venue-selector')).toBeVisible();
|
|
});
|
|
|
|
test.describe('Organizer Multi-Select Component', () => {
|
|
test('should initialize organizer selector correctly', async ({ page }) => {
|
|
const organizerSelector = page.locator('.organizer-selector');
|
|
|
|
// Verify initial elements
|
|
await expect(organizerSelector.locator('input[type="text"]')).toBeVisible();
|
|
await expect(organizerSelector.locator('.selected-items')).toBeVisible();
|
|
await expect(organizerSelector.locator('.create-new-btn')).toBeVisible();
|
|
|
|
// Verify placeholder text
|
|
const placeholder = await organizerSelector.locator('input').getAttribute('placeholder');
|
|
expect(placeholder).toContain('Search organizers');
|
|
|
|
// Verify initial state
|
|
const selectedCount = await organizerSelector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(0);
|
|
});
|
|
|
|
test('should show dropdown on focus with all available options', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
|
|
// Dropdown should appear
|
|
await expect(page.locator('.organizer-dropdown')).toBeVisible();
|
|
|
|
// Should show available organizers
|
|
const options = await page.locator('.organizer-dropdown .option').count();
|
|
expect(options).toBeGreaterThan(0);
|
|
|
|
// Should show "Create New Organizer" option
|
|
await expect(page.locator('.organizer-dropdown .create-new-option')).toBeVisible();
|
|
});
|
|
|
|
test('should filter options based on search input', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await input.type('john');
|
|
|
|
// Should filter results
|
|
const visibleOptions = await page.locator('.organizer-dropdown .option:visible').count();
|
|
const totalOptions = await page.locator('.organizer-dropdown .option').count();
|
|
|
|
expect(visibleOptions).toBeLessThanOrEqual(totalOptions);
|
|
|
|
// Options should contain search term
|
|
const firstOption = await page.locator('.organizer-dropdown .option:visible').first().textContent();
|
|
expect(firstOption.toLowerCase()).toContain('john');
|
|
});
|
|
|
|
test('should select multiple organizers up to limit', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
const selector = page.locator('.organizer-selector');
|
|
|
|
// Select first organizer
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').first().click();
|
|
|
|
// Verify first selection
|
|
let selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(1);
|
|
|
|
// Select second organizer
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').nth(1).click();
|
|
|
|
selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(2);
|
|
|
|
// Select third organizer
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').nth(2).click();
|
|
|
|
selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(3);
|
|
|
|
// Try to select fourth (should be prevented)
|
|
await input.click();
|
|
const fourthOption = page.locator('.organizer-dropdown .option').nth(3);
|
|
if (await fourthOption.isVisible()) {
|
|
await fourthOption.click();
|
|
|
|
// Should still be at limit
|
|
selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(3);
|
|
|
|
// Should show limit warning
|
|
await expect(page.locator('.selection-limit-warning')).toContainText('Maximum 3 organizers');
|
|
}
|
|
});
|
|
|
|
test('should allow removing selected organizers', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
const selector = page.locator('.organizer-selector');
|
|
|
|
// Select an organizer
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').first().click();
|
|
|
|
let selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(1);
|
|
|
|
// Remove the selected organizer
|
|
await selector.locator('.selected-item .remove-btn').click();
|
|
|
|
selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(0);
|
|
});
|
|
|
|
test('should prevent duplicate selections', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
const selector = page.locator('.organizer-selector');
|
|
|
|
// Select an organizer
|
|
await input.click();
|
|
const firstOption = page.locator('.organizer-dropdown .option').first();
|
|
const firstOptionText = await firstOption.textContent();
|
|
await firstOption.click();
|
|
|
|
// Try to select the same organizer again
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').filter({ hasText: firstOptionText }).click();
|
|
|
|
// Should still have only one selection
|
|
const selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(1);
|
|
});
|
|
|
|
test('should open create new organizer modal', async ({ page }) => {
|
|
await page.locator('.organizer-selector .create-new-btn').click();
|
|
|
|
// Modal should open
|
|
await expect(page.locator('#organizer-modal')).toBeVisible();
|
|
await expect(page.locator('#organizer-modal .modal-title')).toContainText('Create New Organizer');
|
|
|
|
// Form fields should be visible
|
|
await expect(page.locator('#new-organizer-name')).toBeVisible();
|
|
await expect(page.locator('#new-organizer-email')).toBeVisible();
|
|
await expect(page.locator('#new-organizer-phone')).toBeVisible();
|
|
await expect(page.locator('#new-organizer-organization')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Category Multi-Select Component', () => {
|
|
test('should function similar to organizer selector', async ({ page }) => {
|
|
const categorySelector = page.locator('.category-selector');
|
|
const input = categorySelector.locator('input');
|
|
|
|
// Test basic functionality
|
|
await input.click();
|
|
await expect(page.locator('.category-dropdown')).toBeVisible();
|
|
|
|
// Select multiple categories (max 3)
|
|
await page.locator('.category-dropdown .option').first().click();
|
|
await input.click();
|
|
await page.locator('.category-dropdown .option').nth(1).click();
|
|
await input.click();
|
|
await page.locator('.category-dropdown .option').nth(2).click();
|
|
|
|
const selectedCount = await categorySelector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(3);
|
|
});
|
|
|
|
test('should enforce category selection limit', async ({ page }) => {
|
|
const categorySelector = page.locator('.category-selector');
|
|
const input = categorySelector.locator('input');
|
|
|
|
// Select maximum categories
|
|
for (let i = 0; i < 4; i++) {
|
|
await input.click();
|
|
const option = page.locator('.category-dropdown .option').nth(i);
|
|
if (await option.isVisible()) {
|
|
await option.click();
|
|
}
|
|
}
|
|
|
|
// Should not exceed limit
|
|
const selectedCount = await categorySelector.locator('.selected-item').count();
|
|
expect(selectedCount).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test('should open create new category modal', async ({ page }) => {
|
|
await page.locator('.category-selector .create-new-btn').click();
|
|
|
|
await expect(page.locator('#category-modal')).toBeVisible();
|
|
await expect(page.locator('#new-category-name')).toBeVisible();
|
|
await expect(page.locator('#new-category-description')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Venue Single-Select Component', () => {
|
|
test('should initialize venue selector correctly', async ({ page }) => {
|
|
const venueSelector = page.locator('.venue-selector');
|
|
|
|
await expect(venueSelector.locator('input[type="text"]')).toBeVisible();
|
|
await expect(venueSelector.locator('.selected-venue')).toBeVisible();
|
|
|
|
const placeholder = await venueSelector.locator('input').getAttribute('placeholder');
|
|
expect(placeholder).toContain('Search venues');
|
|
});
|
|
|
|
test('should allow single venue selection only', async ({ page }) => {
|
|
const input = page.locator('.venue-selector input');
|
|
|
|
await input.click();
|
|
await expect(page.locator('.venue-dropdown')).toBeVisible();
|
|
|
|
// Select first venue
|
|
await page.locator('.venue-dropdown .option').first().click();
|
|
|
|
// Should show selected venue
|
|
await expect(page.locator('.selected-venue .venue-name')).toBeVisible();
|
|
|
|
// Select different venue (should replace first)
|
|
await input.click();
|
|
await page.locator('.venue-dropdown .option').nth(1).click();
|
|
|
|
// Should have only one venue selected
|
|
const selectedVenues = await page.locator('.selected-venue .venue-name').count();
|
|
expect(selectedVenues).toBe(1);
|
|
});
|
|
|
|
test('should clear venue selection', async ({ page }) => {
|
|
const input = page.locator('.venue-selector input');
|
|
|
|
// Select a venue
|
|
await input.click();
|
|
await page.locator('.venue-dropdown .option').first().click();
|
|
|
|
// Clear selection
|
|
await page.locator('.selected-venue .clear-btn').click();
|
|
|
|
// Should have no selection
|
|
await expect(page.locator('.selected-venue .venue-name')).not.toBeVisible();
|
|
});
|
|
|
|
test('should open create new venue modal', async ({ page }) => {
|
|
await page.locator('.venue-selector .create-new-btn').click();
|
|
|
|
await expect(page.locator('#venue-modal')).toBeVisible();
|
|
await expect(page.locator('#new-venue-name')).toBeVisible();
|
|
await expect(page.locator('#new-venue-address')).toBeVisible();
|
|
await expect(page.locator('#new-venue-capacity')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Search Functionality', () => {
|
|
test('should perform case-insensitive search', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await input.type('JOHN'); // Uppercase
|
|
|
|
const options = await page.locator('.organizer-dropdown .option:visible').all();
|
|
for (const option of options) {
|
|
const text = await option.textContent();
|
|
expect(text.toLowerCase()).toContain('john');
|
|
}
|
|
});
|
|
|
|
test('should search across multiple fields', async ({ page }) => {
|
|
const input = page.locator('.venue-selector input');
|
|
|
|
await input.click();
|
|
await input.type('conference'); // Could match name or description
|
|
|
|
await expect(page.locator('.venue-dropdown .option:visible')).toHaveCount({ min: 1 });
|
|
});
|
|
|
|
test('should show no results message for empty search', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await input.type('zyxwvu'); // Non-existent search
|
|
|
|
await expect(page.locator('.organizer-dropdown .no-results')).toBeVisible();
|
|
await expect(page.locator('.organizer-dropdown .no-results')).toContainText('No organizers found');
|
|
});
|
|
|
|
test('should clear search when input is cleared', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await input.type('john');
|
|
|
|
// Clear input
|
|
await input.fill('');
|
|
|
|
// Should show all options again
|
|
const visibleCount = await page.locator('.organizer-dropdown .option:visible').count();
|
|
const totalCount = await page.locator('.organizer-dropdown .option').count();
|
|
|
|
expect(visibleCount).toBe(totalCount);
|
|
});
|
|
});
|
|
|
|
test.describe('Keyboard Navigation', () => {
|
|
test('should support arrow key navigation in dropdown', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
|
|
// Navigate with arrow keys
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect(page.locator('.organizer-dropdown .option.highlighted')).toBeVisible();
|
|
|
|
await page.keyboard.press('ArrowDown');
|
|
const highlightedOptions = await page.locator('.organizer-dropdown .option.highlighted').count();
|
|
expect(highlightedOptions).toBe(1); // Only one should be highlighted
|
|
|
|
// Navigate up
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(page.locator('.organizer-dropdown .option.highlighted')).toBeVisible();
|
|
});
|
|
|
|
test('should select option with Enter key', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
const selector = page.locator('.organizer-selector');
|
|
|
|
await input.click();
|
|
await page.keyboard.press('ArrowDown');
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Should have selected an organizer
|
|
const selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBe(1);
|
|
});
|
|
|
|
test('should close dropdown with Escape key', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await expect(page.locator('.organizer-dropdown')).toBeVisible();
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('.organizer-dropdown')).not.toBeVisible();
|
|
});
|
|
|
|
test('should support tab navigation between selectors', async ({ page }) => {
|
|
// Start from organizer selector
|
|
await page.locator('.organizer-selector input').click();
|
|
|
|
// Tab to category selector
|
|
await page.keyboard.press('Tab');
|
|
const categoryFocused = await page.evaluate(() =>
|
|
document.activeElement.closest('.category-selector') !== null
|
|
);
|
|
expect(categoryFocused).toBe(true);
|
|
|
|
// Tab to venue selector
|
|
await page.keyboard.press('Tab');
|
|
const venueFocused = await page.evaluate(() =>
|
|
document.activeElement.closest('.venue-selector') !== null
|
|
);
|
|
expect(venueFocused).toBe(true);
|
|
});
|
|
});
|
|
|
|
test.describe('Accessibility', () => {
|
|
test('should have proper ARIA attributes', async ({ page }) => {
|
|
const organizerInput = page.locator('.organizer-selector input');
|
|
|
|
// Check ARIA attributes
|
|
await expect(organizerInput).toHaveAttribute('role', 'combobox');
|
|
await expect(organizerInput).toHaveAttribute('aria-expanded', 'false');
|
|
await expect(organizerInput).toHaveAttribute('aria-haspopup', 'listbox');
|
|
|
|
// Open dropdown
|
|
await organizerInput.click();
|
|
await expect(organizerInput).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
// Check dropdown ARIA
|
|
const dropdown = page.locator('.organizer-dropdown');
|
|
await expect(dropdown).toHaveAttribute('role', 'listbox');
|
|
|
|
const options = page.locator('.organizer-dropdown .option');
|
|
await expect(options.first()).toHaveAttribute('role', 'option');
|
|
});
|
|
|
|
test('should announce selections to screen readers', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').first().click();
|
|
|
|
// Check for live region announcement
|
|
await expect(page.locator('[aria-live="polite"]')).toContainText('Organizer selected');
|
|
});
|
|
|
|
test('should support screen reader navigation', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
await input.click();
|
|
|
|
// Options should be readable by screen reader
|
|
const firstOption = page.locator('.organizer-dropdown .option').first();
|
|
const ariaLabel = await firstOption.getAttribute('aria-label');
|
|
expect(ariaLabel).toBeTruthy();
|
|
});
|
|
|
|
test('should handle focus management properly', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
// Focus should return to input after selection
|
|
await input.click();
|
|
await page.locator('.organizer-dropdown .option').first().click();
|
|
|
|
const focused = await page.evaluate(() =>
|
|
document.activeElement.classList.contains('selector-input')
|
|
);
|
|
expect(focused).toBe(true);
|
|
});
|
|
});
|
|
|
|
test.describe('Error Handling', () => {
|
|
test('should handle AJAX errors gracefully', async ({ page }) => {
|
|
// Mock AJAX failure
|
|
await page.route('**/*organizer*', route => route.abort());
|
|
|
|
const input = page.locator('.organizer-selector input');
|
|
await input.click();
|
|
|
|
// Should show error message
|
|
await expect(page.locator('.organizer-dropdown .error-message')).toBeVisible();
|
|
await expect(page.locator('.organizer-dropdown .error-message')).toContainText('Failed to load organizers');
|
|
});
|
|
|
|
test('should recover from network errors', async ({ page }) => {
|
|
// Temporarily fail requests
|
|
await page.route('**/*organizer*', route => route.abort());
|
|
|
|
const input = page.locator('.organizer-selector input');
|
|
await input.click();
|
|
await expect(page.locator('.organizer-dropdown .error-message')).toBeVisible();
|
|
|
|
// Restore network and retry
|
|
await page.unroute('**/*organizer*');
|
|
await page.locator('.retry-btn').click();
|
|
|
|
await expect(page.locator('.organizer-dropdown .option')).toHaveCount({ min: 1 });
|
|
});
|
|
|
|
test('should validate selection limits client-side', async ({ page }) => {
|
|
// Mock server that would allow over-limit selections
|
|
await page.addInitScript(() => {
|
|
window.organizerSelectionLimit = 3;
|
|
});
|
|
|
|
const selector = page.locator('.organizer-selector');
|
|
const input = selector.locator('input');
|
|
|
|
// Try to exceed limit programmatically
|
|
await page.evaluate(() => {
|
|
const selector = document.querySelector('.organizer-selector');
|
|
// Simulate multiple rapid selections
|
|
for (let i = 0; i < 5; i++) {
|
|
const event = new CustomEvent('organizer-selected', {
|
|
detail: { id: i, name: `Organizer ${i}` }
|
|
});
|
|
selector.dispatchEvent(event);
|
|
}
|
|
});
|
|
|
|
// Should still respect client-side limit
|
|
const selectedCount = await selector.locator('.selected-item').count();
|
|
expect(selectedCount).toBeLessThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
test.describe('Performance', () => {
|
|
test('should handle large datasets efficiently', async ({ page }) => {
|
|
// Mock large dataset
|
|
await page.addInitScript(() => {
|
|
window.mockLargeDataset = true;
|
|
window.organizerCount = 1000;
|
|
});
|
|
|
|
const input = page.locator('.organizer-selector input');
|
|
|
|
const startTime = Date.now();
|
|
await input.click();
|
|
await expect(page.locator('.organizer-dropdown')).toBeVisible();
|
|
const endTime = Date.now();
|
|
|
|
// Should render within reasonable time
|
|
expect(endTime - startTime).toBeLessThan(1000); // 1 second
|
|
});
|
|
|
|
test('should virtualize long lists', async ({ page }) => {
|
|
const input = page.locator('.organizer-selector input');
|
|
await input.click();
|
|
|
|
// Check if virtualization is implemented
|
|
const visibleOptions = await page.locator('.organizer-dropdown .option:visible').count();
|
|
const totalOptions = await page.locator('.organizer-dropdown .option').count();
|
|
|
|
// If there are many options, only a subset should be visible
|
|
if (totalOptions > 50) {
|
|
expect(visibleOptions).toBeLessThan(totalOptions);
|
|
}
|
|
});
|
|
|
|
test('should debounce search requests', async ({ page }) => {
|
|
let requestCount = 0;
|
|
page.on('request', request => {
|
|
if (request.url().includes('search')) {
|
|
requestCount++;
|
|
}
|
|
});
|
|
|
|
const input = page.locator('.organizer-selector input');
|
|
await input.click();
|
|
|
|
// Type rapidly
|
|
await input.type('john', { delay: 50 });
|
|
|
|
await page.waitForTimeout(1000); // Wait for debounce
|
|
|
|
// Should not make a request for each keystroke
|
|
expect(requestCount).toBeLessThan(4); // Less than number of characters typed
|
|
});
|
|
});
|
|
}); |