f4a466e83f88

test that koh3 updates itself from a release Kepr served: check exit codes, an unreachable download refused with the binary untouched, and the installed copy signed and running (v3.0.54)

time
2026-09-27 14:07 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr 3 implementation
parent
dedcbd4f3830
4 files package.json+1 −1tests/fixture.ts+4 −3tests/koh-version.txt+1 −1tests/kohupdate.test.ts+222 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.53",3+	"version": "3.0.54",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

tests/fixture.ts+4 −3
@@ -166,7 +166,8 @@166166 }167167 168168 /** A throwaway HOME, a repo folder and a bucket, with koh3 runnable inside (§4.3's pinned binary). */169-export async function kohWorkspace(): Promise<{169+export async function kohWorkspace(repo = "shipped"): Promise<{170+	root: string;170171 	home: string;171172 	repoDir: string;172173 	bucketDir: string;@@ -174,11 +175,11 @@174175 }> {175176 	const root = await mkdtemp(join(tmpdir(), "kepr-workspace-"));176177 	const home = join(root, "home");177-	const repoDir = join(root, "work", "shipped");178+	const repoDir = join(root, "work", repo);178179 	const bucketDir = join(root, "bucket");179180 	await mkdir(home);180181 	await mkdir(repoDir, { recursive: true });181-	return { home, repoDir, bucketDir, run: (...args) => run(repoDir, home, args) };182+	return { root, home, repoDir, bucketDir, run: (...args) => run(repoDir, home, args) };182183 }183184 184185 /** A release file with its bytes, or only as a record declares it (the bytes are then a stand-in). */

view file

tests/koh-version.txt+1 −1
@@ -1 +1 @@1-3.0.841+3.0.87

view file

tests/kohupdate.test.ts+222 −0
@@ -0,0 +1,222 @@1+import { afterAll, beforeAll, describe, expect, test } from "bun:test";2+import { cp, mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import type { Server } from "bun";6+import { openBucket } from "../src/bucket/reader.ts";7+import { syncAll } from "../src/bucket/sync.ts";8+import { createApp } from "../src/server/app.ts";9+import { openKeprDb } from "../src/server/db.ts";10+import { kohWorkspace } from "./fixture.ts";11+12+/**13+ * The joint update contract (§3 item 8): koh3 publishes a newer koh into a folder remote, Kepr14+ * syncs and serves it, and a second koh3 — a copy of the installed binary, never the installed one15+ * — replaces itself with what it downloads.16+ *17+ * The release file is a real copy of the koh3 binary, so the ad-hoc codesign on macOS is the real18+ * thing. One environment limit is documented below: Kepr builds its download URLs as `https://…`19+ * by design, and this test's server has no TLS, so the document it serves for the install points20+ * the download at the `file://` path a folder-bucket Kepr hands out (which koh3 reads).21+ */22+23+const NEW_VERSION = "9.9.9";24+const REPO = "koh";25+const NOTES =26+	"the updater replaces itself, end to end, through a Kepr that serves the release it was told about";27+28+/** What koh3 reads: the release document (§22.4). */29+type Feed = {30+	version: string;31+	notes?: string;32+	platforms: Record<string, { url: string; blake3: string; sha256: string }>;33+};34+35+/** The record koh3 writes, as far as this test reads it (§11.2). */36+type ReleaseRecord = { files: { platform: string; name: string }[] };37+38+/** The CLI's own `--json` document, `UpdateJson` in koh3's src/cli/cmd_update.v. */39+type UpdateJson = { available?: boolean; installed?: string };40+41+describe("koh3 updating itself through Kepr", () => {42+	let server: Server<undefined>;43+	let checkSource = "";44+	let installSource = "";45+	let installed = "";46+	let published = "";47+	let checks: {48+		code: number;49+		json: UpdateJson;50+		install: UpdateJson;51+		refused: string;52+		refusedStdout: string;53+		baseline: Uint8Array;54+		kept: Uint8Array;55+		target: string;56+	};57+58+	beforeAll(async () => {59+		const space = await kohWorkspace(REPO);60+		const koh = Bun.which("koh3") as string;61+		const platformName = platform();62+		installed = join(space.root, "bin", "koh3");63+		published = join(space.repoDir, "dist", "koh3");64+		await mkdir(join(space.root, "bin"), { recursive: true });65+		await mkdir(join(space.repoDir, "dist"), { recursive: true });66+		await cp(koh, installed);67+		await cp(koh, published);68+		const baseline = new Uint8Array(await Bun.file(installed).arrayBuffer());69+70+		await writeFile(join(space.repoDir, "VERSION"), `${NEW_VERSION}\n`);71+		await writeFile(72+			join(space.repoDir, "koh.build"),73+			[74+				"# a release is one file: this copies the one the test prepared",75+				"command = mkdir -p out && cp dist/koh3 out/koh3",76+				"output = out/koh3",77+				`platforms = ${platformName}`,78+				"",79+			].join("\n"),80+		);81+		space.run("init");82+		space.run("remote", "folder", space.bucketDir);83+		space.run("save", "the new koh");84+		space.run("offer", "--builds", "--notes", NOTES);85+86+		const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-"));87+		const kepr = openKeprDb(dataDir);88+		await syncAll({89+			bucket: openBucket({ kind: "folder", path: space.bucketDir }),90+			kepr,91+			mirrorDir: join(dataDir, "mirror"),92+			tarballDir: join(dataDir, "tarballs"),93+		});94+		kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");95+96+		// A download URL in the feed carries the configured domain, so the app is built97+		// with the real one once the port is known.98+		const config = (domain: string) => ({99+			domain,100+			dataDir,101+			adminPasswordHash: "$argon2id$x",102+			bucket: { kind: "folder" as const, path: space.bucketDir },103+		});104+		let app = createApp(config("127.0.0.1:1"), kepr);105+		let local: Feed | null = null;106+		const localPath = `/${REPO}/releases/latest.local.json`;107+		server = Bun.serve({108+			port: 0,109+			fetch: (request) => {110+				const url = new URL(request.url);111+				if (url.pathname === localPath && local) return Response.json(local);112+				return app.fetch(request);113+			},114+		});115+		app = createApp(config(`127.0.0.1:${server.port}`), kepr);116+		checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;117+		installSource = `http://127.0.0.1:${server.port}${localPath}`;118+119+		// The document Kepr served, with its download pointed at the very file the120+		// folder bucket holds (the TLS gap above).121+		const served: Feed = (await (await fetch(checkSource)).json()) as Feed;122+		const record: ReleaseRecord = (await Bun.file(123+			join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),124+		).json()) as ReleaseRecord;125+		const artifact = record.files.find((file) => file.platform === platformName);126+		if (!artifact) throw new Error("the published record has no file for this platform");127+		const artifactPath = join(128+			space.bucketDir,129+			REPO,130+			"releases",131+			NEW_VERSION,132+			platformName,133+			artifact.name,134+		);135+		local = {136+			...served,137+			platforms: {138+				...served.platforms,139+				[platformName]: { ...served.platforms[platformName], url: `file://${artifactPath}` },140+			},141+		};142+143+		const check = await run(installed, space.home, checkSource, "update", "--check");144+		const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");145+		const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");146+		const kept = new Uint8Array(await Bun.file(installed).arrayBuffer());147+		const install = await run(installed, space.home, installSource, "update", "--yes", "--json");148+		checks = {149+			code: check.exitCode,150+			json: JSON.parse(checkJson.stdout) as UpdateJson,151+			install: JSON.parse(install.stdout) as UpdateJson,152+			refused: refused.stderr,153+			refusedStdout: refused.stdout,154+			baseline,155+			kept,156+			// koh names the binary it replaced by its real path, and /var is a symlink.157+			target: await realpath(installed),158+		};159+	});160+161+	afterAll(() => {162+		server?.stop(true);163+	});164+165+	/**166+	 * Runs a koh3 with the update source pointed at this test's Kepr. Asynchronous on purpose:167+	 * `Bun.spawnSync` would block the event loop this test's own HTTP server runs on, and the two168+	 * would wait for each other forever.169+	 */170+	async function run(path: string, home: string, source: string, ...args: string[]) {171+		const proc = Bun.spawn([path, ...args], {172+			env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source },173+			stdout: "pipe",174+			stderr: "pipe",175+		});176+		const [stdout, stderr, exitCode] = await Promise.all([177+			new Response(proc.stdout).text(),178+			new Response(proc.stderr).text(),179+			proc.exited,180+		]);181+		return { stdout, stderr, exitCode };182+	}183+184+	test("--check reports an update and exits 1, and --json says the same thing", () => {185+		expect(checks.code).toBe(1);186+		expect(checks.json.available).toBe(true);187+	});188+189+	test("a download it cannot reach is refused, and the binary is left alone", () => {190+		// Kepr's own URL is https on a server that has no TLS, so this is what an191+		// unreachable release file looks like: a refusal, never a half-install.192+		expect(checks.refused).toContain("could not download");193+		expect(checks.refusedStdout).toBe("");194+		expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);195+	});196+197+	test("the update replaced the running binary with the published file", async () => {198+		expect(checks.install.installed).toBe(checks.target);199+		const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());200+		const released = new Uint8Array(await Bun.file(published).arrayBuffer());201+		if (process.platform === "darwin") {202+			// Apple silicon refuses to run an unsigned binary, and the ad-hoc signature203+			// koh writes is part of the file, so the installed copy is the artifact plus204+			// that signature. Being validly signed and running is the proof here; the205+			// Blake3 of the bytes that arrived is checked inside koh before signing.206+			const signed = Bun.spawn(["codesign", "--verify", installed]);207+			expect(await signed.exited).toBe(0);208+		} else {209+			expect(Buffer.compare(downloaded, released)).toBe(0);210+		}211+		// And it is a working koh still: the replaced binary runs.212+		const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();213+		expect(version.startsWith("koh ")).toBe(true);214+	});215+});216+217+/** This machine's platform, which is what `koh build` uses with no flags. */218+function platform(): string {219+	const os = process.platform === "darwin" ? "macos" : "linux";220+	const arch = process.arch === "arm64" ? "aarch64" : "x86_64";221+	return `${os}-${arch}`;222+}

view file