From 316dd6b01e3061b7d1223e9b25c0ccf7e49a2676 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 26 Oct 2025 18:37:41 +0530 Subject: [PATCH 1/3] ISSUE-44: Hithomelabs/HomeLabDocker#44 Decoupling the controller and the service layer --- .../Services/CloudflareAPIService.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java new file mode 100644 index 0000000..2e0d71c --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java @@ -0,0 +1,61 @@ +package com.hithomelabs.CFTunnels.Services; + +import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Models.Config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class CloudflareAPIService { + + @Autowired + CloudflareConfig cloudflareConfig; + + @Autowired + AuthKeyEmailHeader authKeyEmailHeader; + + @Autowired + RestTemplate restTemplate; + + 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); + return responseEntity; + } + + public ResponseEntity getCloudflareTunnelConfigurations(String tunnelId, RestTemplate restTemplate, Class responseType) { + + // * * Resource URL to hit get request at + String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; + + HttpEntity httpEntity = new HttpEntity<>("",authKeyEmailHeader.getHttpHeaders()); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, responseType); + return responseEntity; + } + + public ResponseEntity putCloudflareTunnelConfigurations(String tunnelId, RestTemplate restTemplate, Class responseType, Config config) { + + // * * Resource URL to hit get request at + String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; + + HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(config, httpHeaders); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.PUT, entity, responseType); + return responseEntity; + } + + + + + +} -- 2.45.2 From 68792e2cbf1a0dfedf164c77fa72b4bbf59b6a56 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 26 Oct 2025 18:49:18 +0530 Subject: [PATCH 2/3] ISSUE-44: Hithomelabs/HomeLabDocker#44 Adding mockMvc tests for web tier --- .gitignore | 2 + build.gradle | 3 + .../Controllers/TunnelController.java | 42 +-- .../CFTunnels/Models/TunnelResponse.java | 50 +--- .../Controllers/TunnelControllerTest.java | 271 ++++++++++++++++++ .../Services/CloudflareAPIServiceTest.java | 55 ++++ src/test/resources/docker-compose.yaml | 13 + 7 files changed, 364 insertions(+), 72 deletions(-) create mode 100644 src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java create mode 100644 src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java create mode 100644 src/test/resources/docker-compose.yaml diff --git a/.gitignore b/.gitignore index c2065bc..c47508c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ HELP.md .gradle +.run build/ +.env* !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/build.gradle b/build.gradle index 4340fce..4328386 100644 --- a/build.gradle +++ b/build.gradle @@ -24,8 +24,11 @@ repositories { dependencies { implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.5' implementation group: 'org.springframework.boot', name:'spring-boot-starter-oauth2-client', version: '3.5.5' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java index 902cb55..3362a83 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java @@ -8,6 +8,7 @@ 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.Services.CloudflareAPIService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.http.*; @@ -40,6 +41,9 @@ public class TunnelController implements ErrorController { @Autowired private RestTemplateConfig restTemplateConfig; + @Autowired + CloudflareAPIService cloudflareAPIService; + @PreAuthorize("hasAnyRole('USER')") @GetMapping("/whoami") public Map whoAmI(@AuthenticationPrincipal OidcUser oidcUser) { @@ -57,12 +61,7 @@ public class TunnelController implements ErrorController { @GetMapping("/tunnels") public ResponseEntity> getTunnels(){ - // * * 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 = cloudflareAPIService.getCloudflareTunnels(); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); @@ -72,14 +71,9 @@ public class TunnelController implements ErrorController { @PreAuthorize("hasAnyRole('DEVELOPER')") @GetMapping("/tunnel/{tunnelId}") - public ResponseEntity> getTunnelConfigurations(@PathVariable String tunnelId) throws JsonProcessingException { - - // * * Resource URL to hit get request at - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - HttpEntity httpEntity = new HttpEntity<>("",authKeyEmailHeader.getHttpHeaders()); - ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, Map.class); + public ResponseEntity> getTunnelConfigurations(@PathVariable String tunnelId) { + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplate, Map.class); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); @@ -92,12 +86,7 @@ public class TunnelController implements ErrorController { @PutMapping("/tunnel/{tunnelId}/add") public ResponseEntity> addTunnelconfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - // * * Getting existing public hostname mappings - HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity<>("",httpHeaders); - ResponseEntity responseEntity = restTemplateConfig.restTemplate().exchange(url, HttpMethod.GET, httpEntity, TunnelResponse.class); + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); // * * Inserting new ingress value at second-to last position in list Config config = responseEntity.getBody().getResult().getConfig(); @@ -105,9 +94,7 @@ public class TunnelController implements ErrorController { response_ingress.add(response_ingress.size()-1, ingress); // * * Hitting put endpoint - httpHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity entity = new HttpEntity<>(config, httpHeaders); - ResponseEntity response = restTemplateConfig.restTemplate().exchange(url, HttpMethod.PUT, entity, Map.class); + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // * * Displaying response Map jsonResponse = new HashMap<>(); @@ -121,12 +108,7 @@ public class TunnelController implements ErrorController { @PutMapping("/tunnel/{tunnelId}/delete") public ResponseEntity> deleteTunnelConfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - // * * Getting existing public hostname mappings - HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity<>("",httpHeaders); - ResponseEntity responseEntity = restTemplateConfig.restTemplate().exchange(url, HttpMethod.GET, httpEntity, TunnelResponse.class); + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); // * * Deleting the selected ingress value Config config = responseEntity.getBody().getResult().getConfig(); @@ -134,9 +116,7 @@ public class TunnelController implements ErrorController { Boolean result = Ingress.deleteByHostName(response_ingress, ingress.getHostname()); // * * Hitting put endpoint - httpHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity entity = new HttpEntity<>(config, httpHeaders); - ResponseEntity response = restTemplateConfig.restTemplate().exchange(url, HttpMethod.PUT, entity, Map.class); + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // * * Displaying response Map jsonResponse = new HashMap<>(); diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java index e51a3d5..f5a4fb4 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java @@ -1,8 +1,17 @@ package com.hithomelabs.CFTunnels.Models; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + import java.util.List; import java.util.Map; +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor public class TunnelResponse { private List> errors; @@ -12,45 +21,4 @@ public class TunnelResponse { private Boolean success; private Result result; - - public Result getResult() { - return result; - } - - public void setResult(Result result) { - this.result = result; - } - - public List> getErrors() { - return errors; - } - - public void setErrors(List> errors) { - this.errors = errors; - } - - public List> getMessages() { - return messages; - } - - public void setMessages(List> messages) { - this.messages = messages; - } - - public boolean isSuccess() { - return success; - } - - public void setSuccess(boolean success) { - this.success = success; - } - - public Boolean getSuccess() { - return success; - } - - public void setSuccess(Boolean success) { - this.success = success; - } - } diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java new file mode 100644 index 0000000..ac85244 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -0,0 +1,271 @@ +package com.hithomelabs.CFTunnels.Controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.Models.Authorities; +import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.Groups; +import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; +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.web.servlet.WebMvcTest; +import org.springframework.http.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.*; + +import static org.hamcrest.core.IsIterableContaining.hasItem; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.not; + + +@WebMvcTest(TunnelController.class) +class TunnelControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + AuthoritiesToGroupMapping authoritiesToGroupMapping; + + @MockitoBean + CloudflareConfig cloudflareConfig; + + @MockitoBean + AuthKeyEmailHeader authKeyEmailHeader; + + @MockitoBean + RestTemplate restTemplate; + + @MockitoBean + CloudflareAPIService cloudflareAPIService; + + @MockitoBean + RestTemplateConfig restTemplateConfig; + + private static String withAdditionalIngress = """ + { + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http://192.168.0.100:3457", + "hostname": "random.hithomelabs.com", + "originRequest": {} + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } + } + """; + + private static final String withoutAdditionalIngress = """ + { + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } + } + """; + + private static final String ingressJson = """ + { + "service": "http://192.168.0.100:3457", + "hostname": "random.hithomelabs.com", + "originRequest": {} + } + """; + + private DefaultOidcUser buildOidcUser(String username, String role) { + + 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) + ); + + 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 { + mockMvc.perform(get("/cloudflare/whoami") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.username").value("username")) + .andExpect(jsonPath("$.roles", hasItem("ROLE_USER"))); + } + + @Test + @DisplayName("should hit tunnels endpoint successfully with ROLE_USER") + public void testGetTunnelsForRoleUser() throws Exception { + + when(cloudflareConfig.getAccountId()).thenReturn("abc123"); + HttpHeaders headers = new HttpHeaders(); + 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); + + when(cloudflareAPIService.getCloudflareTunnels()).thenReturn(mockResponse); + + mockMvc.perform(get("/cloudflare/tunnels") + .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")); + + } + + @Test + void getTunnelConfigurations() throws Exception { + + Map tunnelData = Map.of("config", Map.of("result", "success", "ingress", "sample ingress object")); + ResponseEntity mockResponse = new ResponseEntity<>(tunnelData, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(Map.class))).thenReturn(mockResponse); + + mockMvc.perform(get("/cloudflare/tunnel/{tunnelId}", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.HOMELAB_DEVELOPER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.config.ingress").value("sample ingress object")); + } + + @Test + void addTunnelconfiguration() throws Exception { + + when(restTemplateConfig.restTemplate()).thenReturn(new RestTemplate()); + + ObjectMapper mapper = new ObjectMapper(); + TunnelResponse tunnelStateBefore = mapper.readValue(withoutAdditionalIngress, TunnelResponse.class); + ResponseEntity tunnelResponseBefore = new ResponseEntity<>(tunnelStateBefore, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class))).thenReturn(tunnelResponseBefore); + + TunnelResponse expectedTunnelConfig = mapper.readValue(withAdditionalIngress, TunnelResponse.class); + 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(put("/cloudflare/tunnel/{tunnelId}/add", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ingressJson)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", hasItem("random.hithomelabs.com"))); + } + + @Test + void deleteTunnelConfiguration() throws Exception { + + when(restTemplateConfig.restTemplate()).thenReturn(new RestTemplate()); + + ObjectMapper mapper = new ObjectMapper(); + TunnelResponse tunnelStateBefore = mapper.readValue(withAdditionalIngress, TunnelResponse.class); + ResponseEntity tunnelResponseBefore = new ResponseEntity<>(tunnelStateBefore, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class))).thenReturn(tunnelResponseBefore); + + TunnelResponse expectedTunnelConfig = mapper.readValue(withoutAdditionalIngress, TunnelResponse.class); + 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(put("/cloudflare/tunnel/{tunnelId}/delete", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ingressJson)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", not(hasItem("random.hithomelabs.com")))); + } + + +} diff --git a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java new file mode 100644 index 0000000..dbafc60 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java @@ -0,0 +1,55 @@ +package com.hithomelabs.CFTunnels.Services; + +import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CloudflareAPIServiceTest { + + @InjectMocks + private CloudflareAPIService cloudflareAPIService; + + @Mock + AuthKeyEmailHeader authKeyEmailHeader; + + @Mock + private RestTemplate restTemplate; + + @Mock + CloudflareConfig cloudflareConfig; + + @Test + void testGetCloudflareTunnels() { + + 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); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(mockResponse); + + ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + + } \ No newline at end of file diff --git a/src/test/resources/docker-compose.yaml b/src/test/resources/docker-compose.yaml new file mode 100644 index 0000000..27068c8 --- /dev/null +++ b/src/test/resources/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + postgres: + image: postgres:15-alpine + container_name: cftunnel-db-${ENV} + environment: + POSTGRES_DB: cftunnel + POSTGRES_USER: ${POSTGRES_USERNAME} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + restart: unless-stopped + ports: + - "${DB_PORT}:5432" + volumes: + - ${DB_PATH}:/var/lib/postgresql/data \ No newline at end of file -- 2.45.2 From 4075eb78c8e2d20b6a24029249284cf02d1368b3 Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 26 Oct 2025 20:30:01 +0530 Subject: [PATCH 3/3] ISSUE-44: Hithomelabs/HomeLabDocker#44 Adding unit tests for service layer --- .../Controllers/TunnelControllerTest.java | 98 +++++-------------- .../Services/CloudflareAPIServiceTest.java | 64 +++++++++++- .../hithomelabs/CFTunnels/TestUtils/Util.java | 17 ++++ .../data/tunnelResponseLargeIngress.json | 38 +++++++ .../data/tunnelResponseSmallIngress.json | 33 +++++++ 5 files changed, 174 insertions(+), 76 deletions(-) create mode 100644 src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java create mode 100644 src/test/resources/data/tunnelResponseLargeIngress.json create mode 100644 src/test/resources/data/tunnelResponseSmallIngress.json diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java index ac85244..644288e 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -24,9 +24,11 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.web.client.RestTemplate; +import java.io.IOException; import java.time.Instant; import java.util.*; +import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; import static org.hamcrest.core.IsIterableContaining.hasItem; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -64,82 +66,28 @@ class TunnelControllerTest { @MockitoBean RestTemplateConfig restTemplateConfig; - private static String withAdditionalIngress = """ - { - "success": true, - "errors": [], - "messages": [], - "result": { - "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", - "version": 63, - "config": { - "ingress": [ - { - "service": "http://192.168.0.100:8928", - "hostname": "giteabkp.hithomelabs.com", - "originRequest": {} - }, - { - "service": "https://192.168.0.100:9442", - "hostname": "devdocker.hithomelabs.com", - "originRequest": { - "noTLSVerify": true - } - }, - { - "service": "http://192.168.0.100:3457", - "hostname": "random.hithomelabs.com", - "originRequest": {} - }, - { - "service": "http_status:404" - } - ], - "warp-routing": { - "enabled": false - } - }, - "source": "cloudflare", - "created_at": "2025-10-24T18:17:26.914217Z" - } - } - """; + private static final String tunnelResponseSmallIngressFile = "tunnelResponseSmallIngress.json"; - private static final String withoutAdditionalIngress = """ - { - "success": true, - "errors": [], - "messages": [], - "result": { - "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", - "version": 63, - "config": { - "ingress": [ - { - "service": "http://192.168.0.100:8928", - "hostname": "giteabkp.hithomelabs.com", - "originRequest": {} - }, - { - "service": "https://192.168.0.100:9442", - "hostname": "devdocker.hithomelabs.com", - "originRequest": { - "noTLSVerify": true - } - }, - { - "service": "http_status:404" - } - ], - "warp-routing": { - "enabled": false - } - }, - "source": "cloudflare", - "created_at": "2025-10-24T18:17:26.914217Z" - } - } - """; + private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; + + private static final String withAdditionalIngress; + + static { + try { + withAdditionalIngress = getClassPathDataResource(tunnelResponseLargeIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static final String withoutAdditionalIngress; + static { + try { + withoutAdditionalIngress = getClassPathDataResource(tunnelResponseSmallIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } private static final String ingressJson = """ { diff --git a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java index dbafc60..03374e5 100644 --- a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java +++ b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java @@ -1,7 +1,11 @@ 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.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.TunnelResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -9,9 +13,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.*; import org.springframework.web.client.RestTemplate; + +import java.io.IOException; import java.util.List; import java.util.Map; +import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -32,6 +39,18 @@ class CloudflareAPIServiceTest { @Mock CloudflareConfig cloudflareConfig; + private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; + + private static final String bigTunnelResponse; + + static { + try { + bigTunnelResponse = getClassPathDataResource(tunnelResponseLargeIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + @Test void testGetCloudflareTunnels() { @@ -52,4 +71,47 @@ class CloudflareAPIServiceTest { } - } \ No newline at end of file + @Test + void getCloudflareTunnelConfigurations() throws JsonProcessingException { + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + + TunnelResponse tunnelResponse = new ObjectMapper().readValue(bigTunnelResponse, TunnelResponse.class); + ResponseEntity tunnelResponseResponseEntity = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(tunnelResponseResponseEntity); + + ResponseEntity response = cloudflareAPIService.getCloudflareTunnelConfigurations("sampleTunnelID", restTemplate, TunnelResponse.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(response.getBody().getResult().getConfig().getIngress().get(0).getHostname(), "giteabkp.hithomelabs.com"); + } + + @Test + void putCloudflareTunnelConfigurations() throws JsonProcessingException { + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + + TunnelResponse tunnelResponse = new ObjectMapper().readValue(bigTunnelResponse, TunnelResponse.class); + ResponseEntity tunnelResponseResponseEntity = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + + Config config = tunnelResponse.getResult().getConfig(); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(tunnelResponseResponseEntity); + + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations("sampleTunnelID", restTemplate, TunnelResponse.class, config); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(response.getBody().getResult().getConfig().getIngress().get(2).getHostname(), "random.hithomelabs.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java b/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java new file mode 100644 index 0000000..a5f8c36 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java @@ -0,0 +1,17 @@ +package com.hithomelabs.CFTunnels.TestUtils; + +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class Util { + + public static String getClassPathDataResource(String filename) throws IOException { + return Files.readString( + new ClassPathResource(String.format("data/%s", filename)).getFile().toPath(), + StandardCharsets.UTF_8); + } + +} diff --git a/src/test/resources/data/tunnelResponseLargeIngress.json b/src/test/resources/data/tunnelResponseLargeIngress.json new file mode 100644 index 0000000..e24b930 --- /dev/null +++ b/src/test/resources/data/tunnelResponseLargeIngress.json @@ -0,0 +1,38 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http://192.168.0.100:3457", + "hostname": "random.hithomelabs.com", + "originRequest": {} + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } +} \ No newline at end of file diff --git a/src/test/resources/data/tunnelResponseSmallIngress.json b/src/test/resources/data/tunnelResponseSmallIngress.json new file mode 100644 index 0000000..9e840f0 --- /dev/null +++ b/src/test/resources/data/tunnelResponseSmallIngress.json @@ -0,0 +1,33 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } +} \ No newline at end of file -- 2.45.2