Skip to content

Commit 438443a

Browse files
committed
test: add direct tests for utility functions and default download implementation
Issue #47: direct tests for patchloomNeedsUpgrade (1 test) and isTrustedManagedInstallDownloadUrl (6 tests covering HTTPS validation, host checking, repo path, malformed URLs, custom repo parameter). Issue #53: new downloadIntegration.test.ts with a local HTTPS server (self-signed cert via openssl) that exercises the real defaultDownloadToFile implementation. Tests cover successful download, redirect following, redirect depth limit, HTTP error rejection, and parent directory creation. Also tests streamingSha256 directly (exported, 3 tests) and verifies performManagedInstall cleans up staging on download failure. Total: 193 tests (was 177). Closes #47 Closes #53 Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent bb22d52 commit 438443a

3 files changed

Lines changed: 276 additions & 1 deletion

File tree

src/install/managed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ async function defaultReadFileContent(filePath: string): Promise<string> {
779779
return (await import("node:fs/promises")).readFile(filePath, "utf8");
780780
}
781781

782-
async function streamingSha256(filePath: string): Promise<string> {
782+
export async function streamingSha256(filePath: string): Promise<string> {
783783
const hash = createHash("sha256");
784784
await pipeline(createReadStream(filePath), hash);
785785
return hash.digest("hex");

test/unit/binary.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import {
1111
describePatchloomSource,
1212
findOnPath,
1313
MINIMUM_SUPPORTED_PATCHLOOM_VERSION,
14+
patchloomNeedsUpgrade,
1415
parsePatchloomVersion,
1516
resolvePatchloomStatusWithInputs
1617
} from "../../src/binary/patchloom.js";
1718
import {
1819
assertTrustedManagedInstallDownloadUrl,
1920
buildManagedInstallReleaseAssets,
21+
isTrustedManagedInstallDownloadUrl,
2022
calculateSha256Hex,
2123
clearManagedInstallFailure,
2224
clearManagedInstallFailureRecord,
@@ -673,3 +675,68 @@ test("describePatchloomCompatibility maps all compatibility levels to labels", (
673675
assert.equal(describePatchloomCompatibility("unknown"), "unable to verify");
674676
assert.equal(describePatchloomCompatibility(undefined), "unknown");
675677
});
678+
679+
// --- patchloomNeedsUpgrade tests ---
680+
681+
test("patchloomNeedsUpgrade returns true only for unsupported compatibility", () => {
682+
assert.equal(patchloomNeedsUpgrade({ compatibility: "unsupported" } as any), true);
683+
assert.equal(patchloomNeedsUpgrade({ compatibility: "supported" } as any), false);
684+
assert.equal(patchloomNeedsUpgrade({ compatibility: "unknown" } as any), false);
685+
assert.equal(patchloomNeedsUpgrade({ compatibility: undefined } as any), false);
686+
});
687+
688+
// --- isTrustedManagedInstallDownloadUrl tests ---
689+
690+
test("isTrustedManagedInstallDownloadUrl accepts valid GitHub release URLs", () => {
691+
assert.equal(
692+
isTrustedManagedInstallDownloadUrl("https://github.com/patchloom/patchloom/releases/download/v0.1.0/archive.tar.xz"),
693+
true
694+
);
695+
assert.equal(
696+
isTrustedManagedInstallDownloadUrl("https://github.com/patchloom/patchloom/releases/download/v1.2.3/patchloom-x86_64.zip"),
697+
true
698+
);
699+
});
700+
701+
test("isTrustedManagedInstallDownloadUrl rejects non-HTTPS URLs", () => {
702+
assert.equal(
703+
isTrustedManagedInstallDownloadUrl("http://github.com/patchloom/patchloom/releases/download/v0.1.0/x"),
704+
false
705+
);
706+
});
707+
708+
test("isTrustedManagedInstallDownloadUrl rejects non-GitHub hosts", () => {
709+
assert.equal(
710+
isTrustedManagedInstallDownloadUrl("https://evil.com/patchloom/patchloom/releases/download/v0.1.0/x"),
711+
false
712+
);
713+
});
714+
715+
test("isTrustedManagedInstallDownloadUrl rejects wrong repo path", () => {
716+
assert.equal(
717+
isTrustedManagedInstallDownloadUrl("https://github.com/other/repo/releases/download/v0.1.0/x"),
718+
false
719+
);
720+
});
721+
722+
test("isTrustedManagedInstallDownloadUrl rejects malformed URLs", () => {
723+
assert.equal(isTrustedManagedInstallDownloadUrl("not-a-url"), false);
724+
assert.equal(isTrustedManagedInstallDownloadUrl(""), false);
725+
});
726+
727+
test("isTrustedManagedInstallDownloadUrl respects custom repo parameter", () => {
728+
assert.equal(
729+
isTrustedManagedInstallDownloadUrl(
730+
"https://github.com/custom/repo/releases/download/v1.0.0/file.tar.xz",
731+
"custom/repo"
732+
),
733+
true
734+
);
735+
assert.equal(
736+
isTrustedManagedInstallDownloadUrl(
737+
"https://github.com/patchloom/patchloom/releases/download/v1.0.0/file.tar.xz",
738+
"custom/repo"
739+
),
740+
false
741+
);
742+
});
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import assert from "node:assert/strict";
2+
import { execSync } from "node:child_process";
3+
import * as fs from "node:fs/promises";
4+
import * as https from "node:https";
5+
import type { AddressInfo } from "node:net";
6+
import * as os from "node:os";
7+
import * as path from "node:path";
8+
import test, { after, before, describe } from "node:test";
9+
import {
10+
calculateSha256Hex,
11+
downloadToFile,
12+
performManagedInstall,
13+
streamingSha256
14+
} from "../../src/install/managed.js";
15+
16+
let server: https.Server;
17+
let baseUrl: string;
18+
let certDir: string;
19+
let originalTlsReject: string | undefined;
20+
21+
before(async () => {
22+
certDir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-cert-"));
23+
const keyPath = path.join(certDir, "key.pem");
24+
const certPath = path.join(certDir, "cert.pem");
25+
execSync(
26+
`openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 1 -nodes -subj "/CN=localhost" 2>/dev/null`
27+
);
28+
29+
const key = await fs.readFile(keyPath, "utf8");
30+
const cert = await fs.readFile(certPath, "utf8");
31+
32+
originalTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
33+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
34+
35+
server = https.createServer({ key, cert }, (req, res) => {
36+
if (req.url === "/ok") {
37+
res.writeHead(200, { "Content-Type": "application/octet-stream" });
38+
res.end("download-content");
39+
} else if (req.url === "/error-500") {
40+
res.writeHead(500, "Internal Server Error");
41+
res.end();
42+
} else if (req.url === "/redirect-ok") {
43+
res.writeHead(302, { Location: `${baseUrl}/ok` });
44+
res.end();
45+
} else if (req.url === "/redirect-chain") {
46+
res.writeHead(302, { Location: `${baseUrl}/redirect-ok` });
47+
res.end();
48+
} else if (req.url?.startsWith("/redirect-loop")) {
49+
res.writeHead(302, { Location: `${baseUrl}/redirect-loop` });
50+
res.end();
51+
} else {
52+
res.writeHead(404);
53+
res.end();
54+
}
55+
});
56+
57+
await new Promise<void>((resolve) => {
58+
server.listen(0, "127.0.0.1", () => {
59+
const addr = server.address() as AddressInfo;
60+
baseUrl = `https://127.0.0.1:${addr.port}`;
61+
resolve();
62+
});
63+
});
64+
});
65+
66+
after(async () => {
67+
await new Promise<void>((resolve) => server.close(() => resolve()));
68+
if (originalTlsReject === undefined) {
69+
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
70+
} else {
71+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsReject;
72+
}
73+
await fs.rm(certDir, { recursive: true, force: true });
74+
});
75+
76+
async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
77+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-dl-test-"));
78+
try {
79+
await fn(dir);
80+
} finally {
81+
await fs.rm(dir, { recursive: true, force: true });
82+
}
83+
}
84+
85+
// --- downloadToFile with real defaultDownloadToFile ---
86+
87+
describe("downloadToFile with default HTTPS implementation", () => {
88+
test("downloads content to the destination file", async () => {
89+
await withTempDir(async (dir) => {
90+
const dest = path.join(dir, "output.bin");
91+
await downloadToFile({ url: `${baseUrl}/ok`, destPath: dest });
92+
const content = await fs.readFile(dest, "utf8");
93+
assert.equal(content, "download-content");
94+
});
95+
});
96+
97+
test("follows redirects and delivers final content", async () => {
98+
await withTempDir(async (dir) => {
99+
const dest = path.join(dir, "redirected.bin");
100+
await downloadToFile({ url: `${baseUrl}/redirect-chain`, destPath: dest });
101+
const content = await fs.readFile(dest, "utf8");
102+
assert.equal(content, "download-content");
103+
});
104+
});
105+
106+
test("rejects after too many redirects", async () => {
107+
await withTempDir(async (dir) => {
108+
const dest = path.join(dir, "loop.bin");
109+
await assert.rejects(
110+
() => downloadToFile({ url: `${baseUrl}/redirect-loop`, destPath: dest }),
111+
/too many redirects/
112+
);
113+
});
114+
});
115+
116+
test("rejects on HTTP error status", async () => {
117+
await withTempDir(async (dir) => {
118+
const dest = path.join(dir, "error.bin");
119+
await assert.rejects(
120+
() => downloadToFile({ url: `${baseUrl}/error-500`, destPath: dest }),
121+
/500/
122+
);
123+
});
124+
});
125+
126+
test("creates parent directories for the destination", async () => {
127+
await withTempDir(async (dir) => {
128+
const dest = path.join(dir, "nested", "subdir", "file.bin");
129+
await downloadToFile({ url: `${baseUrl}/ok`, destPath: dest });
130+
const content = await fs.readFile(dest, "utf8");
131+
assert.equal(content, "download-content");
132+
});
133+
});
134+
});
135+
136+
// --- streamingSha256 ---
137+
138+
describe("streamingSha256", () => {
139+
test("computes the same hash as the in-memory calculateSha256Hex", async () => {
140+
await withTempDir(async (dir) => {
141+
const testContent = "hello-streaming-sha256-test";
142+
const filePath = path.join(dir, "hashme.txt");
143+
await fs.writeFile(filePath, testContent, "utf8");
144+
145+
const streamingHash = await streamingSha256(filePath);
146+
const inMemoryHash = calculateSha256Hex(testContent);
147+
assert.equal(streamingHash, inMemoryHash);
148+
});
149+
});
150+
151+
test("produces correct SHA-256 for known input", async () => {
152+
await withTempDir(async (dir) => {
153+
const filePath = path.join(dir, "known.txt");
154+
await fs.writeFile(filePath, "abc", "utf8");
155+
const hash = await streamingSha256(filePath);
156+
// SHA-256("abc") is a well-known constant
157+
assert.equal(hash, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
158+
});
159+
});
160+
161+
test("handles empty files", async () => {
162+
await withTempDir(async (dir) => {
163+
const filePath = path.join(dir, "empty.txt");
164+
await fs.writeFile(filePath, "", "utf8");
165+
const hash = await streamingSha256(filePath);
166+
const expected = calculateSha256Hex("");
167+
assert.equal(hash, expected);
168+
});
169+
});
170+
});
171+
172+
// --- performManagedInstall staging cleanup on failure ---
173+
174+
describe("performManagedInstall staging cleanup", () => {
175+
test("cleans up staging directory after download failure", async () => {
176+
await withTempDir(async (installRoot) => {
177+
let capturedStagingDir: string | undefined;
178+
179+
await assert.rejects(
180+
() => performManagedInstall({
181+
installRoot,
182+
version: "0.1.0",
183+
platform: "darwin",
184+
arch: "arm64",
185+
downloadFile: async (inputs) => {
186+
// Capture the staging directory from the dest path
187+
capturedStagingDir = path.dirname(inputs.destPath);
188+
await fs.mkdir(capturedStagingDir, { recursive: true });
189+
// Simulate a partial write then failure
190+
await fs.writeFile(inputs.destPath, "partial-data", "utf8");
191+
throw new Error("network error during download");
192+
},
193+
failurePersistence: { storageRoot: installRoot }
194+
}),
195+
/network error/
196+
);
197+
198+
// Verify staging directory was cleaned up
199+
assert.ok(capturedStagingDir, "should have captured the staging directory");
200+
try {
201+
await fs.access(capturedStagingDir);
202+
assert.fail("staging directory should have been removed after failure");
203+
} catch (err: any) {
204+
assert.equal(err.code, "ENOENT", "staging should not exist");
205+
}
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)