feat: add audit domain model, repository interface, AuditService, and unit test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 15:36:21 +01:00
parent fa3bc592d1
commit a0944a1c72
7 changed files with 167 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
package com.cameleer3.server.app.admin;
import com.cameleer3.server.core.admin.*;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AuditServiceTest {
private AuditRepository mockRepository;
private AuditService auditService;
@BeforeEach
void setUp() {
mockRepository = mock(AuditRepository.class);
auditService = new AuditService(mockRepository);
}
@Test
void log_withExplicitUsername_insertsRecordWithCorrectFields() {
var request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("192.168.1.1");
when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0");
auditService.log("admin", "kill_query", AuditCategory.INFRA, "PID 42",
Map.of("query", "SELECT 1"), AuditResult.SUCCESS, request);
var captor = ArgumentCaptor.forClass(AuditRecord.class);
verify(mockRepository).insert(captor.capture());
var record = captor.getValue();
assertEquals("admin", record.username());
assertEquals("kill_query", record.action());
assertEquals(AuditCategory.INFRA, record.category());
assertEquals("PID 42", record.target());
assertEquals("192.168.1.1", record.ipAddress());
assertEquals("Mozilla/5.0", record.userAgent());
}
@Test
void log_withNullRequest_handlesGracefully() {
auditService.log("admin", "test", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, null);
verify(mockRepository).insert(any(AuditRecord.class));
}
}

View File

@@ -27,6 +27,16 @@
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG
}

View File

@@ -0,0 +1,24 @@
package com.cameleer3.server.core.admin;
import java.time.Instant;
import java.util.Map;
public record AuditRecord(
long id,
Instant timestamp,
String username,
String action,
AuditCategory category,
String target,
Map<String, Object> detail,
AuditResult result,
String ipAddress,
String userAgent
) {
/** Factory for creating new records (id and timestamp assigned by DB) */
public static AuditRecord create(String username, String action, AuditCategory category,
String target, Map<String, Object> detail, AuditResult result,
String ipAddress, String userAgent) {
return new AuditRecord(0, null, username, action, category, target, detail, result, ipAddress, userAgent);
}
}

View File

@@ -0,0 +1,25 @@
package com.cameleer3.server.core.admin;
import java.time.Instant;
import java.util.List;
public interface AuditRepository {
void insert(AuditRecord record);
record AuditQuery(
String username,
AuditCategory category,
String search,
Instant from,
Instant to,
String sort,
String order,
int page,
int size
) {}
record AuditPage(List<AuditRecord> items, long totalCount) {}
AuditPage find(AuditQuery query);
}

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditResult {
SUCCESS, FAILURE
}

View File

@@ -0,0 +1,49 @@
package com.cameleer3.server.core.admin;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Map;
public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
private final AuditRepository repository;
public AuditService(AuditRepository repository) {
this.repository = repository;
}
/** Log an action using the current SecurityContext for username */
public void log(String action, AuditCategory category, String target,
Map<String, Object> detail, AuditResult result,
HttpServletRequest request) {
String username = extractUsername();
log(username, action, category, target, detail, result, request);
}
/** Log an action with explicit username (for pre-auth contexts like login) */
public void log(String username, String action, AuditCategory category, String target,
Map<String, Object> detail, AuditResult result,
HttpServletRequest request) {
String ip = request != null ? request.getRemoteAddr() : null;
String userAgent = request != null ? request.getHeader("User-Agent") : null;
AuditRecord record = AuditRecord.create(username, action, category, target, detail, result, ip, userAgent);
repository.insert(record);
log.info("AUDIT: user={} action={} category={} target={} result={}",
username, action, category, target, result);
}
private String extractUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null) {
String name = auth.getName();
return name.startsWith("user:") ? name.substring(5) : name;
}
return "unknown";
}
}