Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/lib/auth/__tests__/acl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
hasPermission,
hasAnyPermission,
hasAllPermissions,
isAtLeast,
isAtLeastRole,
clearAclCaches,
ROLES_PERMISSIONS,
} from '../acl';
import { UserRole, Permission } from '@/types/api';

describe('ACL caching', () => {
beforeEach(() => {
clearAclCaches();
});

describe('hasPermission', () => {
it('returns true for admin with any permission', () => {
const user = { role: UserRole.ADMIN };
expect(hasPermission(user, Permission.COURSE_VIEW)).toBe(true);
expect(hasPermission(user, Permission.COURSE_CREATE)).toBe(true);
expect(hasPermission(user, Permission.ANALYTICS_VIEW)).toBe(true);
});

it('returns true for instructor with allowed permissions', () => {
const user = { role: UserRole.INSTRUCTOR };
expect(hasPermission(user, Permission.COURSE_VIEW)).toBe(true);
expect(hasPermission(user, Permission.COURSE_CREATE)).toBe(true);
expect(hasPermission(user, Permission.CONTENT_UPLOAD)).toBe(true);
});

it('returns false for instructor with admin-only permissions', () => {
const user = { role: UserRole.INSTRUCTOR };
expect(hasPermission(user, Permission.ANALYTICS_VIEW)).toBe(false);
expect(hasPermission(user, Permission.USER_MANAGE)).toBe(false);
});

it('returns true for student with allowed permissions', () => {
const user = { role: UserRole.STUDENT };
expect(hasPermission(user, Permission.COURSE_VIEW)).toBe(true);
expect(hasPermission(user, Permission.COURSE_DOWNLOAD)).toBe(true);
expect(hasPermission(user, Permission.CONTENT_ACCESS)).toBe(true);
});

it('returns false for student with instructor permissions', () => {
const user = { role: UserRole.STUDENT };
expect(hasPermission(user, Permission.COURSE_CREATE)).toBe(false);
expect(hasPermission(user, Permission.COURSE_EDIT)).toBe(false);
});

it('returns true for guest with course view permission', () => {
const user = { role: UserRole.GUEST };
expect(hasPermission(user, Permission.COURSE_VIEW)).toBe(true);
});

it('returns false for guest with other permissions', () => {
const user = { role: UserRole.GUEST };
expect(hasPermission(user, Permission.COURSE_CREATE)).toBe(false);
expect(hasPermission(user, Permission.COURSE_DOWNLOAD)).toBe(false);
});

it('returns false for null or undefined user', () => {
expect(hasPermission(null, Permission.COURSE_VIEW)).toBe(false);
expect(hasPermission(undefined, Permission.COURSE_VIEW)).toBe(false);
});

it('caches results for same role and permission', () => {
const user = { role: UserRole.STUDENT };
const result1 = hasPermission(user, Permission.COURSE_VIEW);
const result2 = hasPermission(user, Permission.COURSE_VIEW);
expect(result1).toBe(result2);
});
});

describe('hasAnyPermission', () => {
it('returns true if user has any of the permissions', () => {
const user = { role: UserRole.STUDENT };
expect(hasAnyPermission(user, [Permission.COURSE_VIEW, Permission.COURSE_CREATE])).toBe(true);
expect(hasAnyPermission(user, [Permission.COURSE_CREATE, Permission.COURSE_VIEW])).toBe(true);
});

it('returns false if user has none of the permissions', () => {
const user = { role: UserRole.GUEST };
expect(hasAnyPermission(user, [Permission.COURSE_CREATE, Permission.COURSE_EDIT])).toBe(false);
});

it('returns false for null or undefined user', () => {
expect(hasAnyPermission(null, [Permission.COURSE_VIEW])).toBe(false);
expect(hasAnyPermission(undefined, [Permission.COURSE_VIEW])).toBe(false);
});
});

describe('hasAllPermissions', () => {
it('returns true if user has all permissions', () => {
const user = { role: UserRole.ADMIN };
expect(hasAllPermissions(user, [Permission.COURSE_VIEW, Permission.COURSE_CREATE])).toBe(true);
});

it('returns false if user is missing any permission', () => {
const user = { role: UserRole.STUDENT };
expect(hasAllPermissions(user, [Permission.COURSE_VIEW, Permission.COURSE_CREATE])).toBe(false);
});

it('returns false for null or undefined user', () => {
expect(hasAllPermissions(null, [Permission.COURSE_VIEW])).toBe(false);
expect(hasAllPermissions(undefined, [Permission.COURSE_VIEW])).toBe(false);
});
});

describe('isAtLeast', () => {
it('returns true for admin at any role', () => {
const user = { role: UserRole.ADMIN };
expect(isAtLeast(user, UserRole.ADMIN)).toBe(true);
expect(isAtLeast(user, UserRole.INSTRUCTOR)).toBe(true);
expect(isAtLeast(user, UserRole.STUDENT)).toBe(true);
expect(isAtLeast(user, UserRole.GUEST)).toBe(true);
});

it('returns true for instructor at instructor or lower', () => {
const user = { role: UserRole.INSTRUCTOR };
expect(isAtLeast(user, UserRole.INSTRUCTOR)).toBe(true);
expect(isAtLeast(user, UserRole.STUDENT)).toBe(true);
expect(isAtLeast(user, UserRole.GUEST)).toBe(true);
});

it('returns false for instructor at admin', () => {
const user = { role: UserRole.INSTRUCTOR };
expect(isAtLeast(user, UserRole.ADMIN)).toBe(false);
});

it('returns false for null or undefined user', () => {
expect(isAtLeast(null, UserRole.GUEST)).toBe(false);
expect(isAtLeast(undefined, UserRole.GUEST)).toBe(false);
});
});

describe('isAtLeastRole', () => {
it('returns true for admin at any role', () => {
expect(isAtLeastRole(UserRole.ADMIN, UserRole.ADMIN)).toBe(true);
expect(isAtLeastRole(UserRole.ADMIN, UserRole.INSTRUCTOR)).toBe(true);
expect(isAtLeastRole(UserRole.ADMIN, UserRole.STUDENT)).toBe(true);
expect(isAtLeastRole(UserRole.ADMIN, UserRole.GUEST)).toBe(true);
});

it('returns true for instructor at instructor or lower', () => {
expect(isAtLeastRole(UserRole.INSTRUCTOR, UserRole.INSTRUCTOR)).toBe(true);
expect(isAtLeastRole(UserRole.INSTRUCTOR, UserRole.STUDENT)).toBe(true);
expect(isAtLeastRole(UserRole.INSTRUCTOR, UserRole.GUEST)).toBe(true);
});

it('returns false for instructor at admin', () => {
expect(isAtLeastRole(UserRole.INSTRUCTOR, UserRole.ADMIN)).toBe(false);
});

it('returns false for null or undefined role', () => {
expect(isAtLeastRole(null, UserRole.GUEST)).toBe(false);
expect(isAtLeastRole(undefined, UserRole.GUEST)).toBe(false);
});
});

describe('clearAclCaches', () => {
it('clears all caches', () => {
const user = { role: UserRole.STUDENT };
hasPermission(user, Permission.COURSE_VIEW);
isAtLeastRole(UserRole.STUDENT, UserRole.GUEST);

clearAclCaches();

// Should still work correctly after clearing
expect(hasPermission(user, Permission.COURSE_VIEW)).toBe(true);
expect(isAtLeastRole(UserRole.STUDENT, UserRole.GUEST)).toBe(true);
});
});
});
44 changes: 42 additions & 2 deletions src/lib/auth/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,30 @@ export const ROLES_PERMISSIONS: Record<UserRole, Permission[]> = {
GUEST: [Permission.COURSE_VIEW],
};

// Cache for permission lookups to avoid repeated evaluation
const permissionCache = new Map<string, boolean>();

/**
* Generate a cache key for permission checks.
*/
function getPermissionCacheKey(role: UserRole, permission: Permission): string {
return `${role}:${permission}`;
}

/**
* Check if a user (or any object that contains a role) has a specific permission.
*/
export function hasPermission(user: RoleHolder | null | undefined, permission: Permission): boolean {
if (!user) return false;

const cacheKey = getPermissionCacheKey(user.role, permission);
const cached = permissionCache.get(cacheKey);
if (cached !== undefined) return cached;

const permissions = ROLES_PERMISSIONS[user.role] ?? [];
return permissions.includes(permission);
const result = permissions.includes(permission);
permissionCache.set(cacheKey, result);
return result;
}

/**
Expand Down Expand Up @@ -68,16 +84,40 @@ export function isAtLeast(user: RoleHolder | null | undefined, role: UserRole):
return isAtLeastRole(user.role, role);
}

// Cache for role hierarchy lookups
const roleHierarchyCache = new Map<string, boolean>();

/**
* Generate a cache key for role hierarchy checks.
*/
function getRoleHierarchyCacheKey(userRole: UserRole, requiredRole: UserRole): string {
return `${userRole}:${requiredRole}`;
}

/**
* Check if a role has at least the minimum required role.
* Roles are hierarchical: ADMIN > INSTRUCTOR > STUDENT > GUEST
*/
export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserRole): boolean {
if (!userRole) return false;

const cacheKey = getRoleHierarchyCacheKey(userRole, role);
const cached = roleHierarchyCache.get(cacheKey);
if (cached !== undefined) return cached;

const hierarchy = [UserRole.GUEST, UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN];
const userRoleIndex = hierarchy.indexOf(userRole);
const requiredRoleIndex = hierarchy.indexOf(role);

return userRoleIndex >= requiredRoleIndex;
const result = userRoleIndex >= requiredRoleIndex;
roleHierarchyCache.set(cacheKey, result);
return result;
}

/**
* Clear all ACL caches. Useful for testing or when caches need to be reset.
*/
export function clearAclCaches(): void {
permissionCache.clear();
roleHierarchyCache.clear();
}
Loading