diff --git a/ApiCore-Backend/src/auth/guards/permissions.guard.ts b/ApiCore-Backend/src/auth/guards/permissions.guard.ts index ae34c1c..ba35c12 100644 --- a/ApiCore-Backend/src/auth/guards/permissions.guard.ts +++ b/ApiCore-Backend/src/auth/guards/permissions.guard.ts @@ -10,10 +10,14 @@ interface RequestWithUser { } /** - * Runs alongside the legacy RolesGuard during the RBAC migration: routes - * without @RequirePermission are untouched here (still governed solely by - * @Roles), routes with it are enforced against the caller's live - * permission set. Both guards must pass for a request to proceed. + * Fail-open by decorator absence: a route with no @RequirePermission is + * allowed through untouched (any authenticated user can reach it), only + * routes that carry the decorator are enforced against the caller's live + * 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() export class PermissionsGuard implements CanActivate { diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts new file mode 100644 index 0000000..1a7da8a --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts index 5da9013..e17acaa 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts @@ -60,6 +60,7 @@ export class PermissionResolverService { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { + isActive: true, role: { select: { 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(user.role.permissions.map((grant) => grant.permission.key)); diff --git a/ApiCore-Backend/src/users/users.service.spec.ts b/ApiCore-Backend/src/users/users.service.spec.ts index 461d428..ba747c8 100644 --- a/ApiCore-Backend/src/users/users.service.spec.ts +++ b/ApiCore-Backend/src/users/users.service.spec.ts @@ -2,6 +2,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; import { UsersService } from './users.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; describe('UsersService', () => { let usersService: UsersService; @@ -11,18 +12,32 @@ describe('UsersService', () => { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; + count: jest.Mock; }; role: { findUnique: jest.Mock; }; }; let auditLog: { record: jest.Mock }; + let permissionResolver: { + invalidateUser: jest.Mock; + invalidateAll: jest.Mock; + }; const staffRole = { id: 'role-staff', key: 'STAFF', name: 'Mitarbeiter', status: 'ACTIVE', + isSystem: false, + }; + + const adminRole = { + id: 'role-admin', + key: 'ADMIN', + name: 'Administrator', + status: 'ACTIVE', + isSystem: true, }; const existingUser = { @@ -37,6 +52,14 @@ describe('UsersService', () => { deactivatedAt: null, }; + const adminUser = { + ...existingUser, + id: 'user-admin', + email: 'admin@example.com', + roleId: adminRole.id, + role: adminRole, + }; + beforeEach(() => { prisma = { user: { @@ -44,15 +67,21 @@ describe('UsersService', () => { findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), + count: jest.fn(), }, role: { findUnique: jest.fn().mockResolvedValue(staffRole), }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + permissionResolver = { + invalidateUser: jest.fn(), + invalidateAll: jest.fn(), + }; usersService = new UsersService( prisma as unknown as PrismaService, 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.update.mockResolvedValue({ ...existingUser, @@ -163,11 +192,39 @@ describe('UsersService', () => { expect(result.isActive).toBe(false); expect(result.deactivatedAt).not.toBeNull(); + expect(permissionResolver.invalidateUser).toHaveBeenCalledWith( + existingUser.id, + ); expect(auditLog.record).toHaveBeenCalledWith( 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 () => { prisma.user.findUnique.mockResolvedValue(existingUser); 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({ ...existingUser, isActive: false, @@ -191,8 +248,46 @@ describe('UsersService', () => { expect(result.isActive).toBe(true); expect(result.deactivatedAt).toBeNull(); + expect(permissionResolver.invalidateUser).toHaveBeenCalledWith( + existingUser.id, + ); expect(auditLog.record).toHaveBeenCalledWith( 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(); + }); }); diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts index 1177ed8..1a76811 100644 --- a/ApiCore-Backend/src/users/users.service.ts +++ b/ApiCore-Backend/src/users/users.service.ts @@ -6,6 +6,7 @@ import { import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { Prisma } from '../../generated/prisma'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -35,6 +36,7 @@ export class UsersService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly permissionResolver: PermissionResolverService, ) {} 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 { + 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 { const users = await this.prisma.user.findMany({ ...USER_WITH_ROLE, @@ -123,9 +151,13 @@ export class UsersService { actingUserId?: string, ): Promise { const before = await this.findById(id); + const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId; if (dto.roleId) { await this.assertActiveRole(dto.roleId); } + if (roleChanged) { + await this.assertNotLastSystemRoleUser(id); + } const user = await this.prisma.user.update({ where: { id }, data: { @@ -134,6 +166,9 @@ export class UsersService { }, ...USER_WITH_ROLE, }); + if (roleChanged) { + this.permissionResolver.invalidateUser(id); + } await this.auditLog.record({ entityType: 'User', entityId: id, @@ -147,11 +182,13 @@ export class UsersService { async deactivate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); + await this.assertNotLastSystemRoleUser(id); const user = await this.prisma.user.update({ where: { id }, data: { isActive: false, deactivatedAt: new Date() }, ...USER_WITH_ROLE, }); + this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, @@ -173,6 +210,7 @@ export class UsersService { data: { isActive: true, deactivatedAt: null }, ...USER_WITH_ROLE, }); + this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, diff --git a/ApiCore-Backend/src/weather/weather.service.spec.ts b/ApiCore-Backend/src/weather/weather.service.spec.ts index 0ae37f0..f0913f0 100644 --- a/ApiCore-Backend/src/weather/weather.service.spec.ts +++ b/ApiCore-Backend/src/weather/weather.service.spec.ts @@ -101,6 +101,20 @@ describe('WeatherService', () => { weatherService.getWeather('apiary-1'), ).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', () => { @@ -179,6 +193,22 @@ describe('WeatherService', () => { 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 () => { mockToday('2026-06-10'); prisma.apiary.findUnique.mockResolvedValue({ diff --git a/ApiCore-Backend/src/weather/weather.service.ts b/ApiCore-Backend/src/weather/weather.service.ts index 63ba5e9..3ebad3e 100644 --- a/ApiCore-Backend/src/weather/weather.service.ts +++ b/ApiCore-Backend/src/weather/weather.service.ts @@ -9,6 +9,7 @@ const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; const PAST_DAYS = 14; const FORECAST_DAYS = 7; const RECENT_WINDOW_DAYS = 7; +const FETCH_TIMEOUT_MS = 8_000; const GOOD_TEMP_THRESHOLD_C = 18; const GOOD_PRECIPITATION_THRESHOLD_MM = 15; const POOR_TEMP_THRESHOLD_C = 12; @@ -26,7 +27,7 @@ export interface ApiaryWeather { } export type TrachtprognoseRating = - 'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT'; + 'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT' | 'UNBEKANNT'; export interface Trachtprognose { rating: TrachtprognoseRating; @@ -100,7 +101,14 @@ export class WeatherService { url.searchParams.set('forecast_days', String(FORECAST_DAYS)); 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) { throw new BadRequestException('Wetterdaten konnten nicht geladen werden'); } @@ -178,7 +186,7 @@ export class WeatherService { 0, ); - let rating: TrachtprognoseRating = 'MAESSIG'; + let rating: TrachtprognoseRating = 'UNBEKANNT'; if (avgTempMax === null) { reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.'); } 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.`, ); } else { + rating = 'MAESSIG'; reasoning.push( `Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`, ); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx index ff89c31..e1a2a8c 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx @@ -12,6 +12,7 @@ const RATING_LABELS: Record = { MAESSIG: "Mäßige Tracht", SCHWACH: "Schwache Tracht", KEINE_TRACHT: "Keine Tracht", + UNBEKANNT: "Prognose nicht möglich", }; const RATING_VARIANTS: Record< @@ -22,6 +23,7 @@ const RATING_VARIANTS: Record< MAESSIG: "secondary", SCHWACH: "warning", KEINE_TRACHT: "outline", + UNBEKANNT: "outline", }; function formatDayLabel(dateIso: string): string { diff --git a/ApiCore-Frontend/src/lib/weather/types.ts b/ApiCore-Frontend/src/lib/weather/types.ts index ac51cc7..bea98e5 100644 --- a/ApiCore-Frontend/src/lib/weather/types.ts +++ b/ApiCore-Frontend/src/lib/weather/types.ts @@ -9,7 +9,12 @@ export interface ApiaryWeather { daily: DailyWeather[]; } -export type TrachtprognoseRating = "GUT" | "MAESSIG" | "SCHWACH" | "KEINE_TRACHT"; +export type TrachtprognoseRating = + | "GUT" + | "MAESSIG" + | "SCHWACH" + | "KEINE_TRACHT" + | "UNBEKANNT"; export interface Trachtprognose { rating: TrachtprognoseRating;