Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setDnsRecord,
exportZone,
checkDomain,
listRedirects,
toRedirectHost,
addRedirect,
updateRedirect,
Expand Down Expand Up @@ -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")
Expand Down
30 changes: 27 additions & 3 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebRedir[]> =>
request<WebRedir[]>(apiKey, `/domain/domains/${domain}/webredirs`)
): Promise<WebRedir[]> => {
const all: WebRedir[] = []

for (let page = 1; page <= REDIRECTS_PAGE_LIMIT; page++) {
const batch = await request<WebRedir[]>(
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
Expand Down