Files
EvoScientist-WebUI/src/lib/server/selfUpdate.test.ts
T

193 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
const BASE = "https://git.foksai.com";
const REPO = "ouyangbo/EvoScientist";
function release(
tag: string,
assets: { name: string; url: string }[] = [],
extra: object = {}
) {
return {
tag_name: tag,
body: "notes",
prerelease: false,
draft: false,
published_at: "2026-01-01",
html_url: `${BASE}/${REPO}/releases/tag/${tag}`,
assets: assets.map((a) => ({ name: a.name, browser_download_url: a.url })),
...extra,
};
}
function mockFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
for (const [prefix, payload] of Object.entries(map)) {
if (String(url).startsWith(prefix)) {
return new Response(JSON.stringify(payload), { status: 200 });
}
}
return new Response("not found", { status: 404 });
})
);
}
describe("selfUpdate", () => {
beforeEach(() => {
process.env.EVOSCIENTIST_UPDATE_BASE_URL = BASE;
process.env.EVOSCIENTIST_UPDATE_REPO = REPO;
delete process.env.EVOSCIENTIST_UPDATE_TOKEN;
delete process.env.EVOSCIENTIST_UPDATE_CHECK_DISABLED;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
delete process.env.EVOSCIENTIST_UPDATE_BASE_URL;
delete process.env.EVOSCIENTIST_UPDATE_REPO;
delete process.env.EVOSCIENTIST_UPDATE_STAGING_DIR;
});
it("picks max semver over releases+tags and reports update", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.1.7"), release("v0.1.9")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [{ name: "v0.1.8" }],
});
const mod = await import("./selfUpdate");
const info = await mod.getSelfUpdateInfo({ force: true, currentVersion: "0.1.8" });
expect(info.latest_version).toBe("0.1.9");
expect(info.has_update).toBe(true);
expect(info.current_version).toBe("0.1.8");
});
it("serves cached info within TTL and warns on failure", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.1.9")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
const first = await mod.getSelfUpdateInfo({ currentVersion: "0.1.8" });
expect(first.cached).toBe(false);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network down");
})
);
// force refetch past the TTL: fails, so stale cache is served with a warning
const second = await mod.getSelfUpdateInfo({ force: true, currentVersion: "0.1.8" });
expect(second.cached).toBe(true);
expect(second.latest_version).toBe("0.1.9");
expect(second.warning).toContain("update check failed");
});
it("downloadSelfUpdate verifies checksum and stages tgz", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgz = Buffer.from("fake tgz bytes");
const digest = createHash("sha256").update(tgz).digest("hex");
const tgzName = "evoscientist-webui-0.1.9.tgz";
const rel = release("v0.1.9", [
{ name: tgzName, url: `${BASE}:8443/attachments/${tgzName}` },
{ name: "checksums.txt", url: `${BASE}:8443/attachments/checksums.txt` },
]);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("checksums.txt"))
return new Response(`${digest} ${tgzName}\n`, { status: 200 });
if (u.includes(tgzName)) return new Response(tgz, { status: 200 });
if (u.includes("/releases")) return new Response(JSON.stringify([rel]), { status: 200 });
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
const result = await mod.downloadSelfUpdate();
expect(result.file).toBe(tgzName);
expect(result.path).toBe(join(dir, "v0.1.9", tgzName));
expect(result.version).toBe("0.1.9");
});
it("downloadSelfUpdate rejects checksum mismatch", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgzName = "evoscientist-webui-0.1.9.tgz";
const rel = release("v0.1.9", [
{ name: tgzName, url: `${BASE}:8443/attachments/${tgzName}` },
{ name: "checksums.txt", url: `${BASE}:8443/attachments/checksums.txt` },
]);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("checksums.txt"))
return new Response(`${"0".repeat(64)} ${tgzName}\n`, { status: 200 });
if (u.includes(tgzName)) return new Response(Buffer.from("bytes"), { status: 200 });
if (u.includes("/releases")) return new Response(JSON.stringify([rel]), { status: 200 });
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
await expect(mod.downloadSelfUpdate()).rejects.toThrow(/checksum mismatch/);
});
it("rejects asset URLs on foreign hosts", async () => {
const dir = mkdtempSync(join(tmpdir(), "selfupdate-"));
process.env.EVOSCIENTIST_UPDATE_STAGING_DIR = dir;
const tgzName = "evoscientist-webui-0.1.9.tgz";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/releases"))
return new Response(
JSON.stringify([
release("v0.1.9", [{ name: tgzName, url: `https://evil.example.com/${tgzName}` }]),
]),
{ status: 200 }
);
if (u.includes("/tags")) return new Response("[]", { status: 200 });
return new Response("not found", { status: 404 });
})
);
const mod = await import("./selfUpdate");
await expect(mod.downloadSelfUpdate()).rejects.toThrow(/not allowed/);
});
it("listSelfRollbackVersions filters, orders and truncates at BREAKING-DB", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [
release("v0.3.0"),
release("v0.2.9", [], { prerelease: true }),
release("v0.2.8"),
release("v0.2.0", [], { prerelease: true, body: "BREAKING-DB" }),
release("v0.1.9"),
],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
const versions = (await mod.listSelfRollbackVersions(3, "0.3.1")).map((v) => v.version);
expect(versions).toEqual(["0.3.0", "0.2.8"]);
});
it("isAllowedSelfRollback accepts listed versions only", async () => {
mockFetch({
[`${BASE}/api/v1/repos/${REPO}/releases`]: [release("v0.3.0")],
[`${BASE}/api/v1/repos/${REPO}/tags`]: [],
});
const mod = await import("./selfUpdate");
expect(await mod.isAllowedSelfRollback("0.3.0", "0.3.1")).toBe(true);
expect(await mod.isAllowedSelfRollback("v0.3.0", "0.3.1")).toBe(true);
expect(await mod.isAllowedSelfRollback("0.2.0", "0.3.1")).toBe(false);
});
});