Files
ApiCore/ApiCore-Backend/test/roles.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 5d11756a87 fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore:

- Bottling booked the honey batch amount from a value read outside the
  transaction and wrote an absolute result back, so two concurrent
  bottlings (double tap, two devices) could both pass the check and one
  deduction was lost. Bookings now use a conditional UPDATE inside the
  transaction; corrections and deletions give amounts back atomically.
- Outgoing stock movements could drive stock below zero; the removal is
  now a conditional decrement that refuses when stock is insufficient.
- Weak, conspicuous and queenless colonies were treated as "not active"
  and could not be moved, split or merged - uniting a queenless colony
  is the textbook merge. Only terminal statuses lock a colony now; merging
  a colony into itself and dissolving twice are rejected.
- Moving a queen to another colony failed on the unique currentQueenId
  because the old colony kept pointing at her. The old colony now releases
  her (and becomes QUEENLESS); a queenless colony receiving a queen becomes
  ACTIVE; dissolved colonies cannot receive a queen.
- Completing a task twice moved completedAt and re-sent the webhook;
  reopening a task kept completedAt. Recurring templates created tasks
  without a due date and caught up missed weeks one stale task at a time.
- Automation rules created a new task and warning on every trigger (every
  stock removal below minimum). Tasks now record their source trigger and
  at most one open task per rule and object exists. The varroa threshold
  (mites per day) is only compared with natural-mite-fall rates, not with
  sample-based counts in another unit.
- New opt-in Idempotency-Key support for record-creating endpoints, so
  the offline queue can retry after a lost response without duplicates.
- Permission catalog sync crashed replicas booting concurrently on a
  fresh database (unique violation); it now uses createMany/skipDuplicates.
- Server-side validation for inspection rating scales (1-5), unique
  colony/harvest lists and net weight <= gross weight.
- Stale e2e expectations updated (permission count, trigger list, real PNG
  fixture for the file-signature check).

Migration is additive only (nullable Task column, new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:27:45 +02:00

241 lines
7.6 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
interface RoleBody {
id: string;
key: string;
name: string;
isSystem: boolean;
status: string;
userCount: number;
permissionKeys: string[];
}
interface MeBody {
id: string;
email: string;
roleKey: string;
permissions: string[];
}
describe('Roles (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const adminEmail = `roles-e2e-admin-${Date.now()}@example.com`;
const staffEmail = `roles-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let adminId: string;
let staffId: string;
let adminToken: string;
let staffToken: string;
let adminRoleId: string;
let staffRoleId: string;
let createdRoleId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
const staffRole = await prisma.role.findUniqueOrThrow({
where: { key: 'STAFF' },
});
adminRoleId = adminRole.id;
staffRoleId = staffRole.id;
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Roles E2E Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Roles E2E Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
adminToken = (await login(app, adminEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
});
afterAll(async () => {
if (createdRoleId) {
await prisma.rolePermission.deleteMany({
where: { roleId: createdRoleId },
});
await prisma.role.delete({ where: { id: createdRoleId } });
}
const userIds = [adminId, staffId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
it('rejects a caller without roles:read from listing roles', () => {
return request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${staffToken}`)
.expect(403);
});
it('lets an admin list the seeded system roles', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const roles = response.body as RoleBody[];
const keys = roles.map((role) => role.key);
expect(keys).toEqual(
expect.arrayContaining([
'ADMIN',
'BEEKEEPER',
'STAFF',
'READONLY',
'API_CLIENT',
]),
);
const admin = roles.find((role) => role.key === 'ADMIN');
expect(admin?.isSystem).toBe(true);
expect(admin?.permissionKeys.length).toBe(PERMISSION_CATALOG.length);
});
it('exposes the full permission catalog', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles/permission-catalog')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect((response.body as unknown[]).length).toBe(PERMISSION_CATALOG.length);
});
it('lets an admin create a role with a chosen permission set', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Inspector (e2e)',
description: 'Read-only inspections role for e2e coverage',
permissionKeys: ['inspections:read', 'colonies:read'],
})
.expect(201);
const created = response.body as RoleBody;
createdRoleId = created.id;
expect(created.permissionKeys.sort()).toEqual(
['colonies:read', 'inspections:read'].sort(),
);
});
it('rejects creating a role with a duplicate key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Duplicate',
permissionKeys: ['colonies:read'],
})
.expect(409);
});
it('rejects an unknown permission key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'BOGUS_E2E',
name: 'Bogus',
permissionKeys: ['not-a-real-permission'],
})
.expect(400);
});
it('lets an admin update a role permission set', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/roles/${createdRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ permissionKeys: ['tasks:read'] })
.expect(200);
const updated = response.body as RoleBody;
expect(updated.permissionKeys).toEqual(['tasks:read']);
});
it('blocks editing the system ADMIN role', () => {
return request(app.getHttpServer())
.patch(`/api/v1/roles/${adminRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'Renamed' })
.expect(403);
});
it('blocks deactivating the system ADMIN role', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${adminRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(403);
});
it('blocks deactivating a role that still has users assigned', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${staffRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('deactivates and reactivates an unused custom role', async () => {
const deactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((deactivated.body as RoleBody).status).toBe('INACTIVE');
const reactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/reactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((reactivated.body as RoleBody).status).toBe('ACTIVE');
});
it("reflects the caller's role and effective permissions on /auth/me", async () => {
const adminMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const adminProfile = adminMe.body as MeBody;
expect(adminProfile.roleKey).toBe('ADMIN');
expect(adminProfile.permissions.length).toBe(PERMISSION_CATALOG.length);
const staffMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const staffProfile = staffMe.body as MeBody;
expect(staffProfile.roleKey).toBe('STAFF');
expect(staffProfile.permissions).toEqual(
expect.arrayContaining(['colonies:read', 'tasks:create']),
);
expect(staffProfile.permissions).not.toEqual(
expect.arrayContaining(['storage:create', 'users:read']),
);
});
});