Files
Haven/test/extensionUpdates.test.js
ancsemi 5467e61a72 Extension updates: keep file permissions, log why a blocklist check failed
A replaced plugin or theme kept the private mode of the temporary file;
it now keeps the original file's mode. The blocklist error gave no cause,
so a 404, a DNS failure and bad JSON looked the same in the log. Also a
colon instead of a dash in the module header.
2026-10-03 15:40:52 -04:00

491 lines
21 KiB
JavaScript

'use strict';
// Exercise the real updater against temporary files and a fake GitHub/blocklist
// transport. No public releases or installed user extensions are changed.
// Assertions cover both the returned result and the bytes left on disk.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createExtensionUpdater,
validateReleaseManifest,
validateBlocklist,
sha256,
isPublicAddress,
} = require('../src/extensionUpdates');
const repo = 'example-owner/extensions';
const id = 'org.example.layout';
const pluginSource = (version, extra = '') =>
Buffer.from(`/**\n * @id ${id}\n * @version ${version}\n * @update-repo ${repo}\n */\n${extra}`);
// Each test starts with version 1.0.0 installed and an immutable 1.1.0 release.
// Tests can change remote bytes, metadata or availability independently to
// reproduce failures at the boundary being checked.
function createUpdateFixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-updates-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const dirs = { plugin: path.join(root, 'plugins'), theme: path.join(root, 'themes') };
Object.values(dirs).forEach(d => fs.mkdirSync(d));
const file = path.join(dirs.plugin, 'Example.plugin.js');
fs.writeFileSync(file, pluginSource('1.0.0'));
const candidate = {
id,
type: 'plugin',
version: '1.1.0',
requires: { haven: '>=4.5.0 <5.0.0' },
asset: 'Example.plugin.js',
sha256: sha256(pluginSource('1.1.0')),
};
const release = {
id: 1,
immutable: true,
draft: false,
prerelease: false,
tag_name: 'v1.1.0',
body: 'Release notes',
assets: [
{ id: 2, name: 'haven-release.json', state: 'uploaded' },
{ id: 3, name: candidate.asset, state: 'uploaded' },
],
};
const fixture = {
dirs,
file,
candidate,
release,
list: { schemaVersion: 1, updatedAt: '2026-09-09T00:00:00Z', blocked: [] },
bytes: pluginSource('1.1.0'),
calls: [],
time: 1,
};
fixture.fetchBytes = async url => {
fixture.calls.push(url);
if (fixture.beforeFetch) await fixture.beforeFetch(url);
if (url.includes('blocklist.json')) {
if (fixture.offline) throw Error('offline');
return Buffer.from(JSON.stringify(fixture.list));
}
if (url.includes('releases?')) return Buffer.from(JSON.stringify([release]));
if (url.endsWith('/assets/2'))
return Buffer.from(JSON.stringify({ schemaVersion: 1, extensions: [candidate] }));
if (url.endsWith('/assets/3')) return fixture.bytes;
if (url.endsWith('/releases/1')) return Buffer.from(JSON.stringify(release));
throw Error('Unexpected URL: ' + url);
};
fixture.options = {
dirs,
stateDir: path.join(root, 'state'),
havenVersion: '4.5.0',
fetchBytes: fixture.fetchBytes,
now: () => fixture.time,
};
fixture.updater = createExtensionUpdater(fixture.options);
fixture.offer = async () =>
(await fixture.updater.check('admin')).extensions[0].offers.find(o => o.action === 'install');
return fixture;
}
test('manual check, install and rollback preserve filename and original bytes', async t => {
const fixture = createUpdateFixture(t);
assert.equal(fixture.calls.length, 0);
const offer = await fixture.offer();
assert.equal(offer.version, '1.1.0');
assert.equal(fs.readFileSync(fixture.file).toString(), pluginSource('1.0.0').toString());
await fixture.updater.apply(offer.token, 'admin');
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.1.0'));
fixture.updater = createExtensionUpdater(fixture.options);
const rollback = (await fixture.updater.check('admin')).extensions[0].offers.find(
o => o.action === 'rollback',
);
await fixture.updater.apply(rollback.token, 'admin');
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
const afterRollback = (await fixture.updater.check('admin')).extensions[0].offers;
assert.equal(afterRollback.some(offer => offer.action === 'rollback'), false);
assert.equal(afterRollback.find(offer => offer.action === 'install').version, '1.1.0');
});
test('broken plugin and theme headers are isolated while healthy extensions can update', async t => {
for (const type of ['plugin', 'theme']) {
for (const source of [
'/**\n * @id dev.example.broken\n * @version 1.0.0\n */',
'/**\n * @id dev.example.broken\n * @version invalid\n * @update-repo bad-owner/broken\n */',
'/**\n * @id dev.example.broken\n * @id dev.example.other\n * @version 1.0.0\n * @update-repo bad-owner/broken\n */',
]) {
const fixture = createUpdateFixture(t);
const file = 'Broken' + (type === 'plugin' ? '.plugin.js' : '.theme.css');
const badFile = path.join(fixture.dirs[type], file);
fs.writeFileSync(badFile, source);
const rows = (await fixture.updater.check('admin')).extensions;
const broken = rows.find(row => row.file === file);
assert.match(broken.error, /metadata|Duplicate extension header field/);
assert.deepEqual(broken.offers, []);
assert.equal(fixture.calls.some(url => url.includes('bad-owner')), false);
const healthy = rows.find(row => row.file === 'Example.plugin.js');
await fixture.updater.apply(healthy.offers.find(offer => offer.action === 'install').token, 'admin');
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.1.0'));
assert.equal(fs.readFileSync(badFile, 'utf8'), source);
}
}
});
test('an unrelated broken file added after checking does not prevent an approved update', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
fs.writeFileSync(path.join(fixture.dirs.plugin, 'Broken.plugin.js'), '/**\n * @id broken\n */');
await fixture.updater.apply(offer.token, 'admin');
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.1.0'));
});
test('all duplicate installed identities get errors while unrelated extensions are checked', async t => {
const fixture = createUpdateFixture(t);
const duplicate = pluginSource('1.0.0').toString().replace(id, 'dev.example.duplicate');
fs.writeFileSync(path.join(fixture.dirs.plugin, 'Duplicate.plugin.js'), duplicate);
fs.writeFileSync(path.join(fixture.dirs.theme, 'Duplicate.theme.css'), duplicate.replace(repo, repo.toUpperCase()));
const rows = (await fixture.updater.check('admin')).extensions;
for (const row of rows.filter(row => row.id === 'dev.example.duplicate')) {
assert.match(row.error, /Duplicate installed extension ID/);
assert.deepEqual(row.offers, []);
}
assert.equal(rows.filter(row => row.error).length, 2);
assert.equal(rows.find(row => row.file === 'Example.plugin.js').offers[0].version, '1.1.0');
});
test('an installed identity becoming ambiguous invalidates an existing offer even when its bytes match', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
fs.copyFileSync(fixture.file, path.join(fixture.dirs.plugin, 'Copy.plugin.js'));
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /Duplicate installed extension ID/);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
});
test('locally edited managed headers remain error rows without update or rollback offers', async t => {
for (const source of [
'/**\n * @version 1.1.0\n */',
pluginSource('1.1.0').toString().replace(repo, 'other-owner/extensions'),
]) {
const fixture = createUpdateFixture(t);
await fixture.updater.apply((await fixture.offer()).token, 'admin');
fs.writeFileSync(fixture.file, source);
const row = (await fixture.updater.check('admin')).extensions[0];
assert.equal(row.file, 'Example.plugin.js');
assert.match(row.error, /Local edits or source changes/);
assert.deepEqual(row.offers, []);
assert.equal(fs.readFileSync(fixture.file, 'utf8'), source);
}
});
test('extensions without update metadata remain outside the managed update list', async t => {
const fixture = createUpdateFixture(t);
fs.writeFileSync(path.join(fixture.dirs.plugin, 'Legacy.plugin.js'), '/**\n * @version 1.0.0\n */');
const rows = (await fixture.updater.check('admin')).extensions;
assert.deepEqual(rows.map(row => row.file), ['Example.plugin.js']);
});
test('mutable, prerelease and incompatible releases are not offered', async t => {
for (const change of [
fixture => (fixture.release.immutable = false),
fixture => (fixture.release.prerelease = true),
fixture => (fixture.candidate.requires.haven = '>=5'),
]) {
const fixture = createUpdateFixture(t);
change(fixture);
assert.equal(await fixture.offer(), undefined);
}
});
test('fresh blocklist failure prevents check and apply without changing installed bytes', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
fixture.offline = true;
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /blocklist/);
await assert.rejects(fixture.updater.check('admin'), /blocklist/);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
});
test('checksum mismatch, demotion and release mutation fail before replacement', async t => {
for (const change of [
fixture => {
fixture.bytes = pluginSource('1.1.0', 'tampered');
},
fixture => {
fixture.release.immutable = false;
},
fixture => {
fixture.denied = true;
},
]) {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
change(fixture);
await assert.rejects(fixture.updater.apply(offer.token, 'admin', () => !fixture.denied));
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
}
});
test('a correct checksum cannot authorize a mismatched header', async t => {
const fixture = createUpdateFixture(t);
fixture.bytes = pluginSource('9.0.0');
fixture.candidate.sha256 = sha256(fixture.bytes);
const offer = await fixture.offer();
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /metadata/);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
});
test('newly blocked candidates are rejected; installed versions produce warnings', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
fixture.list.blocked.push({ repo, id, versions: ['1.1.0', '1.0.0'], reason: 'Withdrawn' });
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /blocked/);
const row = (await fixture.updater.check('admin')).extensions[0];
assert.equal(row.warning, 'Withdrawn');
assert.equal(row.offers.length, 0);
});
test('rollback is blocked if previous version is flagged or blocklist is unavailable', async t => {
const fixture = createUpdateFixture(t);
await fixture.updater.apply((await fixture.offer()).token, 'admin');
const rollback = (await fixture.updater.check('admin')).extensions[0].offers[0];
fixture.offline = true;
await assert.rejects(fixture.updater.apply(rollback.token, 'admin'), /blocklist/);
fixture.offline = false;
fixture.list.blocked.push({ repo, id, versions: ['1.0.0'], reason: 'Withdrawn' });
await assert.rejects(fixture.updater.apply(rollback.token, 'admin'), /blocked/);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.1.0'));
});
test('offers are user-bound, expire, and detect local edits including during download', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
await assert.rejects(fixture.updater.apply(offer.token, 'other'), /expired/);
fixture.time += 11 * 60000;
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /expired/);
const next = await fixture.offer();
fixture.beforeFetch = async url => {
if (url.endsWith('/assets/3')) fs.writeFileSync(fixture.file, 'local edit');
};
await assert.rejects(fixture.updater.apply(next.token, 'admin'), /changed/);
assert.equal(fs.readFileSync(fixture.file, 'utf8'), 'local edit');
});
test('concurrent operations do not race file replacement', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
let release;
fixture.beforeFetch = url =>
url.includes('blocklist')
? new Promise(resolve => {
release = resolve;
})
: undefined;
const pending = fixture.updater.apply(offer.token, 'admin');
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /already running/);
release();
await pending;
});
test('recovery completes metadata after interrupted file replacement', async t => {
const fixture = createUpdateFixture(t);
const item = { type: 'plugin', file: 'Example.plugin.js' };
const stateFile = path.join(fixture.options.stateDir, 'state.json');
const oldState = {},
newState = { example: 'committed' };
fs.writeFileSync(
path.join(fixture.options.stateDir, 'transaction.json'),
JSON.stringify({
item,
before: sha256(pluginSource('1.0.0')),
after: sha256(pluginSource('1.1.0')),
oldState,
newState,
}),
);
fs.writeFileSync(fixture.file, pluginSource('1.1.0'));
createExtensionUpdater(fixture.options);
assert.deepEqual(JSON.parse(fs.readFileSync(stateFile)), newState);
});
test('a leftover journal that cannot be recovered does not stop the server from starting', async t => {
const fixture = createUpdateFixture(t);
fs.writeFileSync(
path.join(fixture.options.stateDir, 'transaction.json'),
JSON.stringify({ item: { type: 'plugin', file: 'Gone.plugin.js' }, before: 'a', after: 'b', oldState: {}, newState: {} }),
);
let updater;
assert.doesNotThrow(() => { updater = createExtensionUpdater(fixture.options); });
// The problem is still reported to the admin on the next check.
await assert.rejects(updater.check('admin'));
});
test('manifest validation rejects traversal, duplicate IDs, bad hashes and unsupported schemas', () => {
const entry = {
id,
type: 'plugin',
version: '1.0.0',
requires: { haven: '^4.5.0' },
asset: 'Example.plugin.js',
sha256: '0'.repeat(64),
};
for (const change of [
m => (m.schemaVersion = 2),
m => m.extensions.push(entry),
m => (m.extensions[0].asset = '../bad.plugin.js'),
m => (m.extensions[0].sha256 = 'bad'),
]) {
const value = { schemaVersion: 1, extensions: [{ ...entry }] };
change(value);
assert.throws(() => validateReleaseManifest(value));
}
assert.throws(() => validateBlocklist({ schemaVersion: 1, blocked: [] }));
for (const ip of ['127.0.0.1', '10.1.2.3', '169.254.169.254', '192.168.0.1', '::1'])
assert.equal(isPublicAddress(ip), false);
});
test('GitHub outage does not hide a safe local rollback', async t => {
const fixture = createUpdateFixture(t);
await fixture.updater.apply((await fixture.offer()).token, 'admin');
fixture.beforeFetch = async url => {
if (url.includes('api.github.com')) throw Error('GitHub unavailable');
};
const row = (await fixture.updater.check('admin')).extensions[0];
assert.match(row.error, /unavailable/);
await fixture.updater.apply(row.offers.find(o => o.action === 'rollback').token, 'admin');
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
});
// Inject failures at the rename boundary, not by fabricating a successful API
// response. Restore the filesystem method before restarting the updater.
test('failed replacement recovers old state and preserves original file', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
const rename = fs.renameSync;
fs.renameSync = (from, to) => {
if (to === fixture.file) throw Error('simulated disk failure');
return rename(from, to);
};
try {
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /disk failure/);
} finally {
fs.renameSync = rename;
}
fixture.updater = createExtensionUpdater(fixture.options);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
assert.ok(await fixture.offer());
});
test('crash after replacement recovers committed metadata on restart', async t => {
const fixture = createUpdateFixture(t);
const offer = await fixture.offer();
const rename = fs.renameSync;
fs.renameSync = (from, to) => {
if (to === path.join(fixture.options.stateDir, 'state.json')) throw Error('simulated crash');
return rename(from, to);
};
try {
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /crash/);
} finally {
fs.renameSync = rename;
}
fixture.updater = createExtensionUpdater(fixture.options);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.1.0'));
assert.ok((await fixture.updater.check('admin')).extensions[0].offers.find(o => o.action === 'rollback'));
});
test('symlinked extensions are rejected without touching their targets', async t => {
const fixture = createUpdateFixture(t);
const external = path.join(fixture.options.stateDir, 'external');
fs.renameSync(fixture.file, external);
fs.symlinkSync(external, fixture.file);
const row = (await fixture.updater.check('admin')).extensions[0];
assert.match(row.error, /regular file/);
assert.deepEqual(row.offers, []);
assert.deepEqual(fs.readFileSync(external), pluginSource('1.0.0'));
});
test('semantic version selection and manifest conflicts are handled explicitly', async t => {
const fixture = createUpdateFixture(t);
fixture.candidate.version = '1.10.0';
fixture.bytes = pluginSource('1.10.0');
fixture.candidate.sha256 = sha256(fixture.bytes);
fs.writeFileSync(fixture.file, pluginSource('1.9.0'));
assert.equal((await fixture.offer()).version, '1.10.0');
fixture.release.assets.push({ id: 4, name: 'Example.plugin.js', state: 'uploaded' });
const row = (await fixture.updater.check('admin')).extensions[0];
assert.match(row.error, /ambiguous/);
assert.equal(row.offers.length, 0);
});
test('pagination limit is an error rather than a false up-to-date result', async t => {
const fixture = createUpdateFixture(t);
const fetchBytes = fixture.options.fetchBytes;
fixture.updater = createExtensionUpdater({
...fixture.options,
fetchBytes: url =>
url.includes('releases?')
? Buffer.from(JSON.stringify(Array(100).fill(fixture.release)))
: fetchBytes(url),
});
const row = (await fixture.updater.check('admin')).extensions[0];
assert.match(row.error, /Too many releases/);
assert.equal(row.offers.length, 0);
});
test('an updated file keeps its original permissions', { skip: process.platform === 'win32' }, async t => {
const fixture = createUpdateFixture(t);
fs.chmodSync(fixture.file, 0o644);
await fixture.updater.apply((await fixture.offer()).token, 'admin');
assert.equal(fs.statSync(fixture.file).mode & 0o777, 0o644);
});
test('release manifests are fetched once and only the newest 30 are read', async t => {
const fixture = createUpdateFixture(t);
await fixture.offer();
await fixture.offer();
assert.equal(fixture.calls.filter(url => url.endsWith('/assets/2')).length, 1, 'immutable manifests are cached');
// Forty releases with manifests: the check reads 30 and still offers the update.
const fetchBytes = fixture.options.fetchBytes;
const many = Array.from({ length: 40 }, (_, i) => ({
...fixture.release,
id: 100 + i,
assets: [{ id: 1000 + i, name: 'haven-release.json', state: 'uploaded' }, { id: 3, name: fixture.candidate.asset, state: 'uploaded' }],
}));
let manifestReads = 0;
fixture.updater = createExtensionUpdater({
...fixture.options,
fetchBytes: url => {
if (url.includes('releases?')) return Buffer.from(JSON.stringify(many));
const manifest = url.match(/\/assets\/1(\d\d\d)$/);
if (manifest) {
manifestReads++;
const version = `1.${Number(manifest[1]) + 1}.0`;
return Buffer.from(JSON.stringify({ schemaVersion: 1, extensions: [{ ...fixture.candidate, version }] }));
}
return fetchBytes(url);
},
});
const row = (await fixture.updater.check('admin')).extensions[0];
assert.equal(row.error, undefined);
assert.equal(manifestReads, 30);
});
test('theme API incompatibility is rejected even with a matching Haven range', async t => {
const fixture = createUpdateFixture(t);
fs.unlinkSync(fixture.file);
fixture.file = path.join(fixture.dirs.theme, 'Example.theme.css');
fs.writeFileSync(fixture.file, pluginSource('1.0.0'));
fixture.candidate.type = 'theme';
fixture.candidate.asset = 'Example.theme.css';
fixture.release.assets[1].name = fixture.candidate.asset;
fixture.bytes = Buffer.from(
pluginSource('1.1.0').toString().replace(' * @id', ' * @haven-theme-api 999\n * @id'),
);
fixture.candidate.sha256 = sha256(fixture.bytes);
const offer = await fixture.offer();
await assert.rejects(fixture.updater.apply(offer.token, 'admin'), /Theme API/);
assert.deepEqual(fs.readFileSync(fixture.file), pluginSource('1.0.0'));
});