Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

659 lines
23 KiB
TypeScript

import { Role, type RoleLevel } from "@emdash-cms/auth";
import type { APIContext } from "astro";
import { afterEach, beforeEach, expect, it } from "vitest";
import {
handleReferenceChildrenGet,
handleReferenceChildrenSet,
handleReferenceParentsGet,
} from "../../../src/api/handlers/relations.js";
import {
GET as getChildren,
POST as setChildren,
} from "../../../src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.js";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import { RelationRepository } from "../../../src/database/repositories/relation.js";
import {
describeEachDialect,
setupForDialectWithCollections,
teardownForDialect,
type DialectTestContext,
} from "../../utils/test-db.js";
function edgeCtx(
db: unknown,
params: { collection: string; id: string; relation: string },
user: { id: string; role: RoleLevel },
init?: { method?: string; body?: unknown },
): APIContext {
const url = new URL(
`http://localhost/_emdash/api/content/${params.collection}/${params.id}/references/${params.relation}/children`,
);
const request = new Request(url, {
method: init?.method ?? "GET",
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
body: init?.body ? JSON.stringify(init.body) : undefined,
});
return { params, url, request, locals: { emdash: { db }, user } } as unknown as APIContext;
}
// setupForDialectWithCollections registers two collections: "post" and "page".
describeEachDialect("reference children handlers", (dialect) => {
let ctx: DialectTestContext;
beforeEach(async () => {
ctx = await setupForDialectWithCollections(dialect);
});
afterEach(async () => {
await teardownForDialect(ctx);
});
async function makeRelation() {
// post (parent) -> page (child)
const repo = new RelationRepository(ctx.db);
return repo.create({
name: "related_pages",
parentCollection: "post",
childCollection: "page",
parentLabel: "Post",
childLabel: "Related page",
});
}
it("set then get returns resolved child entries in order", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
const b = await content.create({ type: "page", slug: "b", data: { title: "B" } });
const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]);
expect(set.success).toBe(true);
if (!set.success) return;
expect(set.data.children.map((c) => c.slug)).toEqual(["a", "b"]);
expect(set.data.children.map((c) => c.sortOrder)).toEqual([0, 1]);
expect(set.data.children.every((c) => c.collection === "page")).toBe(true);
const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true);
if (!get.success) return;
expect(get.data.children.map((c) => c.slug)).toEqual(["a", "b"]);
});
it("resolved children carry their actual locale", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]);
if (!set.success) return;
expect(set.data.children[0]?.locale).toBe("en");
});
it("children GET paginates with a cursor", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
const b = await content.create({ type: "page", slug: "b", data: { title: "B" } });
const c = await content.create({ type: "page", slug: "c", data: { title: "C" } });
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id, c.id]);
const page1 = await handleReferenceChildrenGet(
ctx.db,
"post",
parent.id,
rel.id,
{ limit: 2 },
true,
);
if (!page1.success) return;
expect(page1.data.children.map((ref) => ref.slug)).toEqual(["a", "b"]);
expect(page1.data.nextCursor).toBeDefined();
const page2 = await handleReferenceChildrenGet(
ctx.db,
"post",
parent.id,
rel.id,
{ limit: 2, cursor: page1.data.nextCursor },
true,
);
if (!page2.success) return;
expect(page2.data.children.map((ref) => ref.slug)).toEqual(["c"]);
expect(page2.data.nextCursor).toBeUndefined();
});
it("parents GET paginates over an unbounded backlink set", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const shared = await content.create({ type: "page", slug: "shared", data: { title: "S" } });
// Three posts all reference the same page.
for (const slug of ["p1", "p2", "p3"]) {
const parent = await content.create({ type: "post", slug, data: { title: slug } });
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [shared.id]);
}
const page1 = await handleReferenceParentsGet(
ctx.db,
"page",
shared.id,
rel.id,
{ limit: 2 },
true,
);
if (!page1.success) return;
expect(page1.data.parents).toHaveLength(2);
expect(page1.data.nextCursor).toBeDefined();
const page2 = await handleReferenceParentsGet(
ctx.db,
"page",
shared.id,
rel.id,
{ limit: 2, cursor: page1.data.nextCursor },
true,
);
if (!page2.success) return;
expect(page2.data.parents).toHaveLength(1);
expect(page2.data.nextCursor).toBeUndefined();
});
it("an invalid pagination cursor is INVALID_CURSOR", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const result = await handleReferenceChildrenGet(
ctx.db,
"post",
parent.id,
rel.id,
{ cursor: "!!!not-a-cursor!!!" },
true,
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_CURSOR");
});
it("unknown relation is NOT_FOUND", async () => {
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, "nope");
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("NOT_FOUND");
});
it("entry on the wrong side (child collection) is VALIDATION_ERROR", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
// A "page" entry is the child side, not the parent — children route rejects it.
const page = await content.create({ type: "page", slug: "x", data: { title: "X" } });
const result = await handleReferenceChildrenGet(ctx.db, "page", page.id, rel.id);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
});
it("a child whose collection != child_collection is rejected", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
// Another post can't be a child (child_collection is "page").
const otherPost = await content.create({ type: "post", slug: "q", data: { title: "Q" } });
const result = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [
otherPost.id,
]);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("NOT_FOUND");
});
it("parents is the backlink view from the child side", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const child = await content.create({ type: "page", slug: "c", data: { title: "C" } });
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [child.id]);
const result = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id, {}, true);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.parents.map((p) => p.slug)).toEqual(["p"]);
expect(result.data.parents.every((p) => p.collection === "post")).toBe(true);
});
it("parents rejects an entry on the parent side", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const result = await handleReferenceParentsGet(ctx.db, "post", parent.id, rel.id);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
});
it("set replaces the whole child set (set [a,b] then [c] yields [c])", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
const b = await content.create({ type: "page", slug: "b", data: { title: "B" } });
const c = await content.create({ type: "page", slug: "c", data: { title: "C" } });
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]);
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [c.id]);
const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true);
if (!get.success) return;
expect(get.data.children.map((r) => r.slug)).toEqual(["c"]);
});
it("set with an empty list clears the child set", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]);
const cleared = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, []);
if (!cleared.success) return;
expect(cleared.data.children).toEqual([]);
const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true);
if (!get.success) return;
expect(get.data.children).toEqual([]);
});
it("set resolves a large mixed id/slug child set correctly (batched)", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const slugs = Array.from({ length: 60 }, (_, i) => `child-${i}`);
const ids: string[] = [];
for (const slug of slugs) {
const child = await content.create({
type: "page",
slug,
data: { title: slug },
status: "published",
});
ids.push(child.id);
}
// Mix ids and slugs in the request to exercise both resolution paths.
const childIds = ids.map((id, i) => (i % 2 === 0 ? id : slugs[i]!));
const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, childIds);
expect(set.success).toBe(true);
if (!set.success) return;
expect(set.data.children).toHaveLength(50); // first page
expect(set.data.nextCursor).toBeDefined();
// sort_order is positional over the full deduped set.
expect(set.data.children.map((r) => r.sortOrder)).toEqual(
Array.from({ length: 50 }, (_, i) => i),
);
});
it("a dangling edge (child deleted) is skipped, not surfaced as an error", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({
type: "page",
slug: "a",
data: { title: "A" },
status: "published",
});
const b = await content.create({
type: "page",
slug: "b",
data: { title: "B" },
status: "published",
});
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]);
// Hard-delete the underlying row so the edge dangles.
await ctx.db
.deleteFrom("ec_page" as never)
.where("id" as never, "=", a.id)
.execute();
const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true);
expect(get.success).toBe(true);
if (!get.success) return;
expect(get.data.children.map((r) => r.slug)).toEqual(["b"]);
});
it("a child collection dropped after the relation is created yields NOT_FOUND, not a 500", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
const a = await content.create({ type: "page", slug: "a", data: { title: "A" } });
// Collection deletion drops the content table but does not cascade to the
// relation def, so the relation still points at a now-missing child table.
// The write path must surface a structured NOT_FOUND rather than 500 —
// mirroring the read path, which tolerates the missing table.
await ctx.db.schema.dropTable("ec_page").ifExists().execute();
const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]);
expect(set.success).toBe(false);
if (set.success) return;
expect(set.error.code).toBe("NOT_FOUND");
});
it("a parent collection dropped after the relation is created yields NOT_FOUND, not a 500", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } });
// The anchor entry lives in the parent collection. If that table is dropped
// (collection deletion doesn't cascade to relation defs), resolving the
// anchor must yield NOT_FOUND rather than a 500 — the symmetric case to the
// child-collection drop above, and consistent across children and parents.
await ctx.db.schema.dropTable("ec_post").ifExists().execute();
const childrenGet = await handleReferenceChildrenGet(
ctx.db,
"post",
parent.id,
rel.id,
{},
true,
);
expect(childrenGet.success).toBe(false);
if (!childrenGet.success) expect(childrenGet.error.code).toBe("NOT_FOUND");
const childrenSet = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, []);
expect(childrenSet.success).toBe(false);
if (!childrenSet.success) expect(childrenSet.error.code).toBe("NOT_FOUND");
// The parents endpoint anchors on the child side; drop that table too.
await ctx.db.schema.dropTable("ec_page").ifExists().execute();
const parentsGet = await handleReferenceParentsGet(
ctx.db,
"page",
"whatever",
rel.id,
{},
true,
);
expect(parentsGet.success).toBe(false);
if (!parentsGet.success) expect(parentsGet.error.code).toBe("NOT_FOUND");
});
it("a hand-crafted cursor with a non-numeric order value is INVALID_CURSOR", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
status: "published",
});
// Base64 of {"orderValue":"x","id":"y"} — structurally valid, semantically bad.
const cursor = Buffer.from(JSON.stringify({ orderValue: "x", id: "y" })).toString("base64url");
const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { cursor });
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_CURSOR");
});
});
// The reference reads must honour the same draft-visibility boundary every other
// content read enforces: a caller without `content:read_drafts` may not see
// non-published entries — neither as resolved children/parents nor as the anchor.
describeEachDialect("reference reads: draft visibility", (dialect) => {
let ctx: DialectTestContext;
beforeEach(async () => {
ctx = await setupForDialectWithCollections(dialect);
});
afterEach(async () => {
await teardownForDialect(ctx);
});
async function makeRelation() {
return new RelationRepository(ctx.db).create({
name: "related_pages",
parentCollection: "post",
childCollection: "page",
parentLabel: "Post",
childLabel: "Related page",
});
}
it("hides a draft child from a caller without draft access", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
status: "published",
});
const published = await content.create({
type: "page",
slug: "pub",
data: { title: "Pub" },
status: "published",
});
const draft = await content.create({
type: "page",
slug: "secret-unpublished",
data: { title: "Draft" },
status: "draft",
});
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [published.id, draft.id]);
// includeDrafts=false: only the published child is visible.
const subscriber = await handleReferenceChildrenGet(
ctx.db,
"post",
parent.id,
rel.id,
{},
false,
);
if (!subscriber.success) return;
expect(subscriber.data.children.map((r) => r.slug)).toEqual(["pub"]);
// includeDrafts=true: both are visible.
const editor = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true);
if (!editor.success) return;
expect(
editor.data.children.map((r) => r.slug).toSorted((a, b) => (a ?? "").localeCompare(b ?? "")),
).toEqual(["pub", "secret-unpublished"]);
});
it("hides a draft parent (backlink) from a caller without draft access", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const child = await content.create({
type: "page",
slug: "c",
data: { title: "C" },
status: "published",
});
const publishedParent = await content.create({
type: "post",
slug: "pub-parent",
data: { title: "PP" },
status: "published",
});
const draftParent = await content.create({
type: "post",
slug: "draft-parent",
data: { title: "DP" },
status: "draft",
});
await handleReferenceChildrenSet(ctx.db, "post", publishedParent.id, rel.id, [child.id]);
await handleReferenceChildrenSet(ctx.db, "post", draftParent.id, rel.id, [child.id]);
const subscriber = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id, {}, false);
if (!subscriber.success) return;
expect(subscriber.data.parents.map((r) => r.slug)).toEqual(["pub-parent"]);
});
it("treats a draft anchor as NOT_FOUND for a caller without draft access", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const draftParent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
status: "draft",
});
const result = await handleReferenceChildrenGet(
ctx.db,
"post",
draftParent.id,
rel.id,
{},
false,
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("NOT_FOUND");
// A caller with draft access can still anchor on it.
const withDrafts = await handleReferenceChildrenGet(
ctx.db,
"post",
draftParent.id,
rel.id,
{},
true,
);
expect(withDrafts.success).toBe(true);
});
it("route: a SUBSCRIBER cannot read a draft anchor's children (404)", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const draftParent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
status: "draft",
});
const params = { collection: "post", id: draftParent.id, relation: rel.id };
const res = await getChildren(
edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }),
);
expect(res.status).toBe(404);
});
it("route: a SUBSCRIBER does not see draft children of a published anchor", async () => {
const rel = await makeRelation();
const content = new ContentRepository(ctx.db);
const parent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
status: "published",
});
const draft = await content.create({
type: "page",
slug: "secret-unpublished",
data: { title: "Draft" },
status: "draft",
});
await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [draft.id]);
const params = { collection: "post", id: parent.id, relation: rel.id };
const res = await getChildren(
edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { children: { slug: string }[] } };
expect(body.data.children).toEqual([]);
});
});
describeEachDialect("reference children route (auth + ownership)", (dialect) => {
let ctx: DialectTestContext;
beforeEach(async () => {
ctx = await setupForDialectWithCollections(dialect);
});
afterEach(async () => {
await teardownForDialect(ctx);
});
it("GET requires content:read; POST gates on parent ownership", async () => {
const repo = new RelationRepository(ctx.db);
const rel = await repo.create({
name: "related_pages",
parentCollection: "post",
childCollection: "page",
parentLabel: "Post",
childLabel: "Related page",
});
const content = new ContentRepository(ctx.db);
const parent = await content.create({
type: "post",
slug: "p",
data: { title: "P" },
authorId: "author-1",
status: "published",
});
const child = await content.create({ type: "page", slug: "c", data: { title: "C" } });
const params = { collection: "post", id: parent.id, relation: rel.id };
// A different AUTHOR cannot edit author-1's content.
const denied = await setChildren(
edgeCtx(
ctx.db,
params,
{ id: "author-2", role: Role.AUTHOR as RoleLevel },
{
method: "POST",
body: { childIds: [child.id] },
},
),
);
expect(denied.status).toBe(403);
// The owner can.
const ok = await setChildren(
edgeCtx(
ctx.db,
params,
{ id: "author-1", role: Role.AUTHOR as RoleLevel },
{
method: "POST",
body: { childIds: [child.id] },
},
),
);
expect(ok.status).toBe(200);
// Anyone with content:read can GET.
const read = await getChildren(
edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }),
);
expect(read.status).toBe(200);
});
it("POST gates the edit permission before the existence lookup (no oracle)", async () => {
const repo = new RelationRepository(ctx.db);
const rel = await repo.create({
name: "related_pages",
parentCollection: "post",
childCollection: "page",
parentLabel: "Post",
childLabel: "Related page",
});
// A SUBSCRIBER has no edit permission. Whether the parent id exists or not,
// they must get 403 — never a 404 that would reveal which ids are real.
const fake = edgeCtx(
ctx.db,
{ collection: "post", id: "does-not-exist", relation: rel.id },
{ id: "sub", role: Role.SUBSCRIBER as RoleLevel },
{ method: "POST", body: { childIds: [] } },
);
const res = await setChildren(fake);
expect(res.status).toBe(403);
});
});