feat: add ThresholdAdminController and AuditLogController with integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 15:57:23 +01:00
parent c6da858c2f
commit 321b8808cc
4 changed files with 367 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AuditLogPageResponse;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditRepository;
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
import com.cameleer3.server.core.admin.AuditRepository.AuditQuery;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
@RestController
@RequestMapping("/api/v1/admin/audit")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Audit Log", description = "Audit log viewer (ADMIN only)")
public class AuditLogController {
private final AuditRepository auditRepository;
public AuditLogController(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
@GetMapping
@Operation(summary = "Search audit log entries with pagination")
public ResponseEntity<AuditLogPageResponse> getAuditLog(
@RequestParam(required = false) String username,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
@RequestParam(defaultValue = "timestamp") String sort,
@RequestParam(defaultValue = "desc") String order,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
size = Math.min(size, 100);
Instant fromInstant = from != null ? from.atStartOfDay(ZoneOffset.UTC).toInstant() : null;
Instant toInstant = to != null ? to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : null;
AuditCategory cat = null;
if (category != null && !category.isEmpty()) {
try {
cat = AuditCategory.valueOf(category.toUpperCase());
} catch (IllegalArgumentException ignored) {
// invalid category is treated as no filter
}
}
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
AuditPage result = auditRepository.find(query);
int totalPages = Math.max(1, (int) Math.ceil((double) result.totalCount() / size));
return ResponseEntity.ok(new AuditLogPageResponse(
result.items(), result.totalCount(), page, size, totalPages));
}
}

View File

@@ -0,0 +1,62 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ThresholdConfigRequest;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.admin.ThresholdConfig;
import com.cameleer3.server.core.admin.ThresholdRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/thresholds")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Threshold Admin", description = "Monitoring threshold configuration (ADMIN only)")
public class ThresholdAdminController {
private final ThresholdRepository thresholdRepository;
private final AuditService auditService;
public ThresholdAdminController(ThresholdRepository thresholdRepository, AuditService auditService) {
this.thresholdRepository = thresholdRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Get current threshold configuration")
public ResponseEntity<ThresholdConfig> getThresholds() {
ThresholdConfig config = thresholdRepository.find().orElse(ThresholdConfig.defaults());
return ResponseEntity.ok(config);
}
@PutMapping
@Operation(summary = "Update threshold configuration")
public ResponseEntity<ThresholdConfig> updateThresholds(@Valid @RequestBody ThresholdConfigRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
if (!errors.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
ThresholdConfig config = request.toConfig();
thresholdRepository.save(config, null);
auditService.log("update_thresholds", AuditCategory.CONFIG, "thresholds",
Map.of("config", config), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(config);
}
}