- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
291 lines
9.1 KiB
TypeScript
291 lines
9.1 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('getFreshPermissions', () => {
|
|
it('the permission check reads fresh, not the 30 s cache', async () => {
|
|
prisma.user.findUnique.mockResolvedValueOnce(
|
|
activeUserWithPermissions('tasks:read'),
|
|
);
|
|
await resolver.getPermissions('user-1');
|
|
// The right is revoked on another replica: this process has no
|
|
// invalidateUser call, the cache still holds the old set.
|
|
prisma.user.findUnique.mockResolvedValueOnce(
|
|
activeUserWithPermissions('colonies:read'),
|
|
);
|
|
|
|
const fresh = await resolver.getFreshPermissions('user-1');
|
|
|
|
expect([...fresh]).toEqual(['colonies:read']);
|
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('fills the cache for getPermissions', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(
|
|
activeUserWithPermissions('tasks:read'),
|
|
);
|
|
|
|
await resolver.getFreshPermissions('user-1');
|
|
const cached = await resolver.getPermissions('user-1');
|
|
|
|
expect([...cached]).toEqual(['tasks:read']);
|
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is empty for an inactive user, an inactive role and a missing user', async () => {
|
|
prisma.user.findUnique.mockResolvedValueOnce({
|
|
isActive: false,
|
|
role: {
|
|
status: 'ACTIVE',
|
|
permissions: [{ permission: { key: 'tasks:read' } }],
|
|
},
|
|
});
|
|
prisma.user.findUnique.mockResolvedValueOnce({
|
|
isActive: true,
|
|
role: {
|
|
status: 'INACTIVE',
|
|
permissions: [{ permission: { key: 'tasks:read' } }],
|
|
},
|
|
});
|
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
|
|
|
expect((await resolver.getFreshPermissions('u1')).size).toBe(0);
|
|
expect((await resolver.getFreshPermissions('u2')).size).toBe(0);
|
|
expect((await resolver.getFreshPermissions('u3')).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|