From 7f7b90df808beabf771d64122fb8d122705cad9f Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 20:00:34 +0530 Subject: [PATCH 01/12] 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 -- 2.45.2 From 927ce563c56fc7256fc74507a19d1045cd14fd3e Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 20:07:41 +0530 Subject: [PATCH 02/12] Set ddl-auto to create-drop for test and prod profiles --- src/main/resources/application-prod.properties | 4 ++-- src/main/resources/application-test.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index cb15c36..aefe0ab 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -7,6 +7,6 @@ spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=false +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index b257503..2064163 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -7,6 +7,6 @@ spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -- 2.45.2 From 6c496c8c277f2ef457608184121b9dec6cd4f96e Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 20:23:29 +0530 Subject: [PATCH 03/12] Fix broken findByCfTunnelId method - property doesn't exist on Tunnel entity --- .../com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java index 178f44e..719d3ce 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java @@ -9,5 +9,4 @@ import java.util.UUID; @Repository public interface TunnelRepository extends JpaRepository { - Optional findByCfTunnelId(UUID cfTunnelId); } -- 2.45.2 From 7e3882febf201121ef7c756ecd87d3b4362f7514 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 20:51:52 +0530 Subject: [PATCH 04/12] Add PaginationRequest DTO for /requests endpoint --- .../CFTunnels/Controllers/TunnelController.java | 9 ++++++++- .../CFTunnels/Models/PaginationRequest.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java index c13e062..a805b96 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java @@ -10,6 +10,7 @@ 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.PaginationRequest; import com.hithomelabs.CFTunnels.Models.TunnelResponse; import com.hithomelabs.CFTunnels.Models.TunnelsResponse; import com.hithomelabs.CFTunnels.Repositories.UserRepository; @@ -22,7 +23,9 @@ 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.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.*; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.GrantedAuthority; @@ -111,8 +114,12 @@ public class TunnelController implements ErrorController { @GetMapping("/requests") public ResponseEntity> getAllRequests( @RequestParam(required = false) Request.RequestStatus status, - Pageable pageable) { + @ModelAttribute PaginationRequest paginationRequest) { try { + Sort sort = paginationRequest.getSort() != null && paginationRequest.getSort().length > 0 + ? Sort.by(paginationRequest.getSort()) + : Sort.by("id"); + Pageable pageable = PageRequest.of(paginationRequest.getPage(), paginationRequest.getSize(), sort); Page requests = mappingRequestService.getAllRequests(status, pageable); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java b/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java new file mode 100644 index 0000000..da16aa2 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java @@ -0,0 +1,14 @@ +package com.hithomelabs.CFTunnels.Models; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PaginationRequest { + private int page = 0; + private int size = 10; + private String[] sort = {"id"}; +} -- 2.45.2 From 9a25495d9cf142fdc90d73255b6325b08fe06212 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 21:24:32 +0530 Subject: [PATCH 05/12] Remove pagination and filtering from /requests endpoint --- CFTunnels/Get tunnels.bru | 11 - CFTunnels/Tunnel.bru | 11 - CFTunnels/Write ingress.bru | 19 -- CFTunnels/bruno.json | 9 - CFTunnels/delete mapping.bru | 19 -- CFTunnels/environments/CFTunnels Local.bru | 4 - CFTunnels/environments/CFTunnels.bru | 4 - .../Controllers/TunnelController.java | 21 +- .../CFTunnels/Models/PaginationRequest.java | 14 -- .../Services/MappingRequestService.java | 9 +- .../resources/application-local.properties | 2 + .../Controllers/TunnelControllerTest.java | 190 +----------------- 12 files changed, 19 insertions(+), 294 deletions(-) delete mode 100644 CFTunnels/Get tunnels.bru delete mode 100644 CFTunnels/Tunnel.bru delete mode 100644 CFTunnels/Write ingress.bru delete mode 100644 CFTunnels/bruno.json delete mode 100644 CFTunnels/delete mapping.bru delete mode 100644 CFTunnels/environments/CFTunnels Local.bru delete mode 100644 CFTunnels/environments/CFTunnels.bru delete mode 100644 src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java diff --git a/CFTunnels/Get tunnels.bru b/CFTunnels/Get tunnels.bru deleted file mode 100644 index 231ee08..0000000 --- a/CFTunnels/Get tunnels.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Get tunnels - type: http - seq: 4 -} - -get { - url: {{base_url}}/cloudflare/tunnels - body: none - auth: none -} diff --git a/CFTunnels/Tunnel.bru b/CFTunnels/Tunnel.bru deleted file mode 100644 index 826b583..0000000 --- a/CFTunnels/Tunnel.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Tunnel - type: http - seq: 5 -} - -get { - url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}} - body: none - auth: none -} diff --git a/CFTunnels/Write ingress.bru b/CFTunnels/Write ingress.bru deleted file mode 100644 index 9930a0b..0000000 --- a/CFTunnels/Write ingress.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: Write ingress - type: http - seq: 2 -} - -put { - url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}}/add - body: json - auth: none -} - -body:json { - { - "service": "http://192.168.0.100:3457", - "hostname": "random.hithomelabs.com", - "originRequest": {} - } -} diff --git a/CFTunnels/bruno.json b/CFTunnels/bruno.json deleted file mode 100644 index 16b8d6b..0000000 --- a/CFTunnels/bruno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "1", - "name": "CFTunnels", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ] -} \ No newline at end of file diff --git a/CFTunnels/delete mapping.bru b/CFTunnels/delete mapping.bru deleted file mode 100644 index c9e3dbb..0000000 --- a/CFTunnels/delete mapping.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: delete mapping - type: http - seq: 3 -} - -put { - url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}}/delete - body: json - auth: none -} - -body:json { - { - "service": "http://192.168.0.100:6000", - "hostname": "random.hithomelabs.com", - "originRequest": {} - } -} diff --git a/CFTunnels/environments/CFTunnels Local.bru b/CFTunnels/environments/CFTunnels Local.bru deleted file mode 100644 index 6d2b6b0..0000000 --- a/CFTunnels/environments/CFTunnels Local.bru +++ /dev/null @@ -1,4 +0,0 @@ -vars { - tunnel_id: 50df9101-f625-4618-b7c5-100338a57124 - base_url: http://localhost:8080 -} diff --git a/CFTunnels/environments/CFTunnels.bru b/CFTunnels/environments/CFTunnels.bru deleted file mode 100644 index d8219ab..0000000 --- a/CFTunnels/environments/CFTunnels.bru +++ /dev/null @@ -1,4 +0,0 @@ -vars { - tunnel_id: 50df9101-f625-4618-b7c5-100338a57124 - base_url: https://testcf.hithomelabs.com -} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java index a805b96..33c4d07 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java @@ -10,7 +10,6 @@ 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.PaginationRequest; import com.hithomelabs.CFTunnels.Models.TunnelResponse; import com.hithomelabs.CFTunnels.Models.TunnelsResponse; import com.hithomelabs.CFTunnels.Repositories.UserRepository; @@ -22,10 +21,7 @@ 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.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.http.*; import org.springframework.http.*; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.GrantedAuthority; @@ -112,21 +108,12 @@ public class TunnelController implements ErrorController { @PreAuthorize("hasAnyRole('USER')") @GetMapping("/requests") - public ResponseEntity> getAllRequests( - @RequestParam(required = false) Request.RequestStatus status, - @ModelAttribute PaginationRequest paginationRequest) { + public ResponseEntity> getAllRequests() { try { - Sort sort = paginationRequest.getSort() != null && paginationRequest.getSort().length > 0 - ? Sort.by(paginationRequest.getSort()) - : Sort.by("id"); - Pageable pageable = PageRequest.of(paginationRequest.getPage(), paginationRequest.getSize(), sort); - Page requests = mappingRequestService.getAllRequests(status, pageable); + List requests = mappingRequestService.getAllRequests(); 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()); + jsonResponse.put("data", requests); return ResponseEntity.ok(jsonResponse); } catch (DataAccessException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java b/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java deleted file mode 100644 index da16aa2..0000000 --- a/src/main/java/com/hithomelabs/CFTunnels/Models/PaginationRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.hithomelabs.CFTunnels.Models; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class PaginationRequest { - private int page = 0; - private int size = 10; - private String[] sort = {"id"}; -} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java index 2c27bc1..f66b1d0 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java @@ -11,8 +11,6 @@ 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; @@ -61,11 +59,8 @@ 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 List getAllRequests() { + return requestRepository.findAll(); } public User mapUser(OidcUser oidcUser){ diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index febde77..4f27f96 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -7,4 +7,6 @@ management.endpoint.health.show-details=always logging.level.org.hibernate.SQL=DEBUG debug=true +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true spring.datasource.url=jdbc:postgresql://localhost:5432/cftunnel diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java index dd384ab..53507de 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -204,197 +204,22 @@ class TunnelControllerTest { } @Test - @DisplayName("should return list of requests with pagination") + @DisplayName("should return list of requests") 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); + when(mappingRequestService.getAllRequests()).thenReturn(requests); mockMvc.perform(get("/cloudflare/requests") - .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER))) - .param("page", "0") - .param("size", "10")) + .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").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()); + .andExpect(jsonPath("$.data.length()").value(2)); } @Test @@ -437,6 +262,13 @@ class TunnelControllerTest { .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", hasItem("random.hithomelabs.com"))); } + 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 void deleteTunnelConfiguration() throws Exception { -- 2.45.2 From 09e631c871973a6c0fc19d61ea6e82416293f2f1 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 22:34:21 +0530 Subject: [PATCH 06/12] Restore removed tests for approve/reject endpoints --- .../Controllers/TunnelControllerTest.java | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java index 53507de..3af77b2 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -222,6 +222,152 @@ class TunnelControllerTest { .andExpect(jsonPath("$.data.length()").value(2)); } + @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")); + } + + @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 { -- 2.45.2 From 3b43039a295add8ea89a8c4c24f28f91a31b9f73 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 15 Feb 2026 22:37:19 +0530 Subject: [PATCH 07/12] Add Request import and simplify createTestRequest method --- .../CFTunnels/Controllers/TunnelControllerTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java index 3af77b2..e2ad575 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -5,6 +5,7 @@ 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.Request; import com.hithomelabs.CFTunnels.Entity.Tunnel; import com.hithomelabs.CFTunnels.Models.Authorities; import com.hithomelabs.CFTunnels.Models.Config; @@ -408,8 +409,8 @@ class TunnelControllerTest { .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", hasItem("random.hithomelabs.com"))); } - 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(); + private Request createTestRequest(UUID id, Request.RequestStatus status) { + Request request = new Request(); request.setId(id); request.setStatus(status); return request; -- 2.45.2 From c78f2713c3d0290feed104a2d3bf16d31b9494f2 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 16 Feb 2026 00:38:23 +0530 Subject: [PATCH 08/12] Fix LazyInitializationException and update hostname format --- .../CFTunnels/Repositories/RequestRepository.java | 10 ++++++++++ .../CFTunnels/Services/MappingRequestService.java | 9 +++++---- src/main/resources/application-local.properties | 2 +- src/main/resources/application-prod.properties | 4 ++-- src/main/resources/application-test.properties | 2 +- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java index cdf9fa6..0b89b5c 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java @@ -4,11 +4,21 @@ 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.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; import java.util.UUID; @Repository public interface RequestRepository extends JpaRepository { Page findByStatus(Request.RequestStatus status, Pageable pageable); + + @Query("SELECT r FROM Request r JOIN FETCH r.mapping m JOIN FETCH m.tunnel JOIN FETCH r.createdBy LEFT JOIN FETCH r.acceptedBy") + List findAllWithDetails(); + + @Query("SELECT r FROM Request r JOIN FETCH r.mapping m JOIN FETCH m.tunnel JOIN FETCH r.createdBy LEFT JOIN FETCH r.acceptedBy WHERE r.id = :id") + Optional findByIdWithDetails(@Param("id") UUID id); } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java index f66b1d0..ba86144 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java @@ -59,8 +59,9 @@ public class MappingRequestService { return createRequest(mapping, user); } + @Transactional(readOnly = true) public List getAllRequests() { - return requestRepository.findAll(); + return requestRepository.findAllWithDetails(); } public User mapUser(OidcUser oidcUser){ @@ -99,7 +100,7 @@ public class MappingRequestService { @Transactional public Request approveRequest(UUID requestId, User approver) { - Request request = requestRepository.findById(requestId) + Request request = requestRepository.findByIdWithDetails(requestId) .orElseThrow(() -> new NoSuchElementException("Request not found")); if (request.getStatus() != Request.RequestStatus.PENDING) { @@ -127,7 +128,7 @@ public class MappingRequestService { @Transactional public Request rejectRequest(UUID requestId, User rejecter) { - Request request = requestRepository.findById(requestId) + Request request = requestRepository.findByIdWithDetails(requestId) .orElseThrow(() -> new NoSuchElementException("Request not found")); if (request.getStatus() != Request.RequestStatus.PENDING) { @@ -145,7 +146,7 @@ public class MappingRequestService { 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"; + String hostname = mapping.getSubdomain() + ".hithomelabs.com"; return new Ingress(service, hostname, null, null); } } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 4f27f96..60ec5d5 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -7,6 +7,6 @@ management.endpoint.health.show-details=always logging.level.org.hibernate.SQL=DEBUG debug=true -spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.datasource.url=jdbc:postgresql://localhost:5432/cftunnel diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index aefe0ab..cb15c36 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -7,6 +7,6 @@ spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.show-sql=true +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 2064163..b257503 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -7,6 +7,6 @@ spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -- 2.45.2 From 3c51b761e00602d761fe730e92113b046ef6bfb0 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 16 Feb 2026 00:56:28 +0530 Subject: [PATCH 09/12] Add RequestRepository integration tests with H2 in-memory database --- .../Repositories/RequestRepositoryTest.java | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/test/java/com/hithomelabs/CFTunnels/Repositories/RequestRepositoryTest.java diff --git a/src/test/java/com/hithomelabs/CFTunnels/Repositories/RequestRepositoryTest.java b/src/test/java/com/hithomelabs/CFTunnels/Repositories/RequestRepositoryTest.java new file mode 100644 index 0000000..257c315 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/Repositories/RequestRepositoryTest.java @@ -0,0 +1,179 @@ +package com.hithomelabs.CFTunnels.Repositories; + +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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@TestPropertySource(properties = { + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class RequestRepositoryTest { + + @Autowired + private RequestRepository requestRepository; + + @Autowired + private TunnelRepository tunnelRepository; + + @Autowired + private MappingRepository mappingRepository; + + @Autowired + private UserRepository userRepository; + + private Tunnel tunnel; + private User createdByUser; + private User acceptedByUser; + private Mapping mapping; + + @BeforeEach + void setUp() { + tunnel = new Tunnel(); + tunnel.setId(UUID.randomUUID()); + tunnel.setEnvironment("test"); + tunnel.setName("test-tunnel"); + tunnel = tunnelRepository.save(tunnel); + + createdByUser = new User(); + createdByUser.setEmail("creator@example.com"); + createdByUser.setName("Creator User"); + createdByUser = userRepository.save(createdByUser); + + acceptedByUser = new User(); + acceptedByUser.setEmail("approver@example.com"); + acceptedByUser.setName("Approver User"); + acceptedByUser = userRepository.save(acceptedByUser); + + mapping = new Mapping(); + mapping.setTunnel(tunnel); + mapping.setPort(8080); + mapping.setProtocol(Protocol.HTTP); + mapping.setSubdomain("test-subdomain"); + mapping = mappingRepository.save(mapping); + } + + @Test + @DisplayName("findAllWithDetails should return requests with all relationships loaded") + void findAllWithDetails_ShouldReturnRequestsWithAllRelationships() { + Request request = new Request(); + request.setMapping(mapping); + request.setCreatedBy(createdByUser); + request.setAcceptedBy(acceptedByUser); + request.setStatus(Request.RequestStatus.PENDING); + requestRepository.save(request); + + List results = requestRepository.findAllWithDetails(); + + assertThat(results).hasSize(1); + Request result = results.get(0); + assertThat(result.getMapping()).isNotNull(); + assertThat(result.getMapping().getTunnel()).isNotNull(); + assertThat(result.getCreatedBy()).isNotNull(); + assertThat(result.getAcceptedBy()).isNotNull(); + } + + @Test + @DisplayName("findByIdWithDetails should return request with all relationships loaded") + void findByIdWithDetails_ShouldReturnRequestWithAllRelationships() { + Request request = new Request(); + request.setMapping(mapping); + request.setCreatedBy(createdByUser); + request.setAcceptedBy(acceptedByUser); + request.setStatus(Request.RequestStatus.PENDING); + UUID requestId = requestRepository.save(request).getId(); + + Optional result = requestRepository.findByIdWithDetails(requestId); + + assertThat(result).isPresent(); + assertThat(result.get().getMapping()).isNotNull(); + assertThat(result.get().getMapping().getTunnel()).isNotNull(); + assertThat(result.get().getCreatedBy()).isNotNull(); + assertThat(result.get().getAcceptedBy()).isNotNull(); + } + + @Test + @DisplayName("findByIdWithDetails should return empty for non-existent id") + void findByIdWithDetails_ShouldReturnEmptyForNonExistentId() { + Optional result = requestRepository.findByIdWithDetails(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findAllWithDetails should return empty list when no requests exist") + void findAllWithDetails_ShouldReturnEmptyListWhenNoRequests() { + List results = requestRepository.findAllWithDetails(); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("findAllWithDetails should handle multiple requests with different statuses") + void findAllWithDetails_ShouldHandleMultipleRequests() { + Mapping mapping1 = new Mapping(); + mapping1.setTunnel(tunnel); + mapping1.setPort(8080); + mapping1.setProtocol(Protocol.HTTP); + mapping1.setSubdomain("pending-subdomain"); + mapping1 = mappingRepository.save(mapping1); + + Mapping mapping2 = new Mapping(); + mapping2.setTunnel(tunnel); + mapping2.setPort(8081); + mapping2.setProtocol(Protocol.HTTP); + mapping2.setSubdomain("approved-subdomain"); + mapping2 = mappingRepository.save(mapping2); + + Mapping mapping3 = new Mapping(); + mapping3.setTunnel(tunnel); + mapping3.setPort(8082); + mapping3.setProtocol(Protocol.HTTP); + mapping3.setSubdomain("rejected-subdomain"); + mapping3 = mappingRepository.save(mapping3); + + Request pendingRequest = new Request(); + pendingRequest.setMapping(mapping1); + pendingRequest.setCreatedBy(createdByUser); + pendingRequest.setStatus(Request.RequestStatus.PENDING); + requestRepository.save(pendingRequest); + + Request approvedRequest = new Request(); + approvedRequest.setMapping(mapping2); + approvedRequest.setCreatedBy(createdByUser); + approvedRequest.setAcceptedBy(acceptedByUser); + approvedRequest.setStatus(Request.RequestStatus.APPROVED); + requestRepository.save(approvedRequest); + + Request rejectedRequest = new Request(); + rejectedRequest.setMapping(mapping3); + rejectedRequest.setCreatedBy(createdByUser); + rejectedRequest.setAcceptedBy(acceptedByUser); + rejectedRequest.setStatus(Request.RequestStatus.REJECTED); + requestRepository.save(rejectedRequest); + + List results = requestRepository.findAllWithDetails(); + + assertThat(results).hasSize(3); + } +} -- 2.45.2 From 3fcea268a985d6b24fd114f21e63da96ef51c3d6 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 16 Feb 2026 01:10:31 +0530 Subject: [PATCH 10/12] Update JPA config to use update mode and disable SQL init --- src/main/resources/application-local.properties | 1 + src/main/resources/application-prod.properties | 3 ++- src/main/resources/application-test.properties | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 60ec5d5..0bdba38 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -9,4 +9,5 @@ debug=true spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true +spring.sql.init.mode=never spring.datasource.url=jdbc:postgresql://localhost:5432/cftunnel diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index cb15c36..09de097 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -9,4 +9,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.sql.init.mode=never \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index b257503..4d768ec 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -10,3 +10,4 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.sql.init.mode=never -- 2.45.2 From 5823d2b6a0d5832d091eea967ab7714e75b3f6d6 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 16 Feb 2026 01:11:20 +0530 Subject: [PATCH 11/12] Revert "Update JPA config to use update mode and disable SQL init" This reverts commit 3fcea268a985d6b24fd114f21e63da96ef51c3d6. --- src/main/resources/application-local.properties | 1 - src/main/resources/application-prod.properties | 3 +-- src/main/resources/application-test.properties | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 0bdba38..60ec5d5 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -9,5 +9,4 @@ debug=true spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true -spring.sql.init.mode=never spring.datasource.url=jdbc:postgresql://localhost:5432/cftunnel diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 09de097..cb15c36 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -9,5 +9,4 @@ spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.sql.init.mode=never \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 4d768ec..b257503 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -10,4 +10,3 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.sql.init.mode=never -- 2.45.2 From 042c7064079c06ab6bafccfb759924472c2d57fe Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Mon, 16 Feb 2026 01:12:32 +0530 Subject: [PATCH 12/12] Update prod JPA config --- src/main/resources/application-prod.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index cb15c36..7522890 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -7,6 +7,6 @@ spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file -- 2.45.2