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
659 lines
23 KiB
TypeScript
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);
|
|
});
|
|
});
|