- 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]>
238 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|