216 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|