193 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|