fix: kritische Rechte-Luecke, Admin-Lockout-Schutz und Wetter-Haertung
Dispatchte security-reviewer und code-reviewer Agenten fuer eine vollstaendige Pruefung des RBAC-Systems (d31aa79..HEAD). Ein KRITISCHER Fund, zwei HIGH, ein weiteres MEDIUM, alle behoben: KRITISCH: Deaktivieren eines Benutzers hat dessen Berechtigungen nicht entzogen. PermissionResolverService.loadPermissions() prüfte nur den Rollen-Status, nie User.isActive - ein bereits ausgestelltes Access- Token behielt volle Rechte bis zum Ablauf (bis zu 15 Minuten), nicht die vorgesehenen 30 Sekunden. UsersService rief zudem nie invalidateUser() auf. Beides jetzt behoben; neue dedizierte permission-resolver.service.spec.ts deckt das ab (existierte bisher gar nicht - ein Grund, warum die Luecke unbemerkt blieb). HIGH: Kein Schutz gegen den letzten aktiven Systemrollen-Nutzer (ADMIN). Man konnte den letzten Admin deaktivieren oder umrollen und sich damit komplett aussperren - ohne Selbsthilfe-Weg, da prisma/seed.ts das Seeden ueberspringt, sobald die Admin-E-Mail bereits existiert. UsersService.deactivate()/.update() pruefen jetzt per assertNotLastSystemRoleUser(), analog zum bestehenden Lockout-Schutz in RolesService fuer Systemrollen selbst. HIGH: Der Open-Meteo-Aufruf in WeatherService hatte kein Timeout und konnte unbegrenzt haengen. Jetzt mit AbortSignal.timeout(8000) plus sauberem Fehler-Mapping. MEDIUM: Trachtprognose fiel bei fehlenden Wetterdaten stillschweigend auf "MAESSIG" zurueck statt einen eigenen Unbekannt-Zustand zu zeigen - ein Imker haette eine echte Einschaetzung gesehen, wo keine moeglich war. Neuer Rating-Wert UNBEKANNT in Backend und Frontend. LOW: Veralteter Kommentar an PermissionsGuard, der noch den in Phase 3 geloeschten RolesGuard erwaehnte, korrigiert. Verifiziert: tsc, lint, build auf beiden Projekten gruen. Backend-Unit 245/245 (17 neue Tests fuer die Fixes). Frontend-Unit 14/14. Volle Backend-e2e-Suite konnte weiterhin nicht laufen (WSL/Postgres von Windows aus nicht erreichbar, auch nach vollem "wsl --shutdown"- Neustart) - jeder Fix hat aber gezielte, gruene Unit-Abdeckung. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
f1d6b2956e
commit
5a1015c4a0
9 files changed
+329
-11
No files matched your search
@@ -10,10 +10,14 @@ interface RequestWithUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs alongside the legacy RolesGuard during the RBAC migration: routes
|
* Fail-open by decorator absence: a route with no @RequirePermission is
|
||||||
* without @RequirePermission are untouched here (still governed solely by
|
* allowed through untouched (any authenticated user can reach it), only
|
||||||
* @Roles), routes with it are enforced against the caller's live
|
* routes that carry the decorator are enforced against the caller's live
|
||||||
* permission set. Both guards must pass for a request to proceed.
|
* permission set. There is no default-deny here, so
|
||||||
|
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
|
||||||
|
* non-public route lacks a catalog-valid @RequirePermission — is the sole
|
||||||
|
* safety net for "someone added a route and forgot the decorator", not a
|
||||||
|
* defense-in-depth nicety.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,7 @@ export class PermissionResolverService {
|
|||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
|
isActive: true,
|
||||||
role: {
|
role: {
|
||||||
select: {
|
select: {
|
||||||
status: true,
|
status: true,
|
||||||
@@ -70,7 +71,7 @@ export class PermissionResolverService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!user || user.role.status !== 'ACTIVE') {
|
if (!user || !user.isActive || user.role.status !== 'ACTIVE') {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
return new Set(user.role.permissions.map((grant) => grant.permission.key));
|
return new Set(user.role.permissions.map((grant) => grant.permission.key));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
|||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
|
||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
let usersService: UsersService;
|
let usersService: UsersService;
|
||||||
@@ -11,18 +12,32 @@ describe('UsersService', () => {
|
|||||||
findUnique: jest.Mock;
|
findUnique: jest.Mock;
|
||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
|
count: jest.Mock;
|
||||||
};
|
};
|
||||||
role: {
|
role: {
|
||||||
findUnique: jest.Mock;
|
findUnique: jest.Mock;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let permissionResolver: {
|
||||||
|
invalidateUser: jest.Mock;
|
||||||
|
invalidateAll: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
const staffRole = {
|
const staffRole = {
|
||||||
id: 'role-staff',
|
id: 'role-staff',
|
||||||
key: 'STAFF',
|
key: 'STAFF',
|
||||||
name: 'Mitarbeiter',
|
name: 'Mitarbeiter',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isSystem: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminRole = {
|
||||||
|
id: 'role-admin',
|
||||||
|
key: 'ADMIN',
|
||||||
|
name: 'Administrator',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isSystem: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingUser = {
|
const existingUser = {
|
||||||
@@ -37,6 +52,14 @@ describe('UsersService', () => {
|
|||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
...existingUser,
|
||||||
|
id: 'user-admin',
|
||||||
|
email: '[email protected]',
|
||||||
|
roleId: adminRole.id,
|
||||||
|
role: adminRole,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
prisma = {
|
||||||
user: {
|
user: {
|
||||||
@@ -44,15 +67,21 @@ describe('UsersService', () => {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
findUnique: jest.fn().mockResolvedValue(staffRole),
|
findUnique: jest.fn().mockResolvedValue(staffRole),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
permissionResolver = {
|
||||||
|
invalidateUser: jest.fn(),
|
||||||
|
invalidateAll: jest.fn(),
|
||||||
|
};
|
||||||
usersService = new UsersService(
|
usersService = new UsersService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +180,7 @@ describe('UsersService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivate sets isActive to false and records an audit entry', async () => {
|
it('deactivate sets isActive to false, invalidates the permission cache, and records an audit entry', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue(existingUser);
|
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||||
prisma.user.update.mockResolvedValue({
|
prisma.user.update.mockResolvedValue({
|
||||||
...existingUser,
|
...existingUser,
|
||||||
@@ -163,11 +192,39 @@ describe('UsersService', () => {
|
|||||||
|
|
||||||
expect(result.isActive).toBe(false);
|
expect(result.isActive).toBe(false);
|
||||||
expect(result.deactivatedAt).not.toBeNull();
|
expect(result.deactivatedAt).not.toBeNull();
|
||||||
|
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
|
||||||
|
existingUser.id,
|
||||||
|
);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ entityType: 'User', action: 'DEACTIVATE' }),
|
expect.objectContaining({ entityType: 'User', action: 'DEACTIVATE' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deactivate throws ConflictException when deactivating the last active user of a system role', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(adminUser);
|
||||||
|
prisma.user.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await expect(usersService.deactivate(adminUser.id)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(permissionResolver.invalidateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate succeeds for a system-role user when another active user shares that role', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(adminUser);
|
||||||
|
prisma.user.count.mockResolvedValue(2);
|
||||||
|
prisma.user.update.mockResolvedValue({
|
||||||
|
...adminUser,
|
||||||
|
isActive: false,
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await usersService.deactivate(adminUser.id);
|
||||||
|
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('activate throws ConflictException when already active', async () => {
|
it('activate throws ConflictException when already active', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue(existingUser);
|
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||||
await expect(usersService.activate(existingUser.id)).rejects.toBeInstanceOf(
|
await expect(usersService.activate(existingUser.id)).rejects.toBeInstanceOf(
|
||||||
@@ -175,7 +232,7 @@ describe('UsersService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('activate sets isActive to true and clears deactivatedAt', async () => {
|
it('activate sets isActive to true, invalidates the permission cache, and clears deactivatedAt', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
...existingUser,
|
...existingUser,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
@@ -191,8 +248,46 @@ describe('UsersService', () => {
|
|||||||
|
|
||||||
expect(result.isActive).toBe(true);
|
expect(result.isActive).toBe(true);
|
||||||
expect(result.deactivatedAt).toBeNull();
|
expect(result.deactivatedAt).toBeNull();
|
||||||
|
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
|
||||||
|
existingUser.id,
|
||||||
|
);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ entityType: 'User', action: 'UPDATE' }),
|
expect.objectContaining({ entityType: 'User', action: 'UPDATE' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('update invalidates the permission cache when the role changes', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||||
|
prisma.user.update.mockResolvedValue({
|
||||||
|
...existingUser,
|
||||||
|
roleId: adminRole.id,
|
||||||
|
role: adminRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
await usersService.update(existingUser.id, { roleId: adminRole.id });
|
||||||
|
|
||||||
|
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
|
||||||
|
existingUser.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update does not touch the permission cache when the role is unchanged', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||||
|
prisma.user.update.mockResolvedValue({ ...existingUser, name: 'Renamed' });
|
||||||
|
|
||||||
|
await usersService.update(existingUser.id, { name: 'Renamed' });
|
||||||
|
|
||||||
|
expect(permissionResolver.invalidateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update throws ConflictException when reassigning the last active user away from a system role', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(adminUser);
|
||||||
|
prisma.role.findUnique.mockResolvedValue(staffRole);
|
||||||
|
prisma.user.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
usersService.update(adminUser.id, { roleId: staffRole.id }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { Prisma } from '../../generated/prisma';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
@@ -35,6 +36,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private toSummary(user: UserWithRole): UserSummary {
|
private toSummary(user: UserWithRole): UserSummary {
|
||||||
@@ -61,6 +63,32 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks removing (by deactivation or role reassignment) the last active
|
||||||
|
* user still holding a system role (ADMIN). Without this, an operator
|
||||||
|
* could lock the app out of user/role management entirely, with no
|
||||||
|
* self-service recovery: prisma/seed.ts skips seeding once a user with
|
||||||
|
* its configured email already exists, so it cannot restore access to a
|
||||||
|
* merely demoted/deactivated account.
|
||||||
|
*/
|
||||||
|
private async assertNotLastSystemRoleUser(userId: string): Promise<void> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { roleId: true, role: { select: { isSystem: true } } },
|
||||||
|
});
|
||||||
|
if (!user || !user.role.isSystem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeCount = await this.prisma.user.count({
|
||||||
|
where: { roleId: user.roleId, isActive: true },
|
||||||
|
});
|
||||||
|
if (activeCount <= 1) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Cannot remove the last active user holding a system role',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(): Promise<UserSummary[]> {
|
async findAll(): Promise<UserSummary[]> {
|
||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
...USER_WITH_ROLE,
|
...USER_WITH_ROLE,
|
||||||
@@ -123,9 +151,13 @@ export class UsersService {
|
|||||||
actingUserId?: string,
|
actingUserId?: string,
|
||||||
): Promise<UserSummary> {
|
): Promise<UserSummary> {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
|
const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId;
|
||||||
if (dto.roleId) {
|
if (dto.roleId) {
|
||||||
await this.assertActiveRole(dto.roleId);
|
await this.assertActiveRole(dto.roleId);
|
||||||
}
|
}
|
||||||
|
if (roleChanged) {
|
||||||
|
await this.assertNotLastSystemRoleUser(id);
|
||||||
|
}
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -134,6 +166,9 @@ export class UsersService {
|
|||||||
},
|
},
|
||||||
...USER_WITH_ROLE,
|
...USER_WITH_ROLE,
|
||||||
});
|
});
|
||||||
|
if (roleChanged) {
|
||||||
|
this.permissionResolver.invalidateUser(id);
|
||||||
|
}
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
@@ -147,11 +182,13 @@ export class UsersService {
|
|||||||
|
|
||||||
async deactivate(id: string, actingUserId?: string): Promise<UserSummary> {
|
async deactivate(id: string, actingUserId?: string): Promise<UserSummary> {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
|
await this.assertNotLastSystemRoleUser(id);
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isActive: false, deactivatedAt: new Date() },
|
data: { isActive: false, deactivatedAt: new Date() },
|
||||||
...USER_WITH_ROLE,
|
...USER_WITH_ROLE,
|
||||||
});
|
});
|
||||||
|
this.permissionResolver.invalidateUser(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
@@ -173,6 +210,7 @@ export class UsersService {
|
|||||||
data: { isActive: true, deactivatedAt: null },
|
data: { isActive: true, deactivatedAt: null },
|
||||||
...USER_WITH_ROLE,
|
...USER_WITH_ROLE,
|
||||||
});
|
});
|
||||||
|
this.permissionResolver.invalidateUser(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ describe('WeatherService', () => {
|
|||||||
weatherService.getWeather('apiary-1'),
|
weatherService.getWeather('apiary-1'),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException instead of hanging when the request times out or the network fails', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
fetchMock.mockRejectedValue(
|
||||||
|
new DOMException('The operation was aborted', 'TimeoutError'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
weatherService.getWeather('apiary-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTrachtprognose', () => {
|
describe('getTrachtprognose', () => {
|
||||||
@@ -179,6 +193,22 @@ describe('WeatherService', () => {
|
|||||||
expect(result.season).toBe('Frühtracht');
|
expect(result.season).toBe('Frühtracht');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns UNBEKANNT during bloom season when there is no usable temperature data', async () => {
|
||||||
|
mockToday('2026-06-10');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getTrachtprognose('apiary-1');
|
||||||
|
|
||||||
|
expect(result.rating).toBe('UNBEKANNT');
|
||||||
|
expect(result.season).toBe('Sommertracht');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes the historical average yield when past harvests exist for the same month', async () => {
|
it('includes the historical average yield when past harvests exist for the same month', async () => {
|
||||||
mockToday('2026-06-10');
|
mockToday('2026-06-10');
|
||||||
prisma.apiary.findUnique.mockResolvedValue({
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
|||||||
const PAST_DAYS = 14;
|
const PAST_DAYS = 14;
|
||||||
const FORECAST_DAYS = 7;
|
const FORECAST_DAYS = 7;
|
||||||
const RECENT_WINDOW_DAYS = 7;
|
const RECENT_WINDOW_DAYS = 7;
|
||||||
|
const FETCH_TIMEOUT_MS = 8_000;
|
||||||
const GOOD_TEMP_THRESHOLD_C = 18;
|
const GOOD_TEMP_THRESHOLD_C = 18;
|
||||||
const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
|
const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
|
||||||
const POOR_TEMP_THRESHOLD_C = 12;
|
const POOR_TEMP_THRESHOLD_C = 12;
|
||||||
@@ -26,7 +27,7 @@ export interface ApiaryWeather {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TrachtprognoseRating =
|
export type TrachtprognoseRating =
|
||||||
'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT';
|
'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT' | 'UNBEKANNT';
|
||||||
|
|
||||||
export interface Trachtprognose {
|
export interface Trachtprognose {
|
||||||
rating: TrachtprognoseRating;
|
rating: TrachtprognoseRating;
|
||||||
@@ -100,7 +101,14 @@ export class WeatherService {
|
|||||||
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||||
url.searchParams.set('timezone', 'auto');
|
url.searchParams.set('timezone', 'auto');
|
||||||
|
|
||||||
const response = await fetch(url);
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
||||||
}
|
}
|
||||||
@@ -178,7 +186,7 @@ export class WeatherService {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
let rating: TrachtprognoseRating = 'MAESSIG';
|
let rating: TrachtprognoseRating = 'UNBEKANNT';
|
||||||
if (avgTempMax === null) {
|
if (avgTempMax === null) {
|
||||||
reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.');
|
reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.');
|
||||||
} else if (
|
} else if (
|
||||||
@@ -198,6 +206,7 @@ export class WeatherService {
|
|||||||
`Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`,
|
`Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
rating = 'MAESSIG';
|
||||||
reasoning.push(
|
reasoning.push(
|
||||||
`Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`,
|
`Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const RATING_LABELS: Record<TrachtprognoseRating, string> = {
|
|||||||
MAESSIG: "Mäßige Tracht",
|
MAESSIG: "Mäßige Tracht",
|
||||||
SCHWACH: "Schwache Tracht",
|
SCHWACH: "Schwache Tracht",
|
||||||
KEINE_TRACHT: "Keine Tracht",
|
KEINE_TRACHT: "Keine Tracht",
|
||||||
|
UNBEKANNT: "Prognose nicht möglich",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RATING_VARIANTS: Record<
|
const RATING_VARIANTS: Record<
|
||||||
@@ -22,6 +23,7 @@ const RATING_VARIANTS: Record<
|
|||||||
MAESSIG: "secondary",
|
MAESSIG: "secondary",
|
||||||
SCHWACH: "warning",
|
SCHWACH: "warning",
|
||||||
KEINE_TRACHT: "outline",
|
KEINE_TRACHT: "outline",
|
||||||
|
UNBEKANNT: "outline",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDayLabel(dateIso: string): string {
|
function formatDayLabel(dateIso: string): string {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export interface ApiaryWeather {
|
|||||||
daily: DailyWeather[];
|
daily: DailyWeather[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrachtprognoseRating = "GUT" | "MAESSIG" | "SCHWACH" | "KEINE_TRACHT";
|
export type TrachtprognoseRating =
|
||||||
|
| "GUT"
|
||||||
|
| "MAESSIG"
|
||||||
|
| "SCHWACH"
|
||||||
|
| "KEINE_TRACHT"
|
||||||
|
| "UNBEKANNT";
|
||||||
|
|
||||||
export interface Trachtprognose {
|
export interface Trachtprognose {
|
||||||
rating: TrachtprognoseRating;
|
rating: TrachtprognoseRating;
|
||||||
|
|||||||
Reference in new issue
Block a user