From 17fbe73e60ab5375408ead43051480d862c4e00b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:08:47 +0200 Subject: [PATCH] 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) --- .../config/TenantIsolationInterceptor.java | 25 +- .../portal/TenantPortalControllerTest.java | 137 ++++++++++ .../saas/portal/TenantPortalServiceTest.java | 187 +++++++++++++ .../vendor/VendorTenantControllerTest.java | 154 +++++++++++ .../saas/vendor/VendorTenantServiceTest.java | 254 ++++++++++++++++++ 5 files changed, 749 insertions(+), 8 deletions(-) create mode 100644 src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java index 18019d4..cf2383c 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java @@ -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 pathVars = (Map) request.getAttribute( diff --git a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java new file mode 100644 index 0000000..debc81b --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java @@ -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()); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java new file mode 100644 index 0000000..fd70315 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java @@ -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 + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java new file mode 100644 index 0000000..cc4fd46 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java @@ -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()); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java new file mode 100644 index 0000000..c2bb776 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -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); + } +}