Files
learnharness/tests/gitea-release.test.ts
T

84 lines
5.3 KiB
TypeScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { createRequire } from "node:module";
import { expectedLinuxAssets, writeChecksums, verifyLocal, verifyRemote, UPDATE_URL } from "../scripts/gitea-release.mjs";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-gitea-gate-"));
after(() => fs.rm(root, { recursive: true, force: true }));
const version = "0.4.0";
const names = expectedLinuxAssets(version);
async function fixture(name: string) {
const directory = path.join(root, name);
await fs.mkdir(directory);
const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), Buffer.from("!<arch>\nDebian fixture")];
for (let i = 0; i < 2; i++) await fs.writeFile(path.join(directory, names[i]!), contents[i]!);
const entries = contents.map((data, i) => ({ url: names[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") }));
await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 }));
await writeChecksums(directory, version);
return directory;
}
test("Linux release inventory and Gitea feed match the current release scope", () => {
assert.deepEqual(names, ["LearnHarness-0.4.0-x86_64.AppImage", "LearnHarness-0.4.0-amd64.deb", "latest-linux.yml", "SHA256SUMS"]);
assert.equal(UPDATE_URL, "https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest");
assert.throws(() => expectedLinuxAssets("0.4.0-preview"));
});
test("local verification checks actual installer bytes, updater hashes, sizes and checksum manifest", async () => {
const directory = await fixture("healthy");
const inventory = await verifyLocal(directory, version);
assert.equal(inventory.length, 4);
await fs.appendFile(path.join(directory, names[0]!), "tampered");
await assert.rejects(verifyLocal(directory, version), /update size differs/);
});
test("updater cannot point at traversal paths, other versions, or substituted installer bytes", async () => {
for (const [name, change, expected] of [
["traversal", (feed: any) => { feed.files[0].url = "../outside.AppImage"; }, /reference exactly/],
["version", (feed: any) => { feed.version = "0.3.7"; }, /metadata version/],
["hash", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/],
] as const) {
const directory = await fixture(name);
const file = path.join(directory, "latest-linux.yml");
const feed = JSON.parse(await fs.readFile(file, "utf8")); change(feed); await fs.writeFile(file, JSON.stringify(feed));
await writeChecksums(directory, version);
await assert.rejects(verifyLocal(directory, version), expected);
}
});
test("empty files and arbitrary downloads cannot pass as installers", async () => {
const directory = await fixture("format");
await fs.writeFile(path.join(directory, names[0]!), "<html>HTTP error</html>");
await writeChecksums(directory, version);
await assert.rejects(verifyLocal(directory, version), /invalid installer header/);
await fs.writeFile(path.join(directory, names[0]!), "");
await assert.rejects(verifyLocal(directory, version), /nonempty regular file/);
});
test("remote verification downloads and hashes every draft attachment, and refuses published or incomplete releases", async () => {
const directory = await fixture("remote");
const inventory = await verifyLocal(directory, version);
const assets = inventory.map((asset) => ({ ...asset, browser_download_url: `https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/v0.4.0/${asset.name}` }));
const release = { draft: true, prerelease: false, assets };
const request = async (input: URL | RequestInfo) => {
const url = input instanceof URL ? input : new URL(String(input));
return new Response(await fs.readFile(path.join(directory, path.basename(url.pathname))));
};
await verifyRemote(release, inventory, {}, request as typeof fetch);
await assert.rejects(verifyRemote({ ...release, draft: false }, inventory), /only a draft/);
await assert.rejects(verifyRemote({ ...release, assets: assets.slice(1) }, inventory), /missing, duplicate/);
await assert.rejects(verifyRemote(release, inventory, {}, (async () => new Response("wrong bytes")) as typeof fetch), /remote bytes differ/);
await assert.rejects(verifyRemote({ ...release, assets: [{ ...assets[0], browser_download_url: "https://other.example/file" }, ...assets.slice(1)] }, inventory), /outside Gitea/);
});
test("release configuration preserves packaging while replacing the upstream update origin", async () => {
const require = createRequire(import.meta.url);
const yaml = require("js-yaml");
const config = yaml.load(await fs.readFile(new URL("../electron-builder.gitea.yml", import.meta.url), "utf8"));
assert.equal(config.extends, "./electron-builder.yml");
assert.deepEqual(config.publish, [{ provider: "generic", url: UPDATE_URL }]);
const workflow = yaml.load(await fs.readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"));
assert(workflow.on.workflow_dispatch);
assert(!workflow.on.pull_request, "untrusted pull requests must not run release publishing");
assert.equal(workflow.permissions.releases, "write");
});