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
322 lines
9.7 KiB
TypeScript
322 lines
9.7 KiB
TypeScript
import { Role } from "@emdash-cms/auth";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
import { PUT as updateContent } from "../../../src/astro/routes/api/content/[collection]/[id].js";
|
|
import { POST as createContent } from "../../../src/astro/routes/api/content/[collection]/index.js";
|
|
|
|
/**
|
|
* Regression tests for the `publishedAt` / `createdAt` permission gate.
|
|
*
|
|
* The gate must trigger on *any* explicit presence of these fields —
|
|
* including `null` (explicit clear) — not just on non-null values. Checking
|
|
* only `!= null` would let a regular AUTHOR clear `published_at` on any item
|
|
* they can edit, bypassing `content:publish_any`.
|
|
*/
|
|
describe("content route — publishedAt / createdAt permission gate", () => {
|
|
const makeUser = (role: (typeof Role)[keyof typeof Role]) => ({
|
|
id: "user-1",
|
|
role,
|
|
});
|
|
|
|
const makeCache = () => ({ enabled: false, invalidate: vi.fn() });
|
|
|
|
describe("POST /_emdash/api/content/{collection}", () => {
|
|
it("returns 403 when an AUTHOR tries to set publishedAt", async () => {
|
|
const request = new Request("http://localhost/_emdash/api/content/post", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
data: { title: "Hi" },
|
|
publishedAt: "2019-03-15T10:30:00.000Z",
|
|
}),
|
|
});
|
|
|
|
const response = await createContent({
|
|
params: { collection: "post" },
|
|
request,
|
|
locals: {
|
|
emdash: {
|
|
handleContentCreate: vi.fn(),
|
|
handleContentGet: vi.fn(),
|
|
},
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof createContent>[0]);
|
|
|
|
expect(response.status).toBe(403);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: { code: "FORBIDDEN" },
|
|
});
|
|
});
|
|
|
|
it("returns 403 when an AUTHOR tries to clear publishedAt via null", async () => {
|
|
const request = new Request("http://localhost/_emdash/api/content/post", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data: { title: "Hi" }, publishedAt: null }),
|
|
});
|
|
|
|
const response = await createContent({
|
|
params: { collection: "post" },
|
|
request,
|
|
locals: {
|
|
emdash: {
|
|
handleContentCreate: vi.fn(),
|
|
handleContentGet: vi.fn(),
|
|
},
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof createContent>[0]);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("returns 403 when an AUTHOR tries to set createdAt", async () => {
|
|
const request = new Request("http://localhost/_emdash/api/content/post", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
data: { title: "Hi" },
|
|
createdAt: "2019-03-15T10:30:00.000Z",
|
|
}),
|
|
});
|
|
|
|
const response = await createContent({
|
|
params: { collection: "post" },
|
|
request,
|
|
locals: {
|
|
emdash: {
|
|
handleContentCreate: vi.fn(),
|
|
handleContentGet: vi.fn(),
|
|
},
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof createContent>[0]);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("lets EDITOR set publishedAt", async () => {
|
|
const handleContentCreate = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
item: { id: "c1", publishedAt: "2019-03-15T10:30:00.000Z" },
|
|
_rev: "rev1",
|
|
},
|
|
});
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
data: { title: "Hi" },
|
|
publishedAt: "2019-03-15T10:30:00.000Z",
|
|
}),
|
|
});
|
|
|
|
const response = await createContent({
|
|
params: { collection: "post" },
|
|
request,
|
|
locals: {
|
|
emdash: { handleContentCreate, handleContentGet: vi.fn() },
|
|
user: makeUser(Role.EDITOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof createContent>[0]);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(handleContentCreate).toHaveBeenCalledWith(
|
|
"post",
|
|
expect.objectContaining({ publishedAt: "2019-03-15T10:30:00.000Z" }),
|
|
);
|
|
});
|
|
|
|
it("lets AUTHOR create without date overrides", async () => {
|
|
const handleContentCreate = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: { item: { id: "c1" }, _rev: "rev1" },
|
|
});
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data: { title: "Hi" } }),
|
|
});
|
|
|
|
const response = await createContent({
|
|
params: { collection: "post" },
|
|
request,
|
|
locals: {
|
|
emdash: { handleContentCreate, handleContentGet: vi.fn() },
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof createContent>[0]);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(handleContentCreate).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("PUT /_emdash/api/content/{collection}/{id}", () => {
|
|
const ownedItem = {
|
|
success: true,
|
|
data: { item: { id: "c1", authorId: "user-1" }, _rev: "rev1" },
|
|
};
|
|
|
|
it("returns 403 when an AUTHOR tries to clear publishedAt via null on their own post", async () => {
|
|
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
|
|
const handleContentUpdate = vi.fn();
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ publishedAt: null }),
|
|
});
|
|
|
|
const response = await updateContent({
|
|
params: { collection: "post", id: "c1" },
|
|
request,
|
|
url: new URL(request.url),
|
|
locals: {
|
|
emdash: { handleContentUpdate, handleContentGet },
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof updateContent>[0]);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(handleContentUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 403 when an AUTHOR tries to set publishedAt on their own post", async () => {
|
|
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
|
|
const handleContentUpdate = vi.fn();
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ publishedAt: "2019-03-15T10:30:00.000Z" }),
|
|
});
|
|
|
|
const response = await updateContent({
|
|
params: { collection: "post", id: "c1" },
|
|
request,
|
|
url: new URL(request.url),
|
|
locals: {
|
|
emdash: { handleContentUpdate, handleContentGet },
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof updateContent>[0]);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(handleContentUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets EDITOR set publishedAt", async () => {
|
|
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
|
|
const handleContentUpdate = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
item: { id: "c1", publishedAt: "2019-03-15T10:30:00.000Z" },
|
|
_rev: "rev2",
|
|
},
|
|
});
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ publishedAt: "2019-03-15T10:30:00.000Z" }),
|
|
});
|
|
|
|
const response = await updateContent({
|
|
params: { collection: "post", id: "c1" },
|
|
request,
|
|
url: new URL(request.url),
|
|
locals: {
|
|
emdash: { handleContentUpdate, handleContentGet },
|
|
user: makeUser(Role.EDITOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof updateContent>[0]);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handleContentUpdate).toHaveBeenCalledWith(
|
|
"post",
|
|
"c1",
|
|
expect.objectContaining({ publishedAt: "2019-03-15T10:30:00.000Z" }),
|
|
);
|
|
});
|
|
|
|
it("lets AUTHOR update their own post without date overrides", async () => {
|
|
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
|
|
const handleContentUpdate = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: { item: { id: "c1" }, _rev: "rev2" },
|
|
});
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data: { title: "Edited" } }),
|
|
});
|
|
|
|
const response = await updateContent({
|
|
params: { collection: "post", id: "c1" },
|
|
request,
|
|
url: new URL(request.url),
|
|
locals: {
|
|
emdash: { handleContentUpdate, handleContentGet },
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof updateContent>[0]);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handleContentUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes locale query through slug ownership lookup and update", async () => {
|
|
const handleContentGet = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: { item: { id: "fr-id", authorId: "user-1" }, _rev: "rev1" },
|
|
});
|
|
const handleContentUpdate = vi.fn().mockResolvedValue({
|
|
success: true,
|
|
data: { item: { id: "fr-id" }, _rev: "rev2" },
|
|
});
|
|
|
|
const request = new Request("http://localhost/_emdash/api/content/post/shared?locale=fr", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data: { title: "FR" } }),
|
|
});
|
|
|
|
const response = await updateContent({
|
|
params: { collection: "post", id: "shared" },
|
|
request,
|
|
locals: {
|
|
emdash: { handleContentUpdate, handleContentGet },
|
|
user: makeUser(Role.AUTHOR),
|
|
},
|
|
cache: makeCache(),
|
|
} as Parameters<typeof updateContent>[0]);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handleContentGet).toHaveBeenCalledWith("post", "shared", "fr");
|
|
expect(handleContentUpdate).toHaveBeenCalledWith(
|
|
"post",
|
|
"fr-id",
|
|
expect.objectContaining({
|
|
data: { title: "FR" },
|
|
locale: "fr",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|