145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
|
import { PermissionService } from './permission.service';
|
|
import type { CurrentUser } from './auth-context.service';
|
|
|
|
describe('PermissionService', () => {
|
|
const versionFindUnique = jest.fn();
|
|
const projectMemberFindUnique = jest.fn();
|
|
const appDataFindUnique = jest.fn();
|
|
|
|
const prisma = {
|
|
version: { findUnique: versionFindUnique },
|
|
projectMember: { findUnique: projectMemberFindUnique },
|
|
appData: { findUnique: appDataFindUnique },
|
|
} as any;
|
|
|
|
const service = new PermissionService(prisma);
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
appDataFindUnique.mockResolvedValue(null);
|
|
});
|
|
|
|
it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => {
|
|
const user = currentUser({ id: 'm-8', roleId: 'role-admin' });
|
|
|
|
await expect(service.can(user, 'audit:view', { versionId: 'version-1' })).resolves.toBe(true);
|
|
|
|
expect(versionFindUnique).not.toHaveBeenCalled();
|
|
expect(projectMemberFindUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies version work mutations for project members outside the version members list', async () => {
|
|
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
|
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
|
|
|
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(false);
|
|
|
|
expect(versionFindUnique).toHaveBeenCalledWith({
|
|
where: { id: 'version-1' },
|
|
select: { id: true, projectId: true, members: true },
|
|
});
|
|
expect(projectMemberFindUnique).toHaveBeenCalledWith({
|
|
where: { projectId_userId: { projectId: 'project-1', userId: 'dev-1' } },
|
|
});
|
|
});
|
|
|
|
it('allows version work mutations when the user has the role permission and is a version member', async () => {
|
|
versionFindUnique.mockResolvedValue({
|
|
id: 'version-1',
|
|
projectId: 'project-1',
|
|
members: [{ id: 'dev-1', name: 'Dev One' }],
|
|
});
|
|
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
|
|
|
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(true);
|
|
});
|
|
|
|
it('uses configured role permissions before built-in role defaults', async () => {
|
|
appDataFindUnique.mockResolvedValue({
|
|
value: {
|
|
roles: [
|
|
{ id: 'role-dev', permissions: ['version.devtask:view'] },
|
|
],
|
|
},
|
|
});
|
|
versionFindUnique.mockResolvedValue({
|
|
id: 'version-1',
|
|
projectId: 'project-1',
|
|
members: [{ id: 'dev-1', name: 'Dev One' }],
|
|
});
|
|
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
|
|
|
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(false);
|
|
});
|
|
|
|
it('allows a version member when no project membership exists', async () => {
|
|
versionFindUnique.mockResolvedValue({
|
|
id: 'version-1',
|
|
projectId: 'project-1',
|
|
members: [{ id: 'tester-1', name: 'Tester One' }],
|
|
});
|
|
projectMemberFindUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.can(currentUser({ id: 'tester-1', name: 'Tester One', roleId: 'role-test' }), 'version.testcase:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(true);
|
|
});
|
|
|
|
it('allows a project member to write version-scoped work activity evidence', async () => {
|
|
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
|
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
|
|
|
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'work-activity:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(true);
|
|
});
|
|
|
|
it('denies users who have a global role permission but are outside the resource scope', async () => {
|
|
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
|
projectMemberFindUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
|
|
versionId: 'version-1',
|
|
})).resolves.toBe(false);
|
|
});
|
|
|
|
it('throws explicit auth exceptions for assertion callers', async () => {
|
|
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
|
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('resolves configured role permissions for the current user', async () => {
|
|
appDataFindUnique.mockResolvedValue({
|
|
value: {
|
|
roles: [
|
|
{ id: 'role-dev', permissions: ['project:view'] },
|
|
],
|
|
},
|
|
});
|
|
|
|
await expect(service.resolveUserPermissions(currentUser({ roleId: 'role-dev' }))).resolves.toEqual(['project:view']);
|
|
});
|
|
|
|
it('returns no permissions for anonymous users', async () => {
|
|
await expect(service.resolveUserPermissions(null)).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
|
return {
|
|
id: 'user-1',
|
|
name: 'User One',
|
|
username: 'user.one',
|
|
roleId: 'role-dev',
|
|
email: '',
|
|
...overrides,
|
|
};
|
|
}
|