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:
hsiegeln
2026-04-04 17:47:05 +02:00
parent 2151801d40
commit 51f5822364
2 changed files with 328 additions and 0 deletions

View 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");
}
}