upskill-event-manager/tests/test-featured-image-upload.js
ben 90193ea18c security: implement Phase 1 critical vulnerability fixes
- 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>
2025-09-25 18:53:23 -03:00

509 lines
No EOL
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
const path = require('path');
const fs = require('fs');
/**
* Featured Image Upload Test Suite
*
* Tests the drag-and-drop image upload system including:
* - File validation (type, size, format)
* - Drag and drop functionality
* - Image preview generation
* - File replacement and removal
* - Security validation
* - Error handling and recovery
*/
test.describe('Featured Image Upload System', () => {
let hvacTest;
const testImagesDir = path.join(__dirname, 'fixtures', 'images');
test.beforeAll(async () => {
// Create test images directory if it doesn't exist
if (!fs.existsSync(testImagesDir)) {
fs.mkdirSync(testImagesDir, { recursive: true });
}
// Create test images
await createTestImages(testImagesDir);
});
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for upload component to be ready
await expect(page.locator('.featured-image-upload')).toBeVisible();
});
test.describe('Basic Upload Functionality', () => {
test('should initialize upload component correctly', async ({ page }) => {
// Verify all upload elements are present
await expect(page.locator('#featured-image-input')).toBeVisible();
await expect(page.locator('.upload-area')).toBeVisible();
await expect(page.locator('.upload-placeholder')).toBeVisible();
// Verify initial state
const hasPreview = await page.locator('#image-preview').isVisible();
expect(hasPreview).toBe(false);
// Verify drag and drop indicators
await expect(page.locator('.drag-drop-text')).toContainText('Drag & drop');
});
test('should upload valid image file', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show preview
await expect(page.locator('#image-preview')).toBeVisible();
await expect(page.locator('#image-preview img')).toBeVisible();
// Should show file info
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
// Should hide upload placeholder
await expect(page.locator('.upload-placeholder')).not.toBeVisible();
// Should show remove button
await expect(page.locator('.remove-image-btn')).toBeVisible();
});
test('should display image preview with correct dimensions', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'large-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('#image-preview img')).toBeVisible();
// Check preview dimensions are constrained
const img = page.locator('#image-preview img');
const { width, height } = await img.boundingBox();
expect(width).toBeLessThanOrEqual(400); // Max preview width
expect(height).toBeLessThanOrEqual(300); // Max preview height
});
test('should show loading state during upload', async ({ page }) => {
// Slow down the FileReader to test loading state
await page.addInitScript(() => {
const originalFileReader = window.FileReader;
window.FileReader = function() {
const reader = new originalFileReader();
const originalReadAsDataURL = reader.readAsDataURL;
reader.readAsDataURL = function(file) {
setTimeout(() => {
originalReadAsDataURL.call(this, file);
}, 1000); // 1 second delay
};
return reader;
};
});
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show loading indicator
await expect(page.locator('.upload-loading')).toBeVisible();
// Wait for upload to complete
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.upload-loading')).not.toBeVisible();
});
});
test.describe('File Validation', () => {
test('should accept valid image formats', async ({ page }) => {
const validFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
for (const format of validFormats) {
const imagePath = path.join(testImagesDir, `test-image.${format}`);
await page.setInputFiles('#featured-image-input', imagePath);
// Should show preview for valid formats
await expect(page.locator('#image-preview img')).toBeVisible();
// Clear for next test
await page.click('.remove-image-btn');
await expect(page.locator('#image-preview')).not.toBeVisible();
}
});
test('should reject invalid file types', async ({ page }) => {
const invalidFiles = [
'document.pdf',
'script.js',
'text-file.txt',
'video.mp4',
'audio.mp3'
];
for (const filename of invalidFiles) {
const filePath = path.join(testImagesDir, filename);
await page.setInputFiles('#featured-image-input', filePath);
// Should show error message
await expect(page.locator('.upload-error')).toBeVisible();
await expect(page.locator('.upload-error')).toContainText('Invalid file type');
// Should not show preview
await expect(page.locator('#image-preview img')).not.toBeVisible();
}
});
test('should enforce file size limits', async ({ page }) => {
const oversizedImagePath = path.join(testImagesDir, 'oversized-image.jpg');
await page.setInputFiles('#featured-image-input', oversizedImagePath);
await expect(page.locator('.upload-error')).toBeVisible();
await expect(page.locator('.upload-error')).toContainText('File size exceeds 5MB limit');
});
test('should validate actual image content vs extension', async ({ page }) => {
// File with .jpg extension but actually a text file
const fakeImagePath = path.join(testImagesDir, 'fake-image.jpg');
await page.setInputFiles('#featured-image-input', fakeImagePath);
await expect(page.locator('.upload-error')).toContainText('File content does not match extension');
});
test('should handle corrupted image files', async ({ page }) => {
const corruptedImagePath = path.join(testImagesDir, 'corrupted-image.jpg');
await page.setInputFiles('#featured-image-input', corruptedImagePath);
// Should show error for corrupted file
await expect(page.locator('.upload-error')).toContainText('Unable to process image file');
});
});
test.describe('Drag and Drop Functionality', () => {
test('should show visual feedback during drag over', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
const file = fs.readFileSync(imagePath);
// Create drag data
const dataTransfer = await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
type: 'image/jpeg'
});
dt.items.add(file);
return dt;
}, Array.from(file));
const uploadArea = page.locator('.upload-area');
// Trigger dragenter event
await uploadArea.dispatchEvent('dragenter', { dataTransfer });
// Should show drag-over state
await expect(uploadArea).toHaveClass(/drag-over/);
await expect(page.locator('.drag-indicator')).toBeVisible();
// Trigger dragleave event
await uploadArea.dispatchEvent('dragleave', { dataTransfer });
// Should remove drag-over state
await expect(uploadArea).not.toHaveClass(/drag-over/);
});
test('should handle drop event with valid image', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
const file = fs.readFileSync(imagePath);
await page.locator('.upload-area').dispatchEvent('drop', {
dataTransfer: await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
type: 'image/jpeg'
});
dt.items.add(file);
return dt;
}, Array.from(file))
});
// Should process dropped file
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
});
test('should handle multiple files dropped (take first valid)', async ({ page }) => {
const imagePath1 = path.join(testImagesDir, 'image1.jpg');
const imagePath2 = path.join(testImagesDir, 'image2.jpg');
const file1 = fs.readFileSync(imagePath1);
const file2 = fs.readFileSync(imagePath2);
await page.locator('.upload-area').dispatchEvent('drop', {
dataTransfer: await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file1 = new File([new Uint8Array(fileData.file1)], 'image1.jpg', {
type: 'image/jpeg'
});
const file2 = new File([new Uint8Array(fileData.file2)], 'image2.jpg', {
type: 'image/jpeg'
});
dt.items.add(file1);
dt.items.add(file2);
return dt;
}, { file1: Array.from(file1), file2: Array.from(file2) })
});
// Should process only the first file
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
// Should show warning about multiple files
await expect(page.locator('.upload-warning')).toContainText('Only the first image was selected');
});
test('should prevent default browser behavior on drag events', async ({ page }) => {
// Monitor for any navigation attempts
let navigationAttempted = false;
page.on('framenavigated', () => {
navigationAttempted = true;
});
const uploadArea = page.locator('.upload-area');
// Simulate drag events that might cause navigation
await uploadArea.dispatchEvent('dragover');
await uploadArea.dispatchEvent('dragenter');
await uploadArea.dispatchEvent('drop');
await page.waitForTimeout(500);
// Should not have attempted navigation
expect(navigationAttempted).toBe(false);
});
});
test.describe('Image Management', () => {
test('should allow image replacement', async ({ page }) => {
const firstImage = path.join(testImagesDir, 'image1.jpg');
const secondImage = path.join(testImagesDir, 'image2.jpg');
// Upload first image
await page.setInputFiles('#featured-image-input', firstImage);
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
// Upload second image (should replace first)
await page.setInputFiles('#featured-image-input', secondImage);
await expect(page.locator('.image-info .filename')).toContainText('image2.jpg');
// Should only have one preview
const previewCount = await page.locator('#image-preview img').count();
expect(previewCount).toBe(1);
});
test('should allow image removal', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('#image-preview img')).toBeVisible();
// Click remove button
await page.click('.remove-image-btn');
// Should hide preview and restore upload area
await expect(page.locator('#image-preview')).not.toBeVisible();
await expect(page.locator('.upload-placeholder')).toBeVisible();
// Should clear file input
const inputValue = await page.locator('#featured-image-input').inputValue();
expect(inputValue).toBe('');
});
test('should show image metadata', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'detailed-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show file information
await expect(page.locator('.image-info .filename')).toBeVisible();
await expect(page.locator('.image-info .filesize')).toBeVisible();
await expect(page.locator('.image-info .dimensions')).toBeVisible();
// Verify information format
const filesize = await page.locator('.image-info .filesize').textContent();
expect(filesize).toMatch(/\d+(\.\d+)?\s?(KB|MB)/);
const dimensions = await page.locator('.image-info .dimensions').textContent();
expect(dimensions).toMatch(/\d+\s?×\s?\d+/);
});
test('should handle image orientation correctly', async ({ page }) => {
const portraitPath = path.join(testImagesDir, 'portrait-image.jpg');
const landscapePath = path.join(testImagesDir, 'landscape-image.jpg');
// Test portrait image
await page.setInputFiles('#featured-image-input', portraitPath);
await expect(page.locator('#image-preview img')).toBeVisible();
let imgBox = await page.locator('#image-preview img').boundingBox();
expect(imgBox.height).toBeGreaterThan(imgBox.width);
await page.click('.remove-image-btn');
// Test landscape image
await page.setInputFiles('#featured-image-input', landscapePath);
await expect(page.locator('#image-preview img')).toBeVisible();
imgBox = await page.locator('#image-preview img').boundingBox();
expect(imgBox.width).toBeGreaterThan(imgBox.height);
});
});
test.describe('Error Handling', () => {
test('should handle FileReader errors gracefully', async ({ page }) => {
// Mock FileReader to throw an error
await page.addInitScript(() => {
const originalFileReader = window.FileReader;
window.FileReader = function() {
const reader = new originalFileReader();
const originalReadAsDataURL = reader.readAsDataURL;
reader.readAsDataURL = function(file) {
setTimeout(() => {
if (this.onerror) {
this.onerror(new Error('FileReader error'));
}
}, 100);
};
return reader;
};
});
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('.upload-error')).toContainText('Error processing image file');
});
test('should recover from upload errors', async ({ page }) => {
const invalidPath = path.join(testImagesDir, 'invalid-file.txt');
// Try invalid file first
await page.setInputFiles('#featured-image-input', invalidPath);
await expect(page.locator('.upload-error')).toBeVisible();
// Should be able to upload valid file after error
const validPath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', validPath);
await expect(page.locator('.upload-error')).not.toBeVisible();
await expect(page.locator('#image-preview img')).toBeVisible();
});
test('should handle network errors during upload', async ({ page }) => {
// Mock network failure for any upload requests
await page.route('**/*upload*', route => route.abort());
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should handle network error gracefully
await expect(page.locator('.upload-error')).toContainText('Upload failed');
});
test('should validate against MIME type spoofing', async ({ page }) => {
// File with image extension but text content
const spoofedPath = path.join(testImagesDir, 'spoofed-image.jpg');
await page.setInputFiles('#featured-image-input', spoofedPath);
await expect(page.locator('.upload-error')).toContainText('File content validation failed');
});
});
test.describe('Accessibility', () => {
test('should support keyboard navigation', async ({ page }) => {
// Focus should move to file input
await page.keyboard.press('Tab');
await page.keyboard.press('Tab'); // Navigate to upload area
const focused = await page.evaluate(() =>
document.activeElement.classList.contains('upload-area') ||
document.activeElement.id === 'featured-image-input'
);
expect(focused).toBe(true);
// Enter key should trigger file dialog
await page.keyboard.press('Enter');
// File dialog behavior varies by browser - just ensure no errors
});
test('should have proper ARIA labels', async ({ page }) => {
const uploadArea = page.locator('.upload-area');
const fileInput = page.locator('#featured-image-input');
// Check for accessibility attributes
await expect(uploadArea).toHaveAttribute('role', 'button');
await expect(uploadArea).toHaveAttribute('aria-label');
await expect(fileInput).toHaveAttribute('aria-describedby');
});
test('should announce upload status to screen readers', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
// Monitor for aria-live announcements
const announcements = [];
page.on('console', msg => {
if (msg.text().includes('aria-live')) {
announcements.push(msg.text());
}
});
await page.setInputFiles('#featured-image-input', imagePath);
// Should announce successful upload
await expect(page.locator('[aria-live="polite"]')).toContainText('Image uploaded successfully');
});
});
});
/**
* Helper function to create test image files
*/
async function createTestImages(dir) {
// Create minimal valid JPEG data
const validJpeg = Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
// ... truncated for brevity - minimal JPEG data
0xFF, 0xD9
]);
const testFiles = {
'valid-image.jpg': validJpeg,
'test-image.jpeg': validJpeg,
'test-image.png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG header
'test-image.gif': Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF header
'test-image.webp': Buffer.from([0x52, 0x49, 0x46, 0x46]), // WEBP header
'large-image.jpg': Buffer.concat([validJpeg, Buffer.alloc(1024 * 1024)]), // ~1MB
'oversized-image.jpg': Buffer.alloc(6 * 1024 * 1024), // 6MB
'fake-image.jpg': Buffer.from('This is not an image file'),
'corrupted-image.jpg': Buffer.from([0xFF, 0xD8, 0x00, 0x00]), // Invalid JPEG
'document.pdf': Buffer.from('%PDF-1.4'),
'script.js': Buffer.from('alert("test");'),
'text-file.txt': Buffer.from('Plain text content'),
'video.mp4': Buffer.from('ftypmp42'),
'audio.mp3': Buffer.from('ID3'),
'image1.jpg': validJpeg,
'image2.jpg': validJpeg,
'detailed-image.jpg': validJpeg,
'portrait-image.jpg': validJpeg,
'landscape-image.jpg': validJpeg,
'spoofed-image.jpg': Buffer.from('<script>alert("XSS")</script>')
};
for (const [filename, data] of Object.entries(testFiles)) {
fs.writeFileSync(path.join(dir, filename), data);
}
}