Phase 3 Completed
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { AuthenticationService } from '../src/modules/auth/authentication.service';
|
||||
|
||||
describe('AuthenticationService', () => {
|
||||
let service: AuthenticationService;
|
||||
const mockOidc: any = { verifyToken: jest.fn() };
|
||||
const mockPrisma: any = {};
|
||||
const mockRoleSync: any = { syncUserRolesFromAuthentik: jest.fn() };
|
||||
const mockAuthz: any = { getUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn(), create: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn() };
|
||||
|
||||
service = new AuthenticationService(mockOidc as any, mockPrisma as any, mockRoleSync as any, mockAuthz as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('authenticate creates context on valid token', async () => {
|
||||
const token = 'valid';
|
||||
mockOidc.verifyToken.mockResolvedValue({ sub: 'sub1', email: 'u@example.com', groups: ['RL-Owner'] });
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.user.create.mockResolvedValue({ id: 'uid', authentikId: 'sub1', email: 'u@example.com' });
|
||||
mockRoleSync.syncUserRolesFromAuthentik.mockResolvedValue({ skipped: false, assignedRoleIds: ['r1'] });
|
||||
mockAuthz.getUserPermissions.mockResolvedValue(['users.read']);
|
||||
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
|
||||
const ctx = await service.authenticate(token);
|
||||
|
||||
expect(ctx.user.id).toBe('uid');
|
||||
expect(ctx.permissions).toContain('users.read');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith('user.authenticated', expect.any(Object));
|
||||
});
|
||||
|
||||
test('authenticate throws for missing sub', async () => {
|
||||
mockOidc.verifyToken.mockResolvedValue({ email: 'u@example.com' });
|
||||
await expect(service.authenticate('bad')).rejects.toThrow('Invalid token: missing sub');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AuthorizationService, PERMISSION_CACHE } from '../src/modules/authorization/authorization.service';
|
||||
|
||||
describe('AuthorizationService', () => {
|
||||
let service: AuthorizationService;
|
||||
const mockPrisma: any = { $queryRaw: jest.fn(), $queryRawUnsafe: jest.fn() };
|
||||
const mockCache: any = { get: jest.fn(), set: jest.fn(), invalidate: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AuthorizationService(mockPrisma as any, mockCache as any);
|
||||
});
|
||||
|
||||
test('getUserPermissions uses cache when available', async () => {
|
||||
mockCache.get.mockResolvedValue(['users.read']);
|
||||
const perms = await service.getUserPermissions('uid');
|
||||
expect(perms).toEqual(['users.read']);
|
||||
expect(mockCache.get).toHaveBeenCalledWith('uid');
|
||||
});
|
||||
|
||||
test('getUserPermissions queries DB and sets cache when missing', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
mockPrisma.$queryRaw = jest.fn().mockResolvedValue([{ code: 'users.read' }]);
|
||||
const perms = await service.getUserPermissions('uid');
|
||||
expect(perms).toEqual(['users.read']);
|
||||
expect(mockCache.set).toHaveBeenCalledWith('uid', ['users.read']);
|
||||
});
|
||||
|
||||
test('hasPermission returns correct boolean', async () => {
|
||||
jest.spyOn(service, 'getUserPermissions' as any).mockResolvedValue(['users.read']);
|
||||
const ok = await service.hasPermission('uid', 'users.read');
|
||||
expect(ok).toBe(true);
|
||||
const nok = await service.hasPermission('uid', 'users.delete');
|
||||
expect(nok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||
|
||||
describe('RoleSyncService - transaction rollback', () => {
|
||||
let service: RoleSyncService;
|
||||
const mockPrisma: any = {};
|
||||
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn() };
|
||||
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
mockPrisma.user = mockPrisma.user;
|
||||
// simulate transaction throwing
|
||||
mockPrisma.$transaction = jest.fn(async (cb: any) => { throw new Error('tx failed'); });
|
||||
|
||||
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('does not invalidate cache or publish event when transaction fails', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
|
||||
await expect(service.syncUserRolesFromAuthentik('uid', ['RL-Owner'])).rejects.toThrow('tx failed');
|
||||
|
||||
expect(mockPermissionCache.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockEvents.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||
|
||||
describe('RoleSyncService', () => {
|
||||
const mockPrisma: any = {};
|
||||
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||
let service: RoleSyncService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn() };
|
||||
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn() };
|
||||
mockPrisma.auditLog = { create: jest.fn() };
|
||||
mockPrisma.user = mockPrisma.user;
|
||||
mockPrisma.$transaction = jest.fn(async (cb: any) => {
|
||||
// simulate transaction by calling provided callback with tx = mockPrisma
|
||||
await cb(mockPrisma);
|
||||
});
|
||||
|
||||
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('computeGroupHash consistent and order independent', () => {
|
||||
const a = ['b', 'a', 'c'];
|
||||
const h1 = service.computeGroupHash(a);
|
||||
const h2 = service.computeGroupHash(['a', 'b', 'c']);
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
|
||||
test('skips sync when group hash unchanged', async () => {
|
||||
const groups = ['RL-Owner'];
|
||||
const hash = service.computeGroupHash(groups);
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: hash });
|
||||
|
||||
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||
expect(res.skipped).toBe(true);
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('performs transaction and writes audit when changed', async () => {
|
||||
const groups = ['RL-Owner'];
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r_old' }]);
|
||||
|
||||
// spy on tx ops
|
||||
mockPrisma.userRole.deleteMany = jest.fn();
|
||||
mockPrisma.userRole.createMany = jest.fn();
|
||||
mockPrisma.user.update = jest.fn();
|
||||
|
||||
mockPrisma.auditLog.create = jest.fn();
|
||||
|
||||
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
expect(mockPrisma.userRole.deleteMany).toHaveBeenCalledWith({ where: { userId: 'uid', source: 'AUTHENTIK' } });
|
||||
expect(mockPrisma.userRole.createMany).toHaveBeenCalled();
|
||||
expect(mockPrisma.user.update).toHaveBeenCalled();
|
||||
expect(mockPermissionCache.invalidate).toHaveBeenCalledWith('uid');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RolesSynchronized' }));
|
||||
expect(res.skipped).toBe(false);
|
||||
expect(res.assignedRoleIds).toEqual(['r1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RoleAssignPermissionHandler } from '../src/modules/identity/application/handlers/role/assign-permission.handler';
|
||||
import { RoleRemovePermissionHandler } from '../src/modules/identity/application/handlers/role/remove-permission.handler';
|
||||
|
||||
describe('Role permission handlers', () => {
|
||||
test('assign permission invalidates cache and publishes event', async () => {
|
||||
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1','u2']) };
|
||||
const mockPermRepo: any = { getById: jest.fn() };
|
||||
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', assignPermission: jest.fn() });
|
||||
mockPermRepo.getById.mockResolvedValue({ id: 'p1' });
|
||||
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const handler = new RoleAssignPermissionHandler(mockRoleRepo, mockPermRepo, mockAuthz as any, mockEvents as any);
|
||||
|
||||
const res = await handler.execute('r1', 'p1');
|
||||
|
||||
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u2');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||
});
|
||||
|
||||
test('remove permission invalidates cache and publishes event', async () => {
|
||||
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1']) };
|
||||
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', removePermission: jest.fn() });
|
||||
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const handler = new RoleRemovePermissionHandler(mockRoleRepo as any, mockAuthz as any, mockEvents as any);
|
||||
|
||||
const res = await handler.execute('r1', 'p1');
|
||||
|
||||
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UpdateUserHandler } from '../src/modules/identity/application/handlers/user/update-user.handler';
|
||||
|
||||
describe('UpdateUserHandler', () => {
|
||||
test('rejects password in DTO', async () => {
|
||||
const mockRepo: any = { getById: jest.fn(), update: jest.fn() };
|
||||
mockRepo.getById.mockResolvedValue({ id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() });
|
||||
|
||||
const handler = new UpdateUserHandler(mockRepo as any);
|
||||
|
||||
await expect(handler.execute('u1', { password: 'secret' } as any)).rejects.toThrow('Password management is not allowed.');
|
||||
});
|
||||
|
||||
test('updates allowed fields', async () => {
|
||||
const userObj: any = { id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() };
|
||||
const mockRepo: any = { getById: jest.fn().mockResolvedValue(userObj), update: jest.fn().mockResolvedValue(userObj) };
|
||||
|
||||
const handler = new UpdateUserHandler(mockRepo as any);
|
||||
|
||||
const dto = { name: 'New Name', email: 'a@b.com', storageQuota: 1000, storageUsed: 10 } as any;
|
||||
|
||||
const res = await handler.execute('u1', dto);
|
||||
|
||||
expect(userObj.changeUsername).toHaveBeenCalledWith('New Name');
|
||||
expect(userObj.changeEmail).toHaveBeenCalledWith('a@b.com');
|
||||
expect(userObj.setStorageQuota).toHaveBeenCalledWith(1000);
|
||||
expect(userObj.setStorageUsed).toHaveBeenCalledWith(10);
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(userObj);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user