diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java new file mode 100644 index 0000000..a761d8f --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java @@ -0,0 +1,61 @@ +package net.siegeln.cameleer.saas.license; + +import net.siegeln.cameleer.saas.license.dto.LicenseResponse; +import net.siegeln.cameleer.saas.tenant.TenantService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.UUID; + +@RestController +@RequestMapping("/api/tenants/{tenantId}/license") +public class LicenseController { + + private final LicenseService licenseService; + private final TenantService tenantService; + + public LicenseController(LicenseService licenseService, TenantService tenantService) { + this.licenseService = licenseService; + this.tenantService = tenantService; + } + + @PostMapping + public ResponseEntity generate(@PathVariable UUID tenantId, + Authentication authentication) { + var tenant = tenantService.getById(tenantId).orElse(null); + if (tenant == null) return ResponseEntity.notFound().build(); + + UUID actorId = authentication.getCredentials() instanceof UUID uid + ? uid : UUID.fromString(authentication.getCredentials().toString()); + + var license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(license)); + } + + @GetMapping + public ResponseEntity getActive(@PathVariable UUID tenantId) { + return licenseService.getActiveLicense(tenantId) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + private LicenseResponse toResponse(LicenseEntity entity) { + return new LicenseResponse( + entity.getId(), + entity.getTenantId(), + entity.getTier(), + entity.getFeatures(), + entity.getLimits(), + entity.getIssuedAt(), + entity.getExpiresAt(), + entity.getToken() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java new file mode 100644 index 0000000..f737692 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java @@ -0,0 +1,16 @@ +package net.siegeln.cameleer.saas.license.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record LicenseResponse( + UUID id, + UUID tenantId, + String tier, + Map features, + Map limits, + Instant issuedAt, + Instant expiresAt, + String token +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java new file mode 100644 index 0000000..6d89f68 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java @@ -0,0 +1,96 @@ +package net.siegeln.cameleer.saas.license; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.TestcontainersConfig; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +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) +@ActiveProfiles("test") +class LicenseControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private String getAuthToken() throws Exception { + var registerRequest = new net.siegeln.cameleer.saas.auth.dto.RegisterRequest( + "license-test-" + System.nanoTime() + "@example.com", "Test User", "password123"); + + var result = mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("token").asText(); + } + + private String createTenantAndGetId(String token) throws Exception { + String slug = "license-tenant-" + System.nanoTime(); + var request = new CreateTenantRequest("License Test Org", slug, "MID"); + + var result = mockMvc.perform(post("/api/tenants") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + @Test + void generateLicense_returns201WithToken() throws Exception { + String token = getAuthToken(); + String tenantId = createTenantAndGetId(token); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/license") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.tier").value("MID")) + .andExpect(jsonPath("$.features.correlation").value(true)); + } + + @Test + void getActiveLicense_returnsLicense() throws Exception { + String token = getAuthToken(); + String tenantId = createTenantAndGetId(token); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/license") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/tenants/" + tenantId + "/license") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tier").value("MID")); + } + + @Test + void getActiveLicense_returns404WhenNone() throws Exception { + String token = getAuthToken(); + String tenantId = createTenantAndGetId(token); + + mockMvc.perform(get("/api/tenants/" + tenantId + "/license") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNotFound()); + } +}