2026-10-02 09:53:35 +01:00
|
|
|
// 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';
|
2026-10-02 11:26:35 +01:00
|
|
|
import { execFile, execFileSync } from 'node:child_process';
|
2026-10-02 10:49:07 +01:00
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import os from 'node:os';
|
2026-10-02 09:53:35 +01:00
|
|
|
import path from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
2026-10-02 11:26:35 +01:00
|
|
|
import { promisify } from 'node:util';
|
2026-10-02 09:53:35 +01:00
|
|
|
import { createApp, FPAGENT_REPO } from '../server.js';
|
|
|
|
|
import { startServer, stopServer, urlOf } from './helpers.js';
|
|
|
|
|
|
2026-10-02 11:26:35 +01:00
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
2026-10-02 10:49:07 +01:00
|
|
|
const SCRIPT = path.join(
|
|
|
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
|
|
|
'..',
|
|
|
|
|
'public',
|
|
|
|
|
'fpagent',
|
|
|
|
|
'install.sh'
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-02 09:53:35 +01:00
|
|
|
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' },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-06 19:19:07 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 09:53:35 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-10-02 11:26:35 +01:00
|
|
|
if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) {
|
|
|
|
|
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
|
|
|
|
|
res.end(JSON.stringify(RELEASE));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-02 09:53:35 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 11:26:35 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 09:53:35 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-06 19:19:07 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 09:53:35 +01:00
|
|
|
test('install.sh is valid bash', () => {
|
2026-10-02 10:49:07 +01:00
|
|
|
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 });
|
|
|
|
|
}
|
2026-10-02 09:53:35 +01:00
|
|
|
});
|