71669b2086
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
/**
|
|
* Version routes and update-check service tests: response shapes, the fail-soft
|
|
* contract of /api/version/update-check (always 200, error field instead of 5xx),
|
|
* cache TTLs with an injected clock, the PENGUIN_UPDATE_CHECK=off opt-out, and the
|
|
* admin gate of POST /api/version/update. Nothing here touches the network or spawns
|
|
* a process: fetch is stubbed, and the update run is exercised only through its pure
|
|
* classifier and the "not launched via the CLI" early exit.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
|
|
import type { UpdateCheckResponse, UpdateRunResponse, VersionResponse } from "../src/api/types.js";
|
|
import {
|
|
FAILURE_TTL_MS,
|
|
SUCCESS_TTL_MS,
|
|
UpdateCheckService,
|
|
} from "../src/services/update-check-service.js";
|
|
import { classifyUpdateRun } from "../src/http/routes/version.js";
|
|
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
|
|
import type { TestApp } from "./helpers.js";
|
|
|
|
/** Counting fetch stub; the handler decides the outcome per call. */
|
|
function makeFetch(handler: () => Response | Promise<Response>): {
|
|
impl: typeof fetch;
|
|
state: { calls: number };
|
|
} {
|
|
const state = { calls: 0 };
|
|
const impl: typeof fetch = async () => {
|
|
state.calls += 1;
|
|
return handler();
|
|
};
|
|
return { impl, state };
|
|
}
|
|
|
|
function releaseResponse(tag: string): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
tag_name: tag,
|
|
html_url: `https://github.com/Prism-Shadow/penguin-harness/releases/tag/${tag}`,
|
|
published_at: "2026-07-01T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
describe("GET /api/version", () => {
|
|
let t: TestApp;
|
|
beforeEach(async () => {
|
|
t = await createTestApp();
|
|
});
|
|
afterEach(async () => {
|
|
await t.cleanup();
|
|
});
|
|
|
|
it("requires auth and reports core's version identity (dev build: buildDate null)", async () => {
|
|
expect((await t.app.request("/api/version")).status).toBe(401);
|
|
|
|
const admin = await loginAdmin(t.app);
|
|
const res = await apiClient(t.app, admin.cookie).get("/api/version");
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as VersionResponse;
|
|
// Both values come from core's constants, which the release workflow STAMPS before it
|
|
// builds and tests (VERSION from the tag, BUILD_DATE with the run's UTC date). Asserting
|
|
// a literal null passed everywhere except the one job that matters — the npm publish —
|
|
// where it failed the release. Compare against the constants themselves.
|
|
expect(body).toEqual({ version: VERSION, buildDate: BUILD_DATE });
|
|
});
|
|
});
|
|
|
|
describe("GET /api/version/update-check", () => {
|
|
let t: TestApp;
|
|
afterEach(async () => {
|
|
await t.cleanup();
|
|
});
|
|
|
|
it("reports a newer release with its URL and publish date", async () => {
|
|
const { impl } = makeFetch(() => releaseResponse("v99.0.0"));
|
|
t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
|
|
const admin = await loginAdmin(t.app);
|
|
const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check");
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as UpdateCheckResponse;
|
|
expect(body.currentVersion).toBe(VERSION);
|
|
expect(body.latestVersion).toBe("99.0.0");
|
|
expect(body.updateAvailable).toBe(true);
|
|
expect(body.releaseUrl).toBe(
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/tag/v99.0.0",
|
|
);
|
|
expect(body.publishedAt).toBe("2026-07-01T00:00:00Z");
|
|
expect(body.error).toBeUndefined();
|
|
expect(body.disabled).toBeUndefined();
|
|
});
|
|
|
|
it("fail-soft: an unreachable endpoint is HTTP 200 with error=network, not a 5xx", async () => {
|
|
const { impl } = makeFetch(() => {
|
|
throw new Error("getaddrinfo ENOTFOUND api.github.com");
|
|
});
|
|
t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
|
|
const admin = await loginAdmin(t.app);
|
|
const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check");
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as UpdateCheckResponse;
|
|
expect(body.error).toBe("network");
|
|
expect(body.latestVersion).toBeNull();
|
|
expect(body.updateAvailable).toBe(false);
|
|
expect(body.releaseUrl).toBeNull();
|
|
});
|
|
|
|
it("?force=1 (the manual check) reaches the service as a cache bypass", async () => {
|
|
const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
|
|
t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
|
|
const admin = await loginAdmin(t.app);
|
|
const client = apiClient(t.app, admin.cookie);
|
|
|
|
// Warm the cache, then confirm a plain GET serves from it.
|
|
expect((await client.get("/api/version/update-check")).status).toBe(200);
|
|
expect((await client.get("/api/version/update-check")).status).toBe(200);
|
|
expect(state.calls).toBe(1);
|
|
|
|
const forced = await client.get("/api/version/update-check?force=1");
|
|
expect(forced.status).toBe(200);
|
|
expect(((await forced.json()) as UpdateCheckResponse).latestVersion).toBe("99.0.0");
|
|
expect(state.calls).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("UpdateCheckService", () => {
|
|
it("maps 403/429 to rate_limited and other bad statuses/bodies to bad_response", async () => {
|
|
for (const [make, expected] of [
|
|
[() => new Response("limited", { status: 403 }), "rate_limited"],
|
|
[() => new Response("limited", { status: 429 }), "rate_limited"],
|
|
[() => new Response("oops", { status: 500 }), "bad_response"],
|
|
[() => new Response("not json", { status: 200 }), "bad_response"],
|
|
[() => new Response(JSON.stringify({ name: "no tag" }), { status: 200 }), "bad_response"],
|
|
] as const) {
|
|
const service = new UpdateCheckService({ fetchImpl: makeFetch(make).impl, env: {} });
|
|
const result = await service.check();
|
|
expect(result.error).toBe(expected);
|
|
expect(result.updateAvailable).toBe(false);
|
|
expect(result.latestVersion).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("updateAvailable is false when the latest release is not newer", async () => {
|
|
const { impl } = makeFetch(() => releaseResponse(`v${VERSION}`));
|
|
const service = new UpdateCheckService({ fetchImpl: impl, env: {} });
|
|
const result = await service.check();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.latestVersion).toBe(VERSION);
|
|
expect(result.updateAvailable).toBe(false);
|
|
});
|
|
|
|
it("PENGUIN_UPDATE_CHECK=off disables the lookup without any network call", async () => {
|
|
const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
|
|
const service = new UpdateCheckService({
|
|
fetchImpl: impl,
|
|
env: { PENGUIN_UPDATE_CHECK: "off" },
|
|
});
|
|
const result = await service.check();
|
|
expect(result.disabled).toBe(true);
|
|
expect(result.updateAvailable).toBe(false);
|
|
expect(result.latestVersion).toBeNull();
|
|
expect(state.calls).toBe(0);
|
|
});
|
|
|
|
it("caches a success for an hour and a failure for ten minutes", async () => {
|
|
let nowMs = 1_000_000_000;
|
|
let fail = false;
|
|
const { impl, state } = makeFetch(() => {
|
|
if (fail) throw new Error("down");
|
|
return releaseResponse("v99.0.0");
|
|
});
|
|
const service = new UpdateCheckService({
|
|
fetchImpl: impl,
|
|
env: {},
|
|
now: () => new Date(nowMs),
|
|
});
|
|
|
|
const first = await service.check();
|
|
expect(first.latestVersion).toBe("99.0.0");
|
|
expect(state.calls).toBe(1);
|
|
|
|
// Within the success TTL: served from cache, original checkedAt preserved.
|
|
nowMs += SUCCESS_TTL_MS - 1;
|
|
const cached = await service.check();
|
|
expect(state.calls).toBe(1);
|
|
expect(cached.checkedAt).toBe(first.checkedAt);
|
|
|
|
// Past the success TTL: refetched; the failure is itself cached, but only briefly.
|
|
nowMs += 2;
|
|
fail = true;
|
|
const failed = await service.check();
|
|
expect(state.calls).toBe(2);
|
|
expect(failed.error).toBe("network");
|
|
|
|
nowMs += FAILURE_TTL_MS - 1;
|
|
expect((await service.check()).error).toBe("network");
|
|
expect(state.calls).toBe(2);
|
|
|
|
nowMs += 2;
|
|
fail = false;
|
|
const healed = await service.check();
|
|
expect(state.calls).toBe(3);
|
|
expect(healed.error).toBeUndefined();
|
|
expect(healed.latestVersion).toBe("99.0.0");
|
|
});
|
|
|
|
it("force bypasses a warm cache and recaches the fresh outcome", async () => {
|
|
let tag = "v99.0.0";
|
|
const { impl, state } = makeFetch(() => releaseResponse(tag));
|
|
const service = new UpdateCheckService({ fetchImpl: impl, env: {} });
|
|
|
|
// Warm the cache; a passive check is then served from it.
|
|
expect((await service.check()).latestVersion).toBe("99.0.0");
|
|
expect((await service.check()).latestVersion).toBe("99.0.0");
|
|
expect(state.calls).toBe(1);
|
|
|
|
// A forced check refetches despite the fresh cache…
|
|
tag = "v100.0.0";
|
|
expect((await service.check(true)).latestVersion).toBe("100.0.0");
|
|
expect(state.calls).toBe(2);
|
|
|
|
// …and stores the outcome: the next passive check reuses it without a call.
|
|
expect((await service.check()).latestVersion).toBe("100.0.0");
|
|
expect(state.calls).toBe(2);
|
|
});
|
|
|
|
it("force never dials out when PENGUIN_UPDATE_CHECK=off (the opt-out stays authoritative)", async () => {
|
|
const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
|
|
const service = new UpdateCheckService({
|
|
fetchImpl: impl,
|
|
env: { PENGUIN_UPDATE_CHECK: "off" },
|
|
});
|
|
const result = await service.check(true);
|
|
expect(result.disabled).toBe(true);
|
|
expect(state.calls).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/version/update", () => {
|
|
let t: TestApp;
|
|
let savedEntry: string | undefined;
|
|
beforeEach(async () => {
|
|
savedEntry = process.env.PENGUIN_CLI_ENTRY;
|
|
delete process.env.PENGUIN_CLI_ENTRY;
|
|
t = await createTestApp();
|
|
});
|
|
afterEach(async () => {
|
|
if (savedEntry === undefined) delete process.env.PENGUIN_CLI_ENTRY;
|
|
else process.env.PENGUIN_CLI_ENTRY = savedEntry;
|
|
await t.cleanup();
|
|
});
|
|
|
|
it("is admin-only", async () => {
|
|
const user = await provisionUser(t.app, "regular_user");
|
|
const res = await apiClient(t.app, user.cookie).post("/api/version/update", {});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("reports unsupported when the server was not launched via the CLI", async () => {
|
|
const admin = await loginAdmin(t.app);
|
|
const res = await apiClient(t.app, admin.cookie).post("/api/version/update", {});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as UpdateRunResponse;
|
|
expect(body).toEqual({
|
|
status: "unsupported",
|
|
reason: "not_launched_via_cli",
|
|
output: "",
|
|
needsRestart: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("classifyUpdateRun", () => {
|
|
it("a non-zero exit is a failed upgrade attempt", () => {
|
|
const r = classifyUpdateRun(1, "Upgrade failed; the previous install was left in place.");
|
|
expect(r.status).toBe("failed");
|
|
expect(r.needsRestart).toBe(false);
|
|
});
|
|
|
|
it("a refusal (exit 0 with the CLI's refusal message) is unsupported", () => {
|
|
// Literal CLI copy (packages/cli/src/i18n.ts update.sourceCheckout / unknownInstall):
|
|
// the classifier matches fragments of these exact strings.
|
|
const source =
|
|
"This penguin runs from a source checkout, so there is nothing to download — update it with `git pull` and rebuild (`pnpm install && pnpm -r build`).";
|
|
expect(classifyUpdateRun(0, source).status).toBe("unsupported");
|
|
const unknown =
|
|
"Cannot tell how this penguin was installed (running from /opt/penguin/cli.js), so it will not be replaced. Re-install with the official installer, or upgrade with the package manager you used.";
|
|
expect(classifyUpdateRun(0, unknown).status).toBe("unsupported");
|
|
});
|
|
|
|
it("a clean exit without a refusal is updated and needs a restart (up-to-date included)", () => {
|
|
const done =
|
|
"Upgrade 0.1.2 -> 0.1.3\nPenguinHarness 0.1.3 installed. Run `penguin --version` in a new shell to confirm.";
|
|
expect(classifyUpdateRun(0, done)).toEqual({
|
|
status: "updated",
|
|
output: done,
|
|
needsRestart: true,
|
|
});
|
|
const upToDate = "Already on the latest version (0.1.3); nothing to do.";
|
|
expect(classifyUpdateRun(0, upToDate).status).toBe("updated");
|
|
});
|
|
});
|