/** * Behavior-pinning tests for downloadAppUpdate (CL4) — written BEFORE extracting * the streamToFile helper so the security-critical invariants are locked at the * public boundary and must survive the refactor unchanged: * * - untrusted initial URL refused (no request made) * - per-hop redirect re-validation on BOTH the checksum fetch and the installer * - missing (404) checksum refused; malformed checksum refused * - SHA-256 verified against the published digest; mismatch deletes the partial * - truncation (received < content-length) detected; partial deleted * - idle timeout aborts a stalled download * - cancel during the checksum phase and mid-download both settle as canceled * - progress events reach the WebContents */ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest' import { EventEmitter } from 'node:events' import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { createHash } from 'node:crypto' const TMP = mkdtempSync(join(tmpdir(), 'aerofetch-updater-test-')) // --- electron + settings mocks ---------------------------------------------- class FakeResponse extends EventEmitter { constructor( public statusCode: number, public headers: Record = {} ) { super() } } class FakeRequest extends EventEmitter { ended = false aborted = false redirectsFollowed = 0 constructor(public url: string) { super() } end(): void { this.ended = true } abort(): void { this.aborted = true } followRedirect(): void { this.redirectsFollowed++ } } type Script = (req: FakeRequest) => void const requestScripts: Script[] = [] const madeRequests: FakeRequest[] = [] vi.mock('electron', () => ({ app: { getPath: () => TMP, getVersion: () => '0.5.0' }, net: { request: (opts: { url: string }) => { const req = new FakeRequest(opts.url) madeRequests.push(req) const script = requestScripts.shift() if (script) queueMicrotask(() => script(req)) return req } }, shell: {} })) import { downloadAppUpdate, cancelAppUpdate } from '../src/main/updater' import type { WebContents } from 'electron' // --- helpers ------------------------------------------------------------------ const HOST = 'https://gitea.netbird.zimspace.uk:5938' const ASSET = `${HOST}/debont80/AeroFetch/releases/download/v9.9.9/AeroFetch-Setup-9.9.9.exe` const INSTALLER = Buffer.from('FAKE-INSTALLER-BYTES-'.repeat(64)) const GOOD_SHA = createHash('sha256').update(INSTALLER).digest('hex') function fakeWc(): { wc: WebContents; sends: unknown[] } { const sends: unknown[] = [] const wc = { isDestroyed: () => false, send: (_ch: string, p: unknown) => { sends.push(p) } } as unknown as WebContents return { wc, sends } } /** Script the checksum (.sha256) fetch to answer 200 with the given text. */ function checksumOk(text: string): Script { return (req) => { const res = new FakeResponse(200) req.emit('response', res) res.emit('data', Buffer.from(text)) res.emit('end') } } /** Wait for the fire-and-forget partial unlink to land. */ async function expectGone(path: string): Promise { for (let i = 0; i < 20 && existsSync(path); i++) await new Promise((r) => setTimeout(r, 25)) expect(existsSync(path)).toBe(false) } const installerPath = join(TMP, 'AeroFetch-Setup-9.9.9.exe') beforeEach(() => { requestScripts.length = 0 madeRequests.length = 0 }) afterEach(() => { vi.useRealTimers() try { rmSync(installerPath, { force: true }) } catch { /* ignore */ } }) afterAll(() => { rmSync(TMP, { recursive: true, force: true }) }) // --- the pins ------------------------------------------------------------------- describe('downloadAppUpdate (CL4 behavior pins)', () => { it('refuses an untrusted initial URL without making any request', async () => { const { wc } = fakeWc() const r = await downloadAppUpdate(wc, 'https://evil.example/x.exe') expect(r.ok).toBe(false) expect(r.error).toMatch(/untrusted/i) expect(madeRequests.length).toBe(0) }) it('downloads, verifies the SHA-256, and reports progress (happy path)', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER.subarray(0, 512)) res.emit('data', INSTALLER.subarray(512)) res.emit('end') }) const { wc, sends } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r).toEqual({ ok: true, filePath: installerPath }) expect(readFileSync(installerPath).equals(INSTALLER)).toBe(true) expect(sends.length).toBeGreaterThan(0) expect(sends.at(-1)).toMatchObject({ received: INSTALLER.length, total: INSTALLER.length }) }) it('rejects a checksum mismatch and deletes the partial', async () => { const wrong = 'a'.repeat(64) requestScripts.push(checksumOk(`${wrong} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER) res.emit('end') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/checksum/i) await expectGone(installerPath) }) it('detects truncation against content-length and deletes the partial', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length * 2) }) req.emit('response', res) res.emit('data', INSTALLER) // half of what was promised res.emit('end') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/incomplete/i) await expectGone(installerPath) }) it('refuses to follow an installer redirect off the trusted host', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { req.emit('redirect', 302, 'GET', 'https://evil.example/attachments/x.exe') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/redirect.*untrusted/i) const installerReq = madeRequests[1]! expect(installerReq.redirectsFollowed).toBe(0) expect(installerReq.aborted).toBe(true) }) it('follows an on-host redirect (per-hop re-validation passes)', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { req.emit('redirect', 302, 'GET', `${HOST}/attachments/abc123`) const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER) res.emit('end') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(true) expect(madeRequests[1]!.redirectsFollowed).toBe(1) }) it('refuses a checksum-fetch redirect off the trusted host', async () => { requestScripts.push((req) => { req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/checksum/i) expect(madeRequests.length).toBe(1) // never reached the installer request }) it('refuses to install when the release has no checksum attached (404)', async () => { requestScripts.push((req) => { req.emit('response', new FakeResponse(404)) }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/no checksum/i) expect(madeRequests.length).toBe(1) }) it('refuses a malformed checksum file (no 64-hex digest)', async () => { requestScripts.push(checksumOk('not a digest at all\n')) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/malformed/i) expect(madeRequests.length).toBe(1) }) it('a cancel during the checksum phase settles as canceled before any installer request', async () => { requestScripts.push((req) => { // Cancel lands while the checksum fetch is in flight. cancelAppUpdate() const res = new FakeResponse(200) req.emit('response', res) res.emit('data', Buffer.from(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) res.emit('end') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/canceled/i) expect(madeRequests.length).toBe(1) }) it('a cancel mid-download aborts, settles as canceled, and deletes the partial', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER.subarray(0, 512)) cancelAppUpdate() }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/canceled/i) expect(madeRequests[1]!.aborted).toBe(true) await expectGone(installerPath) }) it('times out a stalled download and deletes the partial', async () => { vi.useFakeTimers() requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER.subarray(0, 512)) // …and then nothing: the stream stalls. }) const { wc } = fakeWc() const pending = downloadAppUpdate(wc, ASSET) await vi.advanceTimersByTimeAsync(61_000) const r = await pending expect(r.ok).toBe(false) expect(r.error).toMatch(/stalled/i) expect(madeRequests[1]!.aborted).toBe(true) vi.useRealTimers() await expectGone(installerPath) }) it('fails on an HTTP error status from the installer download', async () => { requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) requestScripts.push((req) => { req.emit('response', new FakeResponse(503)) }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(false) expect(r.error).toMatch(/503/) }) it('matches the checksum line for THIS asset in a combined checksum file', async () => { const other = 'b'.repeat(64) requestScripts.push( checksumOk(`${other} SomethingElse.exe\n${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`) ) requestScripts.push((req) => { const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) req.emit('response', res) res.emit('data', INSTALLER) res.emit('end') }) const { wc } = fakeWc() const r = await downloadAppUpdate(wc, ASSET) expect(r.ok).toBe(true) }) })