Phase 3 Completed

This commit is contained in:
Rayyan
2026-08-02 17:44:50 +07:00
parent 7ce0de4e91
commit ffc5ecd259
68 changed files with 3132 additions and 163 deletions
+38
View File
@@ -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');
});
});
+34
View File
@@ -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);
});
});
+30
View File
@@ -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();
});
});
+65
View File
@@ -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']);
});
});
+41
View File
@@ -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' }));
});
});
+29
View File
@@ -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);
});
});