Files
ApiCore/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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]>
2026-10-14 21:13:23 +02:00

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();
});
});
});