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.Entity.Request; 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; 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.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; 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.*; 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; @MockitoBean MappingRequestService mappingRequestService; @MockitoBean UserRepository userRepository; private static final String tunnelResponseSmallIngressFile = "tunnelResponseSmallIngress.json"; 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 = """ { "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"); } 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 { 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); 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); 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.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") 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) ); when(mappingRequestService.getAllRequests()).thenReturn(requests); mockMvc.perform(get("/cloudflare/requests") .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("$.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 { 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/tunnels/{tunnelId}/mappings", "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(post("/cloudflare/tunnels/{tunnelId}/mappings", "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"))); } private Request createTestRequest(UUID id, Request.RequestStatus status) { Request request = new Request(); request.setId(id); request.setStatus(status); return request; } @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(delete("/cloudflare/tunnels/{tunnelId}/mappings", "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")))); } @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()); } }