* fix(setup): report the failed Hopper request URL and HTTP status Hopper download callers collapsed every transport failure into a bare `ok: false`, so HTTP 404 and 503 from the macOS feed and DMG, the Linux publisher feed, the public key and the launch-time package download produced identical generic advice without the failed URL. The shared downloader now returns a typed failure (url plus http_status, timeout, network, response_too_large or url_refused) and keeps caller cancellation as a rejection. Installers attribute it to the request (release_feed, package or public_key); HopperReleaseError carries it, and setup projects it once into `download` and a remediation naming the URL, outcome and next step. Replaces the Linux-only "Package download returned HTTP" special case. Fixes #8503 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(setup): refuse credentialed Hopper URLs and keep known HTTP outcomes Native Fetch rejects a URL with userinfo using a TypeError that echoes the whole URL, so a credentialed redirect reached `download.detail`, setup remediation and publisher messages as a network failure. The downloader now refuses request and redirect URLs carrying credentials as `url_refused: credentials` before requesting them, and reports the caller URL through the shared userinfo-removing `sanitizeBrowserUrl`. Releasing an unadmitted body no longer replaces the outcome it was released for: an errored (truncated) body after HTTP 503, or a failed cancel at the size limit, keeps `http_status` or `response_too_large`. Tests compare delivered remediation with the owning formatters and assert request, URL, status and recovery class independently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
import { downloadHopperPackage } from "../../src/application/HopperDownload.js";
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
const redirect = (location: string, cancelled: () => void) =>
|
|
new Response(new ReadableStream({ cancel: cancelled }), {
|
|
status: 302,
|
|
headers: { location },
|
|
});
|
|
|
|
it("refuses an HTTPS downgrade before sending the redirected request and releases its body", async () => {
|
|
let cancelled = 0;
|
|
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
|
|
redirect("http://www.hopperapp.com/package", () => {
|
|
cancelled++;
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetch);
|
|
expect(
|
|
await downloadHopperPackage("https://www.hopperapp.com/package"),
|
|
).toEqual({
|
|
ok: false,
|
|
error: {
|
|
url: "https://www.hopperapp.com/package",
|
|
kind: "url_refused",
|
|
refusal: "non_https",
|
|
},
|
|
});
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://www.hopperapp.com/package",
|
|
expect.objectContaining({ redirect: "manual" }),
|
|
);
|
|
expect(cancelled).toBe(1);
|
|
});
|
|
|
|
it("preserves HTTPS relative and cross-origin redirects under one request deadline", async () => {
|
|
let cancelled = 0;
|
|
const fetch = vi
|
|
.fn<typeof globalThis.fetch>()
|
|
.mockResolvedValueOnce(
|
|
redirect("/next", () => {
|
|
cancelled++;
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
redirect("https://downloads.hopperapp.com/final", () => {
|
|
cancelled++;
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(new Response("package"));
|
|
vi.stubGlobal("fetch", fetch);
|
|
const selected = "https://www.hopperapp.com:443/start#selected";
|
|
const result = await downloadHopperPackage(selected);
|
|
if (!result.ok) throw new Error("Expected the redirected package");
|
|
expect(new TextDecoder().decode(result.value)).toBe("package");
|
|
expect(fetch.mock.calls.map(([input]) => input)).toEqual([
|
|
selected,
|
|
"https://www.hopperapp.com/next#selected",
|
|
"https://downloads.hopperapp.com/final#selected",
|
|
]);
|
|
const signal = fetch.mock.calls[0]?.[1]?.signal;
|
|
expect(signal).toBeInstanceOf(AbortSignal);
|
|
expect(
|
|
fetch.mock.calls.every(([, options]) => options?.signal === signal),
|
|
).toBe(true);
|
|
expect(cancelled).toBe(2);
|
|
});
|
|
|
|
it("retains Fetch's twenty-redirect bound and releases every redirect response", async () => {
|
|
let cancelled = 0;
|
|
const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async () =>
|
|
redirect("/loop", () => {
|
|
cancelled++;
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetch);
|
|
expect(await downloadHopperPackage("https://www.hopperapp.com/loop")).toEqual(
|
|
{
|
|
ok: false,
|
|
error: {
|
|
url: "https://www.hopperapp.com/loop",
|
|
kind: "url_refused",
|
|
refusal: "redirect_limit",
|
|
},
|
|
},
|
|
);
|
|
expect(fetch).toHaveBeenCalledTimes(21);
|
|
expect(cancelled).toBe(21);
|
|
});
|
|
|
|
it("refuses an initial non-HTTPS URL before any request", async () => {
|
|
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
vi.stubGlobal("fetch", fetch);
|
|
expect(
|
|
await downloadHopperPackage("http://www.hopperapp.com/package"),
|
|
).toEqual({
|
|
ok: false,
|
|
error: {
|
|
url: "http://www.hopperapp.com/package",
|
|
kind: "url_refused",
|
|
refusal: "non_https",
|
|
},
|
|
});
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|