test: CodeRabbit Generated Unit Tests: Add unit tests for permissions hooks, registry, and traversal (#23441)
* introduce a dependsOn permission string * fix permissions toggle * add DFS to allow permission chains * Update packages/features/pbac/utils/permissionTraversal.ts Co-authored-by: Eunjae Lee <hey@eunjae.dev> * fix splitPerms to work with attributes or dotted resources * CodeRabbit Generated Unit Tests: Add unit tests for permissions hooks, registry, and traversal * Fix UTG issues (iteration 1) --------- Co-authored-by: Sean Brydon <sean@cal.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
4 files changed
+633
-9
No files matched your search
+271
@@ -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<typeof usePermissions>;
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "<resource>.<action>" format with valid enum values', () => {
|
||||
const registry: PermissionRegistry = PERMISSION_REGISTRY;
|
||||
|
||||
const validResourceValues = new Set<string>(Object.values(Resource));
|
||||
const validActionValues = new Set<string>([...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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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<string, Record<string, any>> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user