b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
1058 lines
30 KiB
TypeScript
1058 lines
30 KiB
TypeScript
import type { Kysely } from "kysely";
|
|
import { sql } from "kysely";
|
|
import { ulid } from "ulidx";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import { createMenuItemBody, updateMenuItemBody } from "../../../src/api/schemas/menus.js";
|
|
import { createDatabase } from "../../../src/database/connection.js";
|
|
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { getMenuWithDb, getMenusWithDb } from "../../../src/menus/index.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import { sanitizeHref } from "../../../src/utils/url.js";
|
|
|
|
describe("Navigation Menus", () => {
|
|
let db: Kysely<Database>;
|
|
|
|
beforeEach(async () => {
|
|
// Fresh in-memory database for each test
|
|
db = createDatabase({ url: ":memory:" });
|
|
await runMigrations(db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.destroy();
|
|
});
|
|
|
|
describe("migration", () => {
|
|
it("should create _emdash_menus table", async () => {
|
|
const tables = await db.introspection.getTables();
|
|
const menusTable = tables.find((t) => t.name === "_emdash_menus");
|
|
expect(menusTable).toBeDefined();
|
|
|
|
const columns = menusTable!.columns.map((c) => c.name);
|
|
expect(columns).toContain("id");
|
|
expect(columns).toContain("name");
|
|
expect(columns).toContain("label");
|
|
expect(columns).toContain("created_at");
|
|
expect(columns).toContain("updated_at");
|
|
});
|
|
|
|
it("should create _emdash_menu_items table", async () => {
|
|
const tables = await db.introspection.getTables();
|
|
const itemsTable = tables.find((t) => t.name === "_emdash_menu_items");
|
|
expect(itemsTable).toBeDefined();
|
|
|
|
const columns = itemsTable!.columns.map((c) => c.name);
|
|
expect(columns).toContain("id");
|
|
expect(columns).toContain("menu_id");
|
|
expect(columns).toContain("parent_id");
|
|
expect(columns).toContain("sort_order");
|
|
expect(columns).toContain("type");
|
|
expect(columns).toContain("reference_collection");
|
|
expect(columns).toContain("reference_id");
|
|
expect(columns).toContain("custom_url");
|
|
expect(columns).toContain("label");
|
|
expect(columns).toContain("target");
|
|
expect(columns).toContain("css_classes");
|
|
});
|
|
|
|
it("should enforce unique constraint on menu name", async () => {
|
|
const id1 = ulid();
|
|
const id2 = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: id1,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
await expect(
|
|
db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: id2,
|
|
name: "primary",
|
|
label: "Primary Again",
|
|
})
|
|
.execute(),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("strips the menu_id FK so DROP TABLE _emdash_menus is safe on D1 (#1021)", async () => {
|
|
// Migration 036 rebuilds _emdash_menu_items without the
|
|
// `ON DELETE CASCADE` FKs declared in migration 005. The cascade
|
|
// on `menu_id` fired on D1 when the i18n migration dropped the
|
|
// old _emdash_menus, wiping every menu item (#1021). The
|
|
// application deletes children explicitly anyway
|
|
// (see MenuRepository.delete), so the FK is no longer
|
|
// load-bearing.
|
|
const rows = await sql<{ table: string }>`
|
|
PRAGMA foreign_key_list(_emdash_menu_items)
|
|
`.execute(db);
|
|
expect(rows.rows).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("getMenus", () => {
|
|
it("should return empty array when no menus exist", async () => {
|
|
const menus = await getMenusWithDb(db);
|
|
expect(menus).toEqual([]);
|
|
});
|
|
|
|
it("should return all menus ordered by name", async () => {
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values([
|
|
{ id: ulid(), name: "footer", label: "Footer Links" },
|
|
{ id: ulid(), name: "primary", label: "Primary Navigation" },
|
|
{ id: ulid(), name: "social", label: "Social Links" },
|
|
])
|
|
.execute();
|
|
|
|
const menus = await getMenusWithDb(db);
|
|
expect(menus).toHaveLength(3);
|
|
expect(menus[0].name).toBe("footer");
|
|
expect(menus[1].name).toBe("primary");
|
|
expect(menus[2].name).toBe("social");
|
|
});
|
|
});
|
|
|
|
describe("getMenu", () => {
|
|
it("should return null for non-existent menu", async () => {
|
|
const menu = await getMenuWithDb("nonexistent", db);
|
|
expect(menu).toBeNull();
|
|
});
|
|
|
|
it("should return menu with empty items array", async () => {
|
|
const menuId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).toMatchObject({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
items: [],
|
|
});
|
|
});
|
|
|
|
it("should resolve custom URLs correctly", async () => {
|
|
const menuId = ulid();
|
|
const itemId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "https://github.com",
|
|
label: "GitHub",
|
|
target: "_blank",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0]).toMatchObject({
|
|
id: itemId,
|
|
label: "GitHub",
|
|
url: "https://github.com",
|
|
target: "_blank",
|
|
});
|
|
});
|
|
|
|
it("should sanitize dangerous URLs from the database", async () => {
|
|
const menuId = ulid();
|
|
const itemId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({ id: menuId, name: "primary", label: "Primary" })
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "javascript:alert(1)",
|
|
label: "XSS",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("#");
|
|
});
|
|
|
|
it("should sanitize data: URLs from the database", async () => {
|
|
const menuId = ulid();
|
|
const itemId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({ id: menuId, name: "primary", label: "Primary" })
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "data:text/html,<script>alert(1)</script>",
|
|
label: "XSS",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("#");
|
|
});
|
|
|
|
it("should sanitize vbscript: URLs from the database", async () => {
|
|
const menuId = ulid();
|
|
const itemId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({ id: menuId, name: "primary", label: "Primary" })
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "vbscript:MsgBox",
|
|
label: "XSS",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("#");
|
|
});
|
|
|
|
it("should skip items with deleted content references", async () => {
|
|
const menuId = ulid();
|
|
const itemId = ulid();
|
|
|
|
// Create menu with item referencing non-existent content
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "page",
|
|
reference_collection: "pages",
|
|
reference_id: "nonexistent",
|
|
label: "Deleted Page",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
// Item should be filtered out because the page doesn't exist
|
|
expect(menu!.items).toHaveLength(0);
|
|
});
|
|
|
|
it("should build nested tree structure", async () => {
|
|
const menuId = ulid();
|
|
const parentId = ulid();
|
|
const childId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
// Create parent item
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: parentId,
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/about",
|
|
label: "About",
|
|
})
|
|
.execute();
|
|
|
|
// Create child item
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: childId,
|
|
menu_id: menuId,
|
|
parent_id: parentId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/about/team",
|
|
label: "Team",
|
|
})
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].label).toBe("About");
|
|
expect(menu!.items[0].children).toHaveLength(1);
|
|
expect(menu!.items[0].children[0].label).toBe("Team");
|
|
});
|
|
|
|
it("should order items by sort_order", async () => {
|
|
const menuId = ulid();
|
|
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: menuId,
|
|
name: "primary",
|
|
label: "Primary Navigation",
|
|
})
|
|
.execute();
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values([
|
|
{
|
|
id: ulid(),
|
|
menu_id: menuId,
|
|
sort_order: 2,
|
|
type: "custom",
|
|
custom_url: "/contact",
|
|
label: "Contact",
|
|
},
|
|
{
|
|
id: ulid(),
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/home",
|
|
label: "Home",
|
|
},
|
|
{
|
|
id: ulid(),
|
|
menu_id: menuId,
|
|
sort_order: 1,
|
|
type: "custom",
|
|
custom_url: "/about",
|
|
label: "About",
|
|
},
|
|
])
|
|
.execute();
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(3);
|
|
expect(menu!.items[0].label).toBe("Home");
|
|
expect(menu!.items[1].label).toBe("About");
|
|
expect(menu!.items[2].label).toBe("Contact");
|
|
});
|
|
});
|
|
|
|
describe("collection menu items", () => {
|
|
// The admin content picker stores entries from custom collections
|
|
// (anything that isn't pages/posts) as type "collection" with
|
|
// referenceCollection + referenceId. The resolver used to ignore the
|
|
// reference_id for this type and emit the archive URL, so picking
|
|
// "Widget Co" from a "projects" collection produced a menu link to
|
|
// /projects/ instead of the entry.
|
|
|
|
async function setupProjectsCollection(urlPattern?: string): Promise<void> {
|
|
const registry = new SchemaRegistry(db);
|
|
await registry.createCollection({
|
|
slug: "projects",
|
|
label: "Projects",
|
|
labelSingular: "Project",
|
|
urlPattern,
|
|
});
|
|
}
|
|
|
|
async function insertMenuWithCollectionItem(item: {
|
|
referenceCollection?: string | null;
|
|
referenceId?: string | null;
|
|
}): Promise<void> {
|
|
const menuId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({ id: menuId, name: "primary", label: "Primary" })
|
|
.execute();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: ulid(),
|
|
menu_id: menuId,
|
|
sort_order: 0,
|
|
type: "collection",
|
|
reference_collection: item.referenceCollection ?? null,
|
|
reference_id: item.referenceId ?? null,
|
|
label: "Projects",
|
|
})
|
|
.execute();
|
|
}
|
|
|
|
it("resolves entry references from the content picker to the entry URL", async () => {
|
|
await setupProjectsCollection();
|
|
await sql`
|
|
INSERT INTO ec_projects (id, slug, locale, translation_group)
|
|
VALUES ('proj-1', 'widget-co', 'en', 'group-proj-1')
|
|
`.execute(db);
|
|
await insertMenuWithCollectionItem({
|
|
referenceCollection: "projects",
|
|
referenceId: "group-proj-1",
|
|
});
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("/projects/widget-co");
|
|
});
|
|
|
|
it("applies the collection url_pattern to entry references", async () => {
|
|
await setupProjectsCollection("/work/{slug}");
|
|
await sql`
|
|
INSERT INTO ec_projects (id, slug, locale, translation_group)
|
|
VALUES ('proj-1', 'widget-co', 'en', 'group-proj-1')
|
|
`.execute(db);
|
|
await insertMenuWithCollectionItem({
|
|
referenceCollection: "projects",
|
|
referenceId: "group-proj-1",
|
|
});
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("/work/widget-co");
|
|
});
|
|
|
|
it("resolves items without a reference_id to the collection archive URL", async () => {
|
|
// Archive links (no entry reference) keep their root-relative
|
|
// /{collection}/ shape — the same URL shape as every other
|
|
// same-site item type.
|
|
await setupProjectsCollection();
|
|
await insertMenuWithCollectionItem({ referenceCollection: "projects" });
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(1);
|
|
expect(menu!.items[0].url).toBe("/projects/");
|
|
});
|
|
|
|
it("skips entry references whose content row no longer exists", async () => {
|
|
// Same contract as page/post items: an unresolvable reference
|
|
// hides the item instead of rendering a dead link.
|
|
await setupProjectsCollection();
|
|
await insertMenuWithCollectionItem({
|
|
referenceCollection: "projects",
|
|
referenceId: "group-gone",
|
|
});
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(0);
|
|
});
|
|
|
|
it("skips items with no reference_collection instead of linking /null/", async () => {
|
|
await insertMenuWithCollectionItem({});
|
|
|
|
const menu = await getMenuWithDb("primary", db);
|
|
expect(menu).not.toBeNull();
|
|
expect(menu!.items).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("menu item URL validation", () => {
|
|
it("should reject javascript: URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "XSS",
|
|
customUrl: "javascript:alert(1)",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should reject data: URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "XSS",
|
|
customUrl: "data:text/html,<script>alert(1)</script>",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should reject vbscript: URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "XSS",
|
|
customUrl: "vbscript:MsgBox",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should allow https URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Link",
|
|
customUrl: "https://example.com",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("should allow relative paths", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Link",
|
|
customUrl: "/about",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("should allow fragment links", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Link",
|
|
customUrl: "#section",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("should reject case-varied javascript: URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "XSS",
|
|
customUrl: "JAVASCRIPT:alert(1)",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should allow mailto URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Email",
|
|
customUrl: "mailto:user@example.com",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("should reject javascript: in update schema", () => {
|
|
const result = updateMenuItemBody.safeParse({
|
|
customUrl: "javascript:alert(1)",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should allow tel: URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Call",
|
|
customUrl: "tel:+15551234567",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("should reject empty string URLs", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Link",
|
|
customUrl: "",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("should trim whitespace before validating", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "Link",
|
|
customUrl: " https://example.com ",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.customUrl).toBe("https://example.com");
|
|
}
|
|
});
|
|
|
|
it("should reject whitespace-prefixed javascript: after trim", () => {
|
|
const result = createMenuItemBody.safeParse({
|
|
type: "custom",
|
|
label: "XSS",
|
|
customUrl: " javascript:alert(1)",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sanitizeHref", () => {
|
|
it("should return # for null input", () => {
|
|
expect(sanitizeHref(null)).toBe("#");
|
|
});
|
|
|
|
it("should return # for undefined input", () => {
|
|
expect(sanitizeHref(undefined)).toBe("#");
|
|
});
|
|
});
|
|
|
|
describe("handleMenuSetItems", () => {
|
|
// The MCP boundary uses Zod with `.nonnegative()` so callers can't
|
|
// pass a negative `parentIndex` from there. Direct handler callers
|
|
// (REST routes, future programmatic users) bypass that guard, so
|
|
// the handler enforces the same constraint.
|
|
|
|
async function setupMenu(name: string, locale = "en"): Promise<string> {
|
|
const id = ulid();
|
|
await db.insertInto("_emdash_menus").values({ id, name, label: name, locale }).execute();
|
|
return id;
|
|
}
|
|
|
|
it("rejects negative parentIndex", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
await setupMenu("main");
|
|
const result = await handleMenuSetItems(db, "main", [
|
|
{ label: "A", type: "custom", customUrl: "/a", parentIndex: -1 },
|
|
]);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("VALIDATION_ERROR");
|
|
expect(result.error?.message).toMatch(/parentIndex/);
|
|
});
|
|
|
|
it("rejects parentIndex >= current index (forward reference)", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
await setupMenu("main");
|
|
const result = await handleMenuSetItems(db, "main", [
|
|
{ label: "A", type: "custom", customUrl: "/a" },
|
|
{ label: "B", type: "custom", customUrl: "/b", parentIndex: 5 },
|
|
]);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("VALIDATION_ERROR");
|
|
expect(result.error?.message).toMatch(/parentIndex/);
|
|
});
|
|
|
|
it("returns NOT_FOUND for missing menu and leaves unrelated items untouched", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
// Seed a real menu with items so the rollback assertion has
|
|
// something to potentially clobber. A regression where the
|
|
// handler deleted ALL items before the existence check (the
|
|
// shape of the bug we want to guard against) would wipe these.
|
|
const otherMenuId = await setupMenu("real");
|
|
const otherItemId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: otherItemId,
|
|
menu_id: otherMenuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/x",
|
|
label: "X",
|
|
})
|
|
.execute();
|
|
|
|
const result = await handleMenuSetItems(db, "ghost", [
|
|
{ label: "A", type: "custom", customUrl: "/a" },
|
|
]);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("NOT_FOUND");
|
|
|
|
// Unrelated menu's item survives — confirms the transaction
|
|
// rolled back (or never started its destructive phase).
|
|
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]?.id).toBe(otherItemId);
|
|
expect(items[0]?.menu_id).toBe(otherMenuId);
|
|
});
|
|
|
|
it("includes locale in NOT_FOUND when a locale-scoped lookup misses", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
const result = await handleMenuSetItems(
|
|
db,
|
|
"main",
|
|
[{ label: "Accueil", type: "custom", customUrl: "/fr" }],
|
|
{ locale: "fr-fr" },
|
|
);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("NOT_FOUND");
|
|
expect(result.error?.message).toContain("fr-fr");
|
|
});
|
|
|
|
it("returns AMBIGUOUS_LOCALE when locale is omitted on a multi-locale install", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
// Two menus with the same name in different locales — exactly the
|
|
// shape that used to silently let the wrong translation be rewritten.
|
|
await setupMenu("main", "en");
|
|
await setupMenu("main", "fr-fr");
|
|
|
|
const result = await handleMenuSetItems(db, "main", [
|
|
{ label: "Whatever", type: "custom", customUrl: "/" },
|
|
]);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
|
|
// Both locales surface in the message so callers can recover.
|
|
expect(result.error?.message).toContain("en");
|
|
expect(result.error?.message).toContain("fr-fr");
|
|
expect(result.error?.message).toMatch(/multiple locales/);
|
|
|
|
// Transaction must have rolled back — no items written to either menu.
|
|
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
|
|
expect(items).toEqual([]);
|
|
});
|
|
|
|
it("targets the requested locale and tags inserted items with the menu locale", async () => {
|
|
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
const enMenuId = await setupMenu("main", "en");
|
|
const frMenuId = await setupMenu("main", "fr-fr");
|
|
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values([
|
|
{
|
|
id: ulid(),
|
|
menu_id: enMenuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/en-home",
|
|
label: "Home",
|
|
locale: "en",
|
|
},
|
|
{
|
|
id: ulid(),
|
|
menu_id: frMenuId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/fr-ancien",
|
|
label: "Ancien",
|
|
locale: "fr-fr",
|
|
},
|
|
])
|
|
.execute();
|
|
|
|
const result = await handleMenuSetItems(
|
|
db,
|
|
"main",
|
|
[
|
|
{ label: "Accueil", type: "custom", customUrl: "/fr" },
|
|
{ label: "Guides", type: "custom", customUrl: "/fr/guides", parentIndex: 0 },
|
|
],
|
|
{ locale: "fr-fr" },
|
|
);
|
|
expect(result.success).toBe(true);
|
|
|
|
// The EN menu must remain untouched; otherwise the locale-aware
|
|
// lookup regressed back to "first matching row wins".
|
|
const enItems = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.select(["label", "custom_url", "locale"])
|
|
.where("menu_id", "=", enMenuId)
|
|
.orderBy("sort_order", "asc")
|
|
.execute();
|
|
expect(enItems).toEqual([{ label: "Home", custom_url: "/en-home", locale: "en" }]);
|
|
|
|
const frItems = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.select(["label", "custom_url", "locale", "parent_id"])
|
|
.where("menu_id", "=", frMenuId)
|
|
.orderBy("sort_order", "asc")
|
|
.execute();
|
|
expect(frItems).toHaveLength(2);
|
|
expect(frItems[0]?.label).toBe("Accueil");
|
|
expect(frItems[0]?.custom_url).toBe("/fr");
|
|
expect(frItems[0]?.locale).toBe("fr-fr");
|
|
expect(frItems[0]?.parent_id).toBeNull();
|
|
expect(frItems[1]?.label).toBe("Guides");
|
|
expect(frItems[1]?.custom_url).toBe("/fr/guides");
|
|
expect(frItems[1]?.locale).toBe("fr-fr");
|
|
expect(frItems[1]?.parent_id).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("item handlers — multi-locale ambiguity", () => {
|
|
// Item-level handlers (create/update/delete/reorder) had the same
|
|
// `name`-only lookup as the menu-level handlers, so they used to
|
|
// silently target an arbitrary translation on multi-locale installs.
|
|
// These tests pin down the AMBIGUOUS_LOCALE behavior so a future
|
|
// regression to "first matching row wins" gets caught.
|
|
|
|
async function setupTwoLocales(name = "main") {
|
|
const enId = ulid();
|
|
const frId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values([
|
|
{ id: enId, name, label: name, locale: "en" },
|
|
{ id: frId, name, label: name, locale: "fr-fr" },
|
|
])
|
|
.execute();
|
|
return { enId, frId };
|
|
}
|
|
|
|
it("handleMenuItemCreate: AMBIGUOUS_LOCALE when locale omitted", async () => {
|
|
const { handleMenuItemCreate } = await import("../../../src/api/handlers/menus.js");
|
|
await setupTwoLocales();
|
|
|
|
const result = await handleMenuItemCreate(db, "main", {
|
|
type: "custom",
|
|
label: "Home",
|
|
customUrl: "/",
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
|
|
expect(result.error?.message).toContain("en");
|
|
expect(result.error?.message).toContain("fr-fr");
|
|
|
|
// No item written to either menu.
|
|
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
|
|
expect(items).toEqual([]);
|
|
});
|
|
|
|
it("handleMenuItemUpdate: AMBIGUOUS_LOCALE when locale omitted", async () => {
|
|
const { handleMenuItemUpdate } = await import("../../../src/api/handlers/menus.js");
|
|
const { enId } = await setupTwoLocales();
|
|
// Seed an item on the EN menu so the update would otherwise resolve.
|
|
const itemId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: enId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/",
|
|
label: "Home",
|
|
locale: "en",
|
|
})
|
|
.execute();
|
|
|
|
const result = await handleMenuItemUpdate(db, "main", itemId, { label: "New" });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
|
|
|
|
// Label untouched.
|
|
const after = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.select("label")
|
|
.where("id", "=", itemId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(after.label).toBe("Home");
|
|
});
|
|
|
|
it("handleMenuItemDelete: AMBIGUOUS_LOCALE when locale omitted", async () => {
|
|
const { handleMenuItemDelete } = await import("../../../src/api/handlers/menus.js");
|
|
const { enId } = await setupTwoLocales();
|
|
const itemId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: enId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/",
|
|
label: "Home",
|
|
locale: "en",
|
|
})
|
|
.execute();
|
|
|
|
const result = await handleMenuItemDelete(db, "main", itemId);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
|
|
|
|
// Item still present.
|
|
const surviving = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.select("id")
|
|
.where("id", "=", itemId)
|
|
.executeTakeFirst();
|
|
expect(surviving).toBeDefined();
|
|
});
|
|
|
|
it("handleMenuItemReorder: AMBIGUOUS_LOCALE when locale omitted", async () => {
|
|
const { handleMenuItemReorder } = await import("../../../src/api/handlers/menus.js");
|
|
const { enId } = await setupTwoLocales();
|
|
const itemId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: itemId,
|
|
menu_id: enId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/",
|
|
label: "Home",
|
|
locale: "en",
|
|
})
|
|
.execute();
|
|
|
|
const result = await handleMenuItemReorder(db, "main", [
|
|
{ id: itemId, parentId: null, sortOrder: 99 },
|
|
]);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
|
|
|
|
// sort_order untouched (not 99).
|
|
const after = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.select("sort_order")
|
|
.where("id", "=", itemId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(after.sort_order).toBe(0);
|
|
});
|
|
|
|
it("handleMenuItemCreate: with locale, succeeds and tags item locale", async () => {
|
|
// Sanity check that the disambiguated path still works.
|
|
const { handleMenuItemCreate } = await import("../../../src/api/handlers/menus.js");
|
|
await setupTwoLocales();
|
|
|
|
const result = await handleMenuItemCreate(
|
|
db,
|
|
"main",
|
|
{ type: "custom", label: "Accueil", customUrl: "/fr" },
|
|
{ locale: "fr-fr" },
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.locale).toBe("fr-fr");
|
|
});
|
|
});
|
|
|
|
describe("handleMenuCreate (translationOf)", () => {
|
|
it("rejects translationOf without locale (handler-level guard)", async () => {
|
|
const { handleMenuCreate } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
// Seed a source menu so the call would otherwise be valid.
|
|
const sourceId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({ id: sourceId, name: "primary", label: "Primary", locale: "en" })
|
|
.execute();
|
|
|
|
const result = await handleMenuCreate(db, {
|
|
name: "primary",
|
|
label: "Principal",
|
|
translationOf: sourceId,
|
|
// locale intentionally omitted
|
|
});
|
|
|
|
// Previously this guard lived only at the MCP boundary, so REST/SDK
|
|
// callers could clone into the default locale by accident. The
|
|
// handler now refuses and the MCP layer relies on this check.
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("VALIDATION_ERROR");
|
|
expect(result.error?.message).toMatch(/locale/i);
|
|
expect(result.error?.message).toMatch(/translationOf/i);
|
|
|
|
// No row created.
|
|
const created = await db
|
|
.selectFrom("_emdash_menus")
|
|
.selectAll()
|
|
.where("name", "=", "primary")
|
|
.execute();
|
|
expect(created).toHaveLength(1);
|
|
expect(created[0]?.id).toBe(sourceId);
|
|
});
|
|
|
|
it("clones items inheriting the source's translation_group", async () => {
|
|
const { handleMenuCreate } = await import("../../../src/api/handlers/menus.js");
|
|
|
|
const sourceId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menus")
|
|
.values({
|
|
id: sourceId,
|
|
name: "primary",
|
|
label: "Primary",
|
|
locale: "en",
|
|
translation_group: sourceId,
|
|
})
|
|
.execute();
|
|
const sourceItemId = ulid();
|
|
await db
|
|
.insertInto("_emdash_menu_items")
|
|
.values({
|
|
id: sourceItemId,
|
|
menu_id: sourceId,
|
|
sort_order: 0,
|
|
type: "custom",
|
|
custom_url: "/",
|
|
label: "Home",
|
|
locale: "en",
|
|
translation_group: sourceItemId,
|
|
})
|
|
.execute();
|
|
|
|
const result = await handleMenuCreate(db, {
|
|
name: "primary",
|
|
label: "Principal",
|
|
locale: "es",
|
|
translationOf: sourceId,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
|
|
const cloned = await db
|
|
.selectFrom("_emdash_menu_items")
|
|
.selectAll()
|
|
.where("locale", "=", "es")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
// Cloned item inherits source's translation_group so EN/ES rows
|
|
// identify as the same logical nav entry across translations.
|
|
expect(cloned.id).not.toBe(sourceItemId);
|
|
expect(cloned.translation_group).toBe(sourceItemId);
|
|
});
|
|
});
|
|
});
|