UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

521 lines (418 loc) 20 kB
/** * Comprehensive Security and Accessibility Test Suite * * Tests all the critical fixes implemented for: * - Template injection vulnerability fixes * - WCAG AA accessibility compliance * - Input validation and sanitization * - Performance optimizations * - Schema validation */ import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; import { ThemeInjector } from '../core/theme-injector'; import { ColorUtils } from './utils'; import { AccessibilityTester, testThemeAccessibility } from './accessibility'; import { ThemeValidationUtils } from './types'; import { presetThemes } from './presets'; import type { Theme, ThemeInjectionOptions } from './types'; describe('Security Fixes', () => { let themeInjector: ThemeInjector; beforeEach(() => { themeInjector = new ThemeInjector(); }); describe('Template Injection Prevention', () => { test('should reject dangerous template variables', async () => { const maliciousTheme = { ...presetThemes[0], id: 'test$malicious', // Invalid characters in ID name: 'Test Theme', }; // The validation should catch this during theme validation const validation = ThemeValidationUtils.validateTheme(maliciousTheme); // Theme ID with invalid characters should fail expect(validation.success).toBe(false); if (!validation.success) { const errors = ThemeValidationUtils.formatValidationErrors(validation.error!); expect(errors.some(msg => msg.includes('lowercase letters, numbers, and hyphens'))).toBe(true); } }); test('should sanitize template variable values', () => { const injector = new ThemeInjector(); // Access the private sanitizeTemplateValue method with proper binding const sanitizeMethod = (injector as any).sanitizeTemplateValue.bind(injector); // Test various malicious inputs expect(sanitizeMethod('<script>alert("xss")</script>')).toBe('scriptalert(xss)script'); expect(sanitizeMethod('${process.exit(1)}')).toBe('process.exit(1)'); expect(sanitizeMethod('normal-color-value')).toBe('normal-color-value'); expect(sanitizeMethod('210 40% 50%')).toBe('210 40% 50%'); }); test('should validate template variable names against allowlist', () => { const injector = new ThemeInjector(); // Access private validation method with proper binding const validateMethod = (injector as any).validateAndSanitizeVariable.bind(injector); // Valid variables should pass expect(() => validateMethod('THEME_ID', 'test-theme')).not.toThrow(); expect(() => validateMethod('LIGHT_PRIMARY', '210 40% 50%')).not.toThrow(); // Invalid variables should throw expect(() => validateMethod('MALICIOUS_VAR', 'value')).toThrow(/Invalid template variable/); expect(() => validateMethod('DANGEROUS', 'value')).toThrow(/Invalid template variable/); }); test('should reject templates with dangerous patterns', () => { const injector = new ThemeInjector(); // Access private processTemplate method with proper binding const processTemplateMethod = (injector as any).processTemplate.bind(injector); const mockContext = { THEME_ID: 'test' }; // Dangerous templates should throw expect(() => processTemplateMethod('<script>alert("xss")</script>', mockContext)).toThrow(/dangerous pattern/); expect(() => processTemplateMethod('${process.env.SECRET}', mockContext)).toThrow(/dangerous pattern/); expect(() => processTemplateMethod('javascript:alert(1)', mockContext)).toThrow(/dangerous pattern/); expect(() => processTemplateMethod('<div onclick="alert()"></div>', mockContext)).toThrow(/dangerous pattern/); // Safe templates should work expect(() => processTemplateMethod('body { color: {{THEME_ID}}; }', mockContext)).not.toThrow(); }); test('should limit template variable value lengths', () => { const injector = new ThemeInjector(); const sanitizeMethod = (injector as any).sanitizeTemplateValue.bind(injector); const longString = 'a'.repeat(201); // Over 200 character limit expect(() => sanitizeMethod(longString)).toThrow(/exceeds maximum length/); const normalString = 'a'.repeat(199); // Under limit expect(() => sanitizeMethod(normalString)).not.toThrow(); }); }); describe('Input Validation with Zod', () => { test('should validate theme structure with Zod schema', () => { // Valid theme should pass const validTheme = presetThemes[0]; const result = ThemeValidationUtils.validateTheme(validTheme); expect(result.success).toBe(true); expect(result.data).toEqual(validTheme); }); test('should reject invalid theme structures', () => { // Missing required fields const invalidTheme = { id: 'test', // Missing other required fields like name, colorScheme, etc. }; const result = ThemeValidationUtils.validateTheme(invalidTheme); expect(result.success).toBe(false); expect(result.error).toBeDefined(); const errorMessages = ThemeValidationUtils.formatValidationErrors(result.error!); // Check for various required field errors expect(errorMessages.length).toBeGreaterThan(0); expect(errorMessages.some(msg => msg.includes('name') || msg.includes('colorScheme') || msg.includes('Required'))).toBe(true); }); test('should validate HSL color formats', () => { const invalidTheme = { ...presetThemes[0], light: { ...presetThemes[0].light, palette: { ...presetThemes[0].light.palette, primary: { name: 'primary', value: 'invalid-color-format' }, }, }, }; const result = ThemeValidationUtils.validateTheme(invalidTheme); expect(result.success).toBe(false); expect(result.error?.errors.some(e => e.message.includes('Invalid HSL color format'))).toBe(true); }); test('should validate theme ID format', () => { const invalidTheme = { ...presetThemes[0], id: 'Invalid Theme ID!', // Contains invalid characters }; const result = ThemeValidationUtils.validateTheme(invalidTheme); expect(result.success).toBe(false); expect(result.error?.errors.some(e => e.message.includes('lowercase letters, numbers, and hyphens'))).toBe(true); }); test('should validate theme injection options', () => { const validOptions: ThemeInjectionOptions = { projectDir: '/valid/path', theme: presetThemes[0], enableThemeSwitching: true, enableDarkMode: true, }; const result = ThemeValidationUtils.validateThemeInjectionOptions(validOptions); expect(result.success).toBe(true); // Invalid options const invalidOptions = { // Missing required projectDir theme: presetThemes[0], enableThemeSwitching: 'not-boolean', // Wrong type }; const invalidResult = ThemeValidationUtils.validateThemeInjectionOptions(invalidOptions); expect(invalidResult.success).toBe(false); }); }); }); describe('Accessibility Compliance', () => { describe('WCAG AA Contrast Requirements', () => { test('all preset themes should meet WCAG AA contrast requirements', async () => { for (const theme of presetThemes) { // Test primary-foreground/primary contrast (most common failure) const lightContrast = ColorUtils.getContrastRatio( theme.light.palette['primary-foreground'].value, theme.light.palette.primary.value ); const darkContrast = ColorUtils.getContrastRatio( theme.dark.palette['primary-foreground'].value, theme.dark.palette.primary.value ); expect(lightContrast).toBeGreaterThanOrEqual(4.5); expect(darkContrast).toBeGreaterThanOrEqual(4.5); } }); test('should test all critical color pairs for accessibility', async () => { const theme = presetThemes[0]; // Test with blue theme const result = await testThemeAccessibility(theme, { includeColorBlindnessTests: true, includeUsabilityTests: true, strictMode: false, // WCAG AA mode }); expect(result.passed).toBe(true); expect(result.wcagLevel).toBe('AA'); // Should have tested multiple color pairs const contrastTests = result.tests.filter(t => t.category === 'contrast'); expect(contrastTests.length).toBeGreaterThan(10); // Multiple pairs × 2 modes × 2 WCAG levels // All critical tests should pass const criticalFailures = result.tests.filter(t => t.severity === 'critical' && !t.passed); expect(criticalFailures).toHaveLength(0); }); test('should provide detailed accessibility test results', async () => { const theme = presetThemes[0]; const result = await testThemeAccessibility(theme); // Verify result structure expect(result.summary.totalTests).toBeGreaterThan(0); expect(result.summary.passedTests + result.summary.failedTests).toBe(result.summary.totalTests); expect(result.performance.testDurationMs).toBeGreaterThanOrEqual(0); // Allow 0 for very fast tests // All tests should have required properties for (const test of result.tests) { expect(test.id).toBeDefined(); expect(test.name).toBeDefined(); expect(test.category).toMatch(/^(contrast|color-blindness|structure|usability)$/); expect(typeof test.passed).toBe('boolean'); expect(test.wcagLevel).toMatch(/^(A|AA|AAA)$/); expect(typeof test.actualValue).toBe('number'); expect(typeof test.requiredValue).toBe('number'); expect(test.severity).toMatch(/^(critical|major|minor)$/); } }); test('should generate helpful accessibility recommendations', async () => { // Create a theme with known accessibility issues for testing const problematicTheme: Theme = { ...presetThemes[0], id: 'test-problematic', light: { ...presetThemes[0].light, palette: { ...presetThemes[0].light.palette, primary: { name: 'primary', value: '210 40% 85%' }, // Too light for good contrast }, }, }; const result = await testThemeAccessibility(problematicTheme); expect(result.passed).toBe(false); expect(result.recommendations.length).toBeGreaterThan(0); // Should have high-priority contrast recommendations const contrastRecs = result.recommendations.filter(r => r.category === 'contrast'); expect(contrastRecs.length).toBeGreaterThan(0); const highPriorityRecs = result.recommendations.filter(r => r.priority === 'high'); expect(highPriorityRecs.length).toBeGreaterThan(0); }); }); describe('Color Blindness Accessibility', () => { test('should test color distinguishability', async () => { const theme = presetThemes[0]; const result = await testThemeAccessibility(theme, { includeColorBlindnessTests: true, }); const colorBlindnessTests = result.tests.filter(t => t.category === 'color-blindness'); expect(colorBlindnessTests.length).toBeGreaterThan(0); // Most themes should pass basic color distinguishability const passedColorBlindnessTests = colorBlindnessTests.filter(t => t.passed); expect(passedColorBlindnessTests.length).toBe(colorBlindnessTests.length); }); }); describe('Structural Accessibility', () => { test('should validate theme structure for accessibility', async () => { const theme = presetThemes[0]; const result = await testThemeAccessibility(theme); const structureTests = result.tests.filter(t => t.category === 'structure'); expect(structureTests.length).toBeGreaterThan(0); // Structure tests should all pass for preset themes const failedStructureTests = structureTests.filter(t => !t.passed); expect(failedStructureTests).toHaveLength(0); }); }); describe('Usability Features', () => { test('should test focus ring visibility', async () => { const theme = presetThemes[0]; const result = await testThemeAccessibility(theme, { includeUsabilityTests: true, }); const usabilityTests = result.tests.filter(t => t.category === 'usability'); expect(usabilityTests.length).toBeGreaterThan(0); // Focus ring tests should exist const focusRingTests = usabilityTests.filter(t => t.name.includes('Focus ring')); expect(focusRingTests.length).toBeGreaterThanOrEqual(2); // Light and dark modes }); }); }); describe('Performance Optimizations', () => { beforeEach(() => { // Clear caches before each test ColorUtils.clearCaches(); }); afterEach(() => { // Clear caches after each test ColorUtils.clearCaches(); }); describe('Color Calculation Memoization', () => { test('should cache HSL parsing results', () => { const hslString = '210 40% 50%'; // First call should compute and cache const result1 = ColorUtils.parseHSL(hslString); const stats1 = ColorUtils.getCacheStats(); // Second call should use cache const result2 = ColorUtils.parseHSL(hslString); const stats2 = ColorUtils.getCacheStats(); expect(result1).toEqual(result2); expect(stats2.hslParseCache).toBe(1); // Should have cached 1 result // Results should be identical (cached) expect(result1.h).toBe(210); expect(result1.s).toBe(40); expect(result1.l).toBe(50); }); test('should cache RGB conversion results', () => { const color = { h: 210, s: 40, l: 50 }; // First call should compute and cache const result1 = ColorUtils.hslToRgb(color); // Second call should use cache const result2 = ColorUtils.hslToRgb(color); expect(result1).toEqual(result2); const stats = ColorUtils.getCacheStats(); expect(stats.rgbConversionCache).toBe(1); }); test('should cache contrast ratio calculations', () => { const color1 = '210 40% 20%'; const color2 = '210 40% 80%'; // First call should compute and cache const result1 = ColorUtils.getContrastRatio(color1, color2); // Second call should use cache const result2 = ColorUtils.getContrastRatio(color1, color2); // Reverse order should also use cache (symmetric) const result3 = ColorUtils.getContrastRatio(color2, color1); expect(result1).toBe(result2); expect(result1).toBe(result3); const stats = ColorUtils.getCacheStats(); expect(stats.contrastCache).toBe(1); // Only one unique pair cached }); test('should implement LRU cache eviction', () => { // Fill cache beyond limit (1000 entries) for (let i = 0; i < 1001; i++) { ColorUtils.parseHSL(`${i % 360} 50% 50%`); } const stats = ColorUtils.getCacheStats(); expect(stats.hslParseCache).toBeLessThanOrEqual(1000); // Should not exceed limit }); test('should provide accurate cache statistics', () => { ColorUtils.parseHSL('210 40% 50%'); ColorUtils.hslToRgb({ h: 210, s: 40, l: 50 }); ColorUtils.getContrastRatio('210 40% 20%', '210 40% 80%'); const stats = ColorUtils.getCacheStats(); expect(stats.hslParseCache).toBeGreaterThan(0); expect(stats.rgbConversionCache).toBeGreaterThan(0); expect(stats.contrastCache).toBeGreaterThan(0); expect(stats.totalMemoryUsage).toBe( stats.hslParseCache + stats.rgbConversionCache + stats.contrastCache ); }); test('should clear caches correctly', () => { // Add some entries to caches ColorUtils.parseHSL('210 40% 50%'); ColorUtils.hslToRgb({ h: 210, s: 40, l: 50 }); ColorUtils.getContrastRatio('210 40% 20%', '210 40% 80%'); expect(ColorUtils.getCacheStats().totalMemoryUsage).toBeGreaterThan(0); // Clear caches ColorUtils.clearCaches(); const stats = ColorUtils.getCacheStats(); expect(stats.hslParseCache).toBe(0); expect(stats.rgbConversionCache).toBe(0); expect(stats.contrastCache).toBe(0); expect(stats.totalMemoryUsage).toBe(0); }); }); describe('Performance Under Load', () => { test('should handle repeated accessibility testing efficiently', async () => { const theme = presetThemes[0]; const startTime = Date.now(); // Run multiple accessibility tests const results = await Promise.all([ testThemeAccessibility(theme), testThemeAccessibility(theme), testThemeAccessibility(theme), ]); const endTime = Date.now(); const totalTime = endTime - startTime; // All results should be identical (cached calculations) expect(results[0].passed).toBe(results[1].passed); expect(results[0].passed).toBe(results[2].passed); // Performance should benefit from caching // (This is hard to test deterministically, but we can verify it completes quickly) expect(totalTime).toBeLessThan(5000); // Should complete within 5 seconds // Cache should have been used const stats = ColorUtils.getCacheStats(); expect(stats.totalMemoryUsage).toBeGreaterThan(0); }); }); }); describe('Integration Tests', () => { test('should validate and inject secure, accessible theme', async () => { const theme = presetThemes[0]; const injector = new ThemeInjector(); // Validate theme first const validation = ThemeValidationUtils.validateTheme(theme); expect(validation.success).toBe(true); // Test accessibility const accessibilityResult = await testThemeAccessibility(theme); expect(accessibilityResult.passed).toBe(true); expect(accessibilityResult.wcagLevel).toBe('AA'); // All critical tests should pass const criticalFailures = accessibilityResult.tests.filter( t => t.severity === 'critical' && !t.passed ); expect(criticalFailures).toHaveLength(0); }); test('should handle all preset themes securely and accessibly', async () => { for (const theme of presetThemes) { // Schema validation const validation = ThemeValidationUtils.validateTheme(theme); expect(validation.success).toBe(true); // Accessibility testing const accessibilityResult = await testThemeAccessibility(theme); expect(accessibilityResult.passed).toBe(true); expect(accessibilityResult.wcagLevel).toBe('AA'); // No critical accessibility failures const criticalFailures = accessibilityResult.tests.filter( t => t.severity === 'critical' && !t.passed ); expect(criticalFailures).toHaveLength(0); } }); test('should maintain performance under comprehensive testing', async () => { const startTime = Date.now(); // Test all themes for accessibility const results = await Promise.all( presetThemes.map(theme => testThemeAccessibility(theme)) ); const endTime = Date.now(); const totalTime = endTime - startTime; // All themes should pass expect(results.every(r => r.passed)).toBe(true); // Should complete in reasonable time (caching helps) expect(totalTime).toBeLessThan(10000); // 10 seconds for all themes // Caches should be populated const stats = ColorUtils.getCacheStats(); expect(stats.totalMemoryUsage).toBeGreaterThan(0); }); });