Files
tools-legacy/tools/test/fpagent-install.test.js
T
adebisi-fa 2ad4aa63c6 feat(fpagent): pin releases, report upgrades, restart the service
The installer gains --tag to install a specific release (gateway route
/fpagent/releases/tags/:tag), derives the git hash from the asset name,
and hands it to the bundle installer as VERSION so upgrades are reported
and the installed version is recorded. Service restart on upgrade lives
in the bundle installer, which owns the systemd unit.
2026-10-02 11:26:35 +01:00

269 lines
9.2 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' },
],
};
// 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 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 });
}
});