Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
/**
* 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");
});
});