feat: add environment service with tier enforcement and audit logging
Implements EnvironmentService with full CRUD, duplicate slug rejection, tier-based environment count limits, and audit logging for create/update/delete. Adds ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE to AuditAction. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
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.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EnvironmentServiceTest {
|
||||
|
||||
@Mock
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Mock
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
@Mock
|
||||
private RuntimeConfig runtimeConfig;
|
||||
|
||||
private EnvironmentService environmentService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
environmentService = new EnvironmentService(environmentRepository, licenseRepository, auditService, runtimeConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldCreateEnvironmentAndLogAudit() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("HIGH");
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.create(tenantId, "prod", "Production", actorId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("prod");
|
||||
assertThat(result.getDisplayName()).isEqualTo("Production");
|
||||
assertThat(result.getTenantId()).isEqualTo(tenantId);
|
||||
assertThat(result.getBootstrapToken()).isEqualTo("test-token");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_CREATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldRejectDuplicateSlug() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> environmentService.create(tenantId, "prod", "Production", actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldEnforceTierLimit() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("LOW");
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "staging")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> environmentService.create(tenantId, "staging", "Staging", actorId))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listByTenantId_shouldReturnEnvironments() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var env1 = new EnvironmentEntity();
|
||||
env1.setSlug("default");
|
||||
var env2 = new EnvironmentEntity();
|
||||
env2.setSlug("prod");
|
||||
|
||||
when(environmentRepository.findByTenantId(tenantId)).thenReturn(List.of(env1, env2));
|
||||
|
||||
var result = environmentService.listByTenantId(tenantId);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).extracting(EnvironmentEntity::getSlug).containsExactly("default", "prod");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_shouldReturnEnvironment() {
|
||||
var id = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("prod");
|
||||
|
||||
when(environmentRepository.findById(id)).thenReturn(Optional.of(env));
|
||||
|
||||
var result = environmentService.getById(id);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getSlug()).isEqualTo("prod");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDisplayName_shouldUpdateAndLogAudit() {
|
||||
var environmentId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("prod");
|
||||
env.setDisplayName("Old Name");
|
||||
env.setTenantId(UUID.randomUUID());
|
||||
|
||||
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.updateDisplayName(environmentId, "New Name", actorId);
|
||||
|
||||
assertThat(result.getDisplayName()).isEqualTo("New Name");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_UPDATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_shouldRejectDefaultEnvironment() {
|
||||
var environmentId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("default");
|
||||
|
||||
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
|
||||
|
||||
assertThatThrownBy(() -> environmentService.delete(environmentId, actorId))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("LOW");
|
||||
|
||||
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.createDefaultForTenant(tenantId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("default");
|
||||
assertThat(result.getDisplayName()).isEqualTo("Default");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user