src/lib/download.test.ts
Rama main · réplica de lectura del repositorio original.
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
import {
filenameFromContentDisposition,
isFirefoxIOS,
saveApiDownload,
} from "./download";
import {
downloadTicketsAPI,
ticketedDownloadUrl,
} from "@/lib/api/download-tickets";
import { navigateToDownload } from "@/lib/navigation";
vi.mock("@/lib/api/download-tickets", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/download-tickets")>()),
downloadTicketsAPI: { create: vi.fn() },
}));
vi.mock("@/lib/navigation", () => ({
navigateToDownload: vi.fn(),
}));
// The parser exists for one header the app actually meets - the export endpoints'
// `attachment; filename="opendiving-alex-20260814.zip"` - but it is the thing standing
// between a server-chosen string and `link.download`, so the cases worth pinning are
// the ones where it should decline to answer rather than the happy path.
describe("filenameFromContentDisposition", () => {
it("reads the plain quoted form the export endpoints send", () => {
expect(
filenameFromContentDisposition(
'attachment; filename="opendiving-alex-20260814.zip"',
),
).toBe("opendiving-alex-20260814.zip");
});
it("reads an unquoted filename", () => {
expect(
filenameFromContentDisposition("attachment; filename=dives.uddf"),
).toBe("dives.uddf");
});
it("is case-insensitive about the parameter name", () => {
expect(
filenameFromContentDisposition('attachment; FileName="dives.csv"'),
).toBe("dives.csv");
});
it("prefers the RFC 6266 extended form, which is the one that can carry non-ASCII", () => {
// A server sending both means the plain parameter to be the lossy fallback for
// clients that cannot read `filename*`. Picking the lossy one when the good one
// is right there would be choosing to mangle the name.
expect(
filenameFromContentDisposition(
"attachment; filename=\"opendiving-jarnsaxa.zip\"; filename*=UTF-8''opendiving-j%C3%A4rns%C3%A4xa.zip",
),
).toBe("opendiving-järnsäxa.zip");
});
it("falls back to the plain form when the extended one is malformed", () => {
// A stray `%` is a `decodeURIComponent` throw, not a bad character. A
// wrong-but-present name beats failing the download.
expect(
filenameFromContentDisposition(
"attachment; filename=\"export.zip\"; filename*=UTF-8''bad%zz.zip",
),
).toBe("export.zip");
});
it("falls back to the plain form for a charset it cannot decode", () => {
// `decodeURIComponent` only speaks UTF-8, so `%E9` in a Latin-1 name is not a
// character it can produce. Declining by the declared charset rather than by
// catching the throw is the difference between a decision and an accident.
expect(
filenameFromContentDisposition(
"attachment; filename=\"cafe.zip\"; filename*=ISO-8859-1''caf%E9.zip",
),
).toBe("cafe.zip");
});
it("accepts an extended form with no charset declared", () => {
// RFC 8187 requires one, but a server that omits it is percent-encoding ASCII,
// which is exactly what `decodeURIComponent` does anyway.
expect(
filenameFromContentDisposition(
"attachment; filename*=''plain%20name.zip",
),
).toBe("plain name.zip");
});
it("strips a directory part rather than passing it to `link.download`", () => {
expect(
filenameFromContentDisposition('attachment; filename="../../etc/passwd"'),
).toBe("passwd");
expect(
filenameFromContentDisposition(
'attachment; filename="C:\\Windows\\system.ini"',
),
).toBe("system.ini");
});
it.each([
// What a cross-origin response without `Access-Control-Expose-Headers` actually
// hands JS: the header is on the wire, and `response.headers[...]` is undefined.
// This is the case that runs in production today, so the callers' fallback name
// is not a defensive branch - it is the normal path.
["a missing header", undefined],
["an explicit null", null],
["an empty header", ""],
["a header with no filename at all", "attachment"],
["an empty quoted filename", 'attachment; filename=""'],
["a filename that is only a path separator", 'attachment; filename="/"'],
])("returns null for %s", (_label, header) => {
expect(filenameFromContentDisposition(header)).toBeNull();
});
});
// Firefox for iOS's own UA, from its `UserAgentBuilder`: the `FxiOS/` token is the
// only thing separating it from Safari on the same device.
const FIREFOX_IOS_IPHONE =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/145.0 Mobile/15E148 Safari/604.1";
const FIREFOX_IOS_IPAD_MOBILE =
"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/145.0 Mobile/15E148 Safari/604.1";
const SAFARI_IOS =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1";
const CHROME_IOS =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.7390.96 Mobile/15E148 Safari/604.1";
const FIREFOX_DESKTOP =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0";
const CHROME_DESKTOP =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
// What Firefox sends in desktop mode, an iPad's default - desktop Safari's UA.
const IPAD_DESKTOP_MODE =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15";
describe("isFirefoxIOS", () => {
it.each([
["on an iPhone", FIREFOX_IOS_IPHONE],
["on an iPad in mobile mode", FIREFOX_IOS_IPAD_MOBILE],
])("recognises Firefox for iOS %s", (_label, ua) => {
expect(isFirefoxIOS(ua)).toBe(true);
});
it.each([
["Safari on iOS", SAFARI_IOS],
["Chrome on iOS", CHROME_IOS],
["desktop Firefox", FIREFOX_DESKTOP],
["desktop Chrome", CHROME_DESKTOP],
["an iPad in desktop mode, whichever browser", IPAD_DESKTOP_MODE],
])("leaves %s alone", (_label, ua) => {
expect(isFirefoxIOS(ua)).toBe(false);
});
});
describe("saveApiDownload", () => {
const blob = new Blob(["bytes"]);
const fetchFile = vi.fn();
let click: MockInstance<HTMLAnchorElement["click"]>;
let clickedAnchor: HTMLAnchorElement | null;
function useUserAgent(ua: string) {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(ua);
}
beforeEach(() => {
fetchFile.mockReset();
fetchFile.mockResolvedValue({ blob, filename: "dive.fit" });
vi.mocked(downloadTicketsAPI.create).mockReset();
vi.mocked(navigateToDownload).mockReset();
// jsdom has no object URLs and does not navigate on an anchor click.
URL.createObjectURL = vi.fn(() => "blob:http://localhost/abc");
URL.revokeObjectURL = vi.fn();
clickedAnchor = null;
click = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(function (this: HTMLAnchorElement) {
clickedAnchor = this;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([SAFARI_IOS, CHROME_IOS, FIREFOX_DESKTOP, CHROME_DESKTOP])(
"saves the fetched blob under its name, minting nothing, in %s",
async (ua) => {
useUserAgent(ua);
// Nothing left to wait on: the bytes are already in hand.
await expect(saveApiDownload("/export/archive", fetchFile)).resolves.toBe(
0,
);
expect(fetchFile).toHaveBeenCalledOnce();
expect(click).toHaveBeenCalledOnce();
expect(clickedAnchor?.download).toBe("dive.fit");
expect(clickedAnchor?.href).toBe("blob:http://localhost/abc");
expect(downloadTicketsAPI.create).not.toHaveBeenCalled();
expect(navigateToDownload).not.toHaveBeenCalled();
},
);
it("navigates to the ticketed route in Firefox for iOS, fetching nothing", async () => {
useUserAgent(FIREFOX_IOS_IPHONE);
vi.mocked(downloadTicketsAPI.create).mockResolvedValue({
ticket: "t.k-n_",
expires_in: 300,
});
// The response is out of the page's sight, so the ticket's lifetime is the only
// bound on how long the download may still need the session.
await expect(saveApiDownload("/dive/d1/file/f1", fetchFile)).resolves.toBe(
300_000,
);
expect(downloadTicketsAPI.create).toHaveBeenCalledWith("/dive/d1/file/f1");
// The URL itself is `ticketedDownloadUrl`'s, tested beside it.
expect(navigateToDownload).toHaveBeenCalledWith(
ticketedDownloadUrl("/dive/d1/file/f1", "t.k-n_"),
);
expect(fetchFile).not.toHaveBeenCalled();
expect(click).not.toHaveBeenCalled();
});
it("rejects without navigating when the ticket cannot be minted", async () => {
useUserAgent(FIREFOX_IOS_IPHONE);
const failure = new Error("429");
vi.mocked(downloadTicketsAPI.create).mockRejectedValue(failure);
await expect(saveApiDownload("/export/csv", fetchFile)).rejects.toBe(
failure,
);
expect(navigateToDownload).not.toHaveBeenCalled();
});
});