Files
LinkPort/scripts/ci-release.test.mjs

323 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { test } from 'node:test';
import { createIpfsIndex, ipfsAddUrl, packageRelease, releaseFiles, targets, uploadRelease } from './ci-release.mjs';
import { resolveProjectPath } from './project-paths.mjs';
const directoryCid = 'bafybei' + 'a'.repeat(52);
const metadata = { version: '0.3.0', commit: 'a'.repeat(64), buildTime: '2026-09-24T00:00:00Z' };
function fixture(context) {
const root = mkdtempSync(join(tmpdir(), 'linkport-ci-release-test-'));
context.after(() => {
assert.equal(dirname(resolve(root)), resolve(tmpdir()));
assert(basename(root).startsWith('linkport-ci-release-test-'));
rmSync(root, { recursive: true, force: true });
});
const input = join(root, 'binaries with spaces');
const output = join(root, 'archives with spaces');
mkdirSync(input);
writeFileSync(join(input, 'build-info.json'), JSON.stringify(metadata));
for (const target of targets) {
const directory = join(input, `${target.os}-${target.arch}`);
mkdirSync(directory);
writeFileSync(join(directory, target.os === 'windows' ? 'linkport.exe' : 'linkport'), `fixture ${target.os}/${target.arch}`, { mode: 0o755 });
}
return { root, input, output };
}
function runUpload(directory, api, token = '') {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [resolveProjectPath('scripts/ci-release.mjs'), 'upload', directory], {
windowsHide: true,
env: { ...process.env, IPFS_API: api, IPFS_TOKEN: token },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => { stdout += data; });
child.stderr.on('data', data => { stderr += data; });
child.once('error', reject);
child.once('close', code => resolveResult({ code, stdout, stderr }));
});
}
async function apiFixture(context, status, response) {
const requests = [];
const server = createServer((request, reply) => {
const chunks = [];
request.on('data', data => chunks.push(data));
request.on('end', () => {
requests.push({ url: request.url, method: request.method, headers: request.headers, body: Buffer.concat(chunks).toString('latin1') });
const result = typeof response === 'function' ? response(requests.at(-1), requests.length) : { status, body: response };
reply.writeHead(result.status, { 'Content-Type': 'application/json' });
reply.end(result.body);
});
});
await new Promise(resolveReady => server.listen(0, '127.0.0.1', resolveReady));
context.after(() => new Promise(resolveClosed => server.close(resolveClosed)));
return { api: `http://127.0.0.1:${server.address().port}/prefix/api/v0/`, requests };
}
test('six platform archives retain executable, metadata and checksums without user data', context => {
const { root, input, output } = fixture(context);
const manifest = packageRelease(input, output);
assert.equal(manifest.commit, metadata.commit);
assert.deepEqual(manifest.packages.map(entry => `${entry.os}/${entry.arch}`), ['linux/amd64', 'linux/arm64', 'windows/amd64', 'windows/arm64', 'darwin/amd64', 'darwin/arm64']);
assert.equal(manifest.packages.length, 6);
assert.equal(manifest.packages.filter(entry => entry.file.endsWith('.zip')).length, 2);
assert.equal(manifest.packages.filter(entry => entry.file.endsWith('.tar.gz')).length, 4);
assert.equal(releaseFiles(output).length, 8);
assert.equal(readdirSync(output).length, 8);
const archiver = process.platform === 'win32' ? 'tar.exe' : 'bsdtar';
for (const entry of manifest.packages) {
const archive = join(output, entry.file);
assert.equal(createHash('sha256').update(readFileSync(archive)).digest('hex'), entry.sha256);
const entries = execFileSync(archiver, ['-tf', archive], { encoding: 'utf8', windowsHide: true });
assert.doesNotMatch(entries, /(?:^|\/)data\/|\.sqlite|\.netrc|node_modules/);
const extracted = join(root, `extracted-${entry.os}-${entry.arch}`);
mkdirSync(extracted);
execFileSync(archiver, ['-xf', archive, '-C', extracted], { windowsHide: true });
const folder = join(extracted, `linkport-${metadata.version}-${entry.os}-${entry.arch}`);
const binary = entry.os === 'windows' ? 'linkport.exe' : 'linkport';
assert.equal(readFileSync(join(folder, binary), 'utf8'), `fixture ${entry.os}/${entry.arch}`);
assert.equal(JSON.parse(readFileSync(join(folder, 'BUILDINFO.json'), 'utf8')).commit, metadata.commit);
assert.equal(readFileSync(join(folder, 'SHA256SUMS.txt'), 'utf8'), `${entry.binarySha256} ${binary}\n`);
}
});
test('packaging rejects missing platforms and does not overwrite an existing release', context => {
const { input, output } = fixture(context);
packageRelease(input, output);
assert.throws(() => packageRelease(input, output), /EEXIST/);
rmSync(join(input, 'linux-amd64', 'linkport'));
assert.throws(() => packageRelease(input, `${output}-missing`), /ENOENT/);
});
test('upload validation rejects modified archives and missing checksums', context => {
const { input, output } = fixture(context);
const manifest = packageRelease(input, output);
const archive = join(output, manifest.packages[0].file);
const original = readFileSync(archive);
writeFileSync(archive, 'modified');
assert.throws(() => releaseFiles(output), /Archive checksum mismatch/);
writeFileSync(archive, original);
rmSync(join(output, 'SHA256SUMS.txt'));
assert.throws(() => releaseFiles(output), /ENOENT/);
});
test('IPFS API root and api/v0 prefix normalize without duplication', () => {
for (const api of ['https://ipfs.example', 'https://ipfs.example/', 'https://ipfs.example/api/v0/']) {
const url = ipfsAddUrl(api);
assert.equal(url.pathname, '/api/v0/add');
assert.equal(url.searchParams.get('pin'), 'true');
assert.equal(url.searchParams.get('wrap-with-directory'), 'true');
assert.equal(url.searchParams.get('cid-version'), '1');
}
assert.equal(ipfsAddUrl('https://ipfs.example', false).searchParams.get('wrap-with-directory'), 'false');
assert.throws(() => ipfsAddUrl(''), /IPFS_API is required/);
assert.throws(() => ipfsAddUrl('file:///tmp/ipfs'), /HTTP or HTTPS/);
});
function multipartFiles(request) {
const boundary = request.headers['content-type'].match(/boundary=(.+)$/)[1];
return request.body.split(`--${boundary}`).flatMap(part => {
const name = part.match(/filename="([^"]+)"/);
return name ? [{ file: name[1], body: part.slice(part.indexOf('\r\n\r\n') + 4, -2) }] : [];
});
}
const archiveCids = 'abcdef'.split('').map(character => directoryCid.slice(0, -3) + character + 'aa');
function successfulResponse(request, count) {
const files = multipartFiles(request);
const records = files.map(entry => ({ Name: entry.file, Hash: count <= targets.length ? archiveCids[count - 1] : directoryCid }));
if (count === targets.length + 1) records.push({ Name: '', Hash: directoryCid });
return { status: 200, body: records.map(record => JSON.stringify(record)).join('\n') };
}
test('seven add requests pin six archives and an index directory without changing originals', async context => {
const { input, output } = fixture(context);
const manifest = packageRelease(input, output);
const files = releaseFiles(output);
const originals = files.map(file => readFileSync(join(output, file)));
const server = await apiFixture(context, 200, successfulResponse);
const token = 'fixture-secret-token';
const result = await runUpload(output, server.api, token);
assert.equal(result.code, 0, result.stderr);
assert.equal(server.requests.length, 7);
for (const [offset, request] of server.requests.entries()) {
const url = new URL(request.url, server.api);
assert.equal(url.pathname, '/prefix/api/v0/add');
assert.equal(url.searchParams.get('pin'), 'true');
assert.equal(url.searchParams.get('cid-version'), '1');
assert.equal(url.searchParams.get('wrap-with-directory'), offset < targets.length ? 'false' : 'true');
assert.equal(request.method, 'POST');
assert.equal(request.headers.authorization, `Bearer ${token}`);
assert.equal(request.headers['user-agent'], 'linkport-ci-release/1');
const parts = multipartFiles(request);
assert.equal(parts.length, offset < targets.length ? 1 : 4);
if (offset < targets.length) assert.equal(parts[0].file, manifest.packages[offset].file);
}
const parts = multipartFiles(server.requests[targets.length]);
assert.deepEqual(parts.map(part => part.file), ['index.html', 'ipfs-index.json', 'release.json', 'SHA256SUMS.txt']);
const index = JSON.parse(parts[1].body);
assert.equal(index.version, manifest.version);
assert.equal(index.commit, manifest.commit);
assert.equal(index.buildTime, manifest.buildTime);
assert.equal(index.packages.length, 6);
for (const [offset, entry] of index.packages.entries()) {
assert.deepEqual(entry, { os: manifest.packages[offset].os, arch: manifest.packages[offset].arch, file: manifest.packages[offset].file, cid: archiveCids[offset], sha256: manifest.packages[offset].sha256, uri: `ipfs://${archiveCids[offset]}` });
assert(parts[0].body.includes(`href="/ipfs/${entry.cid}?filename=${encodeURIComponent(entry.file)}"`));
assert(parts[0].body.includes(entry.sha256));
}
assert(parts[0].body.includes('href="release.json"'));
assert(parts[0].body.includes('href="SHA256SUMS.txt"'));
assert(!parts[0].body.includes('../'));
for (const part of parts.slice(2)) assert.equal(part.body, readFileSync(join(output, part.file), 'utf8'));
assert.equal(result.stdout.includes(token) || result.stderr.includes(token), false);
assert(result.stdout.includes(`IPFS index directory CID (pin requested): ${directoryCid}`));
assert(result.stdout.includes(`Download page:\n${new URL(server.api).origin}/ipfs/${directoryCid}/`));
const summary = result.stdout.split('=== IPFS upload complete: download URLs ===')[1];
assert(summary && !summary.includes('ipfs://'));
assert.equal(summary.split('\n').filter(line => /^https?:\/\//.test(line)).length, 11);
for (const [offset, entry] of manifest.packages.entries()) {
assert(result.stdout.includes(`Uploading IPFS archive ${offset + 1}/${targets.length}: ${entry.file}`));
}
assert(result.stdout.includes('Uploading IPFS download index (4 files)'));
const receipt = JSON.parse(readFileSync(`${output}.ipfs.json`, 'utf8'));
assert.equal(receipt.cid, directoryCid);
assert.deepEqual(receipt.files.slice(0, targets.length), index.packages);
assert.deepEqual(receipt.files.slice(targets.length), parts.map(part => ({ file: part.file, uri: `ipfs://${directoryCid}/${part.file}` })));
for (const entry of receipt.files) {
const download = new URL(`${new URL(server.api).origin}/ipfs/${entry.uri.slice('ipfs://'.length)}`);
if (entry.cid) download.searchParams.set('filename', entry.file);
assert(summary.includes(`Download ${entry.file}:\n${download.href}`));
assert(result.stdout.includes(entry.uri));
}
assert(!JSON.stringify(receipt).includes(token));
assert.deepEqual(releaseFiles(output), files);
assert.equal(readdirSync(output).length, 8);
files.forEach((file, offset) => assert.deepEqual(readFileSync(join(output, file)), originals[offset]));
const retry = await runUpload(output, server.api, token);
assert.equal(retry.code, 1);
assert.match(retry.stderr, /receipt already exists/);
assert.equal(server.requests.length, 7);
});
test('pure index generation validates configured uploads, preserves input and safely encodes HTML links', context => {
const { input, output } = fixture(context);
const manifest = packageRelease(input, output);
const entries = manifest.packages.map((entry, offset) => ({ file: entry.file, sha256: entry.sha256, cid: archiveCids[offset] }));
const before = JSON.stringify({ manifest, entries });
const result = createIpfsIndex(manifest, entries);
assert.equal(JSON.stringify({ manifest, entries }), before);
assert.deepEqual(createIpfsIndex(manifest, [...entries].reverse()), result);
assert.throws(() => createIpfsIndex(manifest, entries.slice(1)), /configured uploaded/);
assert.throws(() => createIpfsIndex(manifest, entries.map(entry => ({ ...entry, file: 'unexpected.tar.gz' }))), /Missing or duplicate/);
assert.throws(() => createIpfsIndex(manifest, [...entries, entries[0]]), /configured uploaded/);
assert.throws(() => createIpfsIndex(manifest, entries.map((entry, offset) => offset ? entry : { ...entry, sha256: '0'.repeat(64) })), /checksum mismatch/);
for (const cid of ['invalid', 'b' + 'a'.repeat(58), directoryCid.slice(0, -1) + 'f', `${directoryCid}/evil`, '\"><script>']) {
assert.throws(() => createIpfsIndex(manifest, entries.map(entry => ({ ...entry, cid }))), /Invalid IPFS file CID/);
}
const version = '0.3.0+test.1';
const changed = { ...manifest, version, buildTime: '2026-09-24T00:00:00Z' };
changed.packages = manifest.packages.map(entry => ({ ...entry, file: entry.file.replace(manifest.version, version) }));
const changedEntries = entries.map(entry => ({ ...entry, file: entry.file.replace(manifest.version, version) }));
const html = createIpfsIndex(changed, changedEntries).html;
assert(html.includes('linkport-0.3.0%2Btest.1-'));
for (const field of ['version', 'commit']) assert.throws(() => createIpfsIndex({ ...manifest, [field]: '<script>alert(1)</script>' }, entries));
const escapedTime = '2026-09-24 (<script>"&\'</script>)';
assert(Number.isFinite(Date.parse(escapedTime)));
const escaped = createIpfsIndex({ ...manifest, buildTime: escapedTime }, entries).html;
assert(escaped.includes('&lt;script&gt;&quot;&amp;&#39;&lt;/script&gt;'));
assert(!escaped.includes('<script>'));
});
test('checksum failures prevent any network upload', async context => {
const { input, output } = fixture(context);
const manifest = packageRelease(input, output);
const server = await apiFixture(context, 200, successfulResponse);
writeFileSync(join(output, manifest.packages[0].file), 'corrupt');
const result = await runUpload(output, server.api);
assert.equal(result.code, 1);
assert.match(result.stderr, /checksum mismatch/);
assert.equal(server.requests.length, 0);
assert(!existsSync(`${output}.ipfs.json`));
});
test('upload fails clearly when API is missing', async context => {
const { input, output } = fixture(context);
packageRelease(input, output);
const result = await runUpload(output, '');
assert.equal(result.code, 1);
assert.match(result.stderr, /IPFS_API is required/);
assert(!existsSync(`${output}.ipfs.json`));
});
test('partial archive and index failures never write success receipt or leak response secrets', async context => {
const { input, output } = fixture(context);
packageRelease(input, output);
const originals = releaseFiles(output).map(file => [file, readFileSync(join(output, file))]);
const token = 'fixture-secret-token';
const failures = [
{ at: 1, status: 401, body: token },
{ at: 1, status: 200, body: token },
{ at: 1, status: 200, body: JSON.stringify({ Name: token, Hash: directoryCid }) },
{ at: 1, status: 200, body: JSON.stringify({ Type: 'error', Message: token }) },
{ at: 1, status: 200, body: 'null' },
{ at: 3, status: 401, body: token },
{ at: 7, status: 413, body: token },
{ at: 7, status: 200, body: JSON.stringify({ Name: '', Hash: directoryCid }) },
{ at: 1, status: 200, mutate: records => records.map(record => ({ ...record, Hash: token })) },
{ at: 7, status: 200, mutate: records => records.map(record => record.Name === '' ? { ...record, Hash: token } : record) },
{ at: 7, status: 200, mutate: records => records.map(record => record.Name === '' ? { ...record, Name: token } : record) },
{ at: 7, status: 200, mutate: records => [records[0], records[0], ...records.slice(2)] },
];
for (const failure of failures) {
const server = await apiFixture(context, 200, (request, count) => {
const success = successfulResponse(request, count);
if (count !== failure.at) return success;
if (!failure.mutate) return failure;
const records = success.body.split('\n').map(line => JSON.parse(line));
return { status: failure.status, body: failure.mutate(records).map(record => JSON.stringify(record)).join('\n') };
});
const result = await runUpload(output, server.api, token);
assert.equal(result.code, 1, result.stdout);
assert.equal(server.requests.length, failure.at);
assert.equal(result.stdout.includes('IPFS archive add accepted (pin requested):'), failure.at > 1);
assert(!result.stdout.includes('IPFS index directory CID'));
assert(!result.stdout.includes('Download page:'));
assert(!result.stdout.includes('Download linkport-'));
assert(!existsSync(`${output}.ipfs.json`));
assert(!(result.stdout + result.stderr).includes(token));
originals.forEach(([file, bytes]) => assert.deepEqual(readFileSync(join(output, file)), bytes));
}
});
test('curl keeps credentials on stdin, disables implicit config and preserves TLS defaults', context => {
const { input, output } = fixture(context);
packageRelease(input, output);
const token = 'fixture-"quoted\\secret';
let called = false;
assert.throws(() => uploadRelease(output, 'https://ipfs.example', token, { runCurl(command, args, options) {
called = true;
assert(command === 'curl' || command === 'curl.exe');
assert.equal(args[0], '--disable');
assert.equal(args[args.indexOf('--config') + 1], '-');
assert.equal(args[args.indexOf('--user-agent') + 1], 'linkport-ci-release/1');
assert(!args.join(' ').includes(token));
assert(!args.includes('--insecure') && !args.includes('-k'));
assert.equal(options.env, undefined);
assert.equal(options.input, 'header = "Authorization: Bearer fixture-\\"quoted\\\\secret"\n');
return { status: 22, stdout: token, stderr: token };
} }), /IPFS upload failed/);
assert(called);
assert(!existsSync(`${output}.ipfs.json`));
assert.throws(() => uploadRelease(output, 'https://ipfs.example', 'bad\nsecret'), /line breaks/);
});