manicanldes-backend/test/identity-rbac.spec.ts

216 lines
6.5 KiB
TypeScript

import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { BootstrapService } from '../src/identity/bootstrap.service';
describe('organization-scoped administration', () => {
let ctx: IdentityApp;
let userId: string;
let roleId: string;
let userToken: string;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
const auth = () => ({ type: 'bearer' as const });
it('bootstraps exactly one owner', async () => {
await expect(
ctx.app.get(BootstrapService).createOwner('Other', {
email: 'other@example.com',
name: 'Other',
password: ownerPassword,
}),
).rejects.toThrow('Owner already exists');
expect(await ctx.db.user.count({ where: { isOwner: true } })).toBe(1);
});
it('creates pending accounts with normalized emails and rejects duplicates', async () => {
const payload = {
name: 'Employee',
email: 'EMPLOYEE@example.com',
password: ownerPassword,
};
const response = await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(201);
userId = response.body.id;
expect(response.body.status).toBe('PENDING');
expect(response.body.email).toBe('employee@example.com');
expect(response.text).not.toContain('passwordHash');
await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(409);
expect((await ctx.login('employee@example.com')).status).toBe(401);
});
it('approves accounts but leaves them without implicit permissions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
const login = await ctx.login('employee@example.com');
expect(login.status).toBe(200);
userToken = login.body.accessToken;
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('creates and assigns roles and reflects permission changes on existing sessions', async () => {
const role = await ctx
.api()
.post('/api/v1/roles')
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: ['users.read'] })
.expect(201);
roleId = role.body.id;
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [roleId] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(200);
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: [] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('prevents delegated administrators from granting permissions they do not hold', async () => {
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({
name: 'Delegated',
permissions: ['roles.manage', 'users.roles.assign'],
})
.expect(200);
await ctx
.api()
.post('/api/v1/roles')
.auth(userToken, auth())
.send({ name: 'Escalation', permissions: ['audit.read'] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(userToken, auth())
.send({ roleIds: [] })
.expect(403);
});
it('protects owner accounts and system roles', async () => {
const systemRole = await ctx.db.role.findFirstOrThrow({
where: { isSystem: true },
});
await ctx
.api()
.patch(`/api/v1/roles/${systemRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Changed', permissions: [] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${ctx.owner.userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [systemRole.id] })
.expect(403);
});
it('rejects cross-organization targets and database-level cross-organization assignments', async () => {
const other = await ctx.db.organization.create({ data: { name: 'Other' } });
const otherRole = await ctx.db.role.create({
data: { organizationId: other.id, name: 'External', permissions: [] },
});
await ctx
.api()
.patch(`/api/v1/roles/${otherRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Hijacked', permissions: [] })
.expect(404);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [otherRole.id] })
.expect(404);
await expect(
ctx.db.userRole.create({
data: {
userId,
roleId: otherRole.id,
organizationId: ctx.owner.organizationId,
},
}),
).rejects.toThrow();
const roles = await ctx
.api()
.get('/api/v1/roles')
.auth(ctx.token, auth())
.expect(200);
expect(
roles.body.some((role: { id: string }) => role.id === otherRole.id),
).toBe(false);
});
it('suspends accounts and permanently revokes their sessions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
expect((await ctx.login('employee@example.com')).status).toBe(401);
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
});
it('lists permissions and audit events with pagination validation', async () => {
await ctx
.api()
.get('/api/v1/roles/permissions')
.auth(ctx.token, auth())
.expect(200);
const events = await ctx
.api()
.get('/api/v1/audit-events?limit=2')
.auth(ctx.token, auth())
.expect(200);
expect(events.body).toHaveLength(2);
expect(
events.body.every(
(event: { organizationId: string }) =>
event.organizationId === ctx.owner.organizationId,
),
).toBe(true);
await ctx
.api()
.get('/api/v1/users?limit=1000')
.auth(ctx.token, auth())
.expect(400);
});
});