diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java new file mode 100644 index 0000000..b2dbd77 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java @@ -0,0 +1,161 @@ +package net.siegeln.cameleer.saas.app; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.runtime.RuntimeConfig; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class AppService { + + private final AppRepository appRepository; + private final EnvironmentRepository environmentRepository; + private final LicenseRepository licenseRepository; + private final AuditService auditService; + private final RuntimeConfig runtimeConfig; + + public AppService(AppRepository appRepository, + EnvironmentRepository environmentRepository, + LicenseRepository licenseRepository, + AuditService auditService, + RuntimeConfig runtimeConfig) { + this.appRepository = appRepository; + this.environmentRepository = environmentRepository; + this.licenseRepository = licenseRepository; + this.auditService = auditService; + this.runtimeConfig = runtimeConfig; + } + + public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) { + validateJarFile(jarFile); + + var env = environmentRepository.findById(envId) + .orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId)); + + if (appRepository.existsByEnvironmentIdAndSlug(envId, slug)) { + throw new IllegalArgumentException("App slug already exists in this environment: " + slug); + } + + var tenantId = env.getTenantId(); + enforceTierLimit(tenantId); + + var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar"; + var checksum = storeJar(jarFile, relativePath); + + var entity = new AppEntity(); + entity.setEnvironmentId(envId); + entity.setSlug(slug); + entity.setDisplayName(displayName); + entity.setJarStoragePath(relativePath); + entity.setJarChecksum(checksum); + entity.setJarOriginalFilename(jarFile.getOriginalFilename()); + entity.setJarSizeBytes(jarFile.getSize()); + + var saved = appRepository.save(entity); + + auditService.log(actorId, null, tenantId, + AuditAction.APP_CREATE, slug, + null, null, "SUCCESS", null); + + return saved; + } + + public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) { + validateJarFile(jarFile); + + var entity = appRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("App not found: " + appId)); + + var checksum = storeJar(jarFile, entity.getJarStoragePath()); + + entity.setJarChecksum(checksum); + entity.setJarOriginalFilename(jarFile.getOriginalFilename()); + entity.setJarSizeBytes(jarFile.getSize()); + + return appRepository.save(entity); + } + + public List listByEnvironmentId(UUID envId) { + return appRepository.findByEnvironmentId(envId); + } + + public Optional getById(UUID id) { + return appRepository.findById(id); + } + + public void delete(UUID appId, UUID actorId) { + var entity = appRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("App not found: " + appId)); + + appRepository.delete(entity); + + var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null); + var tenantId = env != null ? env.getTenantId() : null; + + auditService.log(actorId, null, tenantId, + AuditAction.APP_DELETE, entity.getSlug(), + null, null, "SUCCESS", null); + } + + public Path resolveJarPath(String relativePath) { + return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath); + } + + private void validateJarFile(MultipartFile jarFile) { + var filename = jarFile.getOriginalFilename(); + if (filename == null || !filename.toLowerCase().endsWith(".jar")) { + throw new IllegalArgumentException("File must be a .jar file"); + } + if (jarFile.getSize() > runtimeConfig.getMaxJarSize()) { + throw new IllegalArgumentException("JAR file exceeds maximum allowed size"); + } + } + + private String storeJar(MultipartFile file, String relativePath) { + try { + var targetPath = resolveJarPath(relativePath); + Files.createDirectories(targetPath.getParent()); + Files.copy(file.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return computeSha256(file); + } catch (IOException e) { + throw new IllegalStateException("Failed to store JAR file", e); + } + } + + private String computeSha256(MultipartFile file) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(file.getBytes()); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException | IOException e) { + throw new IllegalStateException("Failed to compute SHA-256 checksum", e); + } + } + + private void enforceTierLimit(UUID tenantId) { + var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId); + if (license.isEmpty()) { + throw new IllegalStateException("No active license"); + } + var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier())); + var maxApps = (int) limits.getOrDefault("max_agents", 3); + if (maxApps != -1 && appRepository.countByTenantId(tenantId) >= maxApps) { + throw new IllegalStateException("App limit reached for current tier"); + } + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java new file mode 100644 index 0000000..7b5a822 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java @@ -0,0 +1,167 @@ +package net.siegeln.cameleer.saas.app; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.environment.EnvironmentEntity; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +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.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Path; +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) +@MockitoSettings(strictness = Strictness.LENIENT) +class AppServiceTest { + + @TempDir + Path tempDir; + + @Mock + private AppRepository appRepository; + + @Mock + private EnvironmentRepository environmentRepository; + + @Mock + private LicenseRepository licenseRepository; + + @Mock + private AuditService auditService; + + @Mock + private RuntimeConfig runtimeConfig; + + private AppService appService; + + @BeforeEach + void setUp() { + when(runtimeConfig.getJarStoragePath()).thenReturn(tempDir.toString()); + when(runtimeConfig.getMaxJarSize()).thenReturn(209715200L); + appService = new AppService(appRepository, environmentRepository, licenseRepository, auditService, runtimeConfig); + } + + @Test + void create_shouldStoreJarAndCreateApp() throws Exception { + var envId = UUID.randomUUID(); + var tenantId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + + var env = new EnvironmentEntity(); + env.setId(envId); + env.setTenantId(tenantId); + env.setSlug("default"); + + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("MID"); + + var jarBytes = "fake-jar-content".getBytes(); + var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", jarBytes); + + when(environmentRepository.findById(envId)).thenReturn(Optional.of(env)); + when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(false); + when(appRepository.countByTenantId(tenantId)).thenReturn(0L); + when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)) + .thenReturn(Optional.of(license)); + when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = appService.create(envId, "myapp", "My App", jarFile, actorId); + + assertThat(result.getSlug()).isEqualTo("myapp"); + assertThat(result.getDisplayName()).isEqualTo("My App"); + assertThat(result.getEnvironmentId()).isEqualTo(envId); + assertThat(result.getJarOriginalFilename()).isEqualTo("myapp.jar"); + assertThat(result.getJarSizeBytes()).isEqualTo((long) jarBytes.length); + assertThat(result.getJarChecksum()).isNotBlank(); + assertThat(result.getJarStoragePath()).contains("tenants") + .contains("envs") + .contains("apps") + .endsWith("app.jar"); + + var actionCaptor = ArgumentCaptor.forClass(AuditAction.class); + verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any()); + assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_CREATE); + } + + @Test + void create_shouldRejectNonJarFile() { + var envId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + + var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes()); + + assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, actorId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(".jar"); + } + + @Test + void create_shouldRejectDuplicateSlug() { + var envId = UUID.randomUUID(); + var tenantId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + + var env = new EnvironmentEntity(); + env.setId(envId); + env.setTenantId(tenantId); + env.setSlug("default"); + + var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", "fake-jar".getBytes()); + + when(environmentRepository.findById(envId)).thenReturn(Optional.of(env)); + when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true); + + assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, actorId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("myapp"); + } + + @Test + void reuploadJar_shouldUpdateChecksumAndPath() throws Exception { + var appId = UUID.randomUUID(); + var envId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + + var existingApp = new AppEntity(); + existingApp.setId(appId); + existingApp.setEnvironmentId(envId); + existingApp.setSlug("myapp"); + existingApp.setDisplayName("My App"); + existingApp.setJarStoragePath("tenants/some-tenant/envs/default/apps/myapp/app.jar"); + existingApp.setJarChecksum("oldchecksum"); + existingApp.setJarOriginalFilename("old.jar"); + existingApp.setJarSizeBytes(100L); + + var newJarBytes = "new-jar-content".getBytes(); + var newJarFile = new MockMultipartFile("file", "new-myapp.jar", "application/java-archive", newJarBytes); + + when(appRepository.findById(appId)).thenReturn(Optional.of(existingApp)); + when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = appService.reuploadJar(appId, newJarFile, actorId); + + assertThat(result.getJarOriginalFilename()).isEqualTo("new-myapp.jar"); + assertThat(result.getJarSizeBytes()).isEqualTo((long) newJarBytes.length); + assertThat(result.getJarChecksum()).isNotBlank(); + assertThat(result.getJarChecksum()).isNotEqualTo("oldchecksum"); + } +}