Files
SubMiner/src/main/runtime/update/appimage-updater.test.ts
T

141 lines
4.7 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater';
const appImageBytes = Buffer.from('appimage');
const appImageHash = createHash('sha256').update(appImageBytes).digest('hex');
test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const renames: Array<{ from: string; to: string }> = [];
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async (targetPath, data) => {
writes.push({ path: targetPath, data });
},
chmod: async (targetPath, mode) => {
chmods.push({ path: targetPath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => {},
},
});
assert.deepEqual(result, {
status: 'updated',
path: '/home/kyle/.local/bin/SubMiner.AppImage',
});
assert.deepEqual(writes, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes },
]);
assert.deepEqual(chmods, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 },
]);
assert.deepEqual(renames, [
{
from: '/home/kyle/.local/bin/.SubMiner.AppImage.update',
to: '/home/kyle/.local/bin/SubMiner.AppImage',
},
]);
});
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {
throw new Error('EACCES');
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'protected');
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
assert.match(result.command ?? '', /sha256sum -c -/);
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
});
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
const command = buildProtectedAppImageUpdateCommand(
"https://example.test/Sub Miner.AppImage?sig='abc'",
"/opt/Sub Miner/SubMiner's.AppImage",
'ABCDEF',
);
assert.match(command, /trap 'rm -f "\$tmp"' EXIT/);
assert.match(
command,
/curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/,
);
assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/);
assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
});
test('updateAppImageFromRelease aborts on hash mismatch', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'hash-mismatch');
});