refactor(housekeeping): reuse request capability context
This commit is contained in:
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { auth, getAdminContext, staleActor } = vi.hoisted(() => ({
|
const { auth, getAdminContext, resetRequestCache, staleActor, wrapCache } =
|
||||||
auth: vi.fn(),
|
vi.hoisted(() => {
|
||||||
getAdminContext: vi.fn(),
|
const resetters: (() => void)[] = [];
|
||||||
staleActor: { id: 9, username: "stale-session", rank: 1 },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react", () => ({
|
return {
|
||||||
cache: <T extends (...args: never[]) => unknown>(callback: T) => callback,
|
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/auth", () => ({ auth }));
|
||||||
vi.mock("@/lib/db", () => {
|
vi.mock("@/lib/db", () => {
|
||||||
@@ -16,11 +31,14 @@ vi.mock("@/lib/db", () => {
|
|||||||
});
|
});
|
||||||
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
|
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
|
||||||
|
|
||||||
|
import { requireHousekeepingCapability } from "@/lib/admin/guard";
|
||||||
import { auth as mockedAuth } from "@/lib/auth";
|
import { auth as mockedAuth } from "@/lib/auth";
|
||||||
|
import { anyCapability } from "./contracts";
|
||||||
import { getHousekeepingCapabilityContext } from "./server-capability-context";
|
import { getHousekeepingCapabilityContext } from "./server-capability-context";
|
||||||
|
|
||||||
describe("getHousekeepingCapabilityContext", () => {
|
describe("getHousekeepingCapabilityContext", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
resetRequestCache();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
auth.mockResolvedValue({ user: staleActor });
|
auth.mockResolvedValue({ user: staleActor });
|
||||||
getAdminContext.mockResolvedValue({
|
getAdminContext.mockResolvedValue({
|
||||||
@@ -53,4 +71,12 @@ describe("getHousekeepingCapabilityContext", () => {
|
|||||||
expect(getAdminContext).toHaveBeenCalledTimes(1);
|
expect(getAdminContext).toHaveBeenCalledTimes(1);
|
||||||
expect(mockedAuth).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
} from "@/features/housekeeping/foundation/contracts";
|
||||||
import { redirectSafe } from "@/lib/foundation/security";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext } from "@/lib/permissions";
|
import { canAccess, getAdminContext } from "@/lib/permissions";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import {
|
import {
|
||||||
|
requireHousekeepingCapability,
|
||||||
requireMod,
|
requireMod,
|
||||||
requireModPermission,
|
requireModPermission,
|
||||||
requirePermission,
|
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", () => {
|
describe("requireMod", () => {
|
||||||
it("allows with MOD_DASHBOARD", async () => {
|
it("allows with MOD_DASHBOARD", async () => {
|
||||||
vi.mocked(getAdminContext).mockResolvedValue({
|
vi.mocked(getAdminContext).mockResolvedValue({
|
||||||
|
|||||||
@@ -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 { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -7,6 +13,18 @@ export interface StaffUser {
|
|||||||
rank: number;
|
rank: number;
|
||||||
username: string;
|
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> {
|
export async function requireStaff(): Promise<StaffUser> {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
|
|||||||
Reference in new issue
Block a user