test: add 25 tests for vendor + portal services and controllers
Some checks failed
CI / build (push) Failing after 1m16s
CI / docker (push) Has been skipped

VendorTenantServiceTest (8): create/provision, suspend, delete, renew
VendorTenantControllerTest (7): CRUD, auth, conflict handling
TenantPortalServiceTest (5): dashboard, license, settings
TenantPortalControllerTest (5): dashboard, license, settings, auth

Fix TenantIsolationInterceptor bugs found by tests:
- org_id resolution now runs before portal path check
- path matching uses URI minus context path (not getServletPath)
- portal path returns 403 sendError instead of empty 200

Total: 50 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 23:08:47 +02:00
parent faac0048c3
commit 17fbe73e60
5 changed files with 749 additions and 8 deletions

View File

@@ -34,19 +34,19 @@ public class TenantIsolationInterceptor implements HandlerInterceptor {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) return true;
String path = request.getRequestURI();
// Strip context-path prefix to get the application-relative path.
// getServletPath() returns empty string in MockMvc, so use getRequestURI() minus contextPath.
String contextPath = request.getContextPath();
String uri = request.getRequestURI();
String path = (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath))
? uri.substring(contextPath.length()) : uri;
// Vendor endpoints: platform:admin already enforced by Spring Security
if (path.startsWith("/platform/api/vendor/")) {
if (path.startsWith("/api/vendor/")) {
return true;
}
// Tenant portal endpoints: tenant resolved from JWT org context (no path variable)
if (path.startsWith("/platform/api/tenant/")) {
return TenantContext.getTenantId() != null;
}
// 1. Resolve: JWT organization_id -> TenantContext
// 1. Resolve: JWT organization_id -> TenantContext (applies to all non-vendor paths)
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
@@ -54,6 +54,15 @@ public class TenantIsolationInterceptor implements HandlerInterceptor {
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
}
// Tenant portal endpoints: tenant resolved from JWT org context (no path variable)
if (path.startsWith("/api/tenant/")) {
if (TenantContext.getTenantId() == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context");
return false;
}
return true;
}
// 2. Validate: read path variables from Spring's HandlerMapping
@SuppressWarnings("unchecked")
Map<String, String> pathVars = (Map<String, String>) request.getAttribute(

View File

@@ -0,0 +1,137 @@
package net.siegeln.cameleer.saas.portal;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class TenantPortalControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TenantRepository tenantRepository;
@Autowired
private LicenseService licenseService;
/**
* Creates a tenant directly in the DB with the given logtoOrgId so the
* TenantIsolationInterceptor can resolve it from the JWT organization_id claim.
* Each test uses a unique orgId to avoid cross-test interference.
*/
private TenantEntity createTenantWithOrgId(String name, String slug, String orgId) {
var tenant = new TenantEntity();
tenant.setName(name);
tenant.setSlug(slug);
tenant.setTier(Tier.LOW);
tenant.setStatus(TenantStatus.ACTIVE);
tenant.setLogtoOrgId(orgId);
return tenantRepository.save(tenant);
}
@Test
void dashboard_returnsDashboardData() throws Exception {
String orgId = "test-org-dashboard-" + System.nanoTime();
createTenantWithOrgId("Portal Org", "portal-dashboard-" + System.nanoTime(), orgId);
mockMvc.perform(get("/api/tenant/dashboard")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", orgId)
.claim("scope", "tenant:manage"))
.authorities(new SimpleGrantedAuthority("SCOPE_tenant:manage"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").isNotEmpty())
.andExpect(jsonPath("$.slug").isNotEmpty())
.andExpect(jsonPath("$.tier").isNotEmpty())
.andExpect(jsonPath("$.status").isNotEmpty());
}
@Test
void license_returnsLicenseData() throws Exception {
String orgId = "test-org-license-" + System.nanoTime();
TenantEntity tenant = createTenantWithOrgId("License Portal Org", "portal-license-" + System.nanoTime(), orgId);
licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
mockMvc.perform(get("/api/tenant/license")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", orgId)
.claim("scope", "tenant:manage"))
.authorities(new SimpleGrantedAuthority("SCOPE_tenant:manage"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tier").isNotEmpty())
.andExpect(jsonPath("$.token").isNotEmpty());
}
@Test
void license_returns404WhenNone() throws Exception {
String orgId = "test-org-nolicense-" + System.nanoTime();
createTenantWithOrgId("No License Org", "portal-nolicense-" + System.nanoTime(), orgId);
mockMvc.perform(get("/api/tenant/license")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", orgId)
.claim("scope", "tenant:manage"))
.authorities(new SimpleGrantedAuthority("SCOPE_tenant:manage"))))
.andExpect(status().isNotFound());
}
@Test
void settings_returnsSettings() throws Exception {
String orgId = "test-org-settings-" + System.nanoTime();
createTenantWithOrgId("Settings Org", "portal-settings-" + System.nanoTime(), orgId);
mockMvc.perform(get("/api/tenant/settings")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", orgId)
.claim("scope", "settings:manage"))
.authorities(new SimpleGrantedAuthority("SCOPE_settings:manage"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").isNotEmpty())
.andExpect(jsonPath("$.slug").isNotEmpty())
.andExpect(jsonPath("$.tier").isNotEmpty())
.andExpect(jsonPath("$.status").isNotEmpty());
}
@Test
void dashboard_returns403WithoutOrgContext() throws Exception {
mockMvc.perform(get("/api/tenant/dashboard")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("scope", "tenant:manage"))
.authorities(new SimpleGrantedAuthority("SCOPE_tenant:manage"))))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,187 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TenantPortalServiceTest {
@Mock
private TenantService tenantService;
@Mock
private LicenseService licenseService;
@Mock
private ServerApiClient serverApiClient;
@Mock
private LogtoManagementClient logtoClient;
private TenantPortalService tenantPortalService;
private final UUID tenantId = UUID.randomUUID();
@BeforeEach
void setUp() {
TenantContext.setTenantId(tenantId);
tenantPortalService = new TenantPortalService(tenantService, licenseService, serverApiClient, logtoClient);
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
// --- Helpers ---
private TenantEntity tenantWithId(String name, String slug, Tier tier, TenantStatus status) throws Exception {
var tenant = new TenantEntity();
tenant.setName(name);
tenant.setSlug(slug);
tenant.setTier(tier);
tenant.setStatus(status);
var f = TenantEntity.class.getDeclaredField("id");
f.setAccessible(true);
f.set(tenant, tenantId);
return tenant;
}
private LicenseEntity licenseWithId(UUID tid, String tier, Instant expiresAt) throws Exception {
var license = new LicenseEntity();
license.setTenantId(tid);
license.setTier(tier);
license.setToken("test-token-" + UUID.randomUUID());
license.setIssuedAt(Instant.now());
license.setExpiresAt(expiresAt);
license.setFeatures(Map.of("feature1", true));
license.setLimits(Map.of("maxApps", 10));
var f = LicenseEntity.class.getDeclaredField("id");
f.setAccessible(true);
f.set(license, UUID.randomUUID());
return license;
}
// --- getDashboard tests ---
@Test
void getDashboard_returnsDashboardData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
tenant.setServerEndpoint("http://server:8080");
var expiresAt = Instant.now().plus(Duration.ofDays(30));
var license = licenseWithId(tenantId, "LOW", expiresAt);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(serverApiClient.getHealth("http://server:8080")).thenReturn(new ServerHealthResponse(true, "UP"));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.of(license));
var result = tenantPortalService.getDashboard();
assertThat(result.name()).isEqualTo("Acme Corp");
assertThat(result.slug()).isEqualTo("acme-corp");
assertThat(result.tier()).isEqualTo("LOW");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.serverHealthy()).isTrue();
assertThat(result.serverStatus()).isEqualTo("UP");
assertThat(result.serverEndpoint()).isEqualTo("http://server:8080");
assertThat(result.licenseTier()).isEqualTo("LOW");
assertThat(result.licenseDaysRemaining()).isGreaterThanOrEqualTo(29);
assertThat(result.limits()).isNotEmpty();
assertThat(result.features()).isNotEmpty();
}
@Test
void getDashboard_handlesNoServer() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.PROVISIONING);
// serverEndpoint is null by default
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.empty());
var result = tenantPortalService.getDashboard();
assertThat(result.serverHealthy()).isFalse();
assertThat(result.serverStatus()).isEqualTo("NO_ENDPOINT");
assertThat(result.serverEndpoint()).isNull();
assertThat(result.licenseTier()).isNull();
assertThat(result.licenseDaysRemaining()).isZero();
}
// --- getLicense tests ---
@Test
void getLicense_returnsLicenseData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
var expiresAt = Instant.now().plus(Duration.ofDays(60));
var license = licenseWithId(tenantId, "LOW", expiresAt);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.of(license));
var result = tenantPortalService.getLicense();
assertThat(result).isNotNull();
assertThat(result.id()).isEqualTo(license.getId());
assertThat(result.tier()).isEqualTo("LOW");
assertThat(result.token()).isEqualTo(license.getToken());
assertThat(result.issuedAt()).isEqualTo(license.getIssuedAt());
assertThat(result.expiresAt()).isEqualTo(expiresAt);
assertThat(result.daysRemaining()).isGreaterThanOrEqualTo(59);
assertThat(result.features()).isEqualTo(Map.of("feature1", true));
assertThat(result.limits()).isEqualTo(Map.of("maxApps", 10));
}
@Test
void getLicense_returnsNullWhenNoLicense() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.empty());
var result = tenantPortalService.getLicense();
assertThat(result).isNull();
}
// --- getSettings tests ---
@Test
void getSettings_returnsSettingsData() throws Exception {
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.MID, TenantStatus.ACTIVE);
tenant.setServerEndpoint("http://server:8080");
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
var result = tenantPortalService.getSettings();
assertThat(result.name()).isEqualTo("Acme Corp");
assertThat(result.slug()).isEqualTo("acme-corp");
assertThat(result.tier()).isEqualTo("MID");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.serverEndpoint()).isEqualTo("http://server:8080");
assertThat(result.createdAt()).isNull(); // no @PrePersist called in test, createdAt is null
}
}

View File

@@ -0,0 +1,154 @@
package net.siegeln.cameleer.saas.vendor;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class VendorTenantControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
private String createTenant(String name, String slug, String tier) throws Exception {
var request = new CreateTenantRequest(name, slug, tier);
var result = mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", "test-org-id")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
@Test
void listTenants_returnsAllTenants() throws Exception {
String slug = "list-test-" + System.nanoTime();
createTenant("List Test Org", slug, "LOW");
mockMvc.perform(get("/api/vendor/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void createTenant_returns201() throws Exception {
String slug = "create-test-" + System.nanoTime();
var request = new CreateTenantRequest("Create Test Org", slug, "MID");
mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", "test-org-id")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Create Test Org"))
.andExpect(jsonPath("$.slug").value(slug))
.andExpect(jsonPath("$.tier").value("MID"))
.andExpect(jsonPath("$.id").isNotEmpty());
}
@Test
void createTenant_returns409ForDuplicateSlug() throws Exception {
String slug = "duplicate-vendor-" + System.nanoTime();
createTenant("First Org", slug, "LOW");
var request = new CreateTenantRequest("Second Org", slug, "LOW");
mockMvc.perform(post("/api/vendor/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", "test-org-id")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void getTenantDetail_returnsDetailWithServerStatus() throws Exception {
String slug = "detail-test-" + System.nanoTime();
String id = createTenant("Detail Test Org", slug, "LOW");
mockMvc.perform(get("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tenant.slug").value(slug))
.andExpect(jsonPath("$.serverState").isNotEmpty())
.andExpect(jsonPath("$.serverHealthy").isBoolean());
}
@Test
void suspendTenant_returnsUpdatedStatus() throws Exception {
String slug = "suspend-test-" + System.nanoTime();
String id = createTenant("Suspend Test Org", slug, "LOW");
mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SUSPENDED"));
}
@Test
void deleteTenant_returns204() throws Exception {
String slug = "delete-test-" + System.nanoTime();
String id = createTenant("Delete Test Org", slug, "LOW");
mockMvc.perform(delete("/api/vendor/tenants/" + id)
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("scope", "platform:admin"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isNoContent());
}
@Test
void createTenant_returns401WithoutAuth() throws Exception {
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "LOW");
mockMvc.perform(post("/api/vendor/tenants")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,254 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
import net.siegeln.cameleer.saas.provisioning.TenantProvisioner;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.time.Instant;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class VendorTenantServiceTest {
@Mock
private TenantService tenantService;
@Mock
private TenantRepository tenantRepository;
@Mock
private LicenseService licenseService;
@Mock
private TenantProvisioner tenantProvisioner;
@Mock
private ServerApiClient serverApiClient;
@Mock
private LogtoManagementClient logtoClient;
@Mock
private AuditService auditService;
private VendorTenantService vendorTenantService;
@BeforeEach
void setUp() {
vendorTenantService = new VendorTenantService(
tenantService, tenantRepository, licenseService,
tenantProvisioner, serverApiClient, logtoClient, auditService);
}
// --- Helpers ---
private TenantEntity tenantWithId(String name, String slug, Tier tier) throws Exception {
var tenant = new TenantEntity();
tenant.setName(name);
tenant.setSlug(slug);
tenant.setTier(tier);
tenant.setStatus(TenantStatus.PROVISIONING);
var f = TenantEntity.class.getDeclaredField("id");
f.setAccessible(true);
f.set(tenant, UUID.randomUUID());
return tenant;
}
private LicenseEntity licenseWithId(UUID tenantId) throws Exception {
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("LOW");
license.setToken("test-token-" + UUID.randomUUID());
license.setIssuedAt(Instant.now());
license.setExpiresAt(Instant.now().plus(Duration.ofDays(365)));
var f = LicenseEntity.class.getDeclaredField("id");
f.setAccessible(true);
f.set(license, UUID.randomUUID());
return license;
}
// --- Tests ---
@Test
void createAndProvision_createsTenantAndLicense() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW");
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var license = licenseWithId(tenant.getId());
when(tenantService.create(request, actorId)).thenReturn(tenant);
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(license);
when(tenantProvisioner.isAvailable()).thenReturn(false);
var result = vendorTenantService.createAndProvision(request, actorId);
verify(tenantService).create(request, actorId);
verify(licenseService).generateLicense(eq(tenant), any(Duration.class), eq(actorId));
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("Acme Corp");
assertThat(result.getSlug()).isEqualTo("acme-corp");
}
@Test
void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW");
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var license = licenseWithId(tenant.getId());
when(tenantService.create(request, actorId)).thenReturn(tenant);
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(license);
when(tenantProvisioner.isAvailable()).thenReturn(true);
when(tenantProvisioner.provision(any())).thenReturn(ProvisionResult.ok("http://server:8080"));
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = vendorTenantService.createAndProvision(request, actorId);
assertThat(result.getStatus()).isEqualTo(TenantStatus.ACTIVE);
assertThat(result.getServerEndpoint()).isEqualTo("http://server:8080");
assertThat(result.getProvisionError()).isNull();
verify(tenantRepository).save(tenant);
}
@Test
void createAndProvision_setsProvisionErrorOnFailure() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW");
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var license = licenseWithId(tenant.getId());
when(tenantService.create(request, actorId)).thenReturn(tenant);
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(license);
when(tenantProvisioner.isAvailable()).thenReturn(true);
when(tenantProvisioner.provision(any())).thenReturn(ProvisionResult.fail("Docker failure"));
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = vendorTenantService.createAndProvision(request, actorId);
assertThat(result.getProvisionError()).isEqualTo("Docker failure");
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
verify(tenantRepository).save(tenant);
}
@Test
void createAndProvision_worksWithoutProvisioner() throws Exception {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW");
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var license = licenseWithId(tenant.getId());
when(tenantService.create(request, actorId)).thenReturn(tenant);
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(license);
when(tenantProvisioner.isAvailable()).thenReturn(false);
var result = vendorTenantService.createAndProvision(request, actorId);
verify(tenantProvisioner, never()).provision(any());
verify(tenantRepository, never()).save(any());
assertThat(result).isSameAs(tenant);
// Tenant was created by tenantService.create; no provisioner means status unchanged (PROVISIONING from factory)
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
}
@Test
void suspend_stopsContainersAndSetsStatus() throws Exception {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
suspended.setStatus(TenantStatus.SUSPENDED);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(tenantProvisioner.isAvailable()).thenReturn(true);
when(tenantService.suspend(tenantId, actorId)).thenReturn(suspended);
var result = vendorTenantService.suspend(tenantId, actorId);
verify(tenantProvisioner).stop("acme-corp");
verify(tenantService).suspend(tenantId, actorId);
assertThat(result.getStatus()).isEqualTo(TenantStatus.SUSPENDED);
}
@Test
void suspend_skipsStopWhenProvisionerUnavailable() throws Exception {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
suspended.setStatus(TenantStatus.SUSPENDED);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(tenantProvisioner.isAvailable()).thenReturn(false);
when(tenantService.suspend(tenantId, actorId)).thenReturn(suspended);
vendorTenantService.suspend(tenantId, actorId);
verify(tenantProvisioner, never()).stop(any());
verify(tenantService).suspend(tenantId, actorId);
}
@Test
void delete_removesContainersRevokesLicenseDeletesOrg() throws Exception {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
tenant.setLogtoOrgId("org-123");
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(tenantProvisioner.isAvailable()).thenReturn(true);
when(logtoClient.isAvailable()).thenReturn(true);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
vendorTenantService.delete(tenantId, actorId);
verify(tenantProvisioner).remove("acme-corp");
verify(licenseService).revokeLicense(tenantId, actorId);
verify(logtoClient).deleteOrganization("org-123");
verify(tenantRepository).save(tenant);
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.DELETED);
}
@Test
void renewLicense_revokesOldAndGeneratesNew() throws Exception {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
tenant.setServerEndpoint("http://server:8080");
var newLicense = licenseWithId(tenantId);
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(newLicense);
var result = vendorTenantService.renewLicense(tenantId, actorId);
verify(licenseService).revokeLicense(tenantId, actorId);
verify(licenseService).generateLicense(eq(tenant), any(Duration.class), eq(actorId));
verify(serverApiClient).pushLicense("http://server:8080", newLicense.getToken());
assertThat(result).isSameAs(newLicense);
}
}