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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer3.server.app.TestSecurityHelper;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuditLogControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
@Autowired
|
||||
private AuditService auditService;
|
||||
|
||||
private String adminJwt;
|
||||
private String viewerJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminJwt = securityHelper.adminToken();
|
||||
viewerJwt = securityHelper.viewerToken();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuditLog_asAdmin_returns200() throws Exception {
|
||||
// Insert a test audit entry
|
||||
auditService.log("test-admin", "test_action", AuditCategory.CONFIG,
|
||||
"test-target", Map.of("key", "value"), AuditResult.SUCCESS, null);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/audit", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.has("items")).isTrue();
|
||||
assertThat(body.has("totalCount")).isTrue();
|
||||
assertThat(body.get("totalCount").asLong()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuditLog_asViewer_returns403() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/audit", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuditLog_withCategoryFilter_returnsFilteredResults() throws Exception {
|
||||
auditService.log("filter-test", "infra_action", AuditCategory.INFRA,
|
||||
"infra-target", null, AuditResult.SUCCESS, null);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/audit?category=INFRA", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("items").isArray()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuditLog_withPagination_respectsPageSize() throws Exception {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/audit?page=0&size=5", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("pageSize").asInt()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuditLog_maxPageSizeEnforced() throws Exception {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/audit?size=500", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.get("pageSize").asInt()).isEqualTo(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer3.server.app.TestSecurityHelper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ThresholdAdminControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String adminJwt;
|
||||
private String viewerJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminJwt = securityHelper.adminToken();
|
||||
viewerJwt = securityHelper.viewerToken();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThresholds_asAdmin_returnsDefaults() throws Exception {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/thresholds", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.has("database")).isTrue();
|
||||
assertThat(body.has("opensearch")).isTrue();
|
||||
assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(80);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThresholds_asViewer_returns403() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/thresholds", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateThresholds_asAdmin_returns200() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"database": {
|
||||
"connectionPoolWarning": 70,
|
||||
"connectionPoolCritical": 90,
|
||||
"queryDurationWarning": 2.0,
|
||||
"queryDurationCritical": 15.0
|
||||
},
|
||||
"opensearch": {
|
||||
"clusterHealthWarning": "YELLOW",
|
||||
"clusterHealthCritical": "RED",
|
||||
"queueDepthWarning": 200,
|
||||
"queueDepthCritical": 1000,
|
||||
"jvmHeapWarning": 80,
|
||||
"jvmHeapCritical": 95,
|
||||
"failedDocsWarning": 5,
|
||||
"failedDocsCritical": 20
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/thresholds", HttpMethod.PUT,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(70);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateThresholds_invalidWarningGreaterThanCritical_returns400() {
|
||||
String json = """
|
||||
{
|
||||
"database": {
|
||||
"connectionPoolWarning": 95,
|
||||
"connectionPoolCritical": 80,
|
||||
"queryDurationWarning": 2.0,
|
||||
"queryDurationCritical": 15.0
|
||||
},
|
||||
"opensearch": {
|
||||
"clusterHealthWarning": "YELLOW",
|
||||
"clusterHealthCritical": "RED",
|
||||
"queueDepthWarning": 100,
|
||||
"queueDepthCritical": 500,
|
||||
"jvmHeapWarning": 75,
|
||||
"jvmHeapCritical": 90,
|
||||
"failedDocsWarning": 1,
|
||||
"failedDocsCritical": 10
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/thresholds", HttpMethod.PUT,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user