import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; 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, verifyPackagedUpdater, 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, appimageOnly = false, fixtureVersion = version) { const directory = path.join(root, name); await fs.mkdir(directory); const fixtureNames = expectedLinuxAssets(fixtureVersion, { appimageOnly }); const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), ...(!appimageOnly ? [Buffer.from("!\nDebian fixture")] : [])]; for (let i = 0; i < contents.length; i++) await fs.writeFile(path.join(directory, fixtureNames[i]!), contents[i]!); const entries = contents.map((data, i) => ({ url: fixtureNames[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") })); await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version: fixtureVersion, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 })); await writeChecksums(directory, fixtureVersion, { appimageOnly }); 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://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/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/], ["duplicate", (feed: any) => { feed.files.push({ ...feed.files[1] }); }, /reference exactly/], ] 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]!), "HTTP error"); 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://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/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 { getConfig } = require("app-builder-lib/out/util/config/config.js"); const config = await getConfig(path.resolve(import.meta.dirname, ".."), "electron-builder.gitea.yml", null); assert.deepEqual(config.publish, { provider: "generic", url: UPDATE_URL }); assert.equal(config.appId, "com.learnharness.LearnHarness"); assert.deepEqual(config.linux.target.map((target: any) => target.target), ["AppImage", "deb"]); 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"); }); const scopedVersion = "0.6.0"; const scopedOptions = { appimageOnly: true }; const scopedNames = expectedLinuxAssets(scopedVersion, scopedOptions); test("AppImage-only offline scope succeeds without deb; default full-release scope still requires deb", async () => { assert.deepEqual(scopedNames, ["LearnHarness-0.6.0-x86_64.AppImage", "latest-linux.yml", "SHA256SUMS"]); const directory = await fixture("scoped-healthy", true, scopedVersion); assert.deepEqual((await verifyLocal(directory, scopedVersion, scopedOptions)).map((asset) => asset.name), scopedNames); await assert.rejects(verifyLocal(directory, scopedVersion), /ENOENT.*amd64\.deb/); await assert.rejects(writeChecksums(directory, scopedVersion), /ENOENT.*amd64\.deb/); const full = await fixture("full-current", false, scopedVersion); assert.equal((await verifyLocal(full, scopedVersion)).length, 4); await assert.rejects(verifyLocal(full, scopedVersion, scopedOptions), /reference exactly/); }); for (const [label, mutate, expected] of [ ["version", (feed: any) => { feed.version = "0.4.0"; }, /metadata version/], ["missing-files", (feed: any) => { delete feed.files; }, /must contain files/], ["duplicate", (feed: any) => { feed.files.push({ ...feed.files[0] }); }, /reference exactly/], ["foreign-url", (feed: any) => { feed.files[0].url = `https://other.example/${scopedNames[0]}`; }, /reference exactly/], ["traversal", (feed: any) => { feed.files[0].url = `../${scopedNames[0]}`; }, /reference exactly/], ["absolute-path", (feed: any) => { feed.files[0].url = `/tmp/${scopedNames[0]}`; }, /reference exactly/], ["wrong-filename", (feed: any) => { feed.files[0].url = "LearnHarness-0.4.0-x86_64.AppImage"; }, /reference exactly/], ["sha512", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/], ["size", (feed: any) => { feed.files[0].size++; }, /update size/], ["legacy-path", (feed: any) => { feed.path = "https://other.example/payload"; }, /legacy updater path/], ["legacy-hash", (feed: any) => { feed.sha512 = "bad"; }, /legacy updater checksum/], ] as const) { test(`AppImage-only rejects ${label} metadata even with regenerated SHA256SUMS`, async () => { const directory = await fixture(`scoped-${label}`, true, scopedVersion); const file = path.join(directory, "latest-linux.yml"); const feed = JSON.parse(await fs.readFile(file, "utf8")); mutate(feed); await fs.writeFile(file, JSON.stringify(feed)); await writeChecksums(directory, scopedVersion, scopedOptions); await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), expected); }); } test("AppImage-only rejects malformed YAML and stale SHA256 manifest", async () => { const directory = await fixture("scoped-malformed", true, scopedVersion); await fs.writeFile(path.join(directory, "SHA256SUMS"), "0".repeat(64) + ` ${scopedNames[0]}\n`); await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /SHA256SUMS/); await fs.writeFile(path.join(directory, "latest-linux.yml"), "version: [unclosed"); await writeChecksums(directory, scopedVersion, scopedOptions); await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /unexpected end/); }); for (const kind of ["missing", "empty", "directory", "symlink", "corrupt"] as const) { test(`AppImage-only fails closed on ${kind} installer`, async () => { const directory = await fixture(`scoped-installer-${kind}`, true, scopedVersion); const file = path.join(directory, scopedNames[0]!); await fs.rm(file); if (kind === "empty") await fs.writeFile(file, ""); if (kind === "directory") await fs.mkdir(file); if (kind === "symlink") await fs.symlink("latest-linux.yml", file); if (kind === "corrupt") { await fs.writeFile(file, "download failed"); await writeChecksums(directory, scopedVersion, scopedOptions); } else await assert.rejects(writeChecksums(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file/); await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file|invalid installer header/); }); } test("CLI retains positional directory for scoped offline commands and rejects unsupported options", async () => { const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); const directory = await fixture("scoped-cli", true, pkg.version); const run = (...args: string[]) => spawnSync(process.execPath, [path.resolve(import.meta.dirname, "../scripts/gitea-release.mjs"), ...args], { encoding: "utf8", timeout: 10_000, }); for (const command of ["checksums", "verify"]) { const result = run(command, directory, "--appimage-only"); assert.equal(result.status, 0, result.stderr); } const defaultResult = run("verify", directory); assert.equal(defaultResult.status, 1); assert.match(defaultResult.stderr, /amd64\.deb/); for (const command of ["verify-package"]) { const result = run(command, directory, "--appimage-only"); assert.equal(result.status, 1); assert.match(result.stderr, /supported only by checksums, verify, upload and verify-remote/); } for (const args of [["--unknown"], ["--appimage-only", "--appimage-only"], [directory, "extra"]]) { assert.equal(run("verify", ...args).status, 1); } }); // Each API command runs in an isolated process: no inherited credentials and no real fetch. function mockedReleaseCommand(directory: string, command: string, options: string[] = [], scenario = "draft") { const preload = ` import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; const directory = ${JSON.stringify(directory)}; const scenario = ${JSON.stringify(scenario)}; const { version } = JSON.parse(await fs.readFile(${JSON.stringify(path.resolve(import.meta.dirname, "../package.json"))}, "utf8")); const tag = "v" + version; const server = "https://git.sorokin-oleg-edu.xyz"; const api = "/api/v1/repos/sorokin.oleg.edu/learnharness"; const calls = { api: [], uploads: [], downloads: [], edits: [] }; const files = (await fs.readdir(directory)).filter(name => name.endsWith(".AppImage") || name.endsWith(".deb") || ["latest-linux.yml", "SHA256SUMS"].includes(name)); const assets = await Promise.all(files.map(async name => ({ name, size: (await fs.stat(path.join(directory, name))).size, browser_download_url: server + "/sorokin.oleg.edu/learnharness/releases/download/" + tag + "/" + name }))); const release = { id: 1, tag_name: tag, draft: scenario !== "published", prerelease: scenario === "prerelease", assets: scenario === "new" ? [] : [...assets], html_url: server + "/draft" }; if (scenario === "missing-asset") release.assets.pop(); if (scenario === "duplicate-asset") release.assets.push(assets[0]); if (scenario === "extra-asset") release.assets.push({ ...assets[0], name: "unexpected.deb" }); process.env.GITEA_TOKEN = "mock-token-not-a-credential"; process.on("exit", () => console.log("MOCK_CALLS=" + JSON.stringify(calls))); globalThis.fetch = async (input, options = {}) => { const url = new URL(String(input)); assert.equal(url.origin, server, "unexpected network origin"); const method = options.method ?? "GET"; const json = value => Response.json(value); if (url.pathname.startsWith(api + "/")) { calls.api.push(method + " " + url.pathname + url.search); assert.equal(options.headers.Authorization, "token mock-token-not-a-credential"); if (method === "GET" && url.pathname === api + "/tags/" + tag) return json(scenario === "missing-tag" ? {} : { commit: { sha: "fixed-tag-sha" } }); if (method === "GET" && url.pathname === api + "/releases") { assert.equal(url.searchParams.get("page"), "1"); return json(scenario === "new" ? [] : scenario === "ambiguous" ? [release, { ...release, id: 2, tag_name: version }] : scenario === "unversioned" ? [{ ...release, tag_name: version }] : [release]); } if (["POST", "PATCH"].includes(method) && [api + "/releases", api + "/releases/1"].includes(url.pathname)) { const data = JSON.parse(options.body); assert.equal(data.draft, true, "must never publish"); assert.equal(data.prerelease, false); assert.equal(data.tag_name, tag); assert.equal(data.target_commitish, "fixed-tag-sha"); calls.edits.push(data); return json(release); } if (method === "POST" && url.pathname === api + "/releases/1/assets") { const name = url.searchParams.get("name"); const attachment = options.body.get("attachment"); assert.equal(attachment.name, name); assert.deepEqual(Buffer.from(await attachment.arrayBuffer()), await fs.readFile(path.join(directory, name))); calls.uploads.push(name); const asset = assets.find(asset => asset.name === name); assert(asset); release.assets.push(asset); return json(asset); } if (method === "GET" && url.pathname === api + "/releases/1") return json(release); } else if (method === "GET" && url.pathname.startsWith("/sorokin.oleg.edu/learnharness/releases/download/" + tag + "/")) { const name = decodeURIComponent(path.basename(url.pathname)); assert(files.includes(name), "download outside expected fixture"); calls.downloads.push(name); const bytes = await fs.readFile(path.join(directory, name)); if (scenario === "corrupt") bytes[0] ^= 1; return new Response(bytes); } throw new Error("Unexpected mocked request: " + method + " " + url); }; `; const result = spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(preload)}`, path.resolve(import.meta.dirname, "../scripts/gitea-release.mjs"), command, directory, ...options], { encoding: "utf8", timeout: 10_000, env: { NODE_ENV: "test" }, }); const trace = result.stdout.split("\n").find(line => line.startsWith("MOCK_CALLS=")); assert(trace, result.stderr || String(result.error)); return { ...result, calls: JSON.parse(trace.slice("MOCK_CALLS=".length)) as { api: string[]; uploads: string[]; downloads: string[]; edits: { draft: boolean }[] } }; } for (const appimageOnly of [true, false]) { test(`${appimageOnly ? "AppImage-only" : "default full-release"} upload and remote verification use exactly the validated inventory`, async () => { const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); const directory = await fixture(`api-${appimageOnly ? "scoped" : "full"}`, appimageOnly, pkg.version); const expected = expectedLinuxAssets(pkg.version, { appimageOnly }).sort(); const options = appimageOnly ? ["--appimage-only"] : []; const uploaded = mockedReleaseCommand(directory, "upload", options, "new"); assert.equal(uploaded.status, 0, uploaded.stderr); assert.deepEqual(uploaded.calls.uploads.sort(), expected); assert.deepEqual(uploaded.calls.downloads.sort(), expected); assert.equal(uploaded.calls.edits.length, 1); assert(uploaded.calls.edits.every(edit => edit.draft)); assert(uploaded.calls.api.some(call => call.startsWith("GET ") && call.includes("/tags/v"))); assert.equal(uploaded.calls.api.filter(call => call.startsWith("POST ")).length, expected.length + 1); const existing = mockedReleaseCommand(directory, "upload", options); assert.equal(existing.status, 0, existing.stderr); assert.deepEqual(existing.calls.uploads, []); assert.deepEqual(existing.calls.downloads.sort(), expected); assert.equal(existing.calls.edits.length, 1); assert(existing.calls.api.some(call => call.startsWith("PATCH "))); const verified = mockedReleaseCommand(directory, "verify-remote", options); assert.equal(verified.status, 0, verified.stderr); assert.deepEqual(verified.calls.downloads.sort(), expected); assert(verified.calls.api.every(call => call.startsWith("GET "))); assert.deepEqual(verified.calls.edits, []); }); } test("API commands preserve full-release defaults and reject invalid scopes before any request", async () => { const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); const directory = await fixture("api-local-rejections", true, pkg.version); for (const command of ["upload", "verify-remote"]) { for (const options of [[], ["--unknown"], ["--appimage-only", "--appimage-only"], ["--appimage-only", "extra"]]) { const result = mockedReleaseCommand(directory, command, options); assert.equal(result.status, 1, result.stderr); assert.deepEqual(result.calls.api, []); assert.deepEqual(result.calls.downloads, []); if (!options.length) assert.match(result.stderr, /amd64\.deb/); } } }); test("AppImage-only API commands retain draft, tag, asset and same-size byte rejection gates", async () => { const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); const directory = await fixture("api-remote-rejections", true, pkg.version); for (const command of ["upload", "verify-remote"]) { for (const [scenario, error] of [ ["published", /already published/], ["ambiguous", /ambiguous releases/], ["unversioned", /exact versioned tag/], ["corrupt", /remote bytes differ/], ["extra-asset", /missing, duplicate, or unexpected/], ["duplicate-asset", /missing, duplicate, or unexpected/], ["prerelease", /release must be stable/], ] as const) { const result = mockedReleaseCommand(directory, command, ["--appimage-only"], scenario); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, error); assert.deepEqual(result.calls.uploads, []); if (["published", "ambiguous", "unversioned"].includes(scenario)) { assert(result.calls.api.every(call => call.startsWith("GET "))); assert.deepEqual(result.calls.downloads, []); } } } const missingTag = mockedReleaseCommand(directory, "upload", ["--appimage-only"], "missing-tag"); assert.equal(missingTag.status, 1, missingTag.stderr); assert.match(missingTag.stderr, /release tag does not resolve to a commit/); assert.equal(missingTag.calls.api.length, 1); assert(missingTag.calls.api[0]!.startsWith("GET ")); assert.deepEqual(missingTag.calls.uploads, []); assert.deepEqual(missingTag.calls.edits, []); for (const [scenario, error] of [["new", /draft not found/], ["missing-asset", /missing, duplicate, or unexpected/]] as const) { const result = mockedReleaseCommand(directory, "verify-remote", ["--appimage-only"], scenario); assert.equal(result.status, 1, result.stderr); assert.match(result.stderr, error); assert(result.calls.api.every(call => call.startsWith("GET "))); } }); test("packaged updater verification refuses inherited GitHub or incorrect generic destinations", async () => { const directory = path.join(root, "package"); await fs.mkdir(path.join(directory, "resources"), { recursive: true }); const file = path.join(directory, "resources", "app-update.yml"); await fs.writeFile(file, JSON.stringify({ provider: "github", owner: "amosblomqvist", repo: "learnharness" })); await assert.rejects(verifyPackagedUpdater(directory), /Gitea generic provider/); await fs.writeFile(file, JSON.stringify({ provider: "generic", url: "https://other.example" })); await assert.rejects(verifyPackagedUpdater(directory), /public Gitea release feed/); await fs.writeFile(file, JSON.stringify({ provider: "generic", url: UPDATE_URL })); await verifyPackagedUpdater(directory); });