128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
import { UserStore } from '../src/identity/user.store';
|
|
import { RoleStore } from '../src/identity/role.store';
|
|
import { AccessStore } from '../src/identity/access.store';
|
|
import { DatabaseService } from '../src/database/database.service';
|
|
import { PasswordService } from '../src/identity/password.service';
|
|
import type { Principal } from '../src/identity/identity.types';
|
|
import type { Prisma } from '../src/generated/prisma/client';
|
|
|
|
describe('administration use-case policy', () => {
|
|
const actor: Principal = {
|
|
userId: 'actor',
|
|
organizationId: 'org',
|
|
sessionId: 'session',
|
|
permissions: ['roles.manage'],
|
|
};
|
|
const tx = {
|
|
user: { create: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
|
|
role: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
findMany: jest.fn(),
|
|
},
|
|
auditEvent: { create: jest.fn() },
|
|
session: { deleteMany: jest.fn() },
|
|
recoveryToken: { deleteMany: jest.fn() },
|
|
userRole: { deleteMany: jest.fn(), createMany: jest.fn() },
|
|
};
|
|
const access = { mutate: jest.fn() };
|
|
const passwords = { hash: jest.fn() };
|
|
const users = new UserStore(
|
|
{} as DatabaseService,
|
|
access as unknown as AccessStore,
|
|
passwords as unknown as PasswordService,
|
|
);
|
|
const roles = new RoleStore(
|
|
{} as DatabaseService,
|
|
access as unknown as AccessStore,
|
|
);
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
access.mutate.mockImplementation(
|
|
(
|
|
_actor,
|
|
_permission,
|
|
work: (tx: Prisma.TransactionClient, current: Principal) => unknown,
|
|
) => work(tx as unknown as Prisma.TransactionClient, actor),
|
|
);
|
|
});
|
|
it('creates pending users without passing plaintext to persistence', async () => {
|
|
passwords.hash.mockResolvedValue('encoded');
|
|
tx.user.create.mockResolvedValue({ id: 'new' });
|
|
await users.create(actor, {
|
|
email: 'a@example.com',
|
|
name: 'A',
|
|
password: 'secret',
|
|
});
|
|
expect(tx.user.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: {
|
|
organizationId: 'org',
|
|
email: 'a@example.com',
|
|
name: 'A',
|
|
passwordHash: 'encoded',
|
|
},
|
|
}),
|
|
);
|
|
expect(tx.auditEvent.create).toHaveBeenCalledWith({
|
|
data: {
|
|
organizationId: 'org',
|
|
actorId: 'actor',
|
|
action: 'user.created',
|
|
targetId: 'new',
|
|
},
|
|
});
|
|
});
|
|
it('hides users outside the organization scope', async () => {
|
|
tx.user.findFirst.mockResolvedValue(null);
|
|
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
|
|
'Not Found',
|
|
);
|
|
expect(tx.user.update).not.toHaveBeenCalled();
|
|
});
|
|
it('revokes sessions and recovery on suspension', async () => {
|
|
tx.user.findFirst.mockResolvedValue({ id: 'employee', isOwner: false });
|
|
await users.setStatus(actor, 'employee', 'SUSPENDED');
|
|
expect(tx.session.deleteMany).toHaveBeenCalledWith({
|
|
where: { userId: 'employee' },
|
|
});
|
|
expect(tx.recoveryToken.deleteMany).toHaveBeenCalledWith({
|
|
where: { userId: 'employee' },
|
|
});
|
|
});
|
|
it('prevents modifying an existing role more powerful than the actor', async () => {
|
|
tx.role.findFirst.mockResolvedValue({
|
|
isSystem: false,
|
|
permissions: ['audit.read'],
|
|
});
|
|
await expect(
|
|
roles.save(actor, { name: 'Changed', permissions: [] }, 'role'),
|
|
).rejects.toThrow('Cannot grant');
|
|
expect(tx.role.update).not.toHaveBeenCalled();
|
|
});
|
|
it('allows only known role targets in the actor organization', async () => {
|
|
tx.role.findFirst.mockResolvedValue(null);
|
|
await expect(
|
|
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
|
|
).rejects.toThrow('Not Found');
|
|
});
|
|
it('does not remove a target user permissions the actor cannot grant', async () => {
|
|
tx.user.findFirst.mockResolvedValue({
|
|
id: 'employee',
|
|
isOwner: false,
|
|
roles: [{ role: { permissions: ['audit.read'] } }],
|
|
});
|
|
await expect(roles.assign(actor, 'employee', [])).rejects.toThrow(
|
|
'Cannot grant',
|
|
);
|
|
expect(tx.userRole.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
it('hides missing assignment targets', async () => {
|
|
tx.user.findFirst.mockResolvedValue(null);
|
|
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
|
|
'Not Found',
|
|
);
|
|
});
|
|
});
|