Files
www/tests/site.test.mjs
T
2026-10-31 15:11:21 -04:00

432 lines
23 KiB
JavaScript
Executable File

import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startPreview, parsePort } from '../scripts/preview.mjs';
const repository = new URL('../', import.meta.url);
// Small scanner for this site's static markup, not a general-purpose HTML validator.
function attributes(tag) {
return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)]
.map(([, name, double, single]) => [name.toLowerCase(), double ?? single]));
}
function text(html) {
return html.replace(/<[^>]*>/gu, ' ').replace(/&amp;/gu, '&')
.replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim();
}
function links(html) {
return [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/giu)]
.map(([, tag, inner]) => ({ attrs: attributes(tag), inner }));
}
function get(server, path = '/', method = 'GET', headers = {}) {
// Raw request paths preserve traversal probes that fetch/URL would normalize away.
return new Promise((accept, reject) => {
const req = request({ host: '127.0.0.1', port: server.address().port, path,
method, headers, agent: false }, res => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('error', reject);
res.on('end', () => accept({ status: res.statusCode, headers: res.headers,
body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out')));
req.end();
});
}
async function preview(t, options) {
const server = await startPreview({ ...options, port: 0 });
t.after(() => new Promise((accept, reject) => {
server.close(error => error ? reject(error) : accept());
server.closeAllConnections();
}));
assert.equal(server.address().address, '127.0.0.1');
return server;
}
async function homepage(t) {
const response = await get(await preview(t));
assert.equal(response.status, 200);
return response.body.toString('utf8');
}
test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => {
const html = await homepage(t);
assert.doesNotMatch(html, /<!--\s*#/u);
assert.match(html, /<!doctype html>/iu);
assert.match(html, /<html\s+lang="en"/u);
assert.equal([...html.matchAll(/<h1\b/giu)].length, 1);
assert.equal([...html.matchAll(/<head>/giu)].length, 1);
assert.equal([...html.matchAll(/<footer>/giu)].length, 1);
assert.match(text(html.match(/<title>([\s\S]*?)<\/title>/iu)?.[1] ?? ''), /Eric.*Meehan/iu);
const metas = [...html.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
assert.ok(metas.some(meta => meta.get('charset')?.toLowerCase() === 'utf-8'));
assert.ok(metas.some(meta => meta.get('name') === 'viewport' && meta.get('content')));
for (const name of ['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']) {
const meta = metas.find(item => (item.get('name') ?? item.get('property')) === name);
assert.ok(meta?.get('content'), `Missing ${name}`);
for (const topic of [/music/iu, /films?/iu, /engineering/iu]) assert.match(meta.get('content'), topic);
}
const canonical = [...html.matchAll(/<link\b[^>]*>/giu)].map(([tag]) => attributes(tag))
.find(attrs => attrs.get('rel') === 'canonical');
assert.equal(canonical?.get('href'), 'https://www.eom.dev/');
for (const [name, expected] of [['og:url', canonical.get('href')], ['og:type', 'website'],
['og:site_name', 'eom.dev'], ['twitter:card', 'summary']]) {
assert.equal(metas.find(meta => (meta.get('name') ?? meta.get('property')) === name)?.get('content'), expected);
}
assert.equal(new URL(metas.find(meta => meta.get('property') === 'og:image')?.get('content')).protocol, 'https:');
assert.ok(metas.find(meta => meta.get('property') === 'og:image:alt')?.get('content'));
const schemas = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/giu)]
.filter(([, tag]) => attributes(tag).get('type') === 'application/ld+json')
.map(([, , json]) => JSON.parse(json));
assert.ok(schemas.length > 0, 'Missing JSON-LD');
for (const schema of schemas) {
assert.equal(schema['@context'], 'https://schema.org');
assert.equal(schema['@type'], 'Person');
assert.match(schema.name, /Meehan/u);
assert.equal(schema.url, canonical.get('href'));
assert.match(schema.jobTitle, /musician/iu);
assert.ok(Array.isArray(schema.sameAs) && schema.sameAs.length > 0);
for (const url of [schema.url, schema.image, ...schema.sameAs]) {
assert.equal(new URL(url).protocol, 'https:');
}
}
});
test('unique anchors, valid local fragments, and four creative paths ahead of services', async t => {
const html = await homepage(t);
const ids = [...html.matchAll(/<[a-z][^>]*>/giu)].map(([tag]) => attributes(tag).get('id'))
.filter(id => id !== undefined);
assert.equal(new Set(ids).size, ids.length, 'Duplicate IDs');
assert.ok(ids.every(id => id && !/\s/u.test(id)), 'IDs must be nonempty and whitespace-free');
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
for (const href of destinations) {
assert.ok(href?.trim(), 'Every link needs a destination');
const url = new URL(href.replaceAll('&amp;', '&'), 'https://www.eom.dev/');
assert.ok(['https:', 'mailto:'].includes(url.protocol), `Unsafe link: ${href}`);
if (url.origin === 'https://www.eom.dev' && url.pathname === '/' && url.hash) {
assert.ok(ids.includes(decodeURIComponent(url.hash.slice(1))), `Broken fragment: ${href}`);
}
}
for (const id of ['films', 'music', 'projects', 'gaming']) {
assert.ok(destinations.has(`#${id}`), `Missing creative path ${id}`);
assert.ok(ids.indexOf(id) >= 0 && ids.indexOf(id) < ids.indexOf('services'), `${id} must precede services`);
}
const cards = [...html.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/giu)]
.filter(([, tag]) => attributes(tag).get('class')?.split(/\s/u).includes('path-card'));
assert.equal(cards.length, 4);
for (const id of ['films', 'music', 'projects', 'gaming']) {
assert.ok(cards.some(([, , card]) => links(card).some(link => link.attrs.get('href') === `#${id}`)));
}
for (const [, tag] of html.matchAll(/<[a-z]\w*\b([^>]*)>/giu)) {
for (const id of (attributes(tag).get('aria-labelledby') ?? '').split(/\s/u).filter(Boolean)) {
assert.ok(ids.includes(id), `Broken aria-labelledby: ${id}`);
}
}
});
test('featured films and music retain actual video destinations and labeled image links', async t => {
const html = await homepage(t);
const anchors = links(html);
for (const [id, label] of [
['b16e17cc-2545-40ad-b9fa-b9200400aff5', /Great Smokey Mountains/iu],
['678e7a68-9beb-48b2-b4cc-8cfede773a81', /La Banda Maguey/iu],
['f9613754-234e-4053-b194-341154f007c5', /Tidal Waltz/iu],
]) {
const href = `https://peertube.eom.dev/videos/watch/${id}`;
const matching = anchors.filter(link => link.attrs.get('href') === href);
assert.ok(matching.length > 0, `Missing featured video ${id}`);
const imageLink = matching.find(link => /<img\b/iu.test(link.inner));
assert.ok(imageLink, `Video ${id} needs an image link`);
assert.match(imageLink.attrs.get('aria-label') ?? text(imageLink.inner), label);
}
for (const [tag] of html.matchAll(/<img\b[^>]*>/giu)) {
const attrs = attributes(tag);
assert.ok(attrs.has('alt'), 'Images need alt text, even if decorative');
assert.ok(Number(attrs.get('width')) > 0 && Number(attrs.get('height')) > 0);
assert.ok(attrs.get('src'));
}
for (const { attrs, inner } of anchors) {
const visible = text(inner.replace(/<[^>]*\baria-hidden="true"[^>]*>[\s\S]*?<\/[^>]+>/giu, ''));
const imageLabels = [...inner.matchAll(/<img\b[^>]*>/giu)]
.map(([tag]) => attributes(tag).get('alt') ?? '').join('');
assert.ok(attrs.get('aria-label')?.trim() || visible || imageLabels.trim(), `Unlabeled link ${attrs.get('href')}`);
}
});
test('prior game, service, account and donation destinations remain intact', async t => {
const html = await homepage(t);
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
for (const href of [
'https://gitea.eom.dev/WikiDeck/wikideck', 'https://button.eom.dev/',
'https://gitea.eom.dev/Gaming/the-button', 'https://discourse.eom.dev/t/luanti-season-v/690',
'https://gitea.eom.dev/explore/repos', 'https://discourse.eom.dev/',
'https://element.eom.dev/', 'https://gitea.eom.dev/', 'https://mastodon.eom.dev/',
'https://peertube.eom.dev/', 'https://owncast.eom.dev/', 'https://discourse.eom.dev/latest',
'https://peertube.eom.dev/videos/browse', 'https://mastodon.eom.dev/@eric/',
'mailto:eric@eom.dev', '/eric@eom.dev.pub', '/members/', '/members/register/',
'https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00', '/xmrig.json',
]) assert.ok(destinations.has(href), `Missing preserved destination: ${href}`);
assert.match(html, /luanti\.eom\.dev:30000/u);
assert.match(html, /48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw/u);
});
test('live publication regions and automatic collection browser are wired to the API module', async t => {
const html = await homepage(t);
const regions = [...html.matchAll(/<(?:section|div)\b[^>]*\bdata-peertube-feed\b[^>]*>/giu)]
.map(([tag]) => attributes(tag));
assert.deepEqual(regions.map(region => region.get('data-channels')),
['eric_channel', 'eric_music', 'eric_tech', 'eric_gaming']);
assert.equal([...html.matchAll(/\bdata-feed-items\b/gu)].length, 4);
assert.equal([...html.matchAll(/\bdata-feed-status\b/gu)].length, 4);
assert.match(html, /<script type="module" src="\/static\/peertube-feeds\.mjs"><\/script>/u);
assert.match(html, /<label for="peertube-collection">/u);
assert.match(html, /<select id="peertube-collection" disabled>/u);
assert.match(html, /data-library-items/u);
assert.match(html, /data-library-status role="status" aria-live="polite"/u);
assert.match(html, /<noscript>/u);
const response = await get(await preview(t), '/static/peertube-feeds.mjs');
assert.equal(response.status, 200);
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
});
test('gaming is separate from engineering and writing links to Discourse', async t => {
const html = await homepage(t);
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
.map(([, tag, content]) => ({ id: attributes(tag).get('id'), content }));
const engineering = sections.find(section => section.id === 'projects').content;
const gaming = sections.find(section => section.id === 'gaming').content;
assert.match(engineering, /data-channels="eric_tech"/u);
assert.doesNotMatch(engineering, /eric_gaming|luanti\.eom\.dev:30000/u);
assert.match(gaming, /data-channels="eric_gaming"/u);
assert.match(gaming, /luanti\.eom\.dev:30000/u);
assert.match(gaming, /https:\/\/button\.eom\.dev\//u);
assert.doesNotMatch(engineering, /<h3>Wikideck<\/h3>/u);
assert.match(gaming, /<h3>Wikideck<\/h3>/u);
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>The Button</h3>'));
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>Wikideck</h3>'));
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://discourse.eom.dev/' && /writing/iu.test(text(link.inner))));
assert.match(html, /href="\/#gaming">Gaming/u);
});
test('my projects follows public cross-organization Gitea activity', async t => {
const html = await homepage(t);
assert.match(html, /<h2 id="projects-title">My projects\.<\/h2>/u);
assert.doesNotMatch(html, /Things I wanted to build/u);
assert.match(html, /id="gitea-projects"/u);
assert.match(html, /data-project-items/u);
assert.match(html, /data-project-status role="status" aria-live="polite"/u);
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://gitea.eom.dev/eric?tab=activity'));
assert.match(html, /<script type="module" src="\/static\/gitea-projects\.mjs"><\/script>/u);
const response = await get(await preview(t), '/static/gitea-projects.mjs');
assert.equal(response.status, 200);
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
});
test('membership templates and routes preserve account and payment actions without emulation', async () => {
// Inspect only: no Django rendering, database, authentication, or network calls.
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
for (const name of ['home', 'register', 'login', 'profile', 'portal', 'logout', 'checkout', 'crypto_checkout']) {
assert.match(template, new RegExp(`\\{% url 'members:${name}'`), `Missing portal action ${name}`);
}
const routes = await readFile(new URL('membership/members/urls.py', repository), 'utf8');
for (const route of ['register/', 'login/', 'logout/', 'profile/', 'billing/', 'checkout/<slug:tier>/']) {
assert.ok(routes.includes(`path("${route}"`), `Missing account route ${route}`);
}
});
test('homepage groups creative sections before the library and membership distinguishes tier benefits', async t => {
const html = await homepage(t);
const order = ['films', 'music', 'projects', 'gaming', 'peertube-library'];
const positions = order.map(id => html.indexOf(`id="${id}"`));
assert.ok(positions.every((position, index) => position >= 0 && (!index || position > positions[index - 1])));
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
assert.match(template, /services included at your membership level/u);
assert.match(template, /Bronze adds exclusive Discourse and Matrix content; Silver adds previews when available; Gold adds project-topic polls/u);
assert.ok(template.indexOf('class="member-grid"') < template.indexOf('Community services by level'));
assert.match(template, /No paid-exclusive game servers are currently included/u);
assert.match(template, /There is no fixed release schedule/u);
assert.match(template, /personal consulting, managed hosting, guaranteed support/u);
assert.doesNotMatch(template, /One account for all of eom\.dev/u);
});
test('creative positioning has no appointments or consulting sales calls', async t => {
const html = await homepage(t);
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
for (const page of [html, template]) {
assert.doesNotMatch(page, /apps\/appointments|calendly\.com|cal\.com\//iu);
assert.doesNotMatch(text(page), /(?:book|schedule|buy|request)\s+(?:a\s+)?(?:free\s+)?(?:introductory\s+|sales\s+|consulting\s+)?(?:call|consultation)|hire me (?:for|to provide)/iu);
}
assert.match(text(html), /music/iu);
assert.match(text(html), /films/iu);
assert.match(text(html), /creative engineering/iu);
});
test('preview serves static MIME, no-store/nosniff and HEAD without a body', async t => {
const server = await preview(t);
for (const [path, mime] of [
['/', 'text/html; charset=utf-8'], ['/index.html', 'text/html; charset=utf-8'],
['/static/home.css', 'text/css; charset=utf-8'], ['/static/favicon.svg', 'image/svg+xml'],
['/static/profile_2026.jpg', 'image/jpeg'], ['/favicon.ico', 'image/x-icon'],
['/xmrig.json', 'application/json; charset=utf-8'], ['/robots.txt', 'text/plain; charset=utf-8'],
['/sitemap.xml', 'application/xml; charset=utf-8'],
]) {
const response = await get(server, path);
assert.equal(response.status, 200, path);
assert.equal(response.headers['content-type'], mime, path);
assert.equal(response.headers['cache-control'], 'no-store');
assert.equal(response.headers['x-content-type-options'], 'nosniff');
assert.equal(Number(response.headers['content-length']), response.body.length);
const head = await get(server, path, 'HEAD');
assert.equal(head.status, response.status);
assert.equal(head.headers['content-type'], mime);
assert.equal(head.headers['content-length'], response.headers['content-length']);
assert.equal(head.body.length, 0);
}
assert.deepEqual((await get(server, '/?preview=true')).body, (await get(server, '/')).body);
});
test('preview rejects traversal, dotfiles, malformed encodings, private files and unsafe methods', async t => {
const server = await preview(t);
for (const path of [
'/../README.md', '/static/../../membership/config/settings.py', '/%2e%2e/README.md',
'/static/%2E%2E/index.html', '/%252e%252e/README.md', '/%2f../README.md',
'/static%5c..%5cindex.html', '/static\\..\\index.html', '/.env', '/%2eenv',
'/.git/config', '/static/.secret', '/%', '/%GG', '/%C0%AF', '/%00',
'/index.html:secret', '/index.html.', '/index.html%20', '/CON', '//README.md',
]) {
const response = await get(server, path);
assert.ok([400, 403].includes(response.status), `${path}: ${response.status}`);
assert.equal(response.headers['x-content-type-options'], 'nosniff');
assert.equal(response.headers['cache-control'], 'no-store');
assert.doesNotMatch(response.body.toString(), /Z:\\|Repositories|Traceback/u);
}
for (const path of ['/README.md', '/membership/config/settings.py', '/conf/httpd.conf',
'/scripts/preview.mjs', '/tests/site.test.mjs', '/static/', '/missing.html']) {
assert.equal((await get(server, path)).status, 404, path);
assert.equal((await get(server, path, 'HEAD')).body.length, 0);
}
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']) {
const response = await get(server, '/', method);
assert.equal(response.status, 405, method);
assert.equal(response.headers.allow, 'GET, HEAD');
}
assert.equal((await get(server, '/', 'GET', { Host: 'attacker.example' })).status, 403);
});
test('members routes are explicitly preview-only, not authentication or billing emulation', async t => {
const server = await preview(t);
for (const path of ['/members', '/members/', '/members/register/', '/members/login/',
'/members/profile/', '/members/billing/', '/members/checkout/gold/']) {
const response = await get(server, path);
assert.equal(response.status, 503, path);
assert.match(response.body.toString(), /local preview only/iu);
assert.match(response.body.toString(), /dynamic membership portal is unavailable/iu);
assert.doesNotMatch(response.body.toString(), /<form|<input|csrf|\{%/iu);
assert.equal(response.headers['set-cookie'], undefined);
const head = await get(server, path, 'HEAD');
assert.equal(head.status, 503);
assert.equal(head.body.length, 0);
assert.equal(head.headers['content-length'], response.headers['content-length']);
assert.equal((await get(server, path, 'POST')).status, 405);
}
});
async function fixture(t) {
const directory = await mkdtemp(join(tmpdir(), 'www-preview-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const root = join(directory, 'htdocs');
await mkdir(join(root, 'common'), { recursive: true });
await writeFile(join(directory, 'private.txt'), 'outside-public-root');
return { directory, root };
}
test('SSI is literal, nonrecursive and cannot include arbitrary files or execute directives', async t => {
const { root } = await fixture(t);
for (const name of ['header', 'topnav', 'footer']) {
await writeFile(join(root, 'common', `${name}.html`), `<p>${name} $&</p>`);
}
const server = await preview(t, { publicRoot: root });
await writeFile(join(root, 'index.html'), ['header', 'topnav', 'footer']
.map(name => `<!--#include virtual="/common/${name}.html" -->`).join('\n'));
const response = await get(server);
assert.equal(response.status, 200);
assert.doesNotMatch(response.body.toString(), /<!--#/u);
assert.match(response.body.toString(), /header \$&/u);
for (const directive of [
'<!--#include virtual="/../private.txt" -->', '<!--#include file="../private.txt" -->',
'<!--#exec cmd="echo should-never-execute" -->', '<!--#include virtual="/common/other.html" -->',
]) {
await writeFile(join(root, 'index.html'), directive);
const rejected = await get(server);
assert.equal(rejected.status, 500);
assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u);
}
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
await writeFile(join(root, 'common', 'header.html'), '<!--#include virtual="/common/footer.html" -->');
assert.equal((await get(server)).status, 500);
});
test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => {
const { directory, root } = await fixture(t);
const outside = join(directory, 'outside');
await mkdir(outside);
await writeFile(join(outside, 'header.html'), 'outside-public-root');
try {
await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir');
} catch (error) {
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
t.skip(`Directory symlinks unavailable on this host (${error.code}).`);
return;
}
throw error;
}
const server = await preview(t, { publicRoot: root });
assert.equal((await get(server, '/escape-dir/header.html')).status, 403);
await rm(join(root, 'common'), { recursive: true });
await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir');
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
const response = await get(server);
assert.equal(response.status, 403);
assert.doesNotMatch(response.body.toString(), /outside-public-root/u);
});
test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => {
const { directory, root } = await fixture(t);
await writeFile(join(root, '.hidden.txt'), 'hidden-target');
try {
await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file');
await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file');
await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file');
} catch (error) {
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`);
return;
}
throw error;
}
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
const server = await preview(t, { publicRoot: root });
for (const path of ['/escape.txt', '/alias.txt', '/']) {
const response = await get(server, path);
assert.equal(response.status, 403, path);
assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/u);
}
});
test('port parsing defaults to 8082 and accepts only numeric ports in range', () => {
assert.equal(parsePort(), 8082);
assert.equal(parsePort('0'), 0);
assert.equal(parsePort('65535'), 65535);
for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) {
assert.throws(() => parsePort(value), /Port must be numeric/u);
}
});