diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java new file mode 100644 index 0000000..058c1f5 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java @@ -0,0 +1,117 @@ +package net.siegeln.cameleer.saas.environment; + +import jakarta.validation.Valid; +import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest; +import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse; +import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/tenants/{tenantId}/environments") +public class EnvironmentController { + + private final EnvironmentService environmentService; + + public EnvironmentController(EnvironmentService environmentService) { + this.environmentService = environmentService; + } + + @PostMapping + public ResponseEntity create( + @PathVariable UUID tenantId, + @Valid @RequestBody CreateEnvironmentRequest request, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @GetMapping + public ResponseEntity> list(@PathVariable UUID tenantId) { + var environments = environmentService.listByTenantId(tenantId) + .stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(environments); + } + + @GetMapping("/{environmentId}") + public ResponseEntity getById( + @PathVariable UUID tenantId, + @PathVariable UUID environmentId) { + return environmentService.getById(environmentId) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + @PatchMapping("/{environmentId}") + public ResponseEntity update( + @PathVariable UUID tenantId, + @PathVariable UUID environmentId, + @Valid @RequestBody UpdateEnvironmentRequest request, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId); + return ResponseEntity.ok(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{environmentId}") + public ResponseEntity delete( + @PathVariable UUID tenantId, + @PathVariable UUID environmentId, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + environmentService.delete(environmentId, actorId); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + private UUID resolveActorId(Authentication authentication) { + String sub = authentication.getName(); + try { + return UUID.fromString(sub); + } catch (IllegalArgumentException e) { + return UUID.nameUUIDFromBytes(sub.getBytes()); + } + } + + private EnvironmentResponse toResponse(EnvironmentEntity entity) { + return new EnvironmentResponse( + entity.getId(), + entity.getTenantId(), + entity.getSlug(), + entity.getDisplayName(), + entity.getStatus().name(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java new file mode 100644 index 0000000..ddff789 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.environment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CreateEnvironmentRequest( + @NotBlank @Size(min = 2, max = 100) + @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") + String slug, + @NotBlank @Size(max = 255) + String displayName +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java new file mode 100644 index 0000000..50efec6 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java @@ -0,0 +1,14 @@ +package net.siegeln.cameleer.saas.environment.dto; + +import java.time.Instant; +import java.util.UUID; + +public record EnvironmentResponse( + UUID id, + UUID tenantId, + String slug, + String displayName, + String status, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java new file mode 100644 index 0000000..19f0873 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java @@ -0,0 +1,9 @@ +package net.siegeln.cameleer.saas.environment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateEnvironmentRequest( + @NotBlank @Size(max = 255) + String displayName +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java new file mode 100644 index 0000000..9bd6482 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java @@ -0,0 +1,180 @@ +package net.siegeln.cameleer.saas.environment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.TestcontainersConfig; +import net.siegeln.cameleer.saas.TestSecurityConfig; +import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest; +import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseEntity; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.tenant.TenantEntity; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.junit.jupiter.api.BeforeEach; +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 java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +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.patch; +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 EnvironmentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EnvironmentRepository environmentRepository; + + @Autowired + private LicenseRepository licenseRepository; + + @Autowired + private TenantRepository tenantRepository; + + private UUID tenantId; + + @BeforeEach + void setUp() { + environmentRepository.deleteAll(); + licenseRepository.deleteAll(); + tenantRepository.deleteAll(); + + var tenant = new TenantEntity(); + tenant.setName("Test Org"); + tenant.setSlug("test-org-" + System.nanoTime()); + var savedTenant = tenantRepository.save(tenant); + tenantId = savedTenant.getId(); + + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("MID"); + license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID)); + license.setLimits(LicenseDefaults.limitsForTier(Tier.MID)); + license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS)); + license.setToken("test-token"); + licenseRepository.save(license); + } + + @Test + void createEnvironment_shouldReturn201() throws Exception { + var request = new CreateEnvironmentRequest("prod", "Production"); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.slug").value("prod")) + .andExpect(jsonPath("$.displayName").value("Production")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + @Test + void createEnvironment_duplicateSlug_shouldReturn409() throws Exception { + var request = new CreateEnvironmentRequest("staging", "Staging"); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void listEnvironments_shouldReturnAll() throws Exception { + var request = new CreateEnvironmentRequest("dev", "Development"); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].slug").value("dev")); + } + + @Test + void updateEnvironment_shouldReturn200() throws Exception { + var createRequest = new CreateEnvironmentRequest("qa", "QA"); + + var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andReturn(); + + String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString()) + .get("id").asText(); + + var updateRequest = new UpdateEnvironmentRequest("QA Updated"); + + mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId) + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("QA Updated")); + } + + @Test + void deleteDefaultEnvironment_shouldReturn403() throws Exception { + var request = new CreateEnvironmentRequest("default", "Default"); + + var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString()) + .get("id").asText(); + + mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isForbidden()); + } + + @Test + void createEnvironment_noAuth_shouldReturn401() throws Exception { + var request = new CreateEnvironmentRequest("no-auth", "No Auth"); + + mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } +}