Files
tools-legacy/tools/test/fpagent-install.test.js
adebisi-fa fa33c6e179 feat(fpagent): pick the installer bundle for the host architecture
The hosted installer only ever chose the x86_64 bundle, so a 32-bit field
laptop running the public one-liner failed with "no compatible bundle" even
though fingerprint-agent v1.5.0 publishes fpagent-<version>-linux-i386.tar.gz.

Resolve the host architecture from uname -m (x86_64 or i386, else fail before
any download), then prefer -ubuntu-<codename>-<arch>.tar.gz and fall back to
-linux-<arch>.tar.gz. The version regex now derives the git hash for every
supported architecture, so a 32-bit install records and reports it too.

The help text matches the bundle installer: the systemd service and the
self-update timer are on by default, with --no-service, --no-updates and
--update-base documented.
2026-10-06 19:19:07 +01:00

372 lines
12 KiB
JavaScript

// The /fpagent installer surface: a public script, and a proxy that reads the
// private Gitea release with GITEA_RELEASE_INSTALL_KEY so the operator's
// laptop never needs credentials.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { createApp, FPAGENT_REPO } from '../server.js';
import { startServer, stopServer, urlOf } from './helpers.js';
const execFileAsync = promisify(execFile);
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'public',
'fpagent',
'install.sh'
);
const TOKEN = 'release-install-secret';
const TAG = 'v9.9.9';
const ARCHIVE = 'fpagent-deadbee-linux-x86_64.tar.gz';
const UBUNTU_ARCHIVE = 'fpagent-deadbee-ubuntu-jammy-x86_64.tar.gz';
const RELEASE = {
tag_name: TAG,
assets: [
{ name: ARCHIVE },
{ name: `${ARCHIVE}.sha256` },
{ name: UBUNTU_ARCHIVE },
{ name: 'not a safe name' },
{ name: '../escape' },
],
};
// A directory holding a fake `uname` that reports the given machine, so the
// installer's architecture-sensitive picker runs on any CI host. PATH is
// prepended with it; everything else resolves as usual.
function fakeUname(machine) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
mode: 0o755,
});
return dir;
}
// Fake Gitea: refuses without the token, serves the release and its assets.
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
const calls = [];
const server = await startServer((req, res) => {
calls.push({ url: req.url, auth: req.headers.authorization });
if (req.headers.authorization !== `token ${TOKEN}`) {
res.writeHead(404);
res.end();
return;
}
if (req.url === `/api/v1/repos/${FPAGENT_REPO}/releases/latest`) {
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
res.end(JSON.stringify(RELEASE));
return;
}
if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) {
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
res.end(JSON.stringify(RELEASE));
return;
}
if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) {
res.writeHead(downloadStatus, { 'content-type': 'application/gzip' });
res.end(Buffer.from('bundle-bytes'));
return;
}
res.writeHead(404);
res.end();
});
return { server, calls };
}
async function bootFpagentApp(giteaUrl, overrides = {}) {
const app = createApp({
giteaUrl,
fpagentRepo: FPAGENT_REPO,
giteaToken: TOKEN,
cropperClients: null,
...overrides,
});
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
return { server, base: urlOf(server) };
}
test('latest release is proxied as tools-relative URLs, without the token', async () => {
const gitea = await startFakeGitea();
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/latest`);
assert.equal(r.status, 200);
const body = await r.json();
assert.equal(body.tag, TAG);
// Unsafe asset names are dropped; the rest point back at this app.
assert.deepEqual(
body.assets.map((a) => a.name),
[ARCHIVE, `${ARCHIVE}.sha256`, UBUNTU_ARCHIVE]
);
for (const a of body.assets) {
assert.equal(a.url, `/fpagent/releases/download/${TAG}/${a.name}`);
}
assert.ok(!JSON.stringify(body).includes(TOKEN));
assert.equal(gitea.calls[0].auth, `token ${TOKEN}`); // server authenticated upstream
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('a pinned tag is proxied from the forge tag endpoint', async () => {
const gitea = await startFakeGitea();
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/tags/v1.0.0`);
assert.equal(r.status, 200);
assert.equal((await r.json()).tag, TAG);
assert.ok(gitea.calls[0].url.endsWith('/releases/tags/v1.0.0'));
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('an unsafe release tag is rejected before reaching the forge', async () => {
const gitea = await startFakeGitea();
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/tags/-bad`);
assert.equal(r.status, 400);
assert.equal(gitea.calls.length, 0);
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('install.sh --tag asks for that tag, not latest', async () => {
const calls = [];
const gateway = await startServer((req, res) => {
calls.push(req.url);
if (req.url === '/releases/tags/v7.7.7') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
tag: 'v7.7.7',
assets: [{ name: 'fpagent-cafebabe-linux-x86_64.tar.gz' }],
})
);
return;
}
res.writeHead(404);
res.end();
});
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-tag-test-'));
try {
let failed = false;
try {
// Async so the in-process gateway can answer while the child runs.
await execFileAsync('bash', [SCRIPT, '--tag', 'v7.7.7', '--check'], {
env: { ...process.env, TMPDIR: tmp, FPAGENT_BASE_URL: urlOf(gateway) },
});
} catch {
failed = true; // the asset download 404s; the metadata request is what matters
}
assert.equal(failed, true);
assert.ok(calls.includes('/releases/tags/v7.7.7'), 'should request the pinned tag');
assert.ok(!calls.includes('/releases/latest'), 'should not fall back to latest');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
await stopServer(gateway);
}
});
test('bundles stream through this app; the client sends no credentials', async () => {
const gitea = await startFakeGitea();
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
assert.equal(r.status, 200);
assert.equal(Buffer.from(await r.arrayBuffer()).toString(), 'bundle-bytes');
const assetCall = gitea.calls.find((c) => c.url.includes('/releases/download/'));
assert.equal(assetCall.auth, `token ${TOKEN}`);
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('unsafe asset names are rejected before reaching the forge', async () => {
const gitea = await startFakeGitea();
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/-bad`);
assert.equal(r.status, 400);
assert.equal(gitea.calls.length, 0);
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('a missing bundle surfaces as 404', async () => {
const gitea = await startFakeGitea({ downloadStatus: 404 });
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
try {
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
assert.equal(r.status, 404);
} finally {
await stopServer(server);
await stopServer(gitea.server);
}
});
test('without GITEA_RELEASE_INSTALL_KEY the installer reports 503', async () => {
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
try {
const r = await fetch(`${base}/fpagent/releases/latest`);
assert.equal(r.status, 503);
} finally {
await stopServer(server);
}
});
test('install.sh is served publicly at /fpagent/install.sh', async () => {
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
try {
const r = await fetch(`${base}/fpagent/install.sh`);
assert.equal(r.status, 200);
assert.match(await r.text(), /FPAGENT_BASE_URL/);
} finally {
await stopServer(server);
}
});
test('install.sh picks the bundle for the host architecture', async () => {
const calls = [];
const gateway = await startServer((req, res) => {
calls.push(req.url);
if (req.url === '/releases/latest') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
tag: 'v9.9.9',
assets: [
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz' },
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' },
{ name: 'fpagent-cafebabe-linux-i386.tar.gz' },
{ name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' },
],
})
);
return;
}
res.writeHead(404);
res.end();
});
const unameDir = fakeUname('i686');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
try {
let out = '';
try {
// Async so the in-process gateway can answer while the child runs.
const r = await execFileAsync('bash', [SCRIPT], {
env: {
...process.env,
PATH: `${unameDir}:${process.env.PATH}`,
TMPDIR: tmp,
FPAGENT_BASE_URL: urlOf(gateway),
},
});
out = r.stdout + r.stderr;
} catch (e) {
out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s
}
assert.match(out, /cafebabe/, 'the i386 version should come from the asset name');
assert.ok(
calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')),
'should request the i386 bundle'
);
assert.ok(
!calls.some((u) => u.includes('x86_64')),
'should never request another architecture\u2019s bundle'
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(unameDir, { recursive: true, force: true });
await stopServer(gateway);
}
});
test('install.sh refuses an unsupported architecture before fetching', async () => {
const calls = [];
const gateway = await startServer((req, res) => {
calls.push(req.url);
res.writeHead(404);
res.end();
});
const unameDir = fakeUname('aarch64');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
try {
let out = '';
let failed = false;
try {
const r = await execFileAsync('bash', [SCRIPT], {
env: {
...process.env,
PATH: `${unameDir}:${process.env.PATH}`,
TMPDIR: tmp,
FPAGENT_BASE_URL: urlOf(gateway),
},
});
out = r.stdout + r.stderr;
} catch (e) {
failed = true;
out = `${e.stdout ?? ''}${e.stderr ?? ''}`;
}
assert.equal(failed, true, 'the installer should reject an unsupported host');
assert.match(out, /unsupported architecture: aarch64/);
assert.equal(calls.length, 0, 'should fail before touching the network');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(unameDir, { recursive: true, force: true });
await stopServer(gateway);
}
});
test('install.sh is valid bash', () => {
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
});
test('install.sh sweeps only its own stale scratch dirs', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-sweep-test-'));
const stale = path.join(base, 'fpagent-install.oldoldold');
const fresh = path.join(base, 'fpagent-install.freshfresh');
const unrelated = path.join(base, 'someone-else.oldold');
for (const dir of [stale, fresh, unrelated]) fs.mkdirSync(dir);
const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
fs.utimesSync(stale, old, old);
fs.utimesSync(unrelated, old, old);
try {
// The base URL is unreachable, so the run fails after the sweep: enough to
// exercise the cleanup without downloading anything.
let failed = false;
try {
execFileSync('bash', [SCRIPT], {
stdio: 'pipe',
env: { ...process.env, TMPDIR: base, FPAGENT_BASE_URL: 'http://127.0.0.1:9' },
});
} catch {
failed = true;
}
assert.equal(failed, true, 'the installer should fail on the unreachable base');
assert.equal(fs.existsSync(stale), false, 'stale fpagent scratch should be removed');
assert.equal(fs.existsSync(fresh), true, 'fresh (possibly concurrent) scratch should survive');
assert.equal(fs.existsSync(unrelated), true, 'another prefix should never be touched');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});