Files
ApiCore/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00

238 lines
7.3 KiB
TypeScript

import { PermissionResolverService } from './permission-resolver.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('PermissionResolverService', () => {
let resolver: PermissionResolverService;
let prisma: { user: { findUnique: jest.Mock } };
function activeUserWithPermissions(...keys: string[]) {
return {
isActive: true,
passwordChangedAt: null as Date | null,
role: {
status: 'ACTIVE',
permissions: keys.map((key) => ({ permission: { key } })),
},
};
}
beforeEach(() => {
prisma = { user: { findUnique: jest.fn() } };
resolver = new PermissionResolverService(
prisma as unknown as PrismaService,
);
});
it('returns the granted permission keys for an active user with an active role', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read', 'tasks:create'),
);
const permissions = await resolver.getPermissions('user-1');
expect(Array.from(permissions).sort()).toEqual(
['colonies:read', 'tasks:create'].sort(),
);
});
it('returns no permissions for a deactivated user, even if their role is active', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the role itself is deactivated', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const permissions = await resolver.getPermissions('missing');
expect(permissions.size).toBe(0);
});
it('caches the result and does not re-query within the TTL', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('re-queries after invalidateUser', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateUser('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('isTokenValid', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
it('returns true for an active user with no recorded password change', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
});
it('returns false for a deactivated user', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
passwordChangedAt: null,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns false when the role is inactive', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
passwordChangedAt: null,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns false when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(resolver.isTokenValid('missing', nowSeconds)).resolves.toBe(
false,
);
});
it('returns false when the token was issued before the last password change', async () => {
const entry = activeUserWithPermissions('colonies:read');
entry.passwordChangedAt = new Date((nowSeconds + 60) * 1000);
prisma.user.findUnique.mockResolvedValue(entry);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns true when the token was issued after the last password change', async () => {
const entry = activeUserWithPermissions('colonies:read');
entry.passwordChangedAt = new Date((nowSeconds - 60) * 1000);
prisma.user.findUnique.mockResolvedValue(entry);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
});
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.isTokenValid('user-1', nowSeconds);
await resolver.getPermissions('user-1');
// One read for the first getPermissions, one fresh read for the
// validity check - the second getPermissions uses that fresh entry.
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('sees a deactivation made elsewhere on the next request', async () => {
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
prisma.user.findUnique.mockResolvedValueOnce({
...activeUserWithPermissions('colonies:read'),
isActive: false,
});
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
});
it('re-queries after invalidateAll', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateAll();
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('hasAny', () => {
it('returns true when the user holds at least one of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(
resolver.hasAny('user-1', ['tasks:create', 'colonies:read']),
).resolves.toBe(true);
});
it('returns false when the user holds none of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.hasAny('user-1', ['tasks:create'])).resolves.toBe(
false,
);
});
it('returns true when no keys are required', async () => {
await expect(resolver.hasAny('user-1', [])).resolves.toBe(true);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
});
});