diff --git a/src/features/housekeeping/foundation/authorization.test.ts b/src/features/housekeeping/foundation/authorization.test.ts new file mode 100644 index 00000000..13c31458 --- /dev/null +++ b/src/features/housekeeping/foundation/authorization.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { authorizeHousekeeping } from "./authorization"; +import { anyCapability, type HousekeepingCapabilityContext } from "./contracts"; + +function capabilityContext( + granted: readonly string[], + rank = 7, +): HousekeepingCapabilityContext { + return { + actor: { id: 42, username: "operator", rank }, + isSuperAdmin: false, + has: (slug) => granted.includes(slug), + hasAny: (...slugs) => slugs.some((slug) => granted.includes(slug)), + hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)), + }; +} + +describe("authorizeHousekeeping", () => { + it("returns the authorized capability context", () => { + const context = capabilityContext(["admin.users.view"]); + + expect( + authorizeHousekeeping(context, anyCapability("admin.users.view")), + ).toMatchObject({ ok: true, data: context }); + }); + + it("returns FORBIDDEN for a missing capability regardless of rank", () => { + const result = authorizeHousekeeping( + capabilityContext([], 999), + anyCapability("admin.users.view"), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "FORBIDDEN", + messageKey: "errors.housekeeping.forbidden", + }, + }); + }); +}); diff --git a/src/features/housekeeping/foundation/authorization.ts b/src/features/housekeeping/foundation/authorization.ts new file mode 100644 index 00000000..45149f4d --- /dev/null +++ b/src/features/housekeeping/foundation/authorization.ts @@ -0,0 +1,19 @@ +import { satisfiesCapability } from "./capability-context"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, + HousekeepingResult, +} from "./contracts"; +import { fail, ok } from "./contracts"; +import { createCorrelationId } from "./correlation"; + +export function authorizeHousekeeping( + context: HousekeepingCapabilityContext, + requirement: CapabilityRequirement, +): HousekeepingResult { + const correlationId = createCorrelationId(); + + return satisfiesCapability(context, requirement) + ? ok(context, correlationId) + : fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId); +} diff --git a/src/features/housekeeping/foundation/server-capability-context.test.ts b/src/features/housekeeping/foundation/server-capability-context.test.ts index 4cfb8641..d83a7671 100644 --- a/src/features/housekeeping/foundation/server-capability-context.test.ts +++ b/src/features/housekeeping/foundation/server-capability-context.test.ts @@ -1,14 +1,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { auth, getAdminContext, staleActor } = vi.hoisted(() => ({ - auth: vi.fn(), - getAdminContext: vi.fn(), - staleActor: { id: 9, username: "stale-session", rank: 1 }, -})); +const { auth, getAdminContext, resetRequestCache, staleActor, wrapCache } = + vi.hoisted(() => { + const resetters: (() => void)[] = []; -vi.mock("react", () => ({ - cache: unknown>(callback: T) => callback, -})); + return { + auth: vi.fn(), + getAdminContext: vi.fn(), + resetRequestCache: () => { + resetters.forEach((reset) => { + reset(); + }); + }, + staleActor: { id: 9, username: "stale-session", rank: 1 }, + wrapCache: (callback: () => Promise) => { + let value: Promise | undefined; + resetters.push(() => { + value = undefined; + }); + return () => (value ??= callback()); + }, + }; + }); + +vi.mock("react", () => ({ cache: wrapCache })); vi.mock("@/lib/auth", () => ({ auth })); vi.mock("@/lib/db", () => { @@ -16,11 +31,14 @@ vi.mock("@/lib/db", () => { }); vi.mock("@/lib/permissions", () => ({ getAdminContext })); +import { requireHousekeepingCapability } from "@/lib/admin/guard"; import { auth as mockedAuth } from "@/lib/auth"; +import { anyCapability } from "./contracts"; import { getHousekeepingCapabilityContext } from "./server-capability-context"; describe("getHousekeepingCapabilityContext", () => { beforeEach(() => { + resetRequestCache(); vi.clearAllMocks(); auth.mockResolvedValue({ user: staleActor }); getAdminContext.mockResolvedValue({ @@ -53,4 +71,12 @@ describe("getHousekeepingCapabilityContext", () => { expect(getAdminContext).toHaveBeenCalledTimes(1); expect(mockedAuth).not.toHaveBeenCalled(); }); + + it("loads the capability context once for shell, page, and command preflight", async () => { + await getHousekeepingCapabilityContext(); + await getHousekeepingCapabilityContext(); + await requireHousekeepingCapability(anyCapability("admin.users.view")); + + expect(getAdminContext).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/admin/guard.test.ts b/src/lib/admin/guard.test.ts index 2f23332e..af4690c2 100644 --- a/src/lib/admin/guard.test.ts +++ b/src/lib/admin/guard.test.ts @@ -1,9 +1,14 @@ // @ts-nocheck import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + anyCapability, + type HousekeepingCapabilityContext, +} from "@/features/housekeeping/foundation/contracts"; import { redirectSafe } from "@/lib/foundation/security"; import { canAccess, getAdminContext } from "@/lib/permissions"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { + requireHousekeepingCapability, requireMod, requireModPermission, requirePermission, @@ -70,6 +75,23 @@ describe("requirePermission", () => { }); }); +describe("requireHousekeepingCapability", () => { + it("reuses an already-created capability context without reloading permissions", async () => { + const context: HousekeepingCapabilityContext = { + actor: { id: 1, username: "admin", rank: 0 }, + isSuperAdmin: false, + has: (slug) => slug === "admin.users.view", + hasAny: (...slugs) => slugs.includes("admin.users.view"), + hasAll: (...slugs) => slugs.every((slug) => slug === "admin.users.view"), + }; + + await expect( + requireHousekeepingCapability(anyCapability("admin.users.view"), context), + ).resolves.toBe(context); + expect(getAdminContext).not.toHaveBeenCalled(); + }); +}); + describe("requireMod", () => { it("allows with MOD_DASHBOARD", async () => { vi.mocked(getAdminContext).mockResolvedValue({ diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 51fc1a54..00bb6e72 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -1,3 +1,9 @@ +import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "@/features/housekeeping/foundation/contracts"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; import { redirectSafe } from "@/lib/foundation/security"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { clientIp, rateLimit } from "@/lib/rate-limit"; @@ -7,6 +13,18 @@ export interface StaffUser { rank: number; username: string; } +export async function requireHousekeepingCapability( + requirement: CapabilityRequirement, + context?: HousekeepingCapabilityContext, +): Promise { + const capabilityContext = + context ?? (await getHousekeepingCapabilityContext()); + const authorization = authorizeHousekeeping(capabilityContext, requirement); + + if (!authorization.ok) redirectSafe("/admin", "/admin"); + + return capabilityContext; +} export async function requireStaff(): Promise { const { session, permissions } = await getAdminContext();