diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.unit.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.unit.test.ts new file mode 100644 index 0000000000..59e6b1bfeb --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.unit.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermissionLevel } from "./usePermissions"; + +vi.mock("@calcom/features/pbac/domain/types/permission-registry", () => { + const CrudAction = { + Create: "create", + Read: "read", + Update: "update", + Delete: "delete", + } as const; + + const Scope = { + Team: "team", + Organization: "organization", + } as const; + + const PERMISSION_REGISTRY = { + "*": { _resource: { i18nKey: "all" } }, + calendar: { + [CrudAction.Create]: {}, + [CrudAction.Read]: {}, + [CrudAction.Update]: {}, + [CrudAction.Delete]: {}, + _resource: { i18nKey: "calendar" }, + _internalMeta: true, // should be ignored + }, + team: { + [CrudAction.Read]: {}, + [CrudAction.Update]: {}, + _resource: { i18nKey: "team" }, + _secret: "ignore", // should be ignored + }, + } as const; + + // Mock getPermissionsForScope to return the registry without the "*" resource + const getPermissionsForScope = () => { + const { "*": _, ...rest } = PERMISSION_REGISTRY; + return rest; + }; + + return { CrudAction, Scope, PERMISSION_REGISTRY, getPermissionsForScope }; +}); + +vi.mock("@calcom/features/pbac/utils/permissionTraversal", () => { + // Transitive graph (small): + // team.update -> depends on team.read + // calendar.delete -> depends on calendar.read + // Dependents (reverse): + // team.read -> dependents: team.update + // calendar.read -> dependents: calendar.delete + function getTransitiveDependencies(permission: string): string[] { + switch (permission) { + case "team.update": + return ["team.read"]; + case "calendar.delete": + return ["calendar.read"]; + default: + return []; + } + } + function getTransitiveDependents(permission: string): string[] { + switch (permission) { + case "team.read": + return ["team.update"]; + case "calendar.read": + return ["calendar.delete"]; + default: + return []; + } + } + return { getTransitiveDependencies, getTransitiveDependents }; +}); + +// Import after mocks are set up +import { usePermissions } from "./usePermissions"; + +describe("usePermissions - permission utilities", () => { + let perms: ReturnType; + + beforeEach(() => { + perms = usePermissions(); + }); + + describe("hasAllPermissions", () => { + it("returns false when no permissions are granted", () => { + expect(perms.hasAllPermissions([])).toBe(false); + }); + + it("ignores internal keys and requires all non-internal actions across all resources", () => { + // Missing team.update + const some = ["calendar.create", "calendar.read", "calendar.update", "calendar.delete", "team.read"]; + expect(perms.hasAllPermissions(some)).toBe(false); + + const all = [ + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + "team.update", + ]; + expect(perms.hasAllPermissions(all)).toBe(true); + }); + + it("treats presence of *.* as sufficient only if individual perms also complete after modifications", () => { + // "*.*" by itself doesn't add individual entries; hasAllPermissions checks registry entries presence + expect(perms.hasAllPermissions(["*.*"])).toBe(false); + + const fullPlusStar = [ + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + "team.update", + "*.*", + ]; + expect(perms.hasAllPermissions(fullPlusStar)).toBe(true); + }); + }); + + describe("getResourcePermissionLevel", () => { + it("returns 'all' for '*' only when *.* is present", () => { + expect(perms.getResourcePermissionLevel("*", [])).toBe("none"); + expect(perms.getResourcePermissionLevel("*", ["*.*"])).toBe("all"); + }); + + it("returns 'none' for unknown resources even with wildcard", () => { + // Unknown resources return 'none' because they're not in the registry + // The wildcard check only applies to known resources + expect(perms.getResourcePermissionLevel("unknown", ["*.*"])).toBe("none"); + expect(perms.getResourcePermissionLevel("unknown", [])).toBe("none"); + }); + + it("returns 'all' when all non-internal actions for resource are granted", () => { + const list = ["calendar.create", "calendar.read", "calendar.update", "calendar.delete"]; + expect(perms.getResourcePermissionLevel("calendar", list)).toBe("all"); + }); + + it("returns 'read' when only read is present", () => { + expect(perms.getResourcePermissionLevel("team", ["team.read"])).toBe("read"); + }); + + it("returns 'none' when no perms for that resource are present", () => { + expect(perms.getResourcePermissionLevel("team", ["calendar.read"])).toBe("none"); + }); + + it("returns 'all' for any resource when wildcard exists", () => { + expect(perms.getResourcePermissionLevel("team", ["*.*"])).toBe("all"); + expect(perms.getResourcePermissionLevel("calendar", ["*.*"])).toBe("all"); + }); + }); + + describe("toggleResourcePermissionLevel", () => { + it("for '*' with level 'all' adds '*.*' and all individual permissions", () => { + const next = perms.toggleResourcePermissionLevel("*", "all", []); + // Should include star and all concrete perms (6 in our registry) + expect(next).toEqual( + expect.arrayContaining([ + "*.*", + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + "team.update", + ]) + ); + // No duplicates + expect(new Set(next).size).toBe(next.length); + }); + + it("for '*' with level 'none' removes '*.*' and leaves empty set", () => { + const next = perms.toggleResourcePermissionLevel("*", "none", ["*.*", "calendar.read", "team.read"]); + expect(next).not.toContain("*.*"); + // Because we removed *.*, we don't auto-remove individuals unless resource-specific; keep others + expect(next).toEqual(expect.arrayContaining(["calendar.read", "team.read"])); + }); + + it("for specific resource with 'none' removes that resource's permissions only", () => { + const current = ["calendar.create", "calendar.read", "calendar.update", "team.read"]; + const next = perms.toggleResourcePermissionLevel("calendar", "none", current); + expect(next).toEqual(["team.read"]); + }); + + it("for specific resource with 'read' keeps only read for that resource", () => { + const current = ["calendar.create", "calendar.read", "calendar.update", "team.read"]; + const next = perms.toggleResourcePermissionLevel("calendar", "read", current); + // calendar.* collapsed to read only; team.read preserved + expect(next.sort()).toEqual(["calendar.read", "team.read"].sort()); + }); + + it("for specific resource with 'all' expands to all non-internal actions for that resource", () => { + const current = ["team.read"]; + const next = perms.toggleResourcePermissionLevel("calendar", "all", current); + expect(next).toEqual( + expect.arrayContaining([ + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + ]) + ); + }); + + it("re-adds '*.*' when post-change state has all permissions across all resources", () => { + const almostAll = [ + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + ]; + const next = perms.toggleResourcePermissionLevel("team", "all", almostAll); + expect(next).toEqual(expect.arrayContaining(["*.*"])); + expect(perms.hasAllPermissions(next)).toBe(true); + }); + + it("no-ops and returns currentPermissions when resource not in registry", () => { + const curr = ["team.read"]; + const next = perms.toggleResourcePermissionLevel("nonexistent", "all", curr); + // Implementation returns currentPermissions unchanged for unknown resource + expect(next).toBe(curr); + }); + }); + + describe("toggleSinglePermission", () => { + it("enabling a permission adds it and its transitive dependencies, without duplicates", () => { + const next = perms.toggleSinglePermission("team.update", true, []); + expect(next).toEqual(expect.arrayContaining(["team.update", "team.read"])); + // unique + expect(new Set(next).size).toBe(next.length); + }); + + it("disabling a permission removes it and all its transitive dependents", () => { + const current = ["team.read", "team.update", "calendar.read"]; + const next = perms.toggleSinglePermission("team.read", false, current); + // team.update depends on team.read, so it should also be removed + expect(next).toEqual(["calendar.read"]); + }); + + it("does not remove unrelated permissions when disabling one", () => { + const current = ["calendar.read", "team.read", "team.update"]; + const next = perms.toggleSinglePermission("calendar.read", false, current); + // calendar.delete depends on read, so delete should be removed too if present; but it's not + expect(next.sort()).toEqual(["team.read", "team.update"].sort()); + }); + + it("re-adds '*.*' when after toggling, all permissions are present", () => { + const allButStar = [ + "calendar.create", + "calendar.read", + "calendar.update", + "calendar.delete", + "team.read", + ]; + const next = perms.toggleSinglePermission("team.update", true, allButStar); + expect(perms.hasAllPermissions(next)).toBe(true); + expect(next).toEqual(expect.arrayContaining(["*.*"])); + }); + + it("adding a permission for unknown resource still just adds/removes string tokens (since traversal returns none)", () => { + const start: string[] = []; + const add = perms.toggleSinglePermission("ghost.read", true, start); + expect(add).toEqual(["ghost.read"]); + const rem = perms.toggleSinglePermission("ghost.read", false, add); + expect(rem).toEqual([]); + }); + }); +}); diff --git a/packages/features/pbac/domain/types/permission-registry.test.ts b/packages/features/pbac/domain/types/permission-registry.test.ts new file mode 100644 index 0000000000..13b1ffa1c6 --- /dev/null +++ b/packages/features/pbac/domain/types/permission-registry.test.ts @@ -0,0 +1,197 @@ +/** + * Tests for permission registry utilities and data structure. + * Testing library/framework: This test suite is written to be compatible with Jest/Vitest style + * (describe/it/expect). It avoids framework-specific globals (like jest/vi) unless needed. + */ + +import { + CrudAction, + CustomAction, + filterResourceConfig, + getPermissionsForScope, + PERMISSION_REGISTRY, + type PermissionRegistry, + parsePermissionString, + Resource, + type ResourceConfig, + Scope, +} from "./permission-registry"; + +describe("permission-registry: filterResourceConfig", () => { + it("removes the _resource property and preserves other keys", () => { + const cfg: ResourceConfig = { + _resource: { i18nKey: "foo" }, + [CrudAction.Read]: { + description: "View", + category: "x", + i18nKey: "read", + descriptionI18nKey: "desc_read", + }, + [CustomAction.Invite]: { + description: "Invite", + category: "x", + i18nKey: "invite", + descriptionI18nKey: "desc_invite", + }, + }; + const copy = JSON.parse(JSON.stringify(cfg)); + + const filtered = filterResourceConfig(cfg); + + // does not mutate original + expect(cfg).toEqual(copy); + + // _resource removed + expect(Object.hasOwn(filtered, "_resource")).toBe(false); + + // remaining keys intact + expect(filtered).toHaveProperty(CrudAction.Read); + expect(filtered).toHaveProperty(CustomAction.Invite); + }); + + it("handles configs without _resource gracefully", () => { + const cfg: ResourceConfig = { + [CrudAction.Create]: { + description: "Create", + category: "x", + i18nKey: "create", + descriptionI18nKey: "desc_create", + }, + }; + + const filtered = filterResourceConfig(cfg); + expect(filtered).toEqual(cfg); + }); + + it("returns empty object when only _resource is present", () => { + const cfg: ResourceConfig = { + _resource: { i18nKey: "only" }, + }; + const filtered = filterResourceConfig(cfg); + expect(filtered).toEqual({}); + }); +}); + +describe("permission-registry: PERMISSION_REGISTRY base structure", () => { + it("contains a _resource i18nKey for every resource", () => { + const registry: PermissionRegistry = PERMISSION_REGISTRY; + Object.entries(registry).forEach(([resource, cfg]) => { + expect(cfg).toBeDefined(); + expect(cfg._resource).toBeDefined(); + expect(typeof cfg._resource?.i18nKey).toBe("string"); + expect(cfg._resource?.i18nKey.length).toBeGreaterThan(0); + }); + }); + + it("all dependsOn permission strings point to existing resource.action entries", () => { + const registry: PermissionRegistry = PERMISSION_REGISTRY; + + const hasPermission = (perm: string): boolean => { + // Use parsePermissionString to handle dotted resource names like "organization.attributes.read" + const { resource: resKey, action } = parsePermissionString(perm); + + // In registry keys, Resource enums are their string values + const resourceConfig = registry[resKey as keyof PermissionRegistry] as ResourceConfig | undefined; + + if (!resourceConfig) return false; + + // Action might be CrudAction.All ("*") which is a valid key + return Object.hasOwn(resourceConfig, action); + }; + + Object.values(registry).forEach((cfg) => { + Object.entries(cfg).forEach(([action, details]) => { + if (action === "_resource") return; + const d = details as any; + if (Array.isArray(d.dependsOn)) { + d.dependsOn.forEach((dep: string) => { + expect(typeof dep).toBe("string"); + expect(dep.includes(".")).toBe(true); + expect(hasPermission(dep)).toBe(true); + }); + } + }); + }); + }); +}); + +describe("permission-registry: getPermissionsForScope", () => { + it("Team scope excludes resources without team-eligible actions", () => { + const team = getPermissionsForScope(Scope.Team); + + // "All" resource has only CrudAction.All with Organization scope -> should be omitted + expect(team[Resource.All]).toBeUndefined(); + + // Booking: Read has no scope (included), ReadTeamBookings has team scope (included), + // ReadOrgBookings has org scope (excluded) + expect(team[Resource.Booking]).toBeDefined(); + const booking = team[Resource.Booking]; + expect(booking).toBeDefined(); + expect(booking[CrudAction.Read]).toBeDefined(); + expect(booking[CustomAction.ReadTeamBookings]).toBeDefined(); + expect(booking[CustomAction.ReadOrgBookings as any]).toBeUndefined(); + + // Team resource: Create has Organization scope -> excluded in Team scope + expect(team[Resource.Team]).toBeDefined(); + expect(team[Resource.Team][CrudAction.Create]).toBeUndefined(); + // But Read (no scope) is included + expect(team[Resource.Team][CrudAction.Read]).toBeDefined(); + }); + + it("Organization scope includes org-specific and scope-unspecified actions", () => { + const org = getPermissionsForScope(Scope.Organization); + + // "All" resource is org-only and should be present with * action + expect(org[Resource.All]).toBeDefined(); + expect(org[Resource.All][CrudAction.All]).toBeDefined(); + + // Booking: Read (no scope) -> included, ReadOrgBookings (org) -> included, ReadTeamBookings (team) -> excluded + expect(org[Resource.Booking][CrudAction.Read]).toBeDefined(); + expect(org[Resource.Booking][CustomAction.ReadOrgBookings]).toBeDefined(); + expect(org[Resource.Booking][CustomAction.ReadTeamBookings as any]).toBeUndefined(); + + // Organization resource contains org-scoped actions + expect(org[Resource.Organization]).toBeDefined(); + const orgRes = org[Resource.Organization]; + expect(orgRes[CrudAction.Read]).toBeDefined(); + expect(orgRes[CustomAction.ManageBilling]).toBeDefined(); + }); + + it("resources with zero eligible actions for a scope are not included", () => { + // Construct a synthetic registry slice to validate "hasActions" logic indirectly + // but we will rely on real data: Resource.All should be absent in Team scope + const team = getPermissionsForScope(Scope.Team); + expect(team[Resource.All]).toBeUndefined(); + }); + + it("backward compatibility: actions without scope appear in both Team and Organization results", () => { + const team = getPermissionsForScope(Scope.Team); + const org = getPermissionsForScope(Scope.Organization); + + // Example: CrudAction.Read on Resource.Workflow has no scope -> present in both + expect(team[Resource.Workflow][CrudAction.Read]).toBeDefined(); + expect(org[Resource.Workflow][CrudAction.Read]).toBeDefined(); + }); +}); + +describe("permission-registry: sanity for PermissionString format", () => { + it('each dependsOn entry follows "." format with valid enum values', () => { + const registry: PermissionRegistry = PERMISSION_REGISTRY; + + const validResourceValues = new Set(Object.values(Resource)); + const validActionValues = new Set([...Object.values(CrudAction), ...Object.values(CustomAction)]); + + Object.values(registry).forEach((cfg) => { + Object.entries(cfg).forEach(([action, details]) => { + if (action === "_resource") return; + const d = details as any; + (d.dependsOn ?? []).forEach((dep: string) => { + // Use parsePermissionString to handle dotted resource names like "organization.attributes.read" + const { resource: res, action: act } = parsePermissionString(dep); + expect(validResourceValues.has(res)).toBe(true); + expect(validActionValues.has(act)).toBe(true); + }); + }); + }); + }); +}); diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 505e191e38..95dd88f734 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -322,17 +322,17 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { descriptionI18nKey: "pbac_desc_list_team_members", dependsOn: ["team.read"], visibleWhen: { - teamPrivacy: "public", // Only show for public teams + teamPrivacy: "public", }, }, [CustomAction.ListMembersPrivate]: { description: "List private team members", category: "team", - i18nKey: "pbac_action_list_members", // Use same UI label as listMembers for consistency - descriptionI18nKey: "pbac_desc_list_team_members", // Use same description as listMembers + i18nKey: "pbac_action_list_members", + descriptionI18nKey: "pbac_desc_list_team_members", dependsOn: ["team.read"], visibleWhen: { - teamPrivacy: "private", // Only show for private teams + teamPrivacy: "private", }, }, [CustomAction.ChangeMemberRole]: { @@ -354,7 +354,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "team", i18nKey: "pbac_action_manage_billing", descriptionI18nKey: "pbac_desc_manage_billing", - scope: [], // Empty scope because this permission is only used for TEAM billing outside of an org. (We dont want to show this in the UI) + scope: [], }, }, [Resource.Organization]: { @@ -383,18 +383,18 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { scope: [Scope.Organization], dependsOn: ["organization.read"], visibleWhen: { - teamPrivacy: "public", // Only show for public orgs + teamPrivacy: "public", }, }, [CustomAction.ListMembersPrivate]: { description: "List private organization members", category: "org", - i18nKey: "pbac_action_list_members", // Same UI label as listMembers for consistency - descriptionI18nKey: "pbac_desc_list_organization_members", // Same description as listMembers + i18nKey: "pbac_action_list_members", + descriptionI18nKey: "pbac_desc_list_organization_members", scope: [Scope.Organization], dependsOn: ["organization.read"], visibleWhen: { - teamPrivacy: "private", // Only show for private orgs + teamPrivacy: "private", }, }, [CustomAction.Invite]: { diff --git a/packages/features/pbac/utils/__tests__/permissionTraversal.unit.test.ts b/packages/features/pbac/utils/__tests__/permissionTraversal.unit.test.ts new file mode 100644 index 0000000000..cfab0b225d --- /dev/null +++ b/packages/features/pbac/utils/__tests__/permissionTraversal.unit.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for permission traversal utilities. + * Framework: This test file uses standard describe/it/expect APIs compatible with Jest and Vitest. + */ +import { describe, expect, it, vi } from "vitest"; + +let traversePermissions: (start: string, dir: "dependencies" | "dependents") => string[]; +let getTransitiveDependencies: (p: string) => string[]; +let getTransitiveDependents: (p: string) => string[]; + +vi.mock("../../domain/types/permission-registry", () => { + enum CrudAction { + Create = "create", + Read = "read", + Update = "update", + Delete = "delete", + } + + enum Scope { + Team = "team", + Organization = "organization", + } + + // Build a registry that lets us test a variety of scenarios + const PERMISSION_REGISTRY: Record> = { + post: { + _resource: { i18nKey: "post" }, + [CrudAction.Read]: { + /* no explicit dependsOn */ + }, + [CrudAction.Create]: { + /* read dependency should be injected by back-compat */ + }, + [CrudAction.Update]: { dependsOn: ["category.read"] }, // explicit cross-resource dep + [CrudAction.Delete]: { dependsOn: ["post.update"] }, // forms a chain and back-compat adds read + publish: { dependsOn: ["post.update", "moderation.read"] } as any, + _internal: { note: "should be ignored" }, + }, + category: { + _resource: { i18nKey: "category" }, + [CrudAction.Read]: {}, + [CrudAction.Update]: { dependsOn: ["category.read"] }, + }, + moderation: { + _resource: { i18nKey: "moderation" }, + [CrudAction.Read]: {}, + archive: { dependsOn: ["post.publish"] }, // cycle across custom actions (publish -> moderation.read via depends; here reverse) + }, + orphan: { + _resource: { i18nKey: "orphan" }, + // Missing read; unusual resource to test unknown actions in traversal + ghost: { dependsOn: ["unknown.resource"] }, + }, + }; + + // Mock getPermissionsForScope to return the full registry for any scope + const getPermissionsForScope = () => PERMISSION_REGISTRY; + + return { CrudAction, Scope, PERMISSION_REGISTRY, getPermissionsForScope }; +}); + +// Now import the module under test, which will consume our mocked registry +import * as mod from "../permissionTraversal"; + +traversePermissions = mod.traversePermissions; +getTransitiveDependencies = mod.getTransitiveDependencies; +getTransitiveDependents = mod.getTransitiveDependents; + +describe("permission traversal - dependencies", () => { + it("includes explicit dependsOn and applies CRUD read back-compat for create/update/delete", () => { + const depsCreate = traversePermissions("post.create", "dependencies").sort(); + // Should include post.read (back-compat) + expect(depsCreate).toContain("post.read"); + + const depsUpdate = traversePermissions("post.update", "dependencies").sort(); + // Explicit dependency + back-compat read for update + expect(depsUpdate).toEqual(expect.arrayContaining(["category.read", "post.read"])); + + const depsDelete = traversePermissions("post.delete", "dependencies").sort(); + // delete -> (explicit) post.update -> category.read and back-compat reads on both post.update and post.delete + expect(depsDelete).toEqual(expect.arrayContaining(["post.update", "post.read", "category.read"])); + }); + + it("traverses transitively across multiple levels without duplicates and excludes the start node", () => { + const depsPublish = traversePermissions("post.publish", "dependencies").sort(); + // publish depends on post.update and moderation.read + // post.update depends on category.read and (back-compat) post.read + expect(depsPublish).toEqual( + expect.arrayContaining(["post.update", "moderation.read", "category.read", "post.read"]) + ); + // Start permission should not be included + expect(depsPublish).not.toContain("post.publish"); + // No duplicates + const set = new Set(depsPublish); + expect(set.size).toBe(depsPublish.length); + }); + + it("handles cycles gracefully with visited set (no infinite loop)", () => { + // moderation.archive depends on post.publish, which depends on post.update -> ... + const depsArchive = traversePermissions("moderation.archive", "dependencies").sort(); + // Ensure traversal completes and contains expected chain without repeating endlessly + expect(depsArchive).toEqual( + expect.arrayContaining(["post.publish", "post.update", "moderation.read", "category.read", "post.read"]) + ); + }); + + it("includes unknown resources/actions from dependsOn even if they don't exist in registry", () => { + const deps = traversePermissions("orphan.ghost", "dependencies"); + // unknown.resource is listed in dependsOn, so it gets added to results + // even though it doesn't exist in the registry (the function doesn't validate existence) + expect(Array.isArray(deps)).toBe(true); + expect(deps).toContain("unknown.resource"); + }); +}); + +describe("permission traversal - dependents", () => { + it("includes dependents via explicit dependsOn references", () => { + // category.read is depended upon by post.update (explicit) + const dependents = traversePermissions("category.read", "dependents").sort(); + expect(dependents).toContain("post.update"); + }); + + it("includes CRUD dependents when current permission is read of the same resource", () => { + const dependents = traversePermissions("post.read", "dependents").sort(); + // Any CRUD C/U/D of post should be included due to back-compat rule + expect(dependents).toEqual(expect.arrayContaining(["post.create", "post.update", "post.delete"])); + // Custom actions like publish are included transitively because post.publish depends on post.update + // which depends on post.read via the back-compat rule + expect(dependents).toContain("post.publish"); + }); + + it("traverses dependents transitively and excludes the start node", () => { + // moderation.read is depended upon by post.publish; and post.publish is depended upon by moderation.archive + const dependents = traversePermissions("moderation.read", "dependents").sort(); + expect(dependents).toEqual(expect.arrayContaining(["post.publish", "moderation.archive"])); + expect(dependents).not.toContain("moderation.read"); + }); + + it("skips internal keys starting with underscore", () => { + const dependents = traversePermissions("post.read", "dependents"); + // Ensure _internal doesn't appear as a dependent + expect(dependents.find((p) => p.includes("_internal"))).toBeUndefined(); + }); +}); + +describe("wrapper helpers", () => { + it("getTransitiveDependencies delegates correctly", () => { + const deps = getTransitiveDependencies("post.delete"); + expect(deps).toEqual(expect.arrayContaining(["post.update", "post.read", "category.read"])); + }); + + it("getTransitiveDependents delegates correctly", () => { + const dependents = getTransitiveDependents("category.read"); + expect(dependents).toContain("post.update"); + }); +});