This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
/**
* Tests for dashboard assets
* HTML template structure, CSS syntax, JS functions
*/
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const assetsDir = path.join(__dirname, '..', 'assets');
const templatePath = path.join(assetsDir, 'dashboard-template.html');
const cssPath = path.join(assetsDir, 'dashboard.css');
const jsPath = path.join(assetsDir, 'dashboard.js');
describe('dashboard-template.html', () => {
let htmlContent;
before(() => {
assert(fs.existsSync(templatePath), `Template file not found: ${templatePath}`);
htmlContent = fs.readFileSync(templatePath, 'utf8');
});
it('should be valid HTML5', () => {
assert(htmlContent.includes('<!DOCTYPE html'));
assert(htmlContent.includes('<html'));
assert(htmlContent.includes('</html>'));
});
it('should have proper head section', () => {
assert(htmlContent.includes('<head>'));
assert(htmlContent.includes('<meta charset="UTF-8">'));
assert(htmlContent.includes('<meta name="viewport"'));
assert(htmlContent.includes('</head>'));
});
it('should have title element', () => {
assert(htmlContent.includes('<title>'));
assert(htmlContent.includes('Plans Dashboard'));
});
it('should link required CSS files', () => {
assert(htmlContent.includes('novel-theme.css'));
assert(htmlContent.includes('dashboard.css'));
});
it('should have main content area', () => {
assert(htmlContent.includes('<main'));
assert(htmlContent.includes('role="main"'));
assert(htmlContent.includes('aria-label="Plans Dashboard"'));
});
it('should have dashboard header', () => {
assert(htmlContent.includes('class="dashboard-header"'));
assert(htmlContent.includes('<h1>Plans Dashboard</h1>'));
});
it('should have theme toggle button', () => {
assert(htmlContent.includes('id="theme-toggle"'));
assert(htmlContent.includes('aria-label="Toggle theme"'));
});
it('should have search input', () => {
assert(htmlContent.includes('id="plan-search"'));
assert(htmlContent.includes('type="search"'));
assert(htmlContent.includes('placeholder="Search plans..."'));
});
it('should have sort select', () => {
assert(htmlContent.includes('id="sort-select"'));
assert(htmlContent.includes('value="date-desc"'));
assert(htmlContent.includes('value="name-asc"'));
});
it('should have filter pills', () => {
assert(htmlContent.includes('class="filter-pills"'));
assert(htmlContent.includes('data-filter="all"'));
assert(htmlContent.includes('data-filter="completed"'));
assert(htmlContent.includes('data-filter="in-progress"'));
assert(htmlContent.includes('data-filter="pending"'));
});
it('should have plans grid section', () => {
assert(htmlContent.includes('class="plans-grid"'));
assert(htmlContent.includes('aria-label="Plans list"'));
});
it('should have template placeholders', () => {
assert(htmlContent.includes('{{plans-grid}}'));
assert(htmlContent.includes('{{plan-count}}'));
assert(htmlContent.includes('{{plans-json}}'));
assert(htmlContent.includes('{{empty-state}}'));
});
it('should have loading skeleton', () => {
assert(htmlContent.includes('class="loading-skeleton"'));
assert(htmlContent.includes('class="skeleton-card"'));
});
it('should have screen reader announcements', () => {
assert(htmlContent.includes('id="sr-announce"'));
assert(htmlContent.includes('aria-live="polite"'));
});
it('should embed plans JSON', () => {
assert(htmlContent.includes('window.__plans'));
});
it('should load dashboard.js', () => {
assert(htmlContent.includes('src="/assets/dashboard.js"'));
});
it('should have proper closing tags', () => {
const openMain = (htmlContent.match(/<main/g) || []).length;
const closeMain = (htmlContent.match(/<\/main>/g) || []).length;
assert.strictEqual(openMain, closeMain, 'Mismatched main tags');
const openBody = (htmlContent.match(/<body/g) || []).length;
const closeBody = (htmlContent.match(/<\/body>/g) || []).length;
assert.strictEqual(openBody, closeBody, 'Mismatched body tags');
});
it('should have data-theme attribute on html', () => {
assert(htmlContent.includes('data-theme='));
});
});
describe('dashboard.css', () => {
let cssContent;
before(() => {
assert(fs.existsSync(cssPath), `CSS file not found: ${cssPath}`);
cssContent = fs.readFileSync(cssPath, 'utf8');
});
it('should have valid CSS syntax', () => {
// Basic check: should have selectors and properties
assert(cssContent.includes('{'));
assert(cssContent.includes('}'));
});
it('should define dashboard-view class', () => {
assert(cssContent.includes('.dashboard-view'));
});
it('should define dashboard-header styles', () => {
assert(cssContent.includes('.dashboard-header'));
});
it('should define plan-card styles', () => {
assert(cssContent.includes('.plan-card'));
});
it('should define progress-ring styles', () => {
assert(cssContent.includes('.progress-ring'));
});
it('should define progress-bar styles', () => {
assert(cssContent.includes('.progress-bar'));
});
it('should define empty-state styles', () => {
assert(cssContent.includes('.empty-state'));
});
it('should have responsive media queries', () => {
assert(cssContent.includes('@media'));
});
it('should define animations', () => {
assert(cssContent.includes('@keyframes'));
});
it('should have accessibility classes', () => {
assert(cssContent.includes('.visually-hidden'));
});
it('should have focus styles', () => {
assert(cssContent.includes(':focus'));
assert(cssContent.includes(':focus-visible'));
});
it('should support reduced motion', () => {
assert(cssContent.includes('prefers-reduced-motion'));
});
it('should define color variables or hex values', () => {
// Check for color definitions
assert(cssContent.includes('var(--') || cssContent.includes('#') || cssContent.includes('rgb'));
});
it('should not have CSS syntax errors (basic check)', () => {
// Check for unclosed braces
const openBraces = (cssContent.match(/{/g) || []).length;
const closeBraces = (cssContent.match(/}/g) || []).length;
assert.strictEqual(openBraces, closeBraces, 'Unmatched CSS braces');
});
it('should define filter pills styling', () => {
assert(cssContent.includes('.filter-pill'));
});
it('should define search box styling', () => {
assert(cssContent.includes('.search-box'));
});
it('should define status count styling', () => {
assert(cssContent.includes('.status-count'));
});
});
describe('dashboard.js', () => {
let jsContent;
before(() => {
assert(fs.existsSync(jsPath), `JS file not found: ${jsPath}`);
jsContent = fs.readFileSync(jsPath, 'utf8');
});
it('should be valid JavaScript', () => {
// Check for syntax errors by looking for basic patterns
assert(jsContent.includes('function') || jsContent.includes('const') || jsContent.includes('let'));
});
it('should have IIFE pattern for encapsulation', () => {
assert(jsContent.includes('(function()'));
assert(jsContent.includes('})()'));
});
it('should initialize state object', () => {
assert(jsContent.includes('const state'));
assert(jsContent.includes('sort:'));
assert(jsContent.includes('filter:'));
assert(jsContent.includes('search:'));
});
it('should have init function', () => {
assert(jsContent.includes('function init()'));
});
it('should bind events', () => {
assert(jsContent.includes('function bindEvents()'));
});
it('should apply filters and sort', () => {
assert(jsContent.includes('function applyFiltersAndSort()'));
});
it('should render grid', () => {
assert(jsContent.includes('renderGrid'));
assert(jsContent.includes('.plans-grid'));
});
it('should parse URL parameters', () => {
assert(jsContent.includes('parseURL'));
assert(jsContent.includes('URLSearchParams'));
});
it('should update URL', () => {
assert(jsContent.includes('updateURL'));
assert(jsContent.includes('history.replaceState'));
});
it('should handle search input', () => {
assert(jsContent.includes('plan-search'));
assert(jsContent.includes('addEventListener'));
});
it('should handle sort select', () => {
assert(jsContent.includes('sort-select'));
assert(jsContent.includes('change'));
});
it('should handle filter pills', () => {
assert(jsContent.includes('.filter-pill'));
});
it('should handle card click navigation', () => {
assert(jsContent.includes('.plan-card'));
assert(jsContent.includes('.view-btn'));
});
it('should have keyboard navigation', () => {
assert(jsContent.includes('setupKeyboardNav'));
assert(jsContent.includes('ArrowRight') || jsContent.includes('ArrowDown'));
});
it('should have theme toggle setup', () => {
assert(jsContent.includes('setupThemeToggle'));
assert(jsContent.includes('theme-toggle'));
assert(jsContent.includes('localStorage'));
});
it('should announce to screen readers', () => {
assert(jsContent.includes('announce'));
assert(jsContent.includes('sr-announce'));
});
it('should use window.__plans data', () => {
assert(jsContent.includes('window.__plans'));
});
it('should initialize on DOM ready', () => {
assert(jsContent.includes('DOMContentLoaded'));
});
it('should have strict mode', () => {
assert(jsContent.includes("'use strict'"));
});
it('should check for required DOM elements', () => {
assert(jsContent.includes('document.querySelector'));
assert(jsContent.includes('.plans-grid'));
assert(jsContent.includes('.result-count'));
assert(jsContent.includes('.empty-state'));
});
it('should validate syntax with basic checks', () => {
// Check for unclosed strings
const singleQuotes = (jsContent.match(/'/g) || []).length;
const doubleQuotes = (jsContent.match(/"/g) || []).length;
// Both should be even (pairs)
assert.strictEqual(singleQuotes % 2, 0, 'Unmatched single quotes');
assert.strictEqual(doubleQuotes % 2, 0, 'Unmatched double quotes');
});
it('should have debounce for search input', () => {
assert(jsContent.includes('debounce'));
assert(jsContent.includes('setTimeout'));
});
it('should support sort options', () => {
assert(jsContent.includes('date-desc'));
assert(jsContent.includes('name-asc'));
assert(jsContent.includes('progress-desc'));
});
});
console.log('\n' + '='.repeat(60));
console.log('Dashboard Assets Tests');
console.log('='.repeat(60));

View File

@@ -0,0 +1,404 @@
/**
* Tests for dashboard-renderer.cjs
* XSS protection, progress ring, plan card generation, and dashboard rendering
*/
const assert = require('assert');
const {
renderDashboard,
generatePlanCard,
generateProgressRing,
generateProgressBar,
generateStatusCounts,
generateEmptyState,
generatePlansGrid,
escapeHtml,
formatDate
} = require('../scripts/lib/dashboard-renderer.cjs');
describe('escapeHtml', () => {
it('should escape HTML special characters', () => {
assert.strictEqual(escapeHtml('<script>alert("xss")</script>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
it('should handle ampersands', () => {
assert.strictEqual(escapeHtml('Tom & Jerry'), 'Tom &amp; Jerry');
});
it('should handle single quotes', () => {
assert.strictEqual(escapeHtml("it's"), 'it&#039;s');
});
it('should handle double quotes', () => {
assert.strictEqual(escapeHtml('He said "hello"'), 'He said &quot;hello&quot;');
});
it('should handle null/undefined', () => {
assert.strictEqual(escapeHtml(null), '');
assert.strictEqual(escapeHtml(undefined), '');
});
it('should handle empty string', () => {
assert.strictEqual(escapeHtml(''), '');
});
it('should escape multiple occurrences', () => {
const result = escapeHtml('<div class="test">Hello & "goodbye"</div>');
assert.strictEqual(result, '&lt;div class=&quot;test&quot;&gt;Hello &amp; &quot;goodbye&quot;&lt;/div&gt;');
});
});
describe('formatDate', () => {
it('should format ISO date string', () => {
const result = formatDate('2025-12-11T10:30:00Z');
assert(result.includes('Dec'));
assert(result.includes('11'));
assert(result.includes('2025'));
});
it('should handle null/undefined', () => {
assert.strictEqual(formatDate(null), '');
assert.strictEqual(formatDate(undefined), '');
});
it('should handle empty string', () => {
assert.strictEqual(formatDate(''), '');
});
it('should format different dates correctly', () => {
const result1 = formatDate('2025-01-01T00:00:00Z');
const result2 = formatDate('2025-12-31T23:59:59Z');
// Check for month indicator - could be "Jan" or "1" depending on locale
assert(result1.length > 0, 'Date 1 should format');
assert(result2.length > 0, 'Date 2 should format');
assert(result1 !== result2, 'Different dates should format differently');
});
});
describe('generateProgressRing', () => {
it('should generate SVG with correct progress percentage', () => {
const svg = generateProgressRing(50);
assert(svg.includes('50%'));
assert(svg.includes('stroke-dasharray'));
});
it('should generate valid SVG structure', () => {
const svg = generateProgressRing(75);
assert(svg.includes('<svg class="progress-ring"'));
assert(svg.includes('<circle'));
assert(svg.includes('<text'));
});
it('should handle 0% progress', () => {
const svg = generateProgressRing(0);
assert(svg.includes('0%'));
assert(svg.includes('0, 100'));
});
it('should handle 100% progress', () => {
const svg = generateProgressRing(100);
assert(svg.includes('100%'));
assert(svg.includes('100, 100'));
});
it('should have aria-hidden for accessibility', () => {
const svg = generateProgressRing(50);
assert(svg.includes('aria-hidden="true"'));
});
});
describe('generateProgressBar', () => {
it('should generate progress bar with correct percentages', () => {
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
assert(bar.includes('50.0%')); // completed
assert(bar.includes('30.0%')); // in-progress
assert(bar.includes('20.0%')); // pending
});
it('should have accessibility attributes', () => {
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
assert(bar.includes('role="progressbar"'));
assert(bar.includes('aria-valuenow="5"'));
assert(bar.includes('aria-valuemin="0"'));
assert(bar.includes('aria-valuemax="10"'));
});
it('should handle zero total (fallback)', () => {
const bar = generateProgressBar({ total: 0, completed: 0, inProgress: 0, pending: 0 });
assert(bar.includes('class="progress-bar"'));
});
it('should create three segments with correct classes', () => {
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
assert(bar.includes('class="bar-segment completed"'));
assert(bar.includes('class="bar-segment in-progress"'));
assert(bar.includes('class="bar-segment pending"'));
});
});
describe('generateStatusCounts', () => {
it('should generate status count HTML', () => {
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
assert(html.includes('3'));
assert(html.includes('2'));
assert(html.includes('1'));
});
it('should have accessibility features', () => {
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
assert(html.includes('visually-hidden'));
assert(html.includes('data-tooltip'));
});
it('should have correct status classes', () => {
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
assert(html.includes('status-count completed'));
assert(html.includes('status-count in-progress'));
assert(html.includes('status-count pending'));
});
});
describe('generatePlanCard', () => {
it('should generate card HTML with plan data', () => {
const plan = {
id: 'plan-001',
name: 'Test Plan',
status: 'in-progress',
progress: 50,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test-plan',
phases: { completed: 2, inProgress: 1, pending: 1, total: 4 }
};
const card = generatePlanCard(plan);
assert(card.includes('Test Plan'));
assert(card.includes('plan-001'));
assert(card.includes('/plans/test-plan'));
});
it('should escape HTML in plan name', () => {
const plan = {
id: 'plan-001',
name: '<script>alert("xss")</script>',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
};
const card = generatePlanCard(plan);
assert(!card.includes('<script>'));
assert(card.includes('&lt;script&gt;'));
});
it('should escape HTML in plan path', () => {
const plan = {
id: 'plan-001',
name: 'Test',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '"><script>alert(1)</script><"',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
};
const card = generatePlanCard(plan);
assert(!card.includes('<script>'));
assert(card.includes('&quot;'));
});
it('should set correct data-status attribute', () => {
const planInProgress = generatePlanCard({
id: 'p1',
name: 'Test',
status: 'in-progress',
progress: 50,
lastModified: '2025-12-11T10:00:00Z',
path: '/test',
phases: { completed: 0, inProgress: 1, pending: 0, total: 1 }
});
assert(planInProgress.includes('data-status="in-progress"'));
});
it('should have accessible structure', () => {
const plan = {
id: 'plan-001',
name: 'Test Plan',
status: 'completed',
progress: 100,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
};
const card = generatePlanCard(plan);
assert(card.includes('<article'));
assert(card.includes('<header'));
assert(card.includes('<footer'));
assert(card.includes('tabindex="0"'));
});
it('should include time element with datetime attribute', () => {
const plan = {
id: 'plan-001',
name: 'Test',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
};
const card = generatePlanCard(plan);
assert(card.includes('<time class="plan-date" datetime='));
});
});
describe('generatePlansGrid', () => {
it('should generate cards for all plans', () => {
const plans = [
{
id: 'p1',
name: 'Plan 1',
status: 'completed',
progress: 100,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/1',
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
},
{
id: 'p2',
name: 'Plan 2',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T09:00:00Z',
path: '/plans/2',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
const grid = generatePlansGrid(plans);
assert(grid.includes('Plan 1'));
assert(grid.includes('Plan 2'));
});
it('should return empty string for empty plans', () => {
assert.strictEqual(generatePlansGrid([]), '');
assert.strictEqual(generatePlansGrid(null), '');
});
});
describe('generateEmptyState', () => {
it('should generate empty state HTML', () => {
const html = generateEmptyState();
assert(html.includes('No plans found'));
assert(html.includes('class="empty-state"'));
assert(html.includes('hidden'));
});
it('should have accessibility features', () => {
const html = generateEmptyState();
assert(html.includes('aria-hidden="true"'));
});
});
describe('renderDashboard', () => {
it('should render dashboard with plans', () => {
const plans = [
{
id: 'p1',
name: 'Test Plan',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
const html = renderDashboard(plans, { assetsDir: '/tmp' });
assert(html.includes('Test Plan'));
assert(html.includes('<!DOCTYPE html'));
});
it('should embed plans JSON for client-side filtering', () => {
const plans = [
{
id: 'p1',
name: 'Test',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
const html = renderDashboard(plans, { assetsDir: '/tmp' });
assert(html.includes('window.__plans'));
assert(html.includes('"id":"p1"'));
});
it('should set plan count', () => {
const plans = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
name: `Plan ${i}`,
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: `/plans/${i}`,
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}));
const html = renderDashboard(plans, { assetsDir: '/tmp' });
assert(html.includes('Showing <strong>5</strong>'));
});
it('should use inline template as fallback', () => {
const plans = [
{
id: 'p1',
name: 'Test',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
// Non-existent assetsDir forces fallback
const html = renderDashboard(plans, { assetsDir: '/nonexistent/path' });
assert(html.includes('<!DOCTYPE html'));
assert(html.includes('Plans Dashboard'));
});
it('should set has-plans class when plans exist', () => {
const plans = [
{
id: 'p1',
name: 'Test',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '/plans/test',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
const html = renderDashboard(plans, { assetsDir: '/tmp' });
assert(html.includes('plans-loaded'));
});
it('should not set has-plans class when no plans', () => {
const html = renderDashboard([], { assetsDir: '/tmp' });
assert(!html.includes('plans-loaded'));
});
});
// Run tests
const tests = [
'escapeHtml',
'formatDate',
'generateProgressRing',
'generateProgressBar',
'generateStatusCounts',
'generatePlanCard',
'generatePlansGrid',
'generateEmptyState',
'renderDashboard'
];
console.log('\n' + '='.repeat(60));
console.log('Dashboard Renderer Tests');
console.log('='.repeat(60));

View File

@@ -0,0 +1,271 @@
/**
* Tests for http-server.cjs
* Route testing, security validation, MIME types
*/
const assert = require('assert');
const {
createHttpServer,
getMimeType,
sendResponse,
sendError,
serveFile,
isPathSafe,
setAllowedDirs,
sanitizeErrorMessage,
MIME_TYPES
} = require('../scripts/lib/http-server.cjs');
const path = require('path');
describe('MIME_TYPES', () => {
it('should have common file types', () => {
assert.strictEqual(MIME_TYPES['.html'], 'text/html');
assert.strictEqual(MIME_TYPES['.css'], 'text/css');
assert.strictEqual(MIME_TYPES['.js'], 'application/javascript');
assert.strictEqual(MIME_TYPES['.json'], 'application/json');
});
it('should have image types', () => {
assert.strictEqual(MIME_TYPES['.png'], 'image/png');
assert.strictEqual(MIME_TYPES['.jpg'], 'image/jpeg');
assert.strictEqual(MIME_TYPES['.svg'], 'image/svg+xml');
});
});
describe('getMimeType', () => {
it('should return correct MIME type for HTML', () => {
assert.strictEqual(getMimeType('file.html'), 'text/html');
});
it('should return correct MIME type for CSS', () => {
assert.strictEqual(getMimeType('style.css'), 'text/css');
});
it('should return correct MIME type for JavaScript', () => {
assert.strictEqual(getMimeType('script.js'), 'application/javascript');
});
it('should handle uppercase extensions', () => {
assert.strictEqual(getMimeType('FILE.HTML'), 'text/html');
assert.strictEqual(getMimeType('style.CSS'), 'text/css');
});
it('should return octet-stream for unknown types', () => {
assert.strictEqual(getMimeType('file.xyz'), 'application/octet-stream');
});
it('should handle files without extensions', () => {
assert.strictEqual(getMimeType('README'), 'application/octet-stream');
});
});
describe('sanitizeErrorMessage', () => {
it('should remove absolute paths from error messages', () => {
const message = 'Error: /home/user/project/file.txt not found';
const sanitized = sanitizeErrorMessage(message);
assert(!sanitized.includes('/home/user'));
assert(sanitized.includes('[path]'));
});
it('should preserve non-path text', () => {
const message = 'Error: File not found';
const sanitized = sanitizeErrorMessage(message);
assert(sanitized.includes('Error'));
assert(sanitized.includes('File not found'));
});
it('should handle multiple paths', () => {
const message = 'Error comparing /path/one and /path/two';
const sanitized = sanitizeErrorMessage(message);
assert.strictEqual((sanitized.match(/\[path\]/g) || []).length, 2);
});
it('should not remove text after URL protocols', () => {
const message = 'Visit https://example.com for help';
const sanitized = sanitizeErrorMessage(message);
// Verify message is preserved after sanitization
assert(sanitized.length > 0, 'Message should not be empty');
assert(sanitized.includes('help'), 'Message text should be preserved');
});
});
describe('isPathSafe', () => {
it('should reject null byte injection', () => {
assert.strictEqual(isPathSafe('/var/www/file.txt\0.jpg'), false);
});
it('should allow normal paths with empty allowedDirs', () => {
// When allowedDirs is empty (during initialization), allow all
setAllowedDirs([]);
assert.strictEqual(isPathSafe('/var/www/file.txt'), true);
});
it('should reject paths outside allowed directories when set', () => {
setAllowedDirs(['/allowed/dir']);
assert.strictEqual(isPathSafe('/other/dir/file.txt'), false);
});
it('should allow paths inside allowed directories', () => {
const allowed = '/allowed/dir';
setAllowedDirs([allowed]);
const filePath = require('path').join(allowed, 'file.txt');
assert.strictEqual(isPathSafe(filePath), true);
});
it('should handle multiple allowed directories', () => {
const dir1 = '/dir1';
const dir2 = '/dir2';
setAllowedDirs([dir1, dir2]);
// Paths must be absolute and within allowed dirs
const path1 = require('path').join(dir1, 'file.txt');
const path2 = require('path').join(dir2, 'file.txt');
assert.strictEqual(isPathSafe(path1), true);
assert.strictEqual(isPathSafe(path2), true);
});
it('should allow empty allowedDirs during initialization', () => {
setAllowedDirs([]);
assert.strictEqual(isPathSafe('/any/path.txt'), true);
});
});
describe('setAllowedDirs', () => {
it('should set allowed directories', () => {
const dirs = ['/home/user', '/tmp'];
setAllowedDirs(dirs);
// Verify by testing path safety
assert.strictEqual(isPathSafe('/home/user/file.txt'), true);
});
it('should resolve relative paths to absolute', () => {
setAllowedDirs(['./relative']);
// Should be resolved to absolute path
assert(isPathSafe(path.resolve('./relative/file.txt')));
});
});
describe('createHttpServer', () => {
it('should create an HTTP server', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
assert(server);
assert(typeof server.listen === 'function');
server.close();
});
it('should require assetsDir', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
assert(server);
server.close();
});
it('should accept plansDir option', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: '/plans'
});
assert(server);
server.close();
});
it('should accept allowedDirs option', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname, '/tmp']
});
assert(server);
server.close();
});
});
describe('Route: /assets/*', () => {
it('should prevent directory traversal in assets path', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
// Route validation happens internally - can't test HTTP response without full setup
server.close();
});
it('should validate asset paths for ../', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
// Security check happens in route handler
server.close();
});
});
describe('Route: /dashboard', () => {
it('should accept plansDir parameter', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: __dirname
});
server.close();
});
it('should validate custom directory parameter', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
server.close();
});
});
describe('Route: /api/dashboard', () => {
it('should return JSON response', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: __dirname
});
server.close();
});
it('should handle missing plansDir gracefully', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
server.close();
});
});
describe('Route: /file/*', () => {
it('should validate file path safety', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
server.close();
});
});
describe('Route: /api/files', () => {
it('should be disabled for security', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
server.close();
});
});
console.log('\n' + '='.repeat(60));
console.log('HTTP Server Tests');
console.log('='.repeat(60));

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Test runner for dashboard tests
* Executes all test suites and generates report
*/
const fs = require('fs');
const path = require('path');
// Load test framework first
require('./test-framework.cjs');
const testsDir = __dirname;
const testFiles = [
'dashboard-renderer.test.cjs',
'http-server.test.cjs',
'dashboard-assets.test.cjs'
];
console.log('\n' + '='.repeat(70));
console.log('Dashboard Implementation Test Suite');
console.log('='.repeat(70));
// Load all test files
let loadErrors = [];
for (const testFile of testFiles) {
const testPath = path.join(testsDir, testFile);
if (!fs.existsSync(testPath)) {
loadErrors.push(`Test file not found: ${testFile}`);
continue;
}
try {
require(testPath);
} catch (error) {
loadErrors.push(`${testFile}: ${error.message}`);
}
}
if (loadErrors.length > 0) {
console.error('\nErrors loading test files:');
loadErrors.forEach(err => {
console.error(` - ${err}`);
});
process.exit(1);
}
// Run all tests
global.runAllTests();

View File

@@ -0,0 +1,154 @@
/**
* Simple test framework for Node.js (mocha-like)
*/
global.testStats = {
total: 0,
passed: 0,
failed: 0,
suites: [],
currentSuite: null
};
class TestSuite {
constructor(name) {
this.name = name;
this.tests = [];
this.beforeFn = null;
this.afterFn = null;
}
addTest(name, fn) {
this.tests.push({ name, fn });
}
setBefore(fn) {
this.beforeFn = fn;
}
setAfter(fn) {
this.afterFn = fn;
}
async run() {
const results = {
name: this.name,
passed: 0,
failed: 0,
errors: []
};
console.log(`\n ${this.name}`);
for (const test of this.tests) {
try {
if (this.beforeFn) {
await this.beforeFn();
}
await test.fn();
if (this.afterFn) {
await this.afterFn();
}
results.passed++;
process.stdout.write('.');
} catch (error) {
results.failed++;
results.errors.push({
test: test.name,
error: error.message
});
process.stdout.write('F');
}
}
return results;
}
}
global.testSuites = {};
global.describe = function(name, fn) {
const suite = new TestSuite(name);
global.testStats.currentSuite = suite;
global.testSuites[name] = suite;
fn();
};
global.it = function(name, fn) {
if (!global.testStats.currentSuite) {
throw new Error('it() called outside describe()');
}
global.testStats.currentSuite.addTest(name, fn);
};
global.before = function(fn) {
if (!global.testStats.currentSuite) {
throw new Error('before() called outside describe()');
}
global.testStats.currentSuite.setBefore(fn);
};
global.after = function(fn) {
if (!global.testStats.currentSuite) {
throw new Error('after() called outside describe()');
}
global.testStats.currentSuite.setAfter(fn);
};
global.runAllTests = async function() {
console.log('\n' + '='.repeat(70));
console.log('Running Test Suites');
console.log('='.repeat(70));
const suites = Object.values(global.testSuites);
const results = [];
for (const suite of suites) {
const result = await suite.run();
results.push(result);
global.testStats.passed += result.passed;
global.testStats.failed += result.failed;
global.testStats.total += result.passed + result.failed;
}
printTestResults(results);
return global.testStats;
};
function printTestResults(results) {
console.log('\n\n' + '='.repeat(70));
console.log('Test Results');
console.log('='.repeat(70) + '\n');
let totalPassed = 0;
let totalFailed = 0;
for (const result of results) {
const status = result.failed > 0 ? '✗' : '✓';
console.log(`${status} ${result.name}`);
if (result.errors.length > 0) {
result.errors.forEach(err => {
console.log(`${err.test}`);
console.log(` ${err.error}`);
});
}
totalPassed += result.passed;
totalFailed += result.failed;
}
console.log('\n' + '='.repeat(70));
console.log(`Total: ${totalPassed + totalFailed} | Passed: ${totalPassed} | Failed: ${totalFailed}`);
console.log('='.repeat(70) + '\n');
if (totalFailed > 0) {
process.exit(1);
}
}
module.exports = { TestSuite, runAllTests };

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* XSS Protection Verification
*/
const renderer = require('../scripts/lib/dashboard-renderer.cjs');
console.log('\nXSS Protection Verification');
console.log('='.repeat(70));
// Test 1: Image onerror payload
const xssPayload1 = '<img src=x onerror="alert(1)">';
const result1 = renderer.escapeHtml(xssPayload1);
console.log('\nTest 1: Image onerror');
console.log(`Input: ${xssPayload1}`);
console.log(`Output: ${result1}`);
const pass1 = !result1.includes('<img') && result1.includes('&lt;');
console.log(`Result: ${pass1 ? 'PASS' : 'FAIL'}`);
// Test 2: Script tag injection
const xssPayload2 = '<script>alert("xss")</script>';
const result2 = renderer.escapeHtml(xssPayload2);
console.log('\nTest 2: Script tag');
console.log(`Input: ${xssPayload2}`);
console.log(`Output: ${result2}`);
const pass2 = !result2.includes('<script') && result2.includes('&lt;script');
console.log(`Result: ${pass2 ? 'PASS' : 'FAIL'}`);
// Test 3: Full dashboard render with malicious plan
const plans = [
{
id: 'xss-test',
name: '<script>alert(1)</script>',
status: 'pending',
progress: 0,
lastModified: '2025-12-11T10:00:00Z',
path: '"><script>alert(1)</script><"',
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
}
];
const html = renderer.renderDashboard(plans, {
assetsDir: 'nonexistent' // Use fallback template
});
console.log('\nTest 3: Full dashboard render');
console.log(`Input: Malicious plan name and path`);
// Check that the HTML contains escaped version in plan card
// JSON will also contain escaped content but that's safe
const cardSectionStart = html.indexOf('<article');
const cardSectionEnd = html.indexOf('</article>');
const cardContent = cardSectionStart !== -1 ? html.substring(cardSectionStart, cardSectionEnd) : '';
const hasEscapedInCard = cardContent.includes('&lt;script&gt;');
const pass3 = hasEscapedInCard;
console.log(`Result: ${pass3 ? 'PASS' : 'FAIL'}`);
// Test 4: HTML structure
console.log('\nTest 4: HTML structure validity');
const hasDoctype = html.includes('<!DOCTYPE html');
const hasHtmlClose = html.includes('</html>');
const hasMain = html.includes('<main');
const hasPlans = html.includes('{{plans-grid}}') || html.includes('class="plans-grid"');
console.log(`DOCTYPE: ${hasDoctype ? 'PASS' : 'FAIL'}`);
console.log(`Closing HTML: ${hasHtmlClose ? 'PASS' : 'FAIL'}`);
console.log(`Main element: ${hasMain ? 'PASS' : 'FAIL'}`);
console.log(`Plans section: ${hasPlans ? 'PASS' : 'FAIL'}`);
const pass4 = hasDoctype && hasHtmlClose && hasMain;
// Test 5: JSON embedded safely
console.log('\nTest 5: JSON embedding');
const jsonIncluded = html.includes('window.__plans');
const jsonValid = html.includes('"id"') && html.includes('"name"');
console.log(`JSON variable: ${jsonIncluded ? 'PASS' : 'FAIL'}`);
console.log(`JSON valid: ${jsonValid ? 'PASS' : 'FAIL'}`);
const pass5 = jsonIncluded && jsonValid;
// Summary
console.log('\n' + '='.repeat(70));
console.log('Summary');
console.log('='.repeat(70));
const allPass = pass1 && pass2 && pass3 && pass4 && pass5;
console.log(`XSS Escaping: ${pass1 && pass2 ? 'PASS (2/2)' : 'FAIL'}`);
console.log(`Dashboard Render: ${pass3 ? 'PASS' : 'FAIL'}`);
console.log(`HTML Structure: ${pass4 ? 'PASS' : 'FAIL'}`);
console.log(`JSON Embedding: ${pass5 ? 'PASS' : 'FAIL'}`);
console.log(`\nOverall: ${allPass ? 'PASS' : 'FAIL'}`);
console.log('='.repeat(70) + '\n');
process.exit(allPass ? 0 : 1);