diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index f2ef3fa..4b7b782 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -7,6 +7,7 @@ import { setDnsRecord, exportZone, checkDomain, + listRedirects, toRedirectHost, addRedirect, updateRedirect, @@ -160,6 +161,43 @@ describe("checkDomain", () => { }) }) +describe("listRedirects", () => { + const page = (n: number) => + Array.from({ length: n }, (_, i) => ({ + host: `h${i}.ex.com`, + type: "http301", + })) + + it("returns everything when a single short page covers it", async () => { + fetchMock.mockResolvedValue(res(200, page(3))) + expect(await listRedirects("k", "ex.com")).toHaveLength(3) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("pages past the first response instead of truncating", async () => { + fetchMock + .mockResolvedValueOnce(res(200, page(100))) + .mockResolvedValueOnce(res(200, page(14))) + expect(await listRedirects("k", "ex.com")).toHaveLength(114) + expect(fetchMock.mock.calls[0][0]).toContain("page=1&per_page=100") + expect(fetchMock.mock.calls[1][0]).toContain("page=2&per_page=100") + }) + + it("stops on an empty page when the total is an exact multiple", async () => { + fetchMock + .mockResolvedValueOnce(res(200, page(100))) + .mockResolvedValueOnce(res(200, [])) + expect(await listRedirects("k", "ex.com")).toHaveLength(100) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it("throws rather than return a truncated list if pages never run short", async () => { + fetchMock.mockResolvedValue(res(200, page(100))) + const e = (await reject(listRedirects("k", "ex.com"))) as Error + expect(e.message).toContain("may be incomplete") + }) +}) + describe("toRedirectHost", () => { it("qualifies a bare label with the domain", () => { expect(toRedirectHost("ex.com", "www")).toBe("www.ex.com") diff --git a/src/lib/api.ts b/src/lib/api.ts index 8b84125..abad6a7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -77,11 +77,35 @@ export const setAutorenew = ( body: JSON.stringify({ enabled }), }) -export const listRedirects = ( +// Gandi paginates web redirects and caps an unqualified request at 50, so a +// domain with more than that lost the tail with no error and no warning — +// `redirect list` reported 50 of 64 as though that were all of them, and the +// missing ones were invisible to any script reading the JSON. Page through +// until a short page comes back rather than trusting a single response. +const REDIRECTS_PER_PAGE = 100 +const REDIRECTS_PAGE_LIMIT = 100 + +export const listRedirects = async ( apiKey: string, domain: string, -): Promise => - request(apiKey, `/domain/domains/${domain}/webredirs`) +): Promise => { + const all: WebRedir[] = [] + + for (let page = 1; page <= REDIRECTS_PAGE_LIMIT; page++) { + const batch = await request( + apiKey, + `/domain/domains/${domain}/webredirs?page=${page}&per_page=${REDIRECTS_PER_PAGE}`, + ) + all.push(...batch) + if (batch.length < REDIRECTS_PER_PAGE) return all + } + + // Refuse to return a silently truncated list: the whole point of this + // function is that a short answer is indistinguishable from a complete one. + throw new Error( + `Stopped after ${REDIRECTS_PAGE_LIMIT} pages of web redirects for ${domain} — the list may be incomplete`, + ) +} // Gandi identifies a web redirect by its fully-qualified source host, in the // list response, in the {host} path segment and in the POST body alike — the