From 7f7b90df808beabf771d64122fb8d122705cad9f Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 20:00:34 +0530 Subject: [PATCH] Add approve/reject mapping requests API with unit tests - Add Protocol enum to Mapping entity for HTTP, HTTPS, TCP, SSH - Add TunnelsResponse and TunnelResult models for typed API responses - Add ExternalServiceException for Cloudflare API errors - Add approve and reject endpoints for mapping requests - Add GET /requests endpoint with pagination and status filtering - Refactor API paths to use 'configured' and 'configure' terminology - Add comprehensive unit tests for all new endpoints - Add service layer tests for new methods --- .../Controllers/TunnelController.java | 115 ++++++- .../hithomelabs/CFTunnels/Entity/Mapping.java | 4 + .../CFTunnels/Entity/Protocol.java | 8 + .../hithomelabs/CFTunnels/Entity/Tunnel.java | 5 +- .../Exceptions/ExternalServiceException.java | 5 + .../CFTunnels/Models/TunnelResult.java | 19 ++ .../CFTunnels/Models/TunnelsResponse.java | 28 ++ .../Repositories/RequestRepository.java | 3 + .../Services/CloudflareAPIService.java | 53 ++- .../Services/MappingRequestService.java | 87 ++++- .../Controllers/TunnelControllerTest.java | 306 +++++++++++++++++- .../CoudflareApiIntegrationTest.java | 9 +- .../Services/CloudflareAPIServiceTest.java | 158 ++++++++- 13 files changed, 773 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Entity/Protocol.java create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Exceptions/ExternalServiceException.java create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResult.java create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Models/TunnelsResponse.java diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java index d8343b9..c13e062 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java @@ -5,16 +5,24 @@ import com.hithomelabs.CFTunnels.Config.AuthoritiesToGroupMapping; import com.hithomelabs.CFTunnels.Config.CloudflareConfig; import com.hithomelabs.CFTunnels.Config.RestTemplateConfig; import com.hithomelabs.CFTunnels.Entity.Request; +import com.hithomelabs.CFTunnels.Entity.Tunnel; +import com.hithomelabs.CFTunnels.Entity.User; import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; import com.hithomelabs.CFTunnels.Models.Config; import com.hithomelabs.CFTunnels.Models.Ingress; import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Models.TunnelsResponse; +import com.hithomelabs.CFTunnels.Repositories.UserRepository; import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; import com.hithomelabs.CFTunnels.Services.MappingRequestService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.dao.DataAccessException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.*; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.GrantedAuthority; @@ -26,6 +34,8 @@ import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; @RestController @RequestMapping("/cloudflare") @@ -51,6 +61,12 @@ public class TunnelController implements ErrorController { @Autowired MappingRequestService mappingRequestService; + @Autowired + private UserRepository userRepository; + + @Value("${spring.profiles.active}") + private String environment; + @PreAuthorize("hasAnyRole('USER')") @GetMapping("/whoami") public Map whoAmI(@AuthenticationPrincipal OidcUser oidcUser) { @@ -69,7 +85,7 @@ public class TunnelController implements ErrorController { @Operation( security = { @SecurityRequirement(name = "oidcAuth") } ) public ResponseEntity> getTunnels(){ - ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnels(); + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnels(); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); @@ -77,8 +93,41 @@ public class TunnelController implements ErrorController { return ResponseEntity.ok(jsonResponse); } + @PreAuthorize("hasAnyRole('USER')") + @GetMapping("/configured/tunnels") + public ResponseEntity> getConfiguredTunnels(){ + try { + List tunnels = cloudflareAPIService.getAllConfiguredTunnels(); + Map jsonResponse = new HashMap<>(); + jsonResponse.put("status", "success"); + jsonResponse.put("data", tunnels); + return ResponseEntity.ok(jsonResponse); + } catch (DataAccessException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PreAuthorize("hasAnyRole('USER')") + @GetMapping("/requests") + public ResponseEntity> getAllRequests( + @RequestParam(required = false) Request.RequestStatus status, + Pageable pageable) { + try { + Page requests = mappingRequestService.getAllRequests(status, pageable); + Map jsonResponse = new HashMap<>(); + jsonResponse.put("status", "success"); + jsonResponse.put("data", requests.getContent()); + jsonResponse.put("currentPage", requests.getNumber()); + jsonResponse.put("totalItems", requests.getTotalElements()); + jsonResponse.put("totalPages", requests.getTotalPages()); + return ResponseEntity.ok(jsonResponse); + } catch (DataAccessException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + @PreAuthorize("hasAnyRole('DEVELOPER')") - @GetMapping("/tunnel/{tunnelId}/mappings") + @GetMapping("/tunnels/{tunnelId}/mappings") public ResponseEntity> getTunnelConfigurations(@PathVariable String tunnelId) { ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplate, Map.class); @@ -91,7 +140,7 @@ public class TunnelController implements ErrorController { // 50df9101-f625-4618-b7c5-100338a57124 @PreAuthorize("hasAnyRole('ADMIN')") - @PostMapping("/tunnel/{tunnelId}/mappings") + @PostMapping("/tunnels/{tunnelId}/mappings") public ResponseEntity> addTunnelconfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); @@ -113,7 +162,7 @@ public class TunnelController implements ErrorController { } @PreAuthorize("hasAnyRole('DEVELOPER')") - @DeleteMapping("/tunnel/{tunnelId}/mappings") + @DeleteMapping("/tunnels/{tunnelId}/mappings") public ResponseEntity> deleteTunnelConfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); @@ -142,7 +191,7 @@ public class TunnelController implements ErrorController { } @PreAuthorize("hasAnyRole('DEVELOPER')") - @PostMapping("/tunnel/{tunnelId}/requests") + @PostMapping("/tunnels/configure/{tunnelId}/requests") public ResponseEntity createTunnelMappingRequest(@PathVariable String tunnelId, @AuthenticationPrincipal OidcUser oidcUser, @RequestBody Ingress ingess){ Request request = mappingRequestService.createMappingRequest(tunnelId, ingess, oidcUser); if(request.getId() != null) @@ -150,4 +199,60 @@ public class TunnelController implements ErrorController { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } + @PreAuthorize("hasAnyRole('APPROVER')") + @PutMapping("/requests/{requestId}/approve") + public ResponseEntity approveMappingRequest(@PathVariable UUID requestId, @AuthenticationPrincipal OidcUser oidcUser) { + try { + User approver = userRepository.findByEmail(oidcUser.getEmail()) + .orElseThrow(() -> new RuntimeException("Approver not found")); + Request request = mappingRequestService.approveRequest(requestId, approver); + return ResponseEntity.ok(request); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PreAuthorize("hasAnyRole('APPROVER')") + @PutMapping("/requests/{requestId}/reject") + public ResponseEntity rejectMappingRequest(@PathVariable UUID requestId, @AuthenticationPrincipal OidcUser oidcUser) { + try { + User rejecter = userRepository.findByEmail(oidcUser.getEmail()) + .orElseThrow(() -> new RuntimeException("Rejecter not found")); + Request request = mappingRequestService.rejectRequest(requestId, rejecter); + return ResponseEntity.ok(request); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PreAuthorize("hasAnyRole('ADMIN')") + @PutMapping("/tunnels/configure/{tunnelId}") + public ResponseEntity configureTunnelForEnvironment(@PathVariable String tunnelId, @AuthenticationPrincipal OidcUser user) { + /* + * * Returns 200 if an object is created or updated with a new representation of the object + * * Returns 204 if the object state did not need any changing. + * * Returns 404 if the tunnelId is not valid + */ + + try { + Tunnel tunnel = cloudflareAPIService.createOrUpdateTunnel(tunnelId, environment); + if (tunnel == null) + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + else + return ResponseEntity.ok(tunnel); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java index 0081205..113f740 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java @@ -25,6 +25,10 @@ public class Mapping { @Column(nullable = false) private int port; + @Enumerated(EnumType.STRING) + @Column(length = 10, nullable = false) + private Protocol protocol; + @Column(length = 50, nullable = false) private String subdomain; diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Protocol.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Protocol.java new file mode 100644 index 0000000..e927c47 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Protocol.java @@ -0,0 +1,8 @@ +package com.hithomelabs.CFTunnels.Entity; + +public enum Protocol { + HTTP, + HTTPS, + TCP, + SSH +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java index 9c56663..3457c9e 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java @@ -17,13 +17,12 @@ import java.util.UUID; public class Tunnel { @Id - @GeneratedValue @Column(columnDefinition = "uuid", insertable = false, updatable = false, nullable = false) private UUID id; @Column(length = 10, unique = true, nullable = false) private String environment; - @Column(name = "cf_tunnel_id", columnDefinition = "uuid", unique = true, nullable = false) - private UUID cfTunnelId; + @Column(length = 50, unique = true, nullable = false) + private String name; } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Exceptions/ExternalServiceException.java b/src/main/java/com/hithomelabs/CFTunnels/Exceptions/ExternalServiceException.java new file mode 100644 index 0000000..2cf144a --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Exceptions/ExternalServiceException.java @@ -0,0 +1,5 @@ +package com.hithomelabs.CFTunnels.Exceptions; + +public class ExternalServiceException extends RuntimeException { + public ExternalServiceException(String message) { super(message); } +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResult.java b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResult.java new file mode 100644 index 0000000..5c5e7b6 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResult.java @@ -0,0 +1,19 @@ +package com.hithomelabs.CFTunnels.Models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TunnelResult { + + private String id; + + private String name; +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelsResponse.java b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelsResponse.java new file mode 100644 index 0000000..0a0af90 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelsResponse.java @@ -0,0 +1,28 @@ +package com.hithomelabs.CFTunnels.Models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TunnelsResponse { + + private List result; + + private List> errors; + + private List> messages; + + private Boolean success; + + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java index fe79625..cdf9fa6 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java @@ -1,6 +1,8 @@ package com.hithomelabs.CFTunnels.Repositories; import com.hithomelabs.CFTunnels.Entity.Request; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,4 +10,5 @@ import java.util.UUID; @Repository public interface RequestRepository extends JpaRepository { + Page findByStatus(Request.RequestStatus status, Pageable pageable); } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java index 2e0d71c..cc7eaeb 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java @@ -1,14 +1,26 @@ package com.hithomelabs.CFTunnels.Services; import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Entity.Tunnel; +import com.hithomelabs.CFTunnels.Exceptions.ExternalServiceException; import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.Ingress; +import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Models.TunnelResult; +import com.hithomelabs.CFTunnels.Models.TunnelsResponse; +import com.hithomelabs.CFTunnels.Repositories.TunnelRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; @Service public class CloudflareAPIService { @@ -22,13 +34,15 @@ public class CloudflareAPIService { @Autowired RestTemplate restTemplate; - public ResponseEntity getCloudflareTunnels() { + @Autowired + TunnelRepository tunnelRepository; + + public ResponseEntity getCloudflareTunnels() { - // * * Resource URL to hit get request at String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel"; HttpEntity httpEntity = new HttpEntity<>("", authKeyEmailHeader.getHttpHeaders()); - ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, Map.class); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, TunnelsResponse.class); return responseEntity; } @@ -54,8 +68,41 @@ public class CloudflareAPIService { return responseEntity; } + private Tunnel getTunnelFromTunnelResponse(TunnelResult tunnelResult, String env){ + return new Tunnel(UUID.fromString(tunnelResult.getId()), env, tunnelResult.getName()); + } + public Tunnel createOrUpdateTunnel(String tunnelId, String environment) throws ExternalServiceException, NoSuchElementException { + ResponseEntity responseEntity = getCloudflareTunnels(); + if (responseEntity.getStatusCode().isError()) + throw new ExternalServiceException("Cloudflare API error: " + responseEntity.getStatusCode()); + TunnelResult tunnelResult = responseEntity.getBody().getResult().stream().filter(t -> t.getId().equals(tunnelId)).findFirst().orElse(null); + if (tunnelResult == null) + throw new NoSuchElementException(); + + Tunnel toBeConfigured = getTunnelFromTunnelResponse(tunnelResult, environment); + Tunnel fromDatabase = tunnelRepository.findById(UUID.fromString(tunnelId)).orElse(null); + + if (fromDatabase != null) + tunnelRepository.deleteById(UUID.fromString(tunnelId)); + tunnelRepository.save(toBeConfigured); + return toBeConfigured; + } + + public List getAllConfiguredTunnels() { + return tunnelRepository.findAll(); + } + + public ResponseEntity addTunnelIngress(String tunnelId, Ingress ingress) { + ResponseEntity currentConfig = getCloudflareTunnelConfigurations(tunnelId, restTemplate, TunnelResponse.class); + + Config config = currentConfig.getBody().getResult().getConfig(); + List ingressList = config.getIngress(); + ingressList.add(ingressList.size() - 1, ingress); + + return putCloudflareTunnelConfigurations(tunnelId, restTemplate, TunnelResponse.class, config); + } } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java index d026f65..2c27bc1 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java @@ -1,6 +1,7 @@ package com.hithomelabs.CFTunnels.Services; import com.hithomelabs.CFTunnels.Entity.Mapping; +import com.hithomelabs.CFTunnels.Entity.Protocol; import com.hithomelabs.CFTunnels.Entity.Request; import com.hithomelabs.CFTunnels.Entity.Tunnel; import com.hithomelabs.CFTunnels.Entity.User; @@ -10,10 +11,16 @@ import com.hithomelabs.CFTunnels.Repositories.RequestRepository; import com.hithomelabs.CFTunnels.Repositories.TunnelRepository; import com.hithomelabs.CFTunnels.Repositories.UserRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.UUID; @Service @@ -31,8 +38,11 @@ public class MappingRequestService { @Autowired TunnelRepository tunnelRepository; + @Autowired + CloudflareAPIService cloudflareAPIService; + public Mapping createMapping(UUID tunnelId, Ingress ingress){ - Tunnel tunnel = tunnelRepository.findByCfTunnelId(tunnelId).orElseThrow(() -> new RuntimeException("Tunnel not found")); + Tunnel tunnel = tunnelRepository.findById(tunnelId).orElseThrow(() -> new RuntimeException("Tunnel not found")); Mapping mapping = createMappingFromTunnelIngress(tunnel, ingress); return mappingRepository.save(mapping); } @@ -51,6 +61,13 @@ public class MappingRequestService { return createRequest(mapping, user); } + public Page getAllRequests(Request.RequestStatus status, Pageable pageable) { + if (status != null) { + return requestRepository.findByStatus(status, pageable); + } + return requestRepository.findAll(pageable); + } + public User mapUser(OidcUser oidcUser){ String email = oidcUser.getEmail(); String name = oidcUser.getNickName(); @@ -64,8 +81,76 @@ public class MappingRequestService { public Mapping createMappingFromTunnelIngress(Tunnel tunnel, Ingress ingress){ Mapping mapping = new Mapping(); mapping.setTunnel(tunnel); + + String serviceString = ingress.getService().toLowerCase(); + Protocol protocol = parseProtocol(serviceString); + mapping.setProtocol(protocol); + mapping.setPort(Integer.parseInt(ingress.getService().split(":")[2])); mapping.setSubdomain(ingress.getHostname().split("\\.")[0]); return mapping; } + + private Protocol parseProtocol(String serviceString) { + if (serviceString.startsWith("https://")) { + return Protocol.HTTPS; + } else if (serviceString.startsWith("tcp://")) { + return Protocol.TCP; + } else if (serviceString.startsWith("ssh://")) { + return Protocol.SSH; + } + return Protocol.HTTP; + } + + @Transactional + public Request approveRequest(UUID requestId, User approver) { + Request request = requestRepository.findById(requestId) + .orElseThrow(() -> new NoSuchElementException("Request not found")); + + if (request.getStatus() != Request.RequestStatus.PENDING) { + throw new IllegalStateException("Request is not in PENDING status"); + } + + Mapping mapping = request.getMapping(); + Tunnel tunnel = mapping.getTunnel(); + + Ingress ingress = createIngressFromMapping(mapping); + + ResponseEntity response = cloudflareAPIService.addTunnelIngress( + tunnel.getId().toString(), + ingress + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException("Failed to add mapping to Cloudflare"); + } + + request.setStatus(Request.RequestStatus.APPROVED); + request.setAcceptedBy(approver); + return requestRepository.save(request); + } + + @Transactional + public Request rejectRequest(UUID requestId, User rejecter) { + Request request = requestRepository.findById(requestId) + .orElseThrow(() -> new NoSuchElementException("Request not found")); + + if (request.getStatus() != Request.RequestStatus.PENDING) { + throw new IllegalStateException("Request is not in PENDING status"); + } + + request.setStatus(Request.RequestStatus.REJECTED); + request.setAcceptedBy(rejecter); + return requestRepository.save(request); + } + + private static final String SERVER_IP = "192.168.0.100"; + + private Ingress createIngressFromMapping(Mapping mapping) { + Tunnel tunnel = mapping.getTunnel(); + String protocol = mapping.getProtocol().name().toLowerCase(); + String service = protocol + "://" + SERVER_IP + ":" + mapping.getPort(); + String hostname = mapping.getSubdomain() + "." + tunnel.getName() + ".hithomelabs.com"; + return new Ingress(service, hostname, null, null); + } } diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java index 69fbbea..dd384ab 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -5,10 +5,14 @@ import com.hithomelabs.CFTunnels.Config.AuthoritiesToGroupMapping; import com.hithomelabs.CFTunnels.Config.CloudflareConfig; import com.hithomelabs.CFTunnels.Config.RestTemplateConfig; import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Entity.Tunnel; import com.hithomelabs.CFTunnels.Models.Authorities; import com.hithomelabs.CFTunnels.Models.Config; import com.hithomelabs.CFTunnels.Models.Groups; import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Models.TunnelResult; +import com.hithomelabs.CFTunnels.Models.TunnelsResponse; +import com.hithomelabs.CFTunnels.Repositories.UserRepository; import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; import com.hithomelabs.CFTunnels.Services.MappingRequestService; import org.junit.jupiter.api.DisplayName; @@ -29,6 +33,10 @@ import java.io.IOException; import java.time.Instant; import java.util.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; import static org.hamcrest.core.IsIterableContaining.hasItem; import static org.mockito.ArgumentMatchers.any; @@ -69,6 +77,9 @@ class TunnelControllerTest { @MockitoBean MappingRequestService mappingRequestService; + @MockitoBean + UserRepository userRepository; + private static final String tunnelResponseSmallIngressFile = "tunnelResponseSmallIngress.json"; private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; @@ -120,6 +131,26 @@ class TunnelControllerTest { return new DefaultOidcUser(authorities, idToken, "preferred_username"); } + private DefaultOidcUser buildOidcUserWithEmail(String username, String role, String email) { + + when(authoritiesToGroupMapping.getAuthorityForGroup()).thenReturn(Map.of(Groups.GITEA_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER))), + Groups.POWER_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER))), + Groups.HOMELAB_DEVELOPER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_DEVELOPER))), + Groups.SYSTEM_ADMIN, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_APPROVER), new SimpleGrantedAuthority(Authorities.ROLE_ADMIN))))); + + Map> roleAuthorityMapping = authoritiesToGroupMapping.getAuthorityForGroup(); + List authorities = roleAuthorityMapping.get(role).stream().toList(); + + OidcIdToken idToken = new OidcIdToken( + "mock-token", + Instant.now(), + Instant.now().plusSeconds(3600), + Map.of("preferred_username", username, "sub", username, "email", email) + ); + + return new DefaultOidcUser(authorities, idToken, "preferred_username"); + } + @Test @DisplayName("should return appropriate user roles when use belongs to group GITEA_USER") public void testWhoAmI_user() throws Exception { @@ -140,8 +171,9 @@ class TunnelControllerTest { headers.set("X-Auth-Email", "me@example.com"); when(authKeyEmailHeader.getHttpHeaders()).thenReturn(headers); - Map tunnelData = Map.of("tunnels", List.of(Map.of("id", "50df9101-f625-4618-b7c5-100338a57124"))); - ResponseEntity mockResponse = new ResponseEntity<>(tunnelData, HttpStatus.OK); + List tunnelResults = List.of(new TunnelResult("50df9101-f625-4618-b7c5-100338a57124", "test-tunnel")); + TunnelsResponse tunnelsResponse = new TunnelsResponse(tunnelResults, null, null, true); + ResponseEntity mockResponse = new ResponseEntity<>(tunnelsResponse, HttpStatus.OK); when(cloudflareAPIService.getCloudflareTunnels()).thenReturn(mockResponse); @@ -149,10 +181,222 @@ class TunnelControllerTest { .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER)))) .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.data.tunnels[0].id").value("50df9101-f625-4618-b7c5-100338a57124")); + .andExpect(jsonPath("$.data.result[0].id").value("50df9101-f625-4618-b7c5-100338a57124")); } + @Test + @DisplayName("should return list of configured tunnels from database") + void getConfiguredTunnels() throws Exception { + List tunnels = List.of( + new Tunnel(UUID.fromString("50df9101-f625-4618-b7c5-100338a57124"), "dev", "devtunnel"), + new Tunnel(UUID.fromString("60df9101-f625-4618-b7c5-100338a57125"), "prod", "prodtunnel") + ); + when(cloudflareAPIService.getAllConfiguredTunnels()).thenReturn(tunnels); + + mockMvc.perform(get("/cloudflare/configured/tunnels") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data[*].name", hasItem("devtunnel"))) + .andExpect(jsonPath("$.data[*].name", hasItem("prodtunnel"))); + } + + @Test + @DisplayName("should return list of requests with pagination") + void getAllRequests_Success() throws Exception { + List requests = Arrays.asList( + createTestRequest(UUID.randomUUID(), com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.PENDING), + createTestRequest(UUID.randomUUID(), com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.APPROVED) + ); + Page page = new PageImpl<>(requests, PageRequest.of(0, 10), 2); + + when(mappingRequestService.getAllRequests(any(), any(PageRequest.class))).thenReturn(page); + + mockMvc.perform(get("/cloudflare/requests") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER))) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.totalItems").value(2)) + .andExpect(jsonPath("$.totalPages").value(1)); + } + + @Test + @DisplayName("should filter requests by status") + void getAllRequests_WithStatusFilter() throws Exception { + List requests = List.of( + createTestRequest(UUID.randomUUID(), com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.PENDING) + ); + Page page = new PageImpl<>(requests, PageRequest.of(0, 10), 1); + + when(mappingRequestService.getAllRequests(eq(com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.PENDING), any(PageRequest.class))).thenReturn(page); + + mockMvc.perform(get("/cloudflare/requests") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER))) + .param("status", "PENDING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data[0].status").value("PENDING")); + } + + @Test + @DisplayName("should create mapping request successfully") + void createTunnelMappingRequest_Success() throws Exception { + UUID tunnelId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.Request createdRequest = new com.hithomelabs.CFTunnels.Entity.Request(); + createdRequest.setId(UUID.randomUUID()); + createdRequest.setStatus(com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.PENDING); + + when(mappingRequestService.createMappingRequest(any(String.class), any(com.hithomelabs.CFTunnels.Models.Ingress.class), any())).thenReturn(createdRequest); + + mockMvc.perform(post("/cloudflare/tunnels/configure/{tunnelId}/requests", tunnelId.toString()) + .with(oauth2Login().oauth2User(buildOidcUser("developer", Groups.HOMELAB_DEVELOPER))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ingressJson)) + .andExpect(status().isCreated()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + private com.hithomelabs.CFTunnels.Entity.Request createTestRequest(UUID id, com.hithomelabs.CFTunnels.Entity.Request.RequestStatus status) { + com.hithomelabs.CFTunnels.Entity.Request request = new com.hithomelabs.CFTunnels.Entity.Request(); + request.setId(id); + request.setStatus(status); + return request; + } + + @Test + @DisplayName("should approve mapping request successfully") + void approveMappingRequest_Success() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User approverUser = new com.hithomelabs.CFTunnels.Entity.User(); + approverUser.setEmail("approver@example.com"); + approverUser.setName("Approver"); + + com.hithomelabs.CFTunnels.Entity.Request approvedRequest = new com.hithomelabs.CFTunnels.Entity.Request(); + approvedRequest.setId(requestId); + approvedRequest.setStatus(com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.APPROVED); + + when(mappingRequestService.approveRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenReturn(approvedRequest); + when(userRepository.findByEmail("approver@example.com")) + .thenReturn(java.util.Optional.of(approverUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/approve", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("approver", Groups.SYSTEM_ADMIN, "approver@example.com"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + @DisplayName("should return 404 when request not found") + void approveMappingRequest_NotFound() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User approverUser = new com.hithomelabs.CFTunnels.Entity.User(); + approverUser.setEmail("approver@example.com"); + approverUser.setName("Approver"); + + when(mappingRequestService.approveRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenThrow(new NoSuchElementException("Request not found")); + when(userRepository.findByEmail("approver@example.com")) + .thenReturn(java.util.Optional.of(approverUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/approve", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("approver", Groups.SYSTEM_ADMIN, "approver@example.com"))) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should return 500 when mapping creation fails") + void approveMappingRequest_InternalServerError() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User approverUser = new com.hithomelabs.CFTunnels.Entity.User(); + approverUser.setEmail("approver@example.com"); + approverUser.setName("Approver"); + + when(mappingRequestService.approveRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenThrow(new RuntimeException("Failed to add mapping to Cloudflare")); + when(userRepository.findByEmail("approver@example.com")) + .thenReturn(java.util.Optional.of(approverUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/approve", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("approver", Groups.SYSTEM_ADMIN, "approver@example.com"))) + .with(csrf())) + .andExpect(status().isInternalServerError()); + } + + @Test + @DisplayName("should reject mapping request successfully") + void rejectMappingRequest_Success() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User rejecterUser = new com.hithomelabs.CFTunnels.Entity.User(); + rejecterUser.setEmail("rejecter@example.com"); + rejecterUser.setName("Rejecter"); + + com.hithomelabs.CFTunnels.Entity.Request rejectedRequest = new com.hithomelabs.CFTunnels.Entity.Request(); + rejectedRequest.setId(requestId); + rejectedRequest.setStatus(com.hithomelabs.CFTunnels.Entity.Request.RequestStatus.REJECTED); + + when(mappingRequestService.rejectRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenReturn(rejectedRequest); + when(userRepository.findByEmail("rejecter@example.com")) + .thenReturn(java.util.Optional.of(rejecterUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/reject", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("rejecter", Groups.SYSTEM_ADMIN, "rejecter@example.com"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + @DisplayName("should return 404 when rejecting non-existent request") + void rejectMappingRequest_NotFound() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User rejecterUser = new com.hithomelabs.CFTunnels.Entity.User(); + rejecterUser.setEmail("rejecter@example.com"); + rejecterUser.setName("Rejecter"); + + when(mappingRequestService.rejectRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenThrow(new NoSuchElementException("Request not found")); + when(userRepository.findByEmail("rejecter@example.com")) + .thenReturn(java.util.Optional.of(rejecterUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/reject", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("rejecter", Groups.SYSTEM_ADMIN, "rejecter@example.com"))) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should return 409 when rejecting already processed request") + void rejectMappingRequest_Conflict() throws Exception { + UUID requestId = UUID.randomUUID(); + com.hithomelabs.CFTunnels.Entity.User rejecterUser = new com.hithomelabs.CFTunnels.Entity.User(); + rejecterUser.setEmail("rejecter@example.com"); + rejecterUser.setName("Rejecter"); + + when(mappingRequestService.rejectRequest(eq(requestId), any(com.hithomelabs.CFTunnels.Entity.User.class))) + .thenThrow(new IllegalStateException("Request is not in PENDING status")); + when(userRepository.findByEmail("rejecter@example.com")) + .thenReturn(java.util.Optional.of(rejecterUser)); + + mockMvc.perform(put("/cloudflare/requests/{requestId}/reject", requestId) + .with(oauth2Login().oauth2User(buildOidcUserWithEmail("rejecter", Groups.SYSTEM_ADMIN, "rejecter@example.com"))) + .with(csrf())) + .andExpect(status().isConflict()); + } + @Test void getTunnelConfigurations() throws Exception { @@ -161,7 +405,7 @@ class TunnelControllerTest { when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(Map.class))).thenReturn(mockResponse); - mockMvc.perform(get("/cloudflare/tunnel/{tunnelId}/mappings", "sampleTunnelId") + mockMvc.perform(get("/cloudflare/tunnels/{tunnelId}/mappings", "sampleTunnelId") .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.HOMELAB_DEVELOPER)))) .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) @@ -183,7 +427,7 @@ class TunnelControllerTest { ResponseEntity expectedHttpTunnelResponse = new ResponseEntity<>(expectedTunnelConfig, HttpStatus.OK); when(cloudflareAPIService.putCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class), any(Config.class))).thenReturn(expectedHttpTunnelResponse); - mockMvc.perform(post("/cloudflare/tunnel/{tunnelId}/mappings", "sampleTunnelId") + mockMvc.perform(post("/cloudflare/tunnels/{tunnelId}/mappings", "sampleTunnelId") .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -208,7 +452,7 @@ class TunnelControllerTest { ResponseEntity expectedHttpTunnelResponse = new ResponseEntity<>(expectedTunnelConfig, HttpStatus.OK); when(cloudflareAPIService.putCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class), any(Config.class))).thenReturn(expectedHttpTunnelResponse); - mockMvc.perform(delete("/cloudflare/tunnel/{tunnelId}/mappings", "sampleTunnelId") + mockMvc.perform(delete("/cloudflare/tunnels/{tunnelId}/mappings", "sampleTunnelId") .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -218,5 +462,55 @@ class TunnelControllerTest { .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", not(hasItem("random.hithomelabs.com")))); } + @Test + @DisplayName("should return 200 OK with tunnel when tunnel is successfully updated") + void configureTunnelForEnvironment_Success() throws Exception { + Tunnel tunnel = new Tunnel(UUID.randomUUID(), "dev", "test-tunnel"); + when(cloudflareAPIService.createOrUpdateTunnel(eq("test-tunnel-id"), any(String.class))).thenReturn(tunnel); + + mockMvc.perform(put("/cloudflare/tunnels/configure/{tunnelId}", "test-tunnel-id") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.name").value("test-tunnel")) + .andExpect(jsonPath("$.environment").value("dev")); + } + + @Test + @DisplayName("should return 204 NO_CONTENT when tunnel does not need changes") + void configureTunnelForEnvironment_NoContent() throws Exception { + when(cloudflareAPIService.createOrUpdateTunnel(eq("test-tunnel-id"), any(String.class))).thenReturn(null); + + mockMvc.perform(put("/cloudflare/tunnels/configure/{tunnelId}", "test-tunnel-id") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("should return 404 NOT_FOUND when tunnelId is not valid") + void configureTunnelForEnvironment_NotFound() throws Exception { + when(cloudflareAPIService.createOrUpdateTunnel(eq("invalid-tunnel-id"), any(String.class))) + .thenThrow(new NoSuchElementException("Tunnel not found")); + + mockMvc.perform(put("/cloudflare/tunnels/configure/{tunnelId}", "invalid-tunnel-id") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should return 500 INTERNAL_SERVER_ERROR when runtime exception occurs") + void configureTunnelForEnvironment_InternalServerError() throws Exception { + when(cloudflareAPIService.createOrUpdateTunnel(eq("test-tunnel-id"), any(String.class))) + .thenThrow(new RuntimeException("Internal error")); + + mockMvc.perform(put("/cloudflare/tunnels/configure/{tunnelId}", "test-tunnel-id") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf())) + .andExpect(status().isInternalServerError()); + } + } diff --git a/src/test/java/com/hithomelabs/CFTunnels/Integration/CoudflareApiIntegrationTest.java b/src/test/java/com/hithomelabs/CFTunnels/Integration/CoudflareApiIntegrationTest.java index 9553db1..0ceb02b 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Integration/CoudflareApiIntegrationTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Integration/CoudflareApiIntegrationTest.java @@ -5,6 +5,8 @@ import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; import com.hithomelabs.CFTunnels.Models.Config; import com.hithomelabs.CFTunnels.Models.Ingress; import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Models.TunnelResult; +import com.hithomelabs.CFTunnels.Models.TunnelsResponse; import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -17,7 +19,6 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.web.client.RestTemplate; import java.util.List; -import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -44,13 +45,13 @@ public class CoudflareApiIntegrationTest { @DisplayName("Calls cloudflare cfd tunnels API and checks that dev tunnel should be a part of the response") public void testGetTunnelsTest() { - ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); + ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); assertEquals(HttpStatus.OK, response.getStatusCode()); - List> tunnelList = (List>) response.getBody().get("result"); + List tunnelList = response.getBody().getResult(); boolean hasName = tunnelList.stream() - .anyMatch(tunnel -> "devtunnel".equals(tunnel.get("name"))); + .anyMatch(tunnel -> "devtunnel".equals(tunnel.getName())); assertTrue(hasName); } diff --git a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java index 03374e5..67f7349 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java @@ -3,9 +3,15 @@ package com.hithomelabs.CFTunnels.Services; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Entity.Tunnel; +import com.hithomelabs.CFTunnels.Exceptions.ExternalServiceException; import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.Ingress; import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Models.TunnelResult; +import com.hithomelabs.CFTunnels.Models.TunnelsResponse; +import com.hithomelabs.CFTunnels.Repositories.TunnelRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -16,12 +22,16 @@ import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.util.List; -import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -39,6 +49,9 @@ class CloudflareAPIServiceTest { @Mock CloudflareConfig cloudflareConfig; + @Mock + TunnelRepository tunnelRepository; + private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; private static final String bigTunnelResponse; @@ -56,17 +69,18 @@ class CloudflareAPIServiceTest { when(cloudflareConfig.getAccountId()).thenReturn("account-123"); when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); - Map mockBody = Map.of("tunnels", List.of(Map.of("id", "t1"))); - ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); + List tunnelResults = List.of(new TunnelResult("t1", "test-tunnel")); + TunnelsResponse mockBody = new TunnelsResponse(tunnelResults, null, null, true); + ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); when(restTemplate.exchange( any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), - eq(Map.class) + eq(TunnelsResponse.class) )).thenReturn(mockResponse); - ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); + ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); assertEquals(HttpStatus.OK, response.getStatusCode()); } @@ -114,4 +128,138 @@ class CloudflareAPIServiceTest { assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(response.getBody().getResult().getConfig().getIngress().get(2).getHostname(), "random.hithomelabs.com"); } + + @Test + void createOrUpdateTunnel_Success() { + String tunnelId = "50df9101-f625-4618-b7c5-100338a57124"; + String environment = "dev"; + + List tunnelResults = List.of(new TunnelResult(tunnelId, "devtunnel")); + TunnelsResponse mockBody = new TunnelsResponse(tunnelResults, null, null, true); + ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelsResponse.class) + )).thenReturn(mockResponse); + + when(tunnelRepository.findById(UUID.fromString(tunnelId))).thenReturn(Optional.empty()); + + Tunnel result = cloudflareAPIService.createOrUpdateTunnel(tunnelId, environment); + + assertEquals(UUID.fromString(tunnelId), result.getId()); + assertEquals("devtunnel", result.getName()); + assertEquals(environment, result.getEnvironment()); + verify(tunnelRepository).save(any(Tunnel.class)); + } + + @Test + void createOrUpdateTunnel_UpdatesExistingTunnel() { + String tunnelId = "50df9101-f625-4618-b7c5-100338a57124"; + String environment = "prod"; + + List tunnelResults = List.of(new TunnelResult(tunnelId, "devtunnel")); + TunnelsResponse mockBody = new TunnelsResponse(tunnelResults, null, null, true); + ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelsResponse.class) + )).thenReturn(mockResponse); + + Tunnel existingTunnel = new Tunnel(UUID.fromString(tunnelId), "dev", "oldname"); + when(tunnelRepository.findById(UUID.fromString(tunnelId))).thenReturn(Optional.of(existingTunnel)); + + cloudflareAPIService.createOrUpdateTunnel(tunnelId, environment); + + verify(tunnelRepository).deleteById(UUID.fromString(tunnelId)); + verify(tunnelRepository).save(any(Tunnel.class)); + } + + @Test + void createOrUpdateTunnel_ThrowsExternalServiceExceptionOnApiError() { + String tunnelId = "50df9101-f625-4618-b7c5-100338a57124"; + + ResponseEntity mockResponse = new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelsResponse.class) + )).thenReturn(mockResponse); + + assertThrows(ExternalServiceException.class, () -> + cloudflareAPIService.createOrUpdateTunnel(tunnelId, "dev") + ); + } + + @Test + void createOrUpdateTunnel_ThrowsNoSuchElementExceptionWhenTunnelNotFound() { + String tunnelId = "50df9101-f625-4618-b7c5-100338a57124"; + + List tunnelResults = List.of(new TunnelResult("other-tunnel-id", "othertunnel")); + TunnelsResponse mockBody = new TunnelsResponse(tunnelResults, null, null, true); + ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelsResponse.class) + )).thenReturn(mockResponse); + + assertThrows(NoSuchElementException.class, () -> + cloudflareAPIService.createOrUpdateTunnel(tunnelId, "dev") + ); + } + + @Test + void getAllConfiguredTunnels_ReturnsAllTunnels() { + List tunnels = List.of( + new Tunnel(UUID.fromString("50df9101-f625-4618-b7c5-100338a57124"), "dev", "devtunnel"), + new Tunnel(UUID.fromString("60df9101-f625-4618-b7c5-100338a57125"), "prod", "prodtunnel") + ); + when(tunnelRepository.findAll()).thenReturn(tunnels); + + List result = cloudflareAPIService.getAllConfiguredTunnels(); + + assertEquals(2, result.size()); + assertEquals("devtunnel", result.get(0).getName()); + assertEquals("prodtunnel", result.get(1).getName()); + } + + @Test + void addTunnelIngress_Success() throws JsonProcessingException { + String tunnelId = "50df9101-f625-4618-b7c5-100338a57124"; + Ingress ingress = new Ingress("http://192.168.0.100:8080", "test.hithomelabs.com", null, null); + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + + TunnelResponse tunnelResponse = new ObjectMapper().readValue(bigTunnelResponse, TunnelResponse.class); + ResponseEntity getResponse = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(getResponse); + + ResponseEntity putResponse = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(putResponse); + + ResponseEntity result = cloudflareAPIService.addTunnelIngress(tunnelId, ingress); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + } } \ No newline at end of file