feat: add app service with JAR upload and tier enforcement
Implements AppService with JAR file storage, SHA-256 checksum computation, tier-based app limit enforcement via LicenseDefaults, and audit logging. Four TDD tests all pass covering creation, JAR validation, duplicate slug rejection, and JAR re-upload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
src/main/java/net/siegeln/cameleer/saas/app/AppService.java
Normal file
161
src/main/java/net/siegeln/cameleer/saas/app/AppService.java
Normal file
@@ -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<AppEntity> listByEnvironmentId(UUID envId) {
|
||||||
|
return appRepository.findByEnvironmentId(envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AppEntity> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java
Normal file
167
src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user