Files
OmniRoute/tests/unit/system-version-check-4100.test.ts
Syed Raheemuddin 8fc6834372 fix(system): propagate abort signal to stream reader in HTTP version checks (#12232)
When fetching version metadata from npm registry or GitHub APIs, if the remote connection stalls during stream reading, the 10-second AbortController timer aborts the request signal but the underlying body reader stream was not listening to the abort signal. This caused readBoundedJson reader.read() loop to hang until external socket close.

Now readBoundedJson listens to AbortSignal abort events, triggers reader.cancel(), and releases locks immediately on abort.
2026-09-01 12:07:02 -03:00

187 lines
6.3 KiB
TypeScript

/**
* Regression test for #4100 — Home "Update Available" banner no longer appears.
*
* Root cause: `GET /api/system/version` derived `latest` ONLY from `npm info` via the
* `npm` CLI binary, returning null on ANY error (binary missing in Docker/desktop,
* registry unreachable) → updateAvailable=false → banner silently never renders.
* Secondary: `isNewer()`'s `v.split(".").map(Number)` collapsed to false on `v`-prefixed
* or pre-release version strings (NaN comparisons).
*
* These assertions fail against the old inline semantics (no module, fragile isNewer,
* no npm-binary-free fallback) and pass once `src/lib/system/versionCheck.ts` exists.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeVersion,
isNewer,
resolveLatestVersion,
getLatestVersionFromGitHub,
getLatestVersionFromRegistry,
} from "@/lib/system/versionCheck";
test("normalizeVersion strips v-prefix, pre-release/build, returns numeric tuple", () => {
assert.deepEqual(normalizeVersion("3.8.28"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("v3.8.28"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("3.8.28-rc.1"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("3.8.28+build.5"), [3, 8, 28]);
assert.equal(normalizeVersion(""), null);
assert.equal(normalizeVersion("not-a-version"), null);
});
test("isNewer: basic ordering and null safety", () => {
assert.equal(isNewer("3.8.29", "3.8.28"), true);
assert.equal(isNewer("3.8.28", "3.8.28"), false);
assert.equal(isNewer("3.8.27", "3.8.28"), false);
assert.equal(isNewer(null, "3.8.28"), false);
});
test("isNewer: v-prefixed latest is handled (#4100 — old code returned false via NaN)", () => {
assert.equal(isNewer("v3.8.29", "3.8.28"), true);
});
test("isNewer: pre-release suffix is handled (#4100 — old code returned false via NaN)", () => {
assert.equal(isNewer("3.8.29-rc.1", "3.8.28"), true);
});
test("isNewer: multi-digit minor ordering", () => {
assert.equal(isNewer("3.10.0", "3.9.9"), true);
assert.equal(isNewer("3.9.9", "3.10.0"), false);
});
test("resolveLatestVersion falls back to the registry when the npm CLI path fails (#4100)", async () => {
const latest = await resolveLatestVersion({
npmCli: async () => null, // npm binary missing / registry unreachable via CLI
registry: async () => "3.8.29", // npm-binary-free HTTP fallback succeeds
});
assert.equal(latest, "3.8.29");
});
test("resolveLatestVersion prefers the npm CLI when it succeeds", async () => {
let registryCalled = false;
const latest = await resolveLatestVersion({
npmCli: async () => "3.8.30",
registry: async () => {
registryCalled = true;
return "3.8.29";
},
});
assert.equal(latest, "3.8.30");
assert.equal(registryCalled, false);
});
test("resolveLatestVersion falls back to GitHub when npm CLI and registry both fail (#4100 — npm blocked, GitHub reachable)", async () => {
let githubCalled = false;
const latest = await resolveLatestVersion({
npmCli: async () => null,
registry: async () => null,
github: async () => {
githubCalled = true;
return "v3.8.39"; // GitHub releases tag_name — v-prefix tolerated downstream
},
});
assert.equal(latest, "v3.8.39");
assert.equal(githubCalled, true);
});
test("resolveLatestVersion does NOT hit GitHub when the registry already answered", async () => {
let githubCalled = false;
const latest = await resolveLatestVersion({
npmCli: async () => null,
registry: async () => "3.8.39",
github: async () => {
githubCalled = true;
return "v3.8.40";
},
});
assert.equal(latest, "3.8.39");
assert.equal(githubCalled, false);
});
test("resolveLatestVersion returns null only when ALL three sources fail (no silent crash)", async () => {
const latest = await resolveLatestVersion({
npmCli: async () => null,
registry: async () => null,
github: async () => null,
});
assert.equal(latest, null);
});
test("getLatestVersionFromGitHub parses tag_name from the releases API", async () => {
const fakeFetch = (async () =>
new Response(JSON.stringify({ tag_name: "v3.8.39" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as unknown as typeof fetch;
assert.equal(await getLatestVersionFromGitHub(fakeFetch), "v3.8.39");
});
test("getLatestVersionFromGitHub returns null on a non-OK response", async () => {
const fakeFetch = (async () =>
new Response("rate limited", { status: 403 })) as unknown as typeof fetch;
assert.equal(await getLatestVersionFromGitHub(fakeFetch), null);
});
test("HTTP version sources reject oversized bodies without parsing them", async () => {
let registryCancelled = false;
let githubCancelled = false;
const oversizedResponse = (onCancel: () => void) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(32 * 1024));
},
cancel() {
onCancel();
},
}),
{ status: 200 }
);
assert.equal(
await getLatestVersionFromRegistry((async () =>
oversizedResponse(() => {
registryCancelled = true;
})) as unknown as typeof fetch),
null
);
assert.equal(
await getLatestVersionFromGitHub((async () =>
oversizedResponse(() => {
githubCancelled = true;
})) as unknown as typeof fetch),
null
);
assert.equal(registryCancelled, true);
assert.equal(githubCancelled, true);
});
test("HTTP version sources handle aborted stream signals gracefully", async () => {
let streamCancelled = false;
const hangingResponse = () =>
new Response(
new ReadableStream({
start() {
// Intentionally do not enqueue or close, simulating a hung stream
},
cancel() {
streamCancelled = true;
},
}),
{ status: 200 }
);
const fakeFetchWithAbort = (async (_url: string, init?: RequestInit) => {
setImmediate(() => {
if (init?.signal && typeof init.signal.dispatchEvent === "function") {
init.signal.dispatchEvent(new Event("abort"));
}
});
return hangingResponse();
}) as unknown as typeof fetch;
assert.equal(await getLatestVersionFromRegistry(fakeFetchWithAbort), null);
assert.equal(await getLatestVersionFromGitHub(fakeFetchWithAbort), null);
assert.equal(streamCancelled, true);
});