refactor(housekeeping): reuse request capability context

This commit is contained in:
Simo committed 2026-08-26 21:20:12 +02:00
1 parent cc6bb9a9a3
commit edc165ba8d
5 files changed
+134 -8

No files matched your search

@@ -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",
},
});
});
});
@@ -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<HousekeepingCapabilityContext> {
const correlationId = createCorrelationId();
return satisfiesCapability(context, requirement)
? ok(context, correlationId)
: fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId);
}
@@ -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: <T extends (...args: never[]) => 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<unknown>) => {
let value: Promise<unknown> | 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);
});
});
+22
View File
@@ -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({
+18
View File
@@ -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<HousekeepingCapabilityContext> {
const capabilityContext =
context ?? (await getHousekeepingCapabilityContext());
const authorization = authorizeHousekeeping(capabilityContext, requirement);
if (!authorization.ok) redirectSafe("/admin", "/admin");
return capabilityContext;
}
export async function requireStaff(): Promise<StaffUser> {
const { session, permissions } = await getAdminContext();