test: add 25 tests for vendor + portal services and controllers
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:
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
154
src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java
vendored
Normal file
154
src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
254
src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java
vendored
Normal file
254
src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user