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
↓ kepr-f4a466e8.tar.gzatlas at this save
files changed4 files
package.json+1 −1tests/fixture.ts+4 −3tests/koh-version.txt+1 −1tests/kohupdate.test.ts+222 −0package.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": {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). */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+}