mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
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.
491 lines
21 KiB
JavaScript
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'));
|
|
});
|