Files
Haven/test/extensionUpdateDownloads.test.js
ancsemi 6fc69bc2d9 Extension updates: a download that drops part way fails instead of hanging
A response that closed without finishing never settled, which left the
update lock taken until Haven restarted.
2026-10-03 15:40:14 -04:00

171 lines
7.0 KiB
JavaScript

'use strict';
// Exercise the downloader's redirect and DNS boundaries without contacting
// GitHub or changing the process-wide HTTPS/DNS modules used by other tests.
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const SOURCE = fs.readFileSync(path.join(__dirname, '../src/extensionUpdates.js'), 'utf8');
const BLOCKLIST_URL = 'https://ancsemi.github.io/Haven/blocklist.json';
const CANONICAL_URL = 'https://haven-app.com/blocklist.json';
function createDownloadFixture(responses, addresses = {}) {
const requests = [];
const lookups = [];
const timers = new Map();
let clock = 0;
let nextTimer = 0;
const https = {
get(url, options, onResponse) {
const req = new EventEmitter();
req.destroy = error => queueMicrotask(() => req.emit('error', error));
requests.push({ url: url.href, options });
// Real HTTPS reports responses and errors after returning its request.
queueMicrotask(() => {
options.lookup(url.hostname, {}, (error, address, family) => {
if (error) return req.destroy(error);
const response = responses[url.href];
assert.ok(response, 'Unexpected request: ' + url.href);
assert.equal(address, addresses[url.hostname] || '93.184.216.34');
assert.equal(family, 4);
clock += response.elapsed || 0;
if (response.hang) return;
const res = new EventEmitter();
res.statusCode = response.status || 200;
res.headers = response.headers || {};
let destroyed = false;
res.destroy = error => {
destroyed = true;
queueMicrotask(() => {
if (error) res.emit('error', error);
res.emit('close');
});
};
onResponse(res);
queueMicrotask(() => {
for (const chunk of response.chunks || []) {
if (destroyed) return;
res.emit('data', Buffer.from(chunk));
}
if (!destroyed) {
// A connection reset closes the response without an 'end'.
if (!response.closeWithoutEnd) res.emit('end');
res.emit('close');
}
});
});
});
return req;
},
};
const dns = {
lookup(hostname, options, callback) {
lookups.push({ hostname, options });
callback(null, addresses[hostname] || '93.184.216.34', 4);
},
};
const context = vm.createContext({
module: { exports: {} }, exports: {}, Buffer, URL,
require: name => name === 'node:https' ? https : name === 'node:dns' ? dns : require(name),
Date: { now: () => clock },
setTimeout(handler, duration) {
const id = ++nextTimer;
timers.set(id, { handler, duration });
return id;
},
clearTimeout: id => timers.delete(id),
});
vm.runInContext(SOURCE, context, { filename: 'extensionUpdates.js' });
return { download: context.module.exports.downloadExtensionAsset, requests, lookups, timers };
}
test('the blocklist follows the GitHub Pages redirect to its exact public canonical URL', async () => {
const json = JSON.stringify({ schemaVersion: 1, updatedAt: '2026-10-03T00:00:00Z', blocked: [] });
const fixture = createDownloadFixture({
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
[CANONICAL_URL]: { chunks: [json.slice(0, 20), json.slice(20)] },
});
const bytes = await fixture.download(BLOCKLIST_URL, 1024);
assert.deepEqual(JSON.parse(bytes.toString()), JSON.parse(json));
assert.deepEqual(fixture.requests.map(request => request.url), [BLOCKLIST_URL, CANONICAL_URL]);
assert.deepEqual(fixture.lookups.map(lookup => lookup.hostname), ['ancsemi.github.io', 'haven-app.com']);
assert.ok(fixture.lookups.every(lookup => lookup.options.family === 4));
assert.ok(fixture.requests.every(request => request.options.headers['Cache-Control'] === 'no-cache'));
assert.equal(fixture.timers.size, 0);
});
test('a blocklist redirect cannot authorize another host, path or URL component', async () => {
for (const destination of [
'https://unapproved.example/blocklist.json',
'https://haven-app.com/another.json',
CANONICAL_URL + '?source=redirect',
CANONICAL_URL + '#fragment',
'https://user:password@haven-app.com/blocklist.json',
'http://haven-app.com/blocklist.json',
'https://haven-app.com:444/blocklist.json',
]) {
const fixture = createDownloadFixture({
[BLOCKLIST_URL]: { status: 301, headers: { location: destination } },
});
await assert.rejects(fixture.download(BLOCKLIST_URL, 1024), /Unsafe download URL|Unapproved download host/);
assert.equal(fixture.requests.length, 1, 'unsafe redirect is rejected before a connection');
assert.equal(fixture.timers.size, 0);
}
});
test('an approved redirect still rejects a private DNS result', async () => {
const fixture = createDownloadFixture({
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
[CANONICAL_URL]: { chunks: ['should not be read'] },
}, { 'haven-app.com': '127.0.0.1' });
await assert.rejects(fixture.download(BLOCKLIST_URL, 1024), /non-public address/);
assert.deepEqual(fixture.lookups.map(lookup => lookup.hostname), ['ancsemi.github.io', 'haven-app.com']);
assert.equal(fixture.timers.size, 0);
});
test('the redirected blocklist retains declared and streamed size limits', async () => {
for (const response of [
{ headers: { 'content-length': '6' }, chunks: ['123456'] },
{ chunks: ['1234', '56'] },
]) {
const fixture = createDownloadFixture({
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
[CANONICAL_URL]: response,
});
await assert.rejects(fixture.download(BLOCKLIST_URL, 5), /size limit/);
assert.equal(fixture.timers.size, 0);
}
});
test('redirects share the original deadline instead of restarting the timeout', async () => {
const fixture = createDownloadFixture({
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL }, elapsed: 15 },
[CANONICAL_URL]: { hang: true },
});
const pending = fixture.download(BLOCKLIST_URL, 1024, 0, 20);
const rejected = assert.rejects(pending, /timed out/);
await new Promise(resolve => setImmediate(resolve));
assert.equal(fixture.requests.length, 2);
assert.equal(fixture.timers.size, 1);
const timer = [...fixture.timers.values()][0];
assert.equal(timer.duration, 5, 'only the time remaining before the original deadline is available');
timer.handler();
await rejected;
assert.equal(fixture.timers.size, 0);
});
test('a response that closes before it finishes rejects instead of hanging', async () => {
const fixture = createDownloadFixture({
[CANONICAL_URL]: { chunks: ['{"schema'], closeWithoutEnd: true },
});
await assert.rejects(fixture.download(CANONICAL_URL, 1024), /ended before it finished/);
assert.equal(fixture.timers.size, 0);
});