Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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");
|
|
});
|
|
});
|