From c63c9830b22963aa8b951b0aee7e7e3dd99de8b5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 25 Aug 2026 19:16:29 +0200 Subject: [PATCH] feat: add housekeeping capability context --- .../foundation/capability-context.test.ts | 89 +++++++++++++++++++ .../foundation/capability-context.ts | 30 +++++++ .../server-capability-context.test.ts | 45 ++++++++++ .../foundation/server-capability-context.ts | 16 ++++ 4 files changed, 180 insertions(+) create mode 100644 src/features/housekeeping/foundation/capability-context.test.ts create mode 100644 src/features/housekeeping/foundation/capability-context.ts create mode 100644 src/features/housekeeping/foundation/server-capability-context.test.ts create mode 100644 src/features/housekeeping/foundation/server-capability-context.ts diff --git a/src/features/housekeeping/foundation/capability-context.test.ts b/src/features/housekeeping/foundation/capability-context.test.ts new file mode 100644 index 00000000..fe504c3f --- /dev/null +++ b/src/features/housekeeping/foundation/capability-context.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PermissionSet } from "@/types/admin"; +import { + createHousekeepingCapabilityContext, + satisfiesCapability, +} from "./capability-context"; +import { + allCapabilities, + anyCapability, + type HousekeepingActor, +} from "./contracts"; + +const actor: HousekeepingActor = { + id: 42, + username: "operator", + rank: 7, +}; + +const permissionSet = ( + slugs: readonly string[], + isSuperAdmin = false, +): PermissionSet => { + const granted = new Set(slugs); + const has = (slug: string) => isSuperAdmin || granted.has(slug); + + return { + isSuperAdmin, + has, + hasAny: (...requested) => requested.some(has), + hasAll: (...requested) => requested.every(has), + }; +}; + +describe("housekeeping capability context", () => { + it("evaluates any and all requirements from effective permission methods", () => { + const context = createHousekeepingCapabilityContext( + actor, + permissionSet(["admin.users.view", "admin.tickets.view"]), + ); + + expect( + satisfiesCapability( + context, + anyCapability("admin.users.view", "admin.logs.view"), + ), + ).toBe(true); + expect( + satisfiesCapability( + context, + allCapabilities("admin.users.view", "admin.logs.view"), + ), + ).toBe(false); + }); + + it("keeps the super administrator bypass explicit", () => { + const context = createHousekeepingCapabilityContext( + actor, + permissionSet([], true), + ); + + expect(context.isSuperAdmin).toBe(true); + expect(context.has("unknown.future.slug")).toBe(true); + expect( + satisfiesCapability(context, allCapabilities("unknown.future.slug")), + ).toBe(true); + }); + + it("delegates capability checks without applying rank logic", () => { + const permissions: PermissionSet = { + isSuperAdmin: false, + has: vi.fn(() => false), + hasAny: vi.fn(() => true), + hasAll: vi.fn(() => false), + }; + const context = createHousekeepingCapabilityContext( + { ...actor, rank: 0 }, + permissions, + ); + + expect( + satisfiesCapability(context, anyCapability("admin.users.view")), + ).toBe(true); + expect( + satisfiesCapability(context, allCapabilities("admin.users.view")), + ).toBe(false); + expect(permissions.hasAny).toHaveBeenCalledWith("admin.users.view"); + expect(permissions.hasAll).toHaveBeenCalledWith("admin.users.view"); + }); +}); diff --git a/src/features/housekeeping/foundation/capability-context.ts b/src/features/housekeeping/foundation/capability-context.ts new file mode 100644 index 00000000..30b4322f --- /dev/null +++ b/src/features/housekeeping/foundation/capability-context.ts @@ -0,0 +1,30 @@ +import type { PermissionSet } from "@/lib/permissions"; +import type { + CapabilityRequirement, + HousekeepingActor, + HousekeepingCapabilityContext, +} from "./contracts"; + +export function createHousekeepingCapabilityContext( + actor: HousekeepingActor, + permissions: PermissionSet, +): HousekeepingCapabilityContext { + return { + actor, + isSuperAdmin: permissions.isSuperAdmin, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => permissions.hasAny(...slugs), + hasAll: (...slugs) => permissions.hasAll(...slugs), + }; +} + +export function satisfiesCapability( + context: HousekeepingCapabilityContext, + requirement: CapabilityRequirement, +): boolean { + if (context.isSuperAdmin) return true; + + return requirement.mode === "any" + ? context.hasAny(...requirement.slugs) + : context.hasAll(...requirement.slugs); +} diff --git a/src/features/housekeeping/foundation/server-capability-context.test.ts b/src/features/housekeeping/foundation/server-capability-context.test.ts new file mode 100644 index 00000000..55066bc6 --- /dev/null +++ b/src/features/housekeeping/foundation/server-capability-context.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getAdminContext } = vi.hoisted(() => ({ getAdminContext: vi.fn() })); + +vi.mock("react", () => ({ + cache: unknown>(callback: T) => callback, +})); + +vi.mock("@/lib/permissions", () => ({ getAdminContext })); + +import { getHousekeepingCapabilityContext } from "./server-capability-context"; + +describe("getHousekeepingCapabilityContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + getAdminContext.mockResolvedValue({ + session: { + user: { + id: 42, + username: "operator", + rank: 7, + }, + }, + permissions: { + isSuperAdmin: false, + has: (slug: string) => slug === "admin.users.view", + hasAny: (...slugs: string[]) => slugs.includes("admin.users.view"), + hasAll: (...slugs: string[]) => + slugs.every((slug) => slug === "admin.users.view"), + }, + }); + }); + + it("uses the refreshed administrator actor and forwarded permissions", async () => { + const context = await getHousekeepingCapabilityContext(); + + expect(context).toMatchObject({ + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + }); + expect(context.has("admin.users.view")).toBe(true); + expect(context.has("admin.logs.view")).toBe(false); + expect(getAdminContext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/housekeeping/foundation/server-capability-context.ts b/src/features/housekeeping/foundation/server-capability-context.ts new file mode 100644 index 00000000..ebaac792 --- /dev/null +++ b/src/features/housekeeping/foundation/server-capability-context.ts @@ -0,0 +1,16 @@ +import { cache } from "react"; +import { getAdminContext } from "@/lib/permissions"; +import { createHousekeepingCapabilityContext } from "./capability-context"; + +export const getHousekeepingCapabilityContext = cache(async () => { + const { session, permissions } = await getAdminContext(); + + return createHousekeepingCapabilityContext( + { + id: session.user.id, + username: session.user.username, + rank: session.user.rank, + }, + permissions, + ); +});