# Plan: Code-Review-Fixes — Kritische und hohe Befunde ## Task-Übersicht | ID | Beschreibung | Schwere | Abhängigkeit | |----|-------------|---------|--------------| | T1 | GlobalExceptionHandler: MethodArgumentNotValidException-Handler | KRITISCH | — | | T2 | GlobalExceptionHandler: AtNumberAlreadyExistsException-Handler fix | HOCH | — | | T3 | EnergyCommunityAdminController: @PreAuthorize hinzufügen | KRITISCH | — | | T4 | NotificationController/Service: IDOR-Fix (markAsRead) | KRITISCH | — | | T5 | AdminIamService.rejectUser: ResponseStatusException → IAE/ISE | HOCH | — | | T6 | EnergyCommunityService: ResponseStatusException → IAE/ISE | HOCH | — | | T7 | TariffService: validateUserTariffRequest() extrahieren | HOCH | — | | T8 | MeteringPoint.memberships: orphanRemoval=true | HOCH | — | | T9 | application-prod.yml: ddl-auto Default auf validate | HOCH | — | | T10 | AdminIamServiceTest: rejectUser-Tests aktualisieren | HOCH | T5 | | T11 | GlobalExceptionHandlerTest: neue Tests schreiben | HOCH | T1, T2 | | T12 | NotificationServiceTest: Ownership-Check testen | HOCH | T4 | | T13 | TariffServiceTest: Tests nach Refactoring prüfen | HOCH | T7 | | T14 | Alle Backend-Tests ausführen | HOCH | T1–T13 | --- ## T1: GlobalExceptionHandler — MethodArgumentNotValidException-Handler **Beschreibung:** Füge einen `@ExceptionHandler(MethodArgumentNotValidException.class)` hinzu, der Spring's `@Valid`-Fehler im konsistenten `ErrorResponse`-Format zurückgibt. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandler.java` **Akzeptanz:** - [ ] Handler vorhanden mit `@ExceptionHandler(MethodArgumentNotValidException.class)` - [ ] Extrahiert `BindingResult`-Fehler als komma-separierte Nachricht - [ ] Gibt 400 BAD_REQUEST mit `ErrorResponse` zurück - [ ] Logging auf WARN-Level **Details:** ```java @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .collect(Collectors.joining(", ")); log.warn("Validation error: {}", message); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(new ErrorResponse(message, HttpStatus.BAD_REQUEST.value())); } ``` Import: `org.springframework.validation.BindException` oder `MethodArgumentNotValidException` + `org.springframework.web.bind.MethodArgumentNotValidException` + `java.util.stream.Collectors`. --- ## T2: GlobalExceptionHandler — AtNumberAlreadyExistsException-Handler fix **Beschreibung:** Ersetze den hardcoded String durch `ex.getMessage()`. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandler.java` **Akzeptanz:** - [ ] Zeile 59: `"Zählerpunkt existiert bereits"` → `ex.getMessage()` - [ ] Kompiliert ohne Fehler **Details:** Vorher: ```java .body(new ErrorResponse("Zählerpunkt existiert bereits", HttpStatus.CONFLICT.value())); ``` Nachher: ```java .body(new ErrorResponse(ex.getMessage(), HttpStatus.CONFLICT.value())); ``` --- ## T3: EnergyCommunityAdminController — @PreAuthorize hinzufügen **Beschreibung:** Füge `@PreAuthorize("hasRole('ADMIN')")` auf Klassenebene hinzu, konsistent mit `AdminIamController`. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/EnergyCommunityAdminController.java` **Akzeptanz:** - [ ] `@PreAuthorize("hasRole('ADMIN')")` auf Klassenebene (nach `@RequestMapping`) - [ ] Import für `org.springframework.security.access.prepost.PreAuthorize` vorhanden - [ ] Kompiliert ohne Fehler **Details:** ```java @RestController @RequestMapping("/api/admin/energy-communities") @PreAuthorize("hasRole('ADMIN')") @RequiredArgsConstructor public class EnergyCommunityAdminController { ``` --- ## T4: NotificationController/Service — IDOR-Fix (markAsRead) **Beschreibung:** Füge `@CurrentUserId` zum Controller hinzu und einen Ownership-Check im Service. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/common/api/NotificationController.java` - `eeg_backend/src/main/java/at/mueller/eeg/backend/common/service/NotificationService.java` **Akzeptanz:** - [ ] Controller: `markAsRead(@PathVariable UUID id, @CurrentUserId String userId)` - [ ] Controller ruft `notificationService.markAsRead(id, UUID.fromString(userId))` - [ ] Service: `markAsRead(UUID id, UUID userId)` — Ownership-Check - [ ] Wenn `notification.getUserId().equals(userId)` nicht zutrifft: `AccessDeniedException` - [ ] Import für `org.springframework.security.access.AccessDeniedException` im Service - [ ] Kompiliert ohne Fehler **Details:** Controller: ```java @PutMapping("/{id}/read") @PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") public ResponseEntity markAsRead(@PathVariable UUID id, @CurrentUserId String userId) { notificationService.markAsRead(id, UUID.fromString(userId)); return ResponseEntity.ok().build(); } ``` Service: ```java @Transactional public void markAsRead(UUID id, UUID userId) { Notification notification = notificationRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Notification nicht gefunden: " + id)); if (!notification.getUserId().equals(userId)) { throw new AccessDeniedException("Keine Berechtigung, diese Notification als gelesen zu markieren."); } notification.setRead(true); notificationRepository.save(notification); } ``` --- ## T5: AdminIamService.rejectUser — ResponseStatusException → IAE/ISE **Beschreibung:** Ersetze `ResponseStatusException` durch `IllegalArgumentException`/`IllegalStateException`, konsistent mit `approveUser`. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AdminIamService.java` **Akzeptanz:** - [ ] Zeile 56: `ResponseStatusException(NOT_FOUND)` → `IllegalArgumentException("User nicht gefunden")` - [ ] Zeile 59: `ResponseStatusException(BAD_REQUEST)` → `IllegalStateException("Nur User im Status PENDING können abgelehnt werden.")` - [ ] Importe für `HttpStatus` und `ResponseStatusException` können entfernt werden (prüfen ob noch used) - [ ] Kompiliert ohne Fehler **Details:** ```java @Transactional public void rejectUser(UUID userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden")); if (user.getStatus() != RegistrationStatus.PENDING) { throw new IllegalStateException("Nur User im Status PENDING können abgelehnt werden."); } user.setStatus(RegistrationStatus.REJECTED); userRepository.save(user); eventPublisher.publishEvent(new UserRejectedEvent(user.getId())); } ``` --- ## T6: EnergyCommunityService — ResponseStatusException → IAE/ISE **Beschreibung:** Ersetze alle `ResponseStatusException` durch `IllegalArgumentException`/`IllegalStateException`. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/EnergyCommunityService.java` **Akzeptanz:** - [ ] `update()`: `ResponseStatusException(NOT_FOUND)` → `IllegalArgumentException("Energiegemeinschaft nicht gefunden: " + id)` - [ ] `delete()`: `ResponseStatusException(CONFLICT)` → `IllegalStateException("Die Energiegemeinschaft kann nicht gelöscht werden, da noch Zählpunkte zugeordnet sind.")` - [ ] Importe für `HttpStatus` und `ResponseStatusException` können entfernt werden - [ ] Kompiliert ohne Fehler **Details:** ```java @Transactional public EnergyCommunityDto update(UUID id, EnergyCommunityDto dto) { EnergyCommunity existingEntity = energyCommunityRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Energiegemeinschaft nicht gefunden: " + id)); // ... rest unchanged } @Transactional public void delete(UUID id) { if (!energyCommunityRepository.existsById(id)) { throw new IllegalArgumentException("Energy Community mit ID " + id + " existiert nicht."); } if (membershipRepository.existsByEnergyCommunityId(id)) { throw new IllegalStateException("Die Energiegemeinschaft kann nicht gelöscht werden, da noch Zählpunkte zugeordnet sind."); } energyCommunityRepository.deleteById(id); } ``` --- ## T7: TariffService — validateUserTariffRequest() extrahieren **Beschreibung:** Extrahiere die gemeinsame Validierungslogik in eine private Methode. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java` **Akzeptanz:** - [ ] Private Methode `validateUserTariffRequest(UUID communityId, UserTariffRequest request)` vorhanden - [ ] Enthält: source != target, Points vorhanden, Points ACTIVE, Community-Tarif vorhanden, Preis <= Max, beide User aktive Members - [ ] `createUserTariff` ruft `validateUserTariffRequest` auf + Duplicate-Tarif-Check + Invite-Check - [ ] `updateUserTariff` ruft `validateUserTariffRequest` auf + Ownership-Check (Ownership-Check bleibt vor der Validierung) - [ ] Alle 25 bestehenden TariffServiceTest weiterhin grün - [ ] Kompiliert ohne Fehler **Details:** Die Methode nimmt `communityId` und `request`, lädt die Points, und wirft Exceptions. Rückgabe: Pair von `(sourcePoint, targetPoint)` odervoid (die Points werden für die Ownership-Check-Logik in updateUserTariff nicht gebraucht, da der Ownership-Check vorher erfolgt). ```java private void validateUserTariffRequest(UUID communityId, UserTariffRequest request) { if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) { throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein."); } MeteringPoint sourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId()) .orElseThrow(() -> new IllegalArgumentException( "Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId())); MeteringPoint targetPoint = meteringPointRepository.findById(request.targetMeteringPointId()) .orElseThrow(() -> new IllegalArgumentException( "Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId())); if (sourcePoint.getMakoState() != MakoState.ACTIVE) { throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv."); } if (targetPoint.getMakoState() != MakoState.ACTIVE) { throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv."); } CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(communityId) .orElseThrow(() -> new IllegalArgumentException( "Kein Community-Tarif für Energiegemeinschaft vorhanden: " + communityId)); if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) { throw new IllegalStateException( "Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " + "Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh"); } UUID sourceUserId = sourcePoint.getUserId(); UUID targetUserId = targetPoint.getUserId(); if (!membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)) { throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft."); } if (!membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)) { throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft."); } } ``` **Achtung:** `updateUserTariff` muss den Ownership-Check BEIDER Validierung durchführen, da der eigene SourcePoint für den Ownership-Check gebraucht wird. Die Validierungsmethode lädt die Points ohnehin, aber der Ownership-Check muss vorher erfolgen (auf dem ursprünglichen tariff.getSourceMeteringPointId(), nicht auf request.sourceMeteringPointId()). --- ## T8: MeteringPoint.memberships — orphanRemoval=true **Beschreibung:** Füge `orphanRemoval = true` zum `@OneToMany`-Mapping hinzu. **Dateien:** - `eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringPoint.java` **Akzeptanz:** - [ ] Zeile 51: `cascade = CascadeType.ALL` → `cascade = CascadeType.ALL, orphanRemoval = true` - [ ] Kompiliert ohne Fehler **Details:** ```java @OneToMany(mappedBy = "meteringPoint", cascade = CascadeType.ALL, orphanRemoval = true) private List memberships; ``` --- ## T9: application-prod.yml — ddl-auto Default auf validate **Beschreibung:** Ändere den Default-Wert für `ddl-auto` von `update` auf `validate`. **Dateien:** - `eeg_backend/src/main/resources/application-prod.yml` **Akzeptanz:** - [ ] Zeile 9: `ddl-auto: ${JPA_DDL_AUTO:update}` → `ddl-auto: ${JPA_DDL_AUTO:validate}` - [ ] Environment-Variable `JPA_DDL_AUTO` kann weiterhin überschreiben **Details:** ```yaml ddl-auto: ${JPA_DDL_AUTO:validate} ``` --- ## T10: AdminIamServiceTest — rejectUser-Tests aktualisieren **Beschreibung:** Aktualisiere die Tests für `rejectUser`, da die Exception-Typen sich ändern (ResponseStatusException → IllegalArgumentException/IllegalStateException). **Dateien:** - `eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/AdminIamServiceTest.java` **Akzeptanz:** - [ ] `rejectUser_notPending_throws()`: `assertThrows(ResponseStatusException.class, ...)` → `assertThrows(IllegalStateException.class, ...)` - [ ] `rejectUser_notFound_throws()`: `assertThrows(ResponseStatusException.class, ...)` → `assertThrows(IllegalArgumentException.class, ...)` - [ ] Import für `ResponseStatusException` kann entfernt werden (prüfen ob noch used) - [ ] Alle Tests grün **Details:** ```java @Test void rejectUser_notPending_throws() { testUser.setStatus(RegistrationStatus.APPROVED); when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser)); assertThrows(IllegalStateException.class, () -> adminIamService.rejectUser(testUser.getId())); verify(userRepository, never()).save(any()); } @Test void rejectUser_notFound_throws() { UUID unknownId = UUID.randomUUID(); when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); assertThrows(IllegalArgumentException.class, () -> adminIamService.rejectUser(unknownId)); } ``` --- ## T11: GlobalExceptionHandlerTest — neue Tests schreiben **Beschreibung:** Erstelle Unit-Tests für den GlobalExceptionHandler. **Dateien:** - `eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java` (neu) **Akzeptanz:** - [ ] Test für `MethodArgumentNotValidException` → 400 mit Fehlermeldungen - [ ] Test für `AtNumberAlreadyExistsException` → 409 mit ex.getMessage() - [ ] Test für `IllegalArgumentException` → 400 - [ ] Test für `IllegalStateException` → 409 - [ ] Test für `AccessDeniedException` → 403 - [ ] Test für `BadCredentialsException` → 401 - [ ] Test für `DisabledException` → 403 - [ ] Test für `Exception` (catch-all) → 500 - [ ] Alle Tests grün **Details:** ```java @ExtendWith(MockitoExtension.class) class GlobalExceptionHandlerTest { private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); @Test void handleValidation_returns400WithFieldErrors() { // Mock MethodArgumentNotValidException mit BindingResult // Prüfe: 400, ErrorResponse mit Feldfehlern } @Test void handleAtNumberAlreadyExists_returns409WithMessage() { AtNumberAlreadyExistsException ex = new AtNumberAlreadyExistsException("AT-Nummer 'AT123' existiert bereits."); ResponseEntity response = handler.handleAtNumberAlreadyExists(ex); assertEquals(409, response.getStatusCode().value()); assertEquals("AT-Nummer 'AT123' existiert bereits.", response.getBody().message()); } // ... weitere Tests } ``` --- ## T12: NotificationServiceTest — Ownership-Check testen **Beschreibung:** Aktualisiere den Test für `markAsRead` und füge Ownership-Check-Tests hinzu. **Dateien:** - `eeg_backend/src/test/java/at/mueller/eeg/backend/common/service/NotificationServiceTest.java` **Akzeptanz:** - [ ] `markAsRead_setsReadToTrue()` aktualisiert: Aufruf mit `(id, userId)` - [ ] Neuer Test: `markAsRead_throwsIfNotOwner()` — Ownership-Check - [ ] Neuer Test: `markAsRead_throwsIfNotFound()` — nicht existierende ID - [ ] Alle Tests grün **Details:** ```java @Test void markAsRead_setsReadToTrue() { when(notificationRepository.findById(testNotification.getId())) .thenReturn(Optional.of(testNotification)); when(notificationRepository.save(any())).thenReturn(testNotification); notificationService.markAsRead(testNotification.getId(), testUserId); assertTrue(testNotification.isRead()); verify(notificationRepository).save(testNotification); } @Test void markAsRead_throwsIfNotOwner() { UUID otherUserId = UUID.randomUUID(); when(notificationRepository.findById(testNotification.getId())) .thenReturn(Optional.of(testNotification)); assertThrows(AccessDeniedException.class, () -> notificationService.markAsRead(testNotification.getId(), otherUserId)); verify(notificationRepository, never()).save(any()); } @Test void markAsRead_throwsIfNotFound() { UUID unknownId = UUID.randomUUID(); when(notificationRepository.findById(unknownId)).thenReturn(Optional.empty()); assertThrows(IllegalArgumentException.class, () -> notificationService.markAsRead(unknownId, testUserId)); } ``` --- ## T13: TariffServiceTest — Tests nach Refactoring prüfen **Beschreibung:** Führe alle 25 TariffServiceTests aus und prüfe, ob sie nach dem Refactoring (T7) weiterhin grün sind. **Dateien:** - `eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java` **Akzeptanz:** - [ ] Alle 25 Tests grün - [ ] Keine neuen Tests nötig (Refactoring ohne Verhaltensänderung) --- ## T14: Alle Backend-Tests ausführen **Beschreibung:** Führe die gesamte Backend-Test-Suite aus, um Regressionen auszuschließen. **Befehl:** ```bash .\mvnw test -pl eeg_backend ``` **Akzeptanz:** - [ ] Alle Tests grün - [ ] Keine Compile-Fehler