/** * Redirect Loop Detection E2E Tests * * Tests write-time loop prevention, pattern-aware detection, * cache behavior, and admin UI warnings. */ import { test, expect } from "../fixtures"; function apiHeaders(token: string, baseUrl: string) { return { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "X-EmDash-Request": "1", Origin: baseUrl, }; } /** Create a redirect via API, return the id */ async function create( page: import("@playwright/test").Page, baseUrl: string, token: string, source: string, destination: string, options?: { enabled?: boolean }, ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), data: { source, destination, ...options }, }); const body = await res.json(); if (!res.ok()) { return body.error?.message ?? "unknown error"; } return body.data.id; } /** Try to create a redirect, expect rejection. Return error message. */ async function createExpectError( page: import("@playwright/test").Page, baseUrl: string, token: string, source: string, destination: string, ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), data: { source, destination }, }); expect(res.ok(), `Expected rejection for ${source} → ${destination}`).toBe(false); const body = await res.json(); return body.error?.message ?? "unknown error"; } /** Try to create a redirect, expect success */ async function createExpectSuccess( page: import("@playwright/test").Page, baseUrl: string, token: string, source: string, destination: string, ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), data: { source, destination }, }); const body = await res.json(); expect(res.ok(), `Expected success for ${source} → ${destination}: ${JSON.stringify(body)}`).toBe( true, ); } /** Delete all redirects */ async function cleanup( page: import("@playwright/test").Page, baseUrl: string, token: string, ): Promise { const headers = apiHeaders(token, baseUrl); const res = await page.request.get(`${baseUrl}/_emdash/api/redirects`, { headers }); if (!res.ok()) return; const data = await res.json(); for (const item of data.data?.items ?? []) { await page.request.delete(`${baseUrl}/_emdash/api/redirects/${item.id}`, { headers }); } } test.describe("redirect loop detection", () => { test.beforeEach(async ({ admin, page, serverInfo }) => { await admin.devBypassAuth(); await cleanup(page, serverInfo.baseUrl, serverInfo.token); }); test.afterEach(async ({ page, serverInfo }) => { await cleanup(page, serverInfo.baseUrl, serverInfo.token); }); // ----------------------------------------------------------------------- // Pattern template loops // ----------------------------------------------------------------------- test("rejects matching pattern template loop: [...path]", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/old/[...path]", "/new/[...path]"); const msg = await createExpectError(page, baseUrl, token, "/new/[...path]", "/old/[...path]"); expect(msg).toContain("loop"); }); // ----------------------------------------------------------------------- // Admin UI warnings // ----------------------------------------------------------------------- test("admin UI shows no loop banner when no loops exist", async ({ admin, page, serverInfo }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/one", "/two"); await createExpectSuccess(page, baseUrl, token, "/two", "/three"); await admin.goto("/redirects"); await admin.waitForShell(); await admin.waitForLoading(); await expect(page.locator("text=Redirect loop detected")).toBeHidden(); }); // ----------------------------------------------------------------------- // Error message format // ----------------------------------------------------------------------- test("error message shows template names, not __p__ dummy values", async ({ page, serverInfo, }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/blog/[slug]", "/articles/[slug]"); const msg = await createExpectError(page, baseUrl, token, "/articles/hello", "/blog/hello"); expect(msg).not.toContain("__p__"); expect(msg).toContain("/articles/hello"); expect(msg).toContain("/blog/hello"); }); // ----------------------------------------------------------------------- // Update-time loop detection // ----------------------------------------------------------------------- test("rejects update that would create a loop", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); await createExpectSuccess(page, baseUrl, token, "/a", "/b"); await createExpectSuccess(page, baseUrl, token, "/b", "/c"); const id = await create(page, baseUrl, token, "/c", "/d"); const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { destination: "/a" }, }); expect(res.ok()).toBe(false); const body = await res.json(); expect(body.error?.message).toContain("loop"); }); test("allows update that does not create a loop", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); await createExpectSuccess(page, baseUrl, token, "/a", "/b"); const id = await create(page, baseUrl, token, "/b", "/c"); const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { destination: "/d" }, }); expect(res.ok()).toBe(true); }); test("rejects update changing both source and destination to create a loop", async ({ page, serverInfo, }) => { const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); await createExpectSuccess(page, baseUrl, token, "/a", "/b"); const id = await create(page, baseUrl, token, "/x", "/y"); const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { source: "/b", destination: "/a" }, }); expect(res.ok()).toBe(false); const body = await res.json(); expect(body.error?.message).toContain("loop"); }); test("rejects update changing destination + enabling simultaneously", async ({ page, serverInfo, }) => { const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); await createExpectSuccess(page, baseUrl, token, "/a", "/b"); const id = await create(page, baseUrl, token, "/b", "/safe", { enabled: false }); const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { destination: "/a", enabled: true }, }); expect(res.ok()).toBe(false); const body = await res.json(); expect(body.error?.message).toContain("loop"); }); // ----------------------------------------------------------------------- // Edge cases // ----------------------------------------------------------------------- test("disabled redirect does not participate in loop detection", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); const id = await create(page, baseUrl, token, "/a", "/b"); await createExpectSuccess(page, baseUrl, token, "/b", "/c"); await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { enabled: false }, }); await createExpectSuccess(page, baseUrl, token, "/c", "/a"); }); test("re-enabling a disabled redirect that creates a loop is allowed", async ({ page, serverInfo, }) => { // Users who had redirects before upgrade should be able to toggle // them freely. The warning banner alerts them to the loop. const { baseUrl, token } = serverInfo; const headers = apiHeaders(token, baseUrl); await createExpectSuccess(page, baseUrl, token, "/a", "/b"); await createExpectSuccess(page, baseUrl, token, "/b", "/c"); const id = await create(page, baseUrl, token, "/c", "/a", { enabled: false }); const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, data: { enabled: true }, }); expect(res.ok()).toBe(true); }); // ----------------------------------------------------------------------- // Advanced pattern combinations // ----------------------------------------------------------------------- test("rejects pattern with different param names that still loops", async ({ page, serverInfo, }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/blog/[slug]", "/articles/[slug]"); const msg = await createExpectError(page, baseUrl, token, "/articles/[id]", "/blog/[id]"); expect(msg).toContain("loop"); }); test("rejects catch-all loop even with deep nesting", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/v1/[...path]", "/v2/[...path]"); const msg = await createExpectError( page, baseUrl, token, "/v2/api/users/[slug]", "/v1/api/users/[slug]", ); expect(msg).toContain("loop"); }); test("multiple overlapping catch-alls: more specific loops back", async ({ page, serverInfo, }) => { const { baseUrl, token } = serverInfo; await createExpectSuccess(page, baseUrl, token, "/a/[...path]", "/b/[...path]"); await createExpectSuccess(page, baseUrl, token, "/a/sub/[...path]", "/c/[...path]"); const msg = await createExpectError(page, baseUrl, token, "/c/[...path]", "/a/sub/[...path]"); expect(msg).toContain("loop"); }); // ----------------------------------------------------------------------- // Long chains (20+) // ----------------------------------------------------------------------- test("rejects loop at the end of a 25-redirect chain", async ({ page, serverInfo }) => { const { baseUrl, token } = serverInfo; for (let i = 1; i <= 24; i++) { await createExpectSuccess(page, baseUrl, token, `/r${i}`, `/r${i + 1}`); } const msg = await createExpectError(page, baseUrl, token, "/r25", "/r1"); expect(msg).toContain("loop"); expect(msg).toContain("/r1"); expect(msg).toContain("/r25"); }); });