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:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,16 @@
|
|||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
|
public enum AuditCategory {
|
||||||
|
INFRA, AUTH, USER_MGMT, CONFIG
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
|
public enum AuditResult {
|
||||||
|
SUCCESS, FAILURE
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user