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
text/typescript
/**
* 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);
});
});