// 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 }); } });