141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
|
import { AuthStore } from '../src/identity/auth.store';
|
|
import { RecoveryStore } from '../src/identity/recovery.store';
|
|
import { AccessStore } from '../src/identity/access.store';
|
|
import { RateLimitService } from '../src/identity/rate-limit.service';
|
|
import { SessionStore } from '../src/identity/session.store';
|
|
import { issueToken, hashToken } from '../src/identity/tokens';
|
|
|
|
describe('identity transactional failure paths', () => {
|
|
let ctx: IdentityApp;
|
|
beforeAll(async () => {
|
|
ctx = await identityApp();
|
|
}, 60000);
|
|
afterAll(async () => {
|
|
await ctx?.close();
|
|
});
|
|
|
|
it('does not issue a session using an obsolete password hash', async () => {
|
|
const issued = issueToken();
|
|
await expect(
|
|
ctx.app
|
|
.get(AuthStore)
|
|
.createSession(
|
|
ctx.owner.userId,
|
|
'outdated',
|
|
issued.tokenHash,
|
|
new Date(Date.now() + 60000),
|
|
),
|
|
).rejects.toThrow();
|
|
expect(
|
|
await ctx.db.session.findUnique({
|
|
where: { tokenHash: issued.tokenHash },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
it('rejects a session whose account is no longer active even without explicit revocation', async () => {
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.owner.userId },
|
|
data: { status: 'SUSPENDED' },
|
|
});
|
|
await expect(
|
|
ctx.app.get(SessionStore).authenticate(ctx.token),
|
|
).rejects.toThrow();
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.owner.userId },
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
});
|
|
it('rejects expired recovery tokens without changing credentials', async () => {
|
|
const issued = issueToken();
|
|
await ctx.app
|
|
.get(RecoveryStore)
|
|
.create(ctx.owner.userId, issued.tokenHash, new Date(0));
|
|
await expect(
|
|
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
|
|
).rejects.toThrow('Invalid or expired');
|
|
expect((await ctx.login()).status).toBe(200);
|
|
});
|
|
it('rechecks account eligibility when storing and consuming recovery tokens', async () => {
|
|
const issued = issueToken();
|
|
await ctx.app
|
|
.get(RecoveryStore)
|
|
.create(ctx.owner.userId, issued.tokenHash, new Date(Date.now() + 60000));
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.owner.userId },
|
|
data: { status: 'SUSPENDED' },
|
|
});
|
|
expect(
|
|
await ctx.app
|
|
.get(RecoveryStore)
|
|
.create(ctx.owner.userId, issueToken().tokenHash, new Date()),
|
|
).toBe(false);
|
|
await expect(
|
|
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
|
|
).rejects.toThrow();
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.owner.userId },
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
});
|
|
it('resets expired rate-limit windows', async () => {
|
|
const limits = ctx.app.get(RateLimitService);
|
|
await limits.consume('test-bucket', 1, 60);
|
|
await expect(limits.consume('test-bucket', 1, 60)).rejects.toThrow(
|
|
'Too many',
|
|
);
|
|
await ctx.db.rateLimit.update({
|
|
where: { key: hashToken('test-bucket') },
|
|
data: { expiresAt: new Date(0) },
|
|
});
|
|
await expect(limits.consume('test-bucket', 1, 60)).resolves.toBeUndefined();
|
|
});
|
|
it('rechecks current permissions inside administration transactions', async () => {
|
|
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
|
|
const ownerRole = await ctx.db.role.findFirstOrThrow({
|
|
where: { isSystem: true },
|
|
});
|
|
const original = ownerRole.permissions;
|
|
await ctx.db.role.update({
|
|
where: { id: ownerRole.id },
|
|
data: { permissions: [] },
|
|
});
|
|
const work = jest.fn();
|
|
await expect(
|
|
ctx.app.get(AccessStore).mutate(actor, 'users.create', work),
|
|
).rejects.toThrow();
|
|
expect(work).not.toHaveBeenCalled();
|
|
await ctx.db.role.update({
|
|
where: { id: ownerRole.id },
|
|
data: { permissions: original },
|
|
});
|
|
});
|
|
it('preserves state when an audited transaction fails', async () => {
|
|
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
|
|
await expect(
|
|
ctx.app.get(AccessStore).mutate(actor, 'roles.manage', async (tx) => {
|
|
await tx.role.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
name: 'Rolled back',
|
|
permissions: [],
|
|
},
|
|
});
|
|
throw new Error('audit unavailable');
|
|
}),
|
|
).rejects.toThrow('audit unavailable');
|
|
expect(await ctx.db.role.count({ where: { name: 'Rolled back' } })).toBe(0);
|
|
});
|
|
it('uses the same public recovery response for an unknown account', async () => {
|
|
const response = await ctx
|
|
.api()
|
|
.post('/api/v1/auth/recovery/request')
|
|
.send({
|
|
organizationId: ctx.owner.organizationId,
|
|
email: 'unknown@example.com',
|
|
})
|
|
.expect(202);
|
|
expect(response.body.message).toContain('If the account is eligible');
|
|
});
|
|
});
|