Focus on content
This commit is contained in:
Executable
+560
@@ -0,0 +1,560 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
ORIGIN, PAGE_SIZE, MAX_PAGES, MAX_BYTES, MAX_CONTENT_BYTES, REFRESH_MS,
|
||||
validateRepositoryURL, normalizeDate, normalizeActivities, fetchActivityPage,
|
||||
fetchRecentProjects, createCard, initGiteaProjects
|
||||
} from '../htdocs/static/gitea-projects.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-31T12:00:00Z');
|
||||
const HASH = 'b96be7076ecae7fcc6db1cc357feb613196a9bd2';
|
||||
function event(id = 111164, created = '2026-10-30T20:38:17Z', fullName = 'Gaming/the-button', extra = {}) {
|
||||
return { id: 110459 + id, op_type: 'commit_repo', created, is_private: false,
|
||||
act_user: { login: 'eric' }, ref_name: 'refs/heads/main',
|
||||
repo: { id, name: fullName.split('/')[1], full_name: fullName, html_url: `${ORIGIN}/${fullName}`,
|
||||
private: false, description: 'The Button Game', updated_at: '2099-01-01T00:00:00Z' },
|
||||
content: JSON.stringify({ HeadCommit: { Sha1: HASH, Message: 'SSO and turn gate\n',
|
||||
AuthorEmail: 'never-display@example.test' }, Commits: [], Len: 2,
|
||||
CompareURL: 'https://evil.test/compare/tracking' }), ...extra };
|
||||
}
|
||||
const jsonResponse = (value, headers = {}) => new Response(JSON.stringify(value), {
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }
|
||||
});
|
||||
const options = fetchImpl => ({ fetchImpl, now: () => NOW });
|
||||
const fullPage = (entries = []) => [...entries, ...Array(PAGE_SIZE - entries.length).fill(null)];
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// Minimal dependency-free DOM; deliberately throws on unsafe HTML sinks.
|
||||
class Node {
|
||||
constructor(tag = 'div', content = '') {
|
||||
this.localName = tag;
|
||||
this.textContent = content;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.selectors = new Map();
|
||||
}
|
||||
set innerHTML(_) { throw new Error('Unsafe HTML sink'); }
|
||||
set outerHTML(_) { throw new Error('Unsafe HTML sink'); }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
querySelector(selector) { return this.selectors.get(selector) ?? null; }
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
removeEventListener(name, callback) { if (this.listeners.get(name) === callback) this.listeners.delete(name); }
|
||||
dispatch(name) { this.listeners.get(name)?.(); }
|
||||
}
|
||||
class Document extends Node {
|
||||
constructor() { super(); this.hidden = false; }
|
||||
createElement(tag) { return new Node(tag); }
|
||||
}
|
||||
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||
function fixture({ hidden = false } = {}) {
|
||||
const document = new Document();
|
||||
document.hidden = hidden;
|
||||
const root = new Node('section');
|
||||
const items = new Node();
|
||||
items.className = 'project-grid latest-grid';
|
||||
const fallback = new Node('article', 'Selected project');
|
||||
items.append(fallback);
|
||||
const status = new Node('p');
|
||||
root.selectors.set('[data-project-items]', items);
|
||||
root.selectors.set('[data-project-status]', status);
|
||||
document.selectors.set('#gitea-projects', root);
|
||||
const permanentLink = new Node('a', 'Public activity');
|
||||
permanentLink.href = `${ORIGIN}/eric`;
|
||||
document.append(permanentLink, root);
|
||||
const intervals = new Map();
|
||||
const cleared = [];
|
||||
let nextID = 0;
|
||||
const timers = {
|
||||
setIntervalImpl(callback, delay) { const id = ++nextID; intervals.set(id, { callback, delay }); return id; },
|
||||
clearIntervalImpl(id) { cleared.push(id); intervals.delete(id); }
|
||||
};
|
||||
return { document, root, items, fallback, status, permanentLink, timers, intervals, cleared };
|
||||
}
|
||||
|
||||
test('repository URLs require exact raw HTTPS origin and exact owner/name attribution', () => {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/Gaming/the-button`, 'Gaming/the-button'), `${ORIGIN}/Gaming/the-button`);
|
||||
for (const name of ['a/b', 'Org-1/name_2.3', '_org/.repo', `${'a'.repeat(100)}/${'b'.repeat(100)}`]) {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), `${ORIGIN}/${name}`);
|
||||
}
|
||||
for (const value of [null, {}, '/Gaming/the-button', '//gitea.eom.dev/Gaming/the-button',
|
||||
'javascript:alert(1)', 'data:text/html,attack', 'http://gitea.eom.dev/Gaming/the-button',
|
||||
'https://evil.test/Gaming/the-button', 'https://gitea.eom.dev.evil.test/Gaming/the-button',
|
||||
'https://user@gitea.eom.dev/Gaming/the-button', 'https://user:pass@gitea.eom.dev/Gaming/the-button',
|
||||
'https://gitea.eom.dev:443/Gaming/the-button', 'https://gitea.eom.dev:8443/Gaming/the-button',
|
||||
`${ORIGIN}/x/../Gaming/the-button`, `${ORIGIN}/Gaming/%74he-button`, `${ORIGIN}/Gaming/the-button#`,
|
||||
`${ORIGIN}/Gaming/the-button?`, `${ORIGIN}/Gaming/the-button/`, `${ORIGIN}/Gaming/the-button\n`,
|
||||
`${ORIGIN}/Gaming/the-button\\`, ` ${ORIGIN}/Gaming/the-button`, `${ORIGIN}/gaming/the-button`]) {
|
||||
assert.equal(validateRepositoryURL(value, 'Gaming/the-button'), null, String(value));
|
||||
}
|
||||
for (const name of ['', '.', '..', './repo', '../repo', 'Org/.', 'Org/..', 'Org/repo/more',
|
||||
'Org//repo', 'Org/<img>', 'Org/a b', 'Org/a%2fb', 'Org/repo\n', 'Org/répo', `${'a'.repeat(101)}/b`, null, {}]) {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), null, String(name));
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamps are strict valid activity times, never repo.updated_at', () => {
|
||||
assert.equal(normalizeDate('2026-10-30T20:38:17Z', { now: NOW }), '2026-10-30T20:38:17.000Z');
|
||||
assert.equal(normalizeDate('2026-10-30T22:38:17+02:00', { now: NOW }), '2026-10-30T20:38:17.000Z');
|
||||
assert.equal(normalizeDate('2024-02-29T00:00:00.123Z', { now: NOW }), '2024-02-29T00:00:00.123Z');
|
||||
for (const value of [null, 123, {}, 'not a date', '2026-10-30', 'Oct 30', '2026-02-30T00:00:00Z',
|
||||
'2026-02-29T00:00:00Z', '2026-00-01T00:00:00Z', '2026-13-01T00:00:00Z',
|
||||
'2026-10-30T24:00:00Z', '2026-10-30T00:60:00Z', '2026-10-30T00:00:60Z',
|
||||
'2026-10-30T00:00:00+24:00', '2026-10-30T00:00:00+00:60', '2026-10-30T00:00:00',
|
||||
'2026-10-30T00:00:00Z\n', '2099-01-01T00:00:00Z']) assert.equal(normalizeDate(value, { now: NOW }), null, String(value));
|
||||
assert.equal(normalizeDate('2026-10-30T00:00:00Z', { now: NaN }), null);
|
||||
});
|
||||
|
||||
test('normalization finds personal public pushes across organizations, sorts and deduplicates repo IDs', () => {
|
||||
const entries = [event(1, '2026-10-28T00:00:00Z', 'eric/old'),
|
||||
event(2, '2026-10-29T00:00:00Z', 'Engineering/tool'),
|
||||
event(3, '2026-10-30T00:00:00Z', 'Gaming/game'),
|
||||
event(2, '2026-10-30T01:00:00Z', 'Engineering/tool'),
|
||||
event(4, '2026-10-30T02:00:00Z', 'Community/site')];
|
||||
const result = normalizeActivities(entries, { now: NOW });
|
||||
assert.deepEqual(result.map(item => item.fullName), ['Community/site', 'Engineering/tool', 'Gaming/game']);
|
||||
assert.deepEqual(result.map(item => item.id), [4, 2, 3]);
|
||||
assert.deepEqual(result.map(item => item.created), ['2026-10-30T02:00:00.000Z', '2026-10-30T01:00:00.000Z', '2026-10-30T00:00:00.000Z']);
|
||||
assert.deepEqual(result, normalizeActivities([...entries].reverse(), { now: NOW }));
|
||||
const tied = [event(10), event(9)];
|
||||
assert.deepEqual(normalizeActivities(tied, { now: NOW }), normalizeActivities([...tied].reverse(), { now: NOW }));
|
||||
assert.deepEqual(normalizeActivities([], { now: NOW }), []);
|
||||
for (const entries of [null, {}, Array(PAGE_SIZE * MAX_PAGES + 1).fill(null)]) assert.throws(() => normalizeActivities(entries));
|
||||
});
|
||||
|
||||
test('all privacy, actor, operation, shape, identity and original URL gates fail closed', () => {
|
||||
const good = event();
|
||||
const invalid = [null, [], {},
|
||||
...[true, undefined, null, 0, 'false'].map(is_private => event(1, undefined, undefined, { is_private })),
|
||||
...[true, undefined, null, 0, 'false'].map(privateValue => ({ ...good, repo: { ...good.repo, private: privateValue } })),
|
||||
...['Eric', 'other', '', null].map(login => ({ ...good, act_user: { login } })),
|
||||
...['create_repo', 'push', 'pull_request', null].map(op_type => ({ ...good, op_type })),
|
||||
...[null, [], {}].map(act_user => ({ ...good, act_user })),
|
||||
...[null, [], {}].map(repo => ({ ...good, repo })),
|
||||
...[0, -1, '111164', 1.5, Number.MAX_SAFE_INTEGER + 1, undefined].map(id => ({ ...good, repo: { ...good.repo, id } })),
|
||||
...[0, -1, '1', undefined].map(id => ({ ...good, id })),
|
||||
...[undefined, 'invalid', '2099-01-01T00:00:00Z'].map(created => ({ ...good, created })),
|
||||
{ ...good, repo: { ...good.repo, name: 'mismatch' } },
|
||||
{ ...good, repo: { ...good.repo, html_url: 'https://evil.test/Gaming/the-button' } }];
|
||||
assert.deepEqual(normalizeActivities(invalid, { now: NOW }), []);
|
||||
assert.equal(normalizeActivities([...invalid, good], { now: NOW }).length, 1);
|
||||
});
|
||||
|
||||
test('embedded commit JSON is bounded, first-line text only; hashes are constructed, sensitive metadata discarded', () => {
|
||||
const [project] = normalizeActivities([event()], { now: NOW });
|
||||
assert.equal(project.summary, 'SSO and turn gate');
|
||||
assert.equal(project.commitURL, `${ORIGIN}/Gaming/the-button/commit/${HASH}`);
|
||||
assert.deepEqual(Object.keys(project).sort(), ['commitURL', 'created', 'description', 'fullName', 'id', 'summary', 'url']);
|
||||
assert.doesNotMatch(JSON.stringify(project), /never-display|CompareURL|AuthorEmail|updated_at|ref_name/);
|
||||
for (const content of [undefined, null, {}, 'invalid JSON', 'null', '[]', '{}', '{"HeadCommit":[]}',
|
||||
'x'.repeat(MAX_CONTENT_BYTES + 1), JSON.stringify({ HeadCommit: { Message: 'é'.repeat(MAX_CONTENT_BYTES / 2) } })]) {
|
||||
const [fallback] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW });
|
||||
assert.equal(fallback.summary, 'The Button Game');
|
||||
assert.equal(fallback.commitURL, null);
|
||||
}
|
||||
for (const Sha1 of ['../bad', HASH + '\n', 'javascript:alert(1)', 'a'.repeat(39), 'g'.repeat(40), null]) {
|
||||
const content = JSON.stringify({ HeadCommit: { Sha1, Message: '<img onerror=alert(1)>\r\nprivate second line' } });
|
||||
const [item] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW });
|
||||
assert.equal(item.summary, '<img onerror=alert(1)>');
|
||||
assert.equal(item.commitURL, null);
|
||||
}
|
||||
for (const Sha1 of [HASH.toUpperCase(), 'A'.repeat(64)]) {
|
||||
const [item] = normalizeActivities([event(1, undefined, undefined, { content: JSON.stringify({ HeadCommit: { Sha1 } }) })], { now: NOW });
|
||||
assert.ok(item.commitURL.endsWith(Sha1.toLowerCase()));
|
||||
}
|
||||
const long = event();
|
||||
long.repo.description = 'd'.repeat(500);
|
||||
long.content = JSON.stringify({ HeadCommit: { Message: 's'.repeat(500) + '\nignored' } });
|
||||
const [bounded] = normalizeActivities([long], { now: NOW });
|
||||
assert.equal(bounded.description.length, 240);
|
||||
assert.equal(bounded.summary.length, 200);
|
||||
});
|
||||
|
||||
test('fetch uses only the fixed public activity endpoint, anonymous safe headers and bounded pages', async () => {
|
||||
assert.equal(PAGE_SIZE, 10);
|
||||
assert.equal(MAX_PAGES, 15);
|
||||
assert.equal(PAGE_SIZE * MAX_PAGES, 150);
|
||||
const requests = [];
|
||||
const result = await fetchActivityPage(1, options(async (url, init) => {
|
||||
requests.push({ url, init }); return jsonResponse([event()], { 'X-Total-Count': '1166' });
|
||||
}));
|
||||
assert.equal(result.total, 1166);
|
||||
assert.equal(result.events.length, 1);
|
||||
const [{ url, init }] = requests;
|
||||
assert.equal(url, `${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=1`);
|
||||
assert.equal(init.method, 'GET');
|
||||
assert.equal(init.credentials, 'omit');
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.referrerPolicy, 'no-referrer');
|
||||
assert.equal(init.cache, 'no-store');
|
||||
assert.deepEqual(init.headers, { Accept: 'application/json' });
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
let calls = 0;
|
||||
for (const page of [0, MAX_PAGES + 1, '1', 1.5, -1, null, 'https://evil.test/', `${ORIGIN}/api/v1/users/eric/repos`]) {
|
||||
await assert.rejects(fetchActivityPage(page, options(async () => { calls++; })), /page/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal((await fetchActivityPage(1, options(async () => jsonResponse([])))).total, null);
|
||||
});
|
||||
|
||||
test('fetch rejects status, redirects, offorigin response, MIME, invalid totals and malformed/oversized arrays', async () => {
|
||||
for (const response of [new Response('{}', { status: 503 }),
|
||||
new Response('[]', { headers: { 'Content-Type': 'text/html' } }),
|
||||
new Response('[]'), new Response('[]', { headers: { 'Content-Type': 'application/problem+json' } }),
|
||||
{ ok: true, redirected: true }, { ok: true, url: 'https://evil.test/' },
|
||||
{ ok: true, headers: new Headers({ 'Content-Type': 'application/json' }) },
|
||||
...['-1', '1.5', 'NaN', '', '9007199254740992'].map(total => jsonResponse([], { 'X-Total-Count': total })),
|
||||
...[{}, null, { data: [] }, '[]', Array(PAGE_SIZE + 1).fill(null)].map(value => jsonResponse(value)),
|
||||
new Response('broken json', { headers: { 'Content-Type': 'application/json' } })]) {
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => response)));
|
||||
}
|
||||
// Unknown fields and malformed individual records are tolerated, but never normalized to cards.
|
||||
const result = await fetchRecentProjects(options(async () => jsonResponse([null, [], { irrelevant: 'field' }])));
|
||||
assert.deepEqual(result.items, []);
|
||||
});
|
||||
|
||||
test('streamed response byte limits include UTF-8 and content-length, without response.text allocation', async () => {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({ start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('["ééé"]'));
|
||||
}, cancel() { cancelled = true; } });
|
||||
await assert.rejects(fetchActivityPage(1, { maxBytes: 8, fetchImpl: async () => new Response(stream,
|
||||
{ headers: { 'Content-Type': 'application/json' } }) }), /too large/);
|
||||
assert.equal(cancelled, true);
|
||||
for (const length of [String(MAX_BYTES + 1), '-1', 'not-a-number']) {
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => jsonResponse([], { 'Content-Length': length }))), /too large/);
|
||||
}
|
||||
const invalidUTF8 = new Response(new Uint8Array([0xff]), { headers: { 'Content-Type': 'application/json' } });
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => invalidUTF8)));
|
||||
let calls = 0;
|
||||
for (const bounds of [{ maxBytes: MAX_BYTES + 1 }, { maxBytes: 0 }, { maxBytes: 1.5 },
|
||||
{ timeoutMs: 0 }, { timeoutMs: 10_001 }, { timeoutMs: Infinity }]) {
|
||||
await assert.rejects(fetchActivityPage(1, { ...bounds, fetchImpl: async () => { calls++; } }), /bounds/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('10-second default timeout is bounded; injected short timeout covers fetch and stalled body', async () => {
|
||||
let signal;
|
||||
await assert.rejects(fetchActivityPage(1, { timeoutMs: 5, fetchImpl: async (_, init) => {
|
||||
signal = init.signal; return new Promise(() => {});
|
||||
} }), { name: 'AbortError' });
|
||||
assert.equal(signal.aborted, true);
|
||||
let cancelled = false;
|
||||
await assert.rejects(fetchActivityPage(1, { timeoutMs: 5, fetchImpl: async () => ({ ok: true,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }), body: { getReader: () => ({
|
||||
read: () => new Promise(() => {}), cancel: async () => { cancelled = true; }
|
||||
}) } }) }), { name: 'AbortError' });
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('external cancellation and already-aborted signals prevent delayed fetch/body results', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) });
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('pagination stops after three valid unique projects, not three events or repository owners', async () => {
|
||||
const pages = [];
|
||||
const result = await fetchRecentProjects(options(async url => {
|
||||
pages.push(Number(new URL(url).searchParams.get('page')));
|
||||
return jsonResponse(fullPage([event(1, undefined, 'Gaming/game'), event(2, undefined, 'Engineering/tool'),
|
||||
event(3, undefined, 'Community/site')]), { 'X-Total-Count': '1166' });
|
||||
}));
|
||||
assert.deepEqual(pages, [1]);
|
||||
assert.equal(result.items.length, 3);
|
||||
assert.equal(result.scannedEvents, PAGE_SIZE);
|
||||
assert.equal(result.total, 1166);
|
||||
assert.equal(result.pages, 1);
|
||||
});
|
||||
|
||||
test('multiple pages merge and sort activity.created across owners; invalid/private records do not stop scanning', async () => {
|
||||
const requested = [];
|
||||
const result = await fetchRecentProjects(options(async url => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
requested.push(page);
|
||||
const entries = page === 1 ? fullPage([event(1, '2026-10-28T00:00:00Z', 'Gaming/game'),
|
||||
event(1, '2026-10-29T00:00:00Z', 'Gaming/game'), event(2, undefined, 'Private/secret', { is_private: true })])
|
||||
: [event(1, '2026-10-30T00:00:00Z', 'Gaming/game'), event(3, '2026-10-29T23:00:00Z', 'Engineering/tool'),
|
||||
event(4, '2026-10-29T22:00:00Z', 'Community/site')];
|
||||
return jsonResponse(entries, { 'X-Total-Count': page === 1 ? '1166' : '1167' });
|
||||
}));
|
||||
assert.deepEqual(requested, [1, 2]);
|
||||
assert.deepEqual(result.items.map(item => item.id), [1, 3, 4]);
|
||||
assert.equal(result.scannedEvents, PAGE_SIZE + 3);
|
||||
assert.equal(result.total, 1166);
|
||||
});
|
||||
|
||||
test('bounded page cap and shorter pages terminate regardless of huge, missing or zero total headers', async () => {
|
||||
for (const headers of [{}, { 'X-Total-Count': '999999999' }, { 'X-Total-Count': '0' }]) {
|
||||
let calls = 0;
|
||||
const requested = [];
|
||||
const capped = await fetchRecentProjects(options(async url => {
|
||||
calls++;
|
||||
requested.push(url);
|
||||
return jsonResponse(fullPage([event()]), headers);
|
||||
}));
|
||||
assert.equal(calls, MAX_PAGES);
|
||||
assert.deepEqual(requested, Array.from({ length: MAX_PAGES }, (_, index) =>
|
||||
`${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=${index + 1}`));
|
||||
assert.equal(capped.pages, MAX_PAGES);
|
||||
assert.equal(capped.scannedEvents, PAGE_SIZE * MAX_PAGES);
|
||||
assert.equal(capped.items.length, 1);
|
||||
calls = 0;
|
||||
const short = await fetchRecentProjects(options(async () => { calls++; return jsonResponse([event()], headers); }));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(short.scannedEvents, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('a required later page failure rejects the whole refresh instead of publishing partial cards', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchRecentProjects(options(async () => {
|
||||
if (++calls === 1) return jsonResponse(fullPage([event()]));
|
||||
throw new Error('Later page unavailable');
|
||||
})), /Later page/);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('card DOM uses honest org/name heading, safe plain text, push date and exact repository/commit links', () => {
|
||||
const document = new Document();
|
||||
const sample = event();
|
||||
sample.repo.description = '<script>alert(1)</script>';
|
||||
sample.content = JSON.stringify({ HeadCommit: { Message: '<img onerror=alert(1)>\nnever show this line', Sha1: HASH,
|
||||
AuthorEmail: 'never-display@example.test' } });
|
||||
const [project] = normalizeActivities([sample], { now: NOW });
|
||||
const card = createCard(document, project);
|
||||
const nodes = walk(card);
|
||||
assert.equal(card.localName, 'article');
|
||||
assert.equal(card.className, 'project-card');
|
||||
assert.equal(nodes.find(node => node.localName === 'h3').textContent, 'Gaming/the-button');
|
||||
assert.ok(nodes.some(node => node.textContent === '<script>alert(1)</script>'));
|
||||
assert.ok(nodes.some(node => node.textContent === '<img onerror=alert(1)>'));
|
||||
assert.ok(nodes.every(node => !['script', 'img', 'iframe'].includes(node.localName)));
|
||||
assert.ok(nodes.every(node => !/never-display|never show this line|2099/.test(node.textContent)));
|
||||
const time = nodes.find(node => node.localName === 'time');
|
||||
assert.equal(time.dateTime, project.created);
|
||||
assert.match(time.textContent, /^Public push · Oct 30, 2026$/);
|
||||
const links = nodes.filter(node => node.localName === 'a');
|
||||
assert.deepEqual(links.map(node => node.textContent), ['Explore repository →', 'View this commit']);
|
||||
assert.deepEqual(links.map(node => node.href), [project.url, project.commitURL]);
|
||||
assert.ok(links.every(node => node.className === 'text-link'));
|
||||
assert.ok(links.every(node => node.rel === 'noopener noreferrer' && node.referrerPolicy === 'no-referrer'));
|
||||
});
|
||||
|
||||
test('public card boundary revalidates URLs and dates, limits text and never accepts arbitrary commit links', () => {
|
||||
const document = new Document();
|
||||
const [project] = normalizeActivities([event()], { now: NOW });
|
||||
for (const changes of [{ url: 'https://evil.test/Gaming/the-button' }, { fullName: '../bad' }, { created: 'invalid' }]) {
|
||||
assert.throws(() => createCard(document, { ...project, ...changes }), /card/);
|
||||
}
|
||||
for (const commitURL of ['https://evil.test/commit/' + HASH, project.url + '/compare/' + HASH,
|
||||
project.commitURL + '?tracking=1', project.commitURL + '\n', 'javascript:alert(1)', null]) {
|
||||
const nodes = walk(createCard(document, { ...project, commitURL, description: 'd'.repeat(500), summary: 's'.repeat(500) }));
|
||||
assert.equal(nodes.filter(node => node.localName === 'a').length, 1);
|
||||
assert.equal(nodes.find(node => node.className === 'project-description').textContent.length, 240);
|
||||
assert.equal(nodes.find(node => node.className === 'project-summary').textContent.length, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('init leaves the host grid and permanent activity link intact, renders results and checks every 15 minutes', async t => {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([event()]); }) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children.length, 1);
|
||||
assert.notEqual(f.items.children[0], f.fallback);
|
||||
assert.equal(f.items.className, 'project-grid latest-grid');
|
||||
assert.equal(f.status.getAttribute('role'), 'status');
|
||||
assert.match(f.status.textContent, /^Recent public pushes · Checked/);
|
||||
assert.match(f.status.textContent, /1 recent activity events checked.*not all history/);
|
||||
assert.match(f.status.textContent, /up to 150; not all history/);
|
||||
assert.equal(f.document.children[0], f.permanentLink);
|
||||
const [{ callback, delay }] = [...f.intervals.values()];
|
||||
assert.equal(delay, REFRESH_MS);
|
||||
callback();
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
controller.stop();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.document.listeners.has('visibilitychange'), false);
|
||||
await controller.refresh();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('successful zero results replace static fallback with honest empty message', async t => {
|
||||
const f = fixture();
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => jsonResponse([])) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children.length, 1);
|
||||
assert.equal(f.items.children[0].localName, 'p');
|
||||
assert.equal(f.items.children[0].textContent, 'No recent public pushes found.');
|
||||
assert.match(f.status.textContent, /Recent public pushes · Checked/);
|
||||
});
|
||||
|
||||
test('initial fetch or later pagination failure retains selected fallback; refresh failure retains previous cards', async t => {
|
||||
for (const partial of [false, true]) {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
let succeed = false;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => {
|
||||
calls++;
|
||||
if (succeed) return jsonResponse([event()]);
|
||||
if (partial && calls === 1) return jsonResponse(fullPage([event()]));
|
||||
throw new Error('<script>do not expose response details</script>');
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing selected projects.');
|
||||
succeed = true;
|
||||
await controller.refresh();
|
||||
const previous = f.items.children[0];
|
||||
succeed = false;
|
||||
await controller.refresh();
|
||||
assert.equal(f.items.children[0], previous);
|
||||
assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing previously checked results.');
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden initial page makes no request or timer; visible return retries and starts timer', async t => {
|
||||
const f = fixture({ hidden: true });
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([]); }) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
f.document.hidden = false;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
});
|
||||
|
||||
test('hiding aborts in-flight refresh, pauses timers and blocks delayed swaps; returning retries', async t => {
|
||||
const f = fixture();
|
||||
const late = deferred();
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => {
|
||||
calls++;
|
||||
if (calls === 1) { signal = init.signal; return late.promise; }
|
||||
return jsonResponse([event(2, undefined, 'Engineering/current')]);
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
f.document.hidden = true;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await controller.ready;
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
assert.match(f.status.textContent, /paused.*selected/);
|
||||
f.document.hidden = false;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
const current = f.items.children[0];
|
||||
assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Engineering/current');
|
||||
late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0], current);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('visible fresh return avoids requests, stale return refreshes using injected time', async t => {
|
||||
const f = fixture();
|
||||
let now = NOW;
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, now: () => now,
|
||||
fetchImpl: async () => { calls++; return jsonResponse([event()]); } });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
const hideAndReturn = () => {
|
||||
f.document.hidden = true; f.document.dispatch('visibilitychange');
|
||||
f.document.hidden = false; f.document.dispatch('visibilitychange');
|
||||
};
|
||||
hideAndReturn();
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.match(f.status.textContent, /Previously checked/);
|
||||
now += REFRESH_MS;
|
||||
hideAndReturn();
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
});
|
||||
|
||||
test('failed or interrupted refresh retries on return even when last success is fresh', async t => {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
const late = deferred();
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => {
|
||||
calls++;
|
||||
return calls === 2 ? late.promise : jsonResponse([event()]);
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
const pending = controller.refresh();
|
||||
f.document.hidden = true; f.document.dispatch('visibilitychange');
|
||||
await pending;
|
||||
f.document.hidden = false; f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
assert.equal(calls, 3);
|
||||
late.resolve(jsonResponse([]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0].localName, 'article');
|
||||
});
|
||||
|
||||
test('new refresh generations and stop both prevent non-cooperative delayed result swaps', async () => {
|
||||
for (const stop of [false, true]) {
|
||||
const f = fixture();
|
||||
const late = deferred();
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => {
|
||||
if (++calls === 1) { signal = init.signal; return late.promise; }
|
||||
return jsonResponse([event(2, undefined, 'Community/current')]);
|
||||
}) });
|
||||
try {
|
||||
if (stop) controller.stop();
|
||||
else await controller.refresh();
|
||||
await controller.ready;
|
||||
assert.equal(signal.aborted, true);
|
||||
const current = f.items.children[0];
|
||||
late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0], current);
|
||||
if (stop) assert.equal(current, f.fallback);
|
||||
else assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Community/current');
|
||||
} finally { controller.stop(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('absent/incomplete DOM contracts are inert and imports have no browser dependency', async () => {
|
||||
let calls = 0;
|
||||
for (const document of [undefined, new Document(), fixture().root]) {
|
||||
const controller = initGiteaProjects(document, options(async () => { calls++; }));
|
||||
await controller.ready;
|
||||
await controller.refresh();
|
||||
controller.stop();
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
const source = await readFile(new URL('../htdocs/static/gitea-projects.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /\b(?:innerHTML|outerHTML|insertAdjacentHTML|eval|localStorage|sessionStorage)\b|document\.write|peertube-feeds|Creator Launch/);
|
||||
});
|
||||
Executable
+579
@@ -0,0 +1,579 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ORIGIN, ORIGINALS_UUID, MAX_BYTES, REFRESH_MS, validateOriginURL, validateWatchURL,
|
||||
validateThumbnailURL, normalizeDate, sortAndDedupe, normalizeJSONVideos,
|
||||
fetchText, fetchJSON, channelVideosURL, fetchChannel, fetchPaginated, normalizeCollections,
|
||||
discoverCollections, fetchCollection, loadRegion, createCard, createLibraryController, initPeerTubeFeeds
|
||||
} from '../htdocs/static/peertube-feeds.mjs';
|
||||
|
||||
const UUID = '12345678-1234-1234-1234-123456789abc';
|
||||
const video = (id, date = '2026-10-01T12:00:00Z', extra = {}) => ({
|
||||
url: `${ORIGIN}/w/${id}`, title: id, publishedAt: date, source: 'Travel', ...extra
|
||||
});
|
||||
const jsonResponse = value => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } });
|
||||
// Minimal DOM contract for cards, selections and refresh behavior.
|
||||
class Node {
|
||||
constructor(tag = 'div', content = '') {
|
||||
this.localName = tag;
|
||||
this.textContent = content;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.selectors = new Map();
|
||||
this.value = '';
|
||||
}
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
addEventListener(name, callback, options) { this.listeners.set(name, { callback, options }); }
|
||||
removeEventListener(name) { this.listeners.delete(name); }
|
||||
dispatch(name) {
|
||||
const listener = this.listeners.get(name);
|
||||
listener?.callback();
|
||||
if (listener?.options?.once) this.listeners.delete(name);
|
||||
}
|
||||
querySelector(selector) { return this.selectors.get(selector) ?? null; }
|
||||
}
|
||||
class Document extends Node {
|
||||
constructor() { super(); this.hidden = false; this.regions = []; }
|
||||
createElement(tag) { return new Node(tag); }
|
||||
querySelectorAll(selector) { return selector === '[data-peertube-feed]' ? this.regions : []; }
|
||||
}
|
||||
function region(document, channels = 'eric_tech,eric_gaming') {
|
||||
const root = new Node();
|
||||
root.setAttribute('data-channels', channels);
|
||||
root.setAttribute('data-title', 'Workshop');
|
||||
const items = new Node();
|
||||
const fallback = new Node('article', 'Selected recording');
|
||||
items.append(fallback);
|
||||
const status = new Node('p');
|
||||
root.selectors.set('[data-feed-items]', items);
|
||||
root.selectors.set('[data-feed-status]', status);
|
||||
document.regions.push(root);
|
||||
return { root, items, fallback, status };
|
||||
}
|
||||
function library(document) {
|
||||
const root = new Node();
|
||||
const select = new Node('select');
|
||||
select.disabled = true;
|
||||
const items = new Node();
|
||||
const status = new Node('p');
|
||||
const link = new Node('a');
|
||||
link.href = `${ORIGIN}/accounts/eric/videos`;
|
||||
for (const [selector, node] of [['#peertube-collection', select], ['[data-library-items]', items], ['[data-library-status]', status], ['[data-library-link]', link]]) root.selectors.set(selector, node);
|
||||
document.selectors.set('#peertube-library', root);
|
||||
return { root, select, items, status, link };
|
||||
}
|
||||
const timers = { setIntervalImpl: () => 1, clearIntervalImpl: () => {} };
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
test('watch and thumbnail URLs use exact secure origin and narrow paths', () => {
|
||||
assert.equal(validateWatchURL('/w/Ab123'), `${ORIGIN}/w/Ab123`);
|
||||
assert.equal(validateWatchURL(`${ORIGIN}/videos/watch/${UUID}`), `${ORIGIN}/videos/watch/${UUID}`);
|
||||
assert.equal(validateThumbnailURL('/lazy-static/thumbnails/test.webp'), `${ORIGIN}/lazy-static/thumbnails/test.webp`);
|
||||
assert.equal(validateThumbnailURL('/static/thumbnails/ab/test.jpg'), `${ORIGIN}/static/thumbnails/ab/test.jpg`);
|
||||
for (const value of [null, {}, 'javascript:alert(1)', 'data:image/png;base64,x', 'http://peertube.eom.dev/w/a',
|
||||
'https://evil.test/w/a', 'https://peertube.eom.dev.evil.test/w/a', 'https://user@peertube.eom.dev/w/a',
|
||||
'https://peertube.eom.dev:443/w/a', 'https://peertube.eom.dev:8443/w/a', '//peertube.eom.dev/w/a',
|
||||
`${ORIGIN}/x/../w/a`, '/w/../w/a', '/w/%2e%2e/a', '/w/%61', '/w/a#x', '/w/a?x=1',
|
||||
'/w/a\\b', '/w/<script>', '/w/a\n', '/videos/watch/not-a-uuid', '/w/a/b', `${ORIGIN}/w/a/`]) {
|
||||
assert.equal(validateWatchURL(value), null, String(value));
|
||||
}
|
||||
for (const value of ['/static/thumbnails/a.svg', '/lazy-static/thumbnails/../a.jpg', '/static/thumbnails/a.jpg?track=1',
|
||||
'/static/thumbnails/%2f.jpg', '/other/a.jpg', 'https://evil.test/static/thumbnails/a.png']) assert.equal(validateThumbnailURL(value), null, value);
|
||||
assert.equal(validateOriginURL(`${ORIGIN}/api/v1/accounts/eric/video-channels?count=100&start=0`).origin, ORIGIN);
|
||||
});
|
||||
|
||||
test('normalization skips invalid dates, deduplicates and sorts latest, not playlist position', () => {
|
||||
const items = [video('older', '2020-01-01T00:00:00Z'), video('same', '2026-10-02T00:00:00Z'),
|
||||
video('same', '2026-10-03T00:00:00Z'), video('invalid', 'not a date'), video('bad', '2026-02-30T00:00:00Z'),
|
||||
video('newest', '2026-10-04T00:00:00Z')];
|
||||
assert.deepEqual(sortAndDedupe(items).map(item => item.title), ['newest', 'same', 'older']);
|
||||
assert.equal(normalizeDate('2026-02-30'), null);
|
||||
assert.equal(normalizeDate('Mon, 30 Feb 2026 12:00:00 GMT'), null);
|
||||
assert.equal(normalizeDate('Jan 1'), null);
|
||||
assert.equal(normalizeDate('2026-13-01'), null);
|
||||
assert.equal(normalizeDate(1234), null);
|
||||
assert.equal(normalizeDate('2027-01-01', { now: Date.parse('2026-10-01'), maxFutureMs: 60_000 }), null);
|
||||
assert.equal(normalizeDate('Wed, 01 Oct 2025 12:00:00 GMT'), '2025-10-01T12:00:00.000Z');
|
||||
const tied = [video('z'), video('a')];
|
||||
assert.deepEqual(sortAndDedupe(tied), sortAndDedupe([...tied].reverse()));
|
||||
assert.throws(() => sortAndDedupe(Array(1001).fill(video('a'))));
|
||||
assert.throws(() => sortAndDedupe([], -1));
|
||||
const normalized = normalizeJSONVideos([
|
||||
{ video: { url: `${ORIGIN}/w/old`, name: 'Older', publishedAt: '2020-01-01', privacy: { id: 1 } } },
|
||||
{ video: { url: `${ORIGIN}/videos/watch/${UUID}`, uuid: UUID, name: '<img onerror=alert(1)>', publishedAt: '2026-10-02', thumbnailPath: '/static/thumbnails/a.jpg' } },
|
||||
{ video: { url: `${ORIGIN}/w/private`, name: 'Private', publishedAt: '2026-10-03', privacy: { id: 2 } } }
|
||||
], 'Originals');
|
||||
assert.equal(normalized[0].title, '<img onerror=alert(1)>');
|
||||
assert.equal(normalized[0].thumbnail, `${ORIGIN}/static/thumbnails/a.jpg`);
|
||||
assert.equal(normalized[0].source, 'Originals');
|
||||
assert.equal(normalized.length, 2);
|
||||
});
|
||||
|
||||
test('actual channel and nested playlist API shapes validate privacy, URLs and plain metadata', () => {
|
||||
const result = normalizeJSONVideos([
|
||||
{ id: 10, uuid: UUID, shortUUID: 'Short123', url: `/videos/watch/${UUID}`, name: '<b>Title</b>',
|
||||
publishedAt: '2026-10-02T12:00:00Z', thumbnailPath: '/lazy-static/thumbnails/a.jpg',
|
||||
privacy: { id: 1, label: 'Public' }, description: '<script>not rendered</script>' },
|
||||
{ position: 1, startTimestamp: null, video: { url: '/w/Short123', name: 'Short', publishedAt: '2026-10-01T12:00:00Z' } },
|
||||
...[2, 3, 4, '1', null].map(id => ({ url: '/w/private', publishedAt: '2026-10-03', privacy: { id } })),
|
||||
{ url: '/w/malformed', publishedAt: '2026-10-03', privacy: null },
|
||||
{ url: 'https://evil.test/w/attack', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/unsafe', publishedAt: '2026-10-03', thumbnailPath: 'javascript:alert(1)' }
|
||||
], 'Music');
|
||||
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/unsafe`, `${ORIGIN}/videos/watch/${UUID}`, `${ORIGIN}/w/Short123`]);
|
||||
assert.equal(result[0].thumbnail, null);
|
||||
assert.equal(result[1].title, '<b>Title</b>');
|
||||
assert.equal(result[1].thumbnail, `${ORIGIN}/lazy-static/thumbnails/a.jpg`);
|
||||
assert.equal(result[1].source, 'Music');
|
||||
assert.equal('description' in result[1], false);
|
||||
assert.deepEqual(normalizeJSONVideos([]), []);
|
||||
});
|
||||
|
||||
test('JSON UUID identity deduplicates short and long watch URLs only after original URL validation', async () => {
|
||||
const entries = [
|
||||
{ url: '/w/Short123', uuid: UUID.toUpperCase(), name: 'Newest copy', publishedAt: '2026-10-03' },
|
||||
{ video: { url: `/videos/watch/${UUID}`, uuid: UUID, name: 'Older copy', publishedAt: '2026-10-02' } },
|
||||
...['https://evil.test/w/Short123', `${ORIGIN}/w/../w/Short123`, '/w/Short123?track=1',
|
||||
'https://peertube.eom.dev:443/w/Short123', 'javascript:alert(1)', undefined]
|
||||
.map(url => ({ url, uuid: UUID, name: 'Unsafe copy', publishedAt: '2026-10-04' }))
|
||||
];
|
||||
const normalized = normalizeJSONVideos(entries, 'Originals');
|
||||
assert.equal(normalized.length, 1);
|
||||
assert.equal(normalized[0].url, `${ORIGIN}/videos/watch/${UUID}`);
|
||||
assert.equal(normalized[0].title, 'Newest copy');
|
||||
assert.equal(normalized[0].source, 'Originals');
|
||||
assert.deepEqual(normalized, normalizeJSONVideos([...entries].reverse(), 'Originals'));
|
||||
for (const uuid of [undefined, null, {}, 'invalid', `${UUID}/../bad`]) {
|
||||
assert.equal(normalizeJSONVideos([{ url: '/w/Short123', uuid, publishedAt: '2026-10-03' }])[0].url, `${ORIGIN}/w/Short123`);
|
||||
}
|
||||
const merged = await loadRegion(['eric_channel', 'eric_music'], 'Publications', { fetchImpl: async value =>
|
||||
jsonResponse({ total: 1, data: [value.includes('/eric_channel/') ? entries[0] : entries[1]] }) });
|
||||
assert.equal(merged.items.length, 1);
|
||||
assert.equal(merged.items[0].url, `${ORIGIN}/videos/watch/${UUID}`);
|
||||
});
|
||||
|
||||
test('invalid local channel names cannot trigger fetches through any channel entry point', async () => {
|
||||
let calls = 0;
|
||||
const dependencies = { fetchImpl: async () => { calls++; return jsonResponse({ total: 0, data: [] }); } };
|
||||
for (const name of ['2', '../evil', 'eric@host', 'eric/other', 'eric?x=1', 'eric%2fother', 'eric#x', 'eric\n', `a${'b'.repeat(100)}`, '', null]) {
|
||||
await assert.rejects(fetchChannel(name, 'Invalid', dependencies), /channel name/);
|
||||
await assert.rejects(fetchCollection({ type: 'channel', id: name, title: 'Invalid' }, dependencies), /channel name/);
|
||||
await assert.rejects(loadRegion(['eric_tech', name], 'Invalid', dependencies), /feed channels/);
|
||||
if (typeof name === 'string') await assert.rejects(fetchPaginated(`/api/v1/video-channels/${name}/videos`, dependencies), /endpoint/);
|
||||
assert.deepEqual(normalizeCollections([{ id: 2, name }], []).channels, []);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('fetch enforces MIME, status, redirect, omitted credentials and streamed byte cap', async () => {
|
||||
const url = channelVideosURL('eric_channel', { count: 3 });
|
||||
let options;
|
||||
assert.equal(await fetchText(url, { fetchImpl: async (_, init) => { options = init; return jsonResponse({ total: 0, data: [] }); } }), '{"total":0,"data":[]}');
|
||||
assert.equal(options.headers.Accept, 'application/json');
|
||||
assert.equal(options.credentials, 'omit');
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.equal(options.referrerPolicy, 'no-referrer');
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
for (const response of [new Response('<html/>', { headers: { 'Content-Type': 'text/html' } }),
|
||||
new Response('fail', { status: 503, headers: { 'Content-Type': 'application/json' } }),
|
||||
new Response('{}', { headers: { 'Content-Type': 'application/json', 'Content-Length': String(MAX_BYTES + 1) } }),
|
||||
new Response('123456789', { headers: { 'Content-Type': 'application/json' } })]) {
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => response, maxBytes: 8 }));
|
||||
}
|
||||
await assert.rejects(fetchJSON(url, { fetchImpl: async () => new Response('invalid JSON', { headers: { 'Content-Type': 'application/json' } }) }), SyntaxError);
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, redirected: true }) }));
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, url: 'https://evil.test/' }) }));
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchText('https://evil.test/', { fetchImpl: async () => { calls++; } }));
|
||||
await assert.rejects(fetchText(`${ORIGIN}/w/notAnAPI`, { fetchImpl: async () => { calls++; } }));
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('timeout and external abort settle even a non-cooperative fetch or hanging reader', async () => {
|
||||
const url = channelVideosURL('eric_channel');
|
||||
let observedSignal;
|
||||
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async (_, init) => { observedSignal = init.signal; return new Promise(() => {}); } }), { name: 'AbortError' });
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
const controller = new AbortController();
|
||||
const pending = fetchText(url, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) });
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
let cancelled = false;
|
||||
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async () => ({ ok: true, headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
body: { getReader: () => ({ read: () => new Promise(() => {}), cancel: async () => { cancelled = true; } }) } }) }), { name: 'AbortError' });
|
||||
assert.equal(cancelled, true);
|
||||
const already = new AbortController();
|
||||
already.abort();
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchText(url, { signal: already.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('channel latest and library selection request exactly three newest videos without pagination', async () => {
|
||||
assert.equal(channelVideosURL('eric_tech'), `${ORIGIN}/api/v1/video-channels/eric_tech/videos?count=100&start=0&sort=-publishedAt`);
|
||||
assert.equal(channelVideosURL('eric_gaming', { start: 100 }), `${ORIGIN}/api/v1/video-channels/eric_gaming/videos?count=100&start=100&sort=-publishedAt`);
|
||||
for (const name of ['eric_channel', 'eric_music', 'a', 'A.name-1_', `a${'b'.repeat(99)}`]) assert.ok(channelVideosURL(name).includes(`/video-channels/${name}/videos`));
|
||||
for (const name of [0, 2, -1, '2', '02', '2/videos', 'eric?count=100', '../eric', 'eric/../other', 'eric%2fother', 'eric#fragment',
|
||||
'eric@host', '//evil.test', '_eric', ' eric', 'eric ', 'eric\\other', 'eric\n', 'eric\r', `a${'b'.repeat(100)}`, '', 1.5, null, {}, undefined]) assert.throws(() => channelVideosURL(name));
|
||||
for (const bounds of [{ count: 101 }, { count: 0 }, { count: '3' }, { start: -1 }, { start: 1.5 }, { start: 1001 }]) assert.throws(() => channelVideosURL('eric_channel', bounds));
|
||||
const requests = [];
|
||||
const dependencies = { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
requests.push(url.href);
|
||||
assert.equal(url.pathname, '/api/v1/video-channels/eric_tech/videos');
|
||||
assert.deepEqual([...url.searchParams], [['count', '3'], ['start', '0'], ['sort', '-publishedAt']]);
|
||||
return jsonResponse({ total: 5000, data: [
|
||||
{ url: `/videos/watch/${UUID}`, name: 'Newest', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/second', name: 'Second', publishedAt: '2026-10-02' },
|
||||
{ url: '/w/third', name: 'Third', publishedAt: '2026-10-01' }
|
||||
] });
|
||||
} };
|
||||
assert.deepEqual((await fetchChannel('eric_tech', 'Travel', dependencies)).map(item => item.title), ['Newest', 'Second', 'Third']);
|
||||
assert.equal(requests.length, 1);
|
||||
const collection = normalizeCollections([{ id: 701, name: 'eric_tech', displayName: 'Travel' }], []).channels[0];
|
||||
assert.equal(collection.key, 'channel:701');
|
||||
const selected = await fetchCollection(collection, dependencies);
|
||||
assert.equal(selected.length, 3);
|
||||
assert.ok(selected.every(item => item.source === 'Travel'));
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('latest channel JSON rejects malformed envelopes and filters nonpublic or unsafe cards', async () => {
|
||||
for (const result of [null, {}, [], { total: '1', data: [] }, { total: -1, data: [] },
|
||||
{ total: 2, data: [] }, { total: 1, data: [null] }, { total: 1, data: [[]] },
|
||||
{ total: 4, data: Array(4).fill(video('a')) }, { total: 0, data: [video('a')] }, { total: 2, data: [video('a')] }]) {
|
||||
await assert.rejects(fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse(result) }));
|
||||
}
|
||||
const result = await fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse({ total: 3, data: [
|
||||
{ url: '/w/public', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/unlisted', publishedAt: '2026-10-02', privacy: { id: 2 } },
|
||||
{ url: 'https://evil.test/w/unsafe', publishedAt: '2026-10-01' }
|
||||
] }) });
|
||||
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/public`]);
|
||||
});
|
||||
|
||||
test('optional full channel pagination requests sorted API pages', async () => {
|
||||
const starts = [];
|
||||
const result = await fetchPaginated('/api/v1/video-channels/eric_gaming/videos', { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
assert.equal(url.pathname, '/api/v1/video-channels/eric_gaming/videos');
|
||||
assert.equal(url.searchParams.get('count'), '100');
|
||||
assert.equal(url.searchParams.get('sort'), '-publishedAt');
|
||||
const start = Number(url.searchParams.get('start'));
|
||||
starts.push(start);
|
||||
return jsonResponse({ total: 101, data: Array.from({ length: Math.min(100, 101 - start) }, (_, i) => ({ id: start + i })) });
|
||||
} });
|
||||
assert.deepEqual(starts, [0, 100]);
|
||||
assert.equal(result.length, 101);
|
||||
});
|
||||
|
||||
test('playlist discovery paginates and includes only explicitly public playlists plus new channels', async () => {
|
||||
const requests = [];
|
||||
const result = await discoverCollections({ fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
requests.push(url);
|
||||
if (url.pathname.endsWith('video-channels')) return jsonResponse({ total: 1, data: [{ id: 900, name: 'new_channel', displayName: 'New channel' }] });
|
||||
const start = +url.searchParams.get('start');
|
||||
const all = [
|
||||
...Array.from({ length: 100 }, (_, id) => ({ uuid: UUID, displayName: `Private ${id}`, privacy: { id: 2 } })),
|
||||
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
|
||||
];
|
||||
return jsonResponse({ total: 101, data: all.slice(start, start + 100) });
|
||||
} });
|
||||
assert.equal(result.playlists.length, 1);
|
||||
assert.equal(result.playlists[0].id, ORIGINALS_UUID);
|
||||
assert.equal(result.playlists[0].link, `${ORIGIN}/video-playlists/${ORIGINALS_UUID}`);
|
||||
assert.equal(result.channels[0].id, 'new_channel');
|
||||
assert.equal(result.channels[0].key, 'channel:900');
|
||||
assert.equal(result.channels[0].title, 'New channel');
|
||||
assert.equal(requests.filter(url => url.pathname.endsWith('video-playlists')).length, 2);
|
||||
assert.ok(requests.every(url => url.searchParams.get('count') === '100'));
|
||||
assert.ok(requests.some(url => url.searchParams.get('start') === '100'));
|
||||
const normalized = normalizeCollections([{ id: 2, name: '../evil' }], [
|
||||
{ uuid: UUID, displayName: 'Missing privacy' }, { uuid: 'invalid', displayName: 'Invalid UUID', privacy: { id: 1 } }
|
||||
]);
|
||||
assert.deepEqual(normalized, { channels: [], playlists: [] });
|
||||
});
|
||||
|
||||
test('pagination rejects malformed totals, too-large data, changing totals and incomplete pages', async () => {
|
||||
const path = '/api/v1/accounts/eric/video-playlists';
|
||||
for (const result of [{ total: -1, data: [] }, { total: '0', data: [] }, { total: 1.1, data: [] },
|
||||
{ total: 1001, data: [] }, { total: 0, data: [{}] }, { total: 1, data: [] }, { total: 200, data: Array(101).fill({}) }, { total: 1, data: {} }]) {
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse(result) }));
|
||||
}
|
||||
let pages = 0;
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: ++pages === 1 ? 2 : 3, data: [{}] }) }), /changed/);
|
||||
pages = 0;
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => { pages++; return jsonResponse({ total: 11, data: [{}] }); } }), /limit/);
|
||||
assert.equal(pages, 10);
|
||||
await assert.rejects(fetchPaginated('/api/v1/accounts/somebody/video-playlists'), /endpoint/);
|
||||
assert.deepEqual(await fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: 0, data: [] }) }), []);
|
||||
});
|
||||
|
||||
test('playlist items paginate and sort by video publication date, with an exact 1000-entry ceiling', async () => {
|
||||
let pages = 0;
|
||||
const result = await fetchCollection({ type: 'playlist', id: UUID, title: 'Playlist' }, { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
assert.equal(url.pathname, `/api/v1/video-playlists/${UUID}/videos`);
|
||||
assert.equal(url.searchParams.get('count'), '100');
|
||||
assert.equal(url.searchParams.has('sort'), false);
|
||||
pages++;
|
||||
const start = +url.searchParams.get('start');
|
||||
return jsonResponse({ total: 1000, data: Array.from({ length: 100 }, (_, offset) => ({ position: start + offset,
|
||||
video: { url: `${ORIGIN}/w/item${start + offset}`, name: `Item ${start + offset}`,
|
||||
publishedAt: new Date(Date.UTC(2020, 0, 1 + start + offset)).toISOString(), privacy: { id: 1 } } })) });
|
||||
} });
|
||||
assert.equal(pages, 10);
|
||||
assert.deepEqual(result.map(item => item.title), ['Item 999', 'Item 998', 'Item 997']);
|
||||
});
|
||||
|
||||
test('visibility check prevents the next API page from making a hidden-tab network request', async () => {
|
||||
let visible = true;
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchPaginated('/api/v1/accounts/eric/video-playlists', {
|
||||
shouldFetch: () => visible,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
visible = false;
|
||||
return jsonResponse({ total: 2, data: [{}] });
|
||||
}
|
||||
}), { name: 'AbortError' });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('API region merging deduplicates watch URLs, with honest partial/empty/all-failed results', async () => {
|
||||
const dependencies = { fetchImpl: async url => {
|
||||
assert.equal(new URL(url).searchParams.get('count'), '3');
|
||||
assert.equal(new URL(url).searchParams.get('sort'), '-publishedAt');
|
||||
if (url.includes('/eric_tech/')) return jsonResponse({ total: 0, data: [] });
|
||||
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
|
||||
} };
|
||||
const available = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', dependencies);
|
||||
assert.equal(available.ok, true);
|
||||
assert.equal(available.items.length, 1);
|
||||
const partial = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', { ...dependencies, fetchImpl: async url => {
|
||||
if (url.includes('/eric_tech/')) throw new Error('offline');
|
||||
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
|
||||
} });
|
||||
assert.equal(partial.unavailable, 1);
|
||||
assert.equal(partial.items.length, 1);
|
||||
const empty = await loadRegion(['eric_tech'], 'Tech', dependencies);
|
||||
assert.deepEqual(empty, { ok: true, unavailable: 0, items: [] });
|
||||
const failed = await loadRegion(['eric_channel', 'eric_music'], 'Films', { fetchImpl: async () => { throw new Error('offline'); } });
|
||||
assert.deepEqual(failed, { ok: false, unavailable: 2, items: [] });
|
||||
});
|
||||
|
||||
test('safe cards preserve plain title, have accessible same-tab links, and hide only failed image', () => {
|
||||
const doc = new Document();
|
||||
const card = createCard(doc, video('safe', undefined, { title: '<script>alert(1)</script>', thumbnail: '/static/thumbnails/a.jpg' }));
|
||||
assert.equal(card.className, 'project-card media-card');
|
||||
const [media, copy] = card.children;
|
||||
assert.equal(media.getAttribute('aria-label'), 'Watch <script>alert(1)</script>');
|
||||
assert.equal(copy.children[1].localName, 'h3');
|
||||
assert.equal(copy.children[1].textContent, '<script>alert(1)</script>');
|
||||
assert.equal(copy.children[2].dateTime, '2026-10-01T12:00:00.000Z');
|
||||
assert.equal(media.target, undefined);
|
||||
const [image, label] = media.children;
|
||||
assert.equal(image.alt, '');
|
||||
assert.equal(image.loading, 'lazy');
|
||||
assert.equal(image.width, 560);
|
||||
assert.equal(image.height, 315);
|
||||
image.dispatch('error');
|
||||
assert.equal(image.hidden, true);
|
||||
assert.equal(image.listeners.has('error'), false);
|
||||
assert.equal(label.textContent, 'Watch ↗');
|
||||
assert.throws(() => createCard(doc, video('bad', undefined, { url: 'javascript:alert(1)' })));
|
||||
});
|
||||
|
||||
test('feed failure retains fallback; partial success replaces only item children; empty success is honest', async () => {
|
||||
for (const result of [{ ok: false, unavailable: 2, items: [] }, { ok: true, unavailable: 1, items: [video('a')] }, { ok: true, unavailable: 0, items: [] }]) {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const sources = new Node('a', 'Static source link');
|
||||
state.root.append(sources);
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async () => result });
|
||||
await app.ready;
|
||||
if (!result.ok) {
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
assert.match(state.status.textContent, /unavailable/);
|
||||
} else if (result.unavailable) {
|
||||
assert.equal(state.items.children[0].localName, 'article');
|
||||
assert.match(state.status.textContent, /Some channels unavailable/);
|
||||
assert.doesNotMatch(state.status.textContent, /Latest uploads/);
|
||||
} else assert.match(state.items.children[0].textContent, /No public uploads/);
|
||||
assert.equal(state.root.children[0], sources);
|
||||
app.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('library selects Originals, sorts content, keeps select enabled and blocks stale selection responses', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
const collections = normalizeCollections([{ id: 2, name: 'travel', displayName: 'Travel' }], [
|
||||
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
|
||||
]);
|
||||
const slow = deferred();
|
||||
let firstSignal;
|
||||
let calls = 0;
|
||||
const controller = createLibraryController(doc, state.root, {
|
||||
discoverCollections: async () => collections,
|
||||
fetchCollection: async (collection, dependencies) => {
|
||||
calls++;
|
||||
if (calls === 1) { firstSignal = dependencies.signal; return slow.promise; }
|
||||
return [video('travel')];
|
||||
}
|
||||
});
|
||||
const discovery = controller.discover();
|
||||
await Promise.resolve();
|
||||
assert.equal(state.select.value, `playlist:${ORIGINALS_UUID}`);
|
||||
assert.equal(state.select.disabled, false);
|
||||
assert.deepEqual(state.select.children.map(group => group.label), ['Channels', 'Playlists']);
|
||||
await controller.selectCollection('channel:2');
|
||||
assert.equal(firstSignal.aborted, true);
|
||||
slow.resolve([video('stale')]);
|
||||
await discovery;
|
||||
assert.equal(state.items.children[0].children[1].children[1].textContent, 'travel');
|
||||
assert.equal(state.link.href, `${ORIGIN}/video-channels/travel/videos`);
|
||||
controller.stop();
|
||||
assert.equal(state.select.listeners.has('change'), false);
|
||||
});
|
||||
|
||||
test('library failure keeps direct playlist link and discovery failure exposes account fallback', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
const controller = createLibraryController(doc, state.root, {
|
||||
discoverCollections: async () => normalizeCollections([], [{ uuid: UUID, displayName: 'First', privacy: { id: 1 } }]),
|
||||
fetchCollection: async () => { throw new Error('timeout'); }
|
||||
});
|
||||
await controller.discover();
|
||||
assert.equal(state.link.href, `${ORIGIN}/video-playlists/${UUID}`);
|
||||
assert.equal(state.select.disabled, false);
|
||||
assert.match(state.status.textContent, /unavailable/);
|
||||
controller.stop();
|
||||
const failureState = library(new Document());
|
||||
const failed = createLibraryController(doc, failureState.root, { discoverCollections: async () => { throw new Error('offline'); } });
|
||||
await failed.discover();
|
||||
assert.equal(failureState.select.disabled, true);
|
||||
assert.equal(failureState.link.hidden, false);
|
||||
assert.equal(failureState.link.href, `${ORIGIN}/accounts/eric/videos`);
|
||||
failed.stop();
|
||||
});
|
||||
|
||||
test('refresh is visibility gated, minimum interval bounded, disjoint and stoppable', async () => {
|
||||
const doc = new Document();
|
||||
region(doc);
|
||||
doc.hidden = true;
|
||||
let now = 0;
|
||||
let calls = 0;
|
||||
let tick;
|
||||
let cleared;
|
||||
const pending = deferred();
|
||||
const app = initPeerTubeFeeds(doc, { now: () => now,
|
||||
setIntervalImpl: callback => { tick = callback; return 42; }, clearIntervalImpl: handle => { cleared = handle; },
|
||||
loadRegion: async () => { calls++; return pending.promise; }
|
||||
});
|
||||
await app.ready;
|
||||
assert.equal(calls, 0);
|
||||
doc.hidden = false;
|
||||
const running = app.refresh();
|
||||
await app.refresh({ force: true });
|
||||
assert.equal(calls, 1);
|
||||
pending.resolve({ ok: true, unavailable: 0, items: [] });
|
||||
await running;
|
||||
tick();
|
||||
assert.equal(calls, 1);
|
||||
now = REFRESH_MS;
|
||||
doc.hidden = true;
|
||||
tick();
|
||||
assert.equal(calls, 1);
|
||||
doc.hidden = false;
|
||||
doc.dispatch('visibilitychange');
|
||||
assert.equal(calls, 2);
|
||||
app.stop();
|
||||
assert.equal(cleared, 42);
|
||||
assert.equal(doc.listeners.has('visibilitychange'), false);
|
||||
now += REFRESH_MS;
|
||||
tick();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('hiding a document aborts active region requests and never renders late thumbnail cards', async () => {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const pending = deferred();
|
||||
let signal;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async (_ids, _source, dependencies) => {
|
||||
signal = dependencies.signal;
|
||||
return pending.promise;
|
||||
} });
|
||||
doc.hidden = true;
|
||||
doc.dispatch('visibilitychange');
|
||||
assert.equal(signal.aborted, true);
|
||||
pending.resolve({ ok: true, unavailable: 0, items: [video('late', undefined, { thumbnail: '/static/thumbnails/a.jpg' })] });
|
||||
await app.ready;
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
app.stop();
|
||||
});
|
||||
|
||||
test('returning to a visible tab retries interrupted regions immediately and ignores stale responses', async () => {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let calls = 0;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, now: () => 0, loadRegion: async names => {
|
||||
assert.deepEqual(names, ['eric_tech', 'eric_gaming']);
|
||||
return ++calls === 1 ? first.promise : second.promise;
|
||||
} });
|
||||
doc.hidden = true;
|
||||
doc.dispatch('visibilitychange');
|
||||
doc.hidden = false;
|
||||
const resumed = app.refresh();
|
||||
assert.equal(calls, 2);
|
||||
first.resolve({ ok: true, unavailable: 0, items: [video('stale')] });
|
||||
await app.ready;
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
await app.refresh({ force: true });
|
||||
assert.equal(calls, 2, 'stale completion must not release the new request busy guard');
|
||||
second.resolve({ ok: true, unavailable: 0, items: [video('fresh')] });
|
||||
await resumed;
|
||||
assert.equal(state.items.children[0].children[1].children[1].textContent, 'fresh');
|
||||
app.stop();
|
||||
});
|
||||
|
||||
test('account discovery is reused across selections and refreshed only on the fifteen-minute schedule', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
let now = 0;
|
||||
let discoveries = 0;
|
||||
let selections = 0;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, now: () => now,
|
||||
discoverCollections: async () => {
|
||||
discoveries++;
|
||||
return normalizeCollections([{ id: 2, name: 'travel' }], [{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }]);
|
||||
},
|
||||
fetchCollection: async () => { selections++; return []; }
|
||||
});
|
||||
await app.ready;
|
||||
await app.library.selectCollection('channel:2');
|
||||
await app.library.selectCollection(`playlist:${ORIGINALS_UUID}`);
|
||||
assert.equal(discoveries, 1);
|
||||
assert.equal(selections, 3);
|
||||
assert.equal(state.select.disabled, false);
|
||||
now = REFRESH_MS - 1;
|
||||
await app.refresh();
|
||||
assert.equal(discoveries, 1);
|
||||
now++;
|
||||
await app.refresh();
|
||||
assert.equal(discoveries, 2);
|
||||
assert.equal(selections, 4);
|
||||
app.stop();
|
||||
});
|
||||
Executable
+431
@@ -0,0 +1,431 @@
|
||||
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(/&/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('&', '&'), '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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user