The EXIT trap cannot run on SIGKILL, so a killed run could leave its fpagent-install.* temp dir behind. Sweep those before any network work, scoped to our prefix and this user and only dirs older than a day so a concurrent install is never touched.
197 lines
6.7 KiB
JavaScript
197 lines
6.7 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 { 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 { createApp, FPAGENT_REPO } from '../server.js';
|
|
import { startServer, stopServer, urlOf } from './helpers.js';
|
|
|
|
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(`/${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('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 });
|
|
}
|
|
});
|