feat: add audit logging framework with immutable append-only log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 10:22:33 +02:00
parent d7cc3a3e04
commit cdd19e180e
7 changed files with 386 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package net.siegeln.cameleer.saas.audit;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@DataJpaTest
@Import(TestcontainersConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
class AuditRepositoryTest {
@Autowired
private AuditRepository auditRepository;
@Test
void save_persistsAuditEntry() {
var entry = new AuditEntity();
entry.setActorId(UUID.randomUUID());
entry.setActorEmail("test@example.com");
entry.setAction("AUTH_LOGIN");
entry.setResult("SUCCESS");
var saved = auditRepository.save(entry);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
assertEquals("AUTH_LOGIN", saved.getAction());
}
@Test
void findByTenantId_returnsFilteredResults() {
UUID tenantA = UUID.randomUUID();
UUID tenantB = UUID.randomUUID();
var entryA = new AuditEntity();
entryA.setTenantId(tenantA);
entryA.setAction("AUTH_LOGIN");
entryA.setResult("SUCCESS");
auditRepository.save(entryA);
var entryB = new AuditEntity();
entryB.setTenantId(tenantB);
entryB.setAction("AUTH_LOGIN");
entryB.setResult("SUCCESS");
auditRepository.save(entryB);
Instant from = Instant.now().minus(1, ChronoUnit.HOURS);
Instant to = Instant.now().plus(1, ChronoUnit.HOURS);
var results = auditRepository.findByTenantIdAndCreatedAtBetween(tenantA, from, to);
assertEquals(1, results.size());
assertEquals(tenantA, results.get(0).getTenantId());
}
@Test
void save_persistsJsonMetadata() {
var entry = new AuditEntity();
entry.setAction("CONFIG_UPDATE");
entry.setResult("SUCCESS");
entry.setMetadata(Map.of("setting", "timeout", "oldValue", "30", "newValue", "60"));
var saved = auditRepository.save(entry);
var found = auditRepository.findById(saved.getId()).orElseThrow();
assertNotNull(found.getMetadata());
assertFalse(found.getMetadata().isEmpty());
assertEquals("timeout", found.getMetadata().get("setting"));
}
}

View File

@@ -0,0 +1,80 @@
package net.siegeln.cameleer.saas.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AuditServiceTest {
@Mock
private AuditRepository auditRepository;
private AuditService auditService;
@BeforeEach
void setUp() {
auditService = new AuditService(auditRepository);
}
@Test
void log_createsAuditEntryWithAllFields() {
UUID actorId = UUID.randomUUID();
UUID tenantId = UUID.randomUUID();
String actorEmail = "user@example.com";
String resource = "/api/tenants/123";
String environment = "production";
String sourceIp = "192.168.1.1";
String result = "SUCCESS";
Map<String, Object> metadata = Map.of("key", "value");
auditService.log(actorId, actorEmail, tenantId,
AuditAction.TENANT_CREATE, resource,
environment, sourceIp, result, metadata);
ArgumentCaptor<AuditEntity> captor = ArgumentCaptor.forClass(AuditEntity.class);
verify(auditRepository).save(captor.capture());
AuditEntity saved = captor.getValue();
assertEquals(actorId, saved.getActorId());
assertEquals(actorEmail, saved.getActorEmail());
assertEquals(tenantId, saved.getTenantId());
assertEquals("TENANT_CREATE", saved.getAction());
assertEquals(resource, saved.getResource());
assertEquals(environment, saved.getEnvironment());
assertEquals(sourceIp, saved.getSourceIp());
assertEquals("SUCCESS", saved.getResult());
assertEquals(metadata, saved.getMetadata());
}
@Test
void log_worksWithNullOptionalFields() {
auditService.log(null, null, null,
AuditAction.AUTH_LOGIN, null,
null, null, null, null);
ArgumentCaptor<AuditEntity> captor = ArgumentCaptor.forClass(AuditEntity.class);
verify(auditRepository).save(captor.capture());
AuditEntity saved = captor.getValue();
assertNull(saved.getActorId());
assertNull(saved.getActorEmail());
assertNull(saved.getTenantId());
assertEquals("AUTH_LOGIN", saved.getAction());
assertNull(saved.getResource());
assertNull(saved.getEnvironment());
assertNull(saved.getSourceIp());
assertEquals("SUCCESS", saved.getResult()); // defaults to SUCCESS when null
assertNull(saved.getMetadata());
}
}