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

103 lines
3.7 KiB
TypeScript

/**
* Auth for the plugin API catch-all (`/_emdash/api/plugins/{id}/*`).
*
* Regression coverage for #1853: because plugin routes dispatch by name only
* (the HTTP method never selects a different handler), a route reached via GET
* or HEAD runs the same code as one reached via POST. The route must not tier
* permission or CSRF by method — every private invocation needs
* `plugins:manage` and the CSRF header, so an editor or a cross-origin HEAD
* can't invoke a mutating admin route by choosing the method.
*/
import type { RoleLevel } from "@emdash-cms/auth";
import { Role } from "@emdash-cms/auth";
import type { APIRoute } from "astro";
import { describe, expect, it, vi } from "vitest";
import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js";
function createLocals(role: RoleLevel | null, routePublic: boolean) {
const handlePluginApiRoute = vi.fn(async () => ({ success: true, data: { ok: true } }));
return {
locals: {
user: role == null ? null : { id: "u1", role },
emdash: {
handlePluginApiRoute,
getPluginRouteMeta: () => ({ public: routePublic }),
},
},
handlePluginApiRoute,
};
}
function invoke(
handler: APIRoute,
method: string,
locals: unknown,
{ csrf = true }: { csrf?: boolean } = {},
) {
const headers = new Headers();
if (csrf) headers.set("X-EmDash-Request", "1");
const request = new Request("https://example.com/_emdash/api/plugins/demo/updateHomeConfig", {
method,
headers,
});
// The catch-all reads params.pluginId / params.path and locals.
return handler({
params: { pluginId: "demo", path: "updateHomeConfig" },
request,
locals,
} as never);
}
describe("plugin API catch-all auth (#1853)", () => {
it("does not run a private route for an editor via GET", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
const res = await invoke(GET, "GET", locals);
expect(res.status).toBe(403);
expect(handlePluginApiRoute).not.toHaveBeenCalled();
});
it("does not run a private route for an editor via HEAD (dispatched to GET export)", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
const res = await invoke(GET, "HEAD", locals);
expect(res.status).toBe(403);
expect(handlePluginApiRoute).not.toHaveBeenCalled();
});
it("still blocks an editor via POST", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
const res = await invoke(POST, "POST", locals);
expect(res.status).toBe(403);
expect(handlePluginApiRoute).not.toHaveBeenCalled();
});
it("rejects a private GET without the CSRF header even for an admin", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
const res = await invoke(GET, "GET", locals, { csrf: false });
expect(res.status).toBe(403);
expect(handlePluginApiRoute).not.toHaveBeenCalled();
});
it("allows an admin with the CSRF header (GET)", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
const res = await invoke(GET, "GET", locals);
expect(res.status).toBe(200);
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
});
it("allows an admin with the CSRF header (POST)", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
const res = await invoke(POST, "POST", locals);
expect(res.status).toBe(200);
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
});
it("leaves public routes reachable without auth or CSRF", async () => {
const { locals, handlePluginApiRoute } = createLocals(null, true);
const res = await invoke(GET, "GET", locals, { csrf: false });
expect(res.status).toBe(200);
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
});
});