diff --git a/common/build.gradle b/common/build.gradle index 4af833f..05cec77 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -8,4 +8,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java index 42de8aa..7aea37c 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java @@ -1,7 +1,13 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerAuthRequest { + + @JsonProperty("Username") private String username; + + @JsonProperty("Password") private String password; public PortainerAuthRequest() {} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java index ce351a8..544d3f8 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java @@ -1,6 +1,10 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerAuthResponse { + + @JsonProperty("jwt") private String jwt; public PortainerAuthResponse() {} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java index d7e0cfb..945d63b 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java @@ -1,7 +1,13 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerRedeployRequest { + + @JsonProperty("PullImage") private boolean pullImage; + + @JsonProperty("Prune") private boolean prune; public PortainerRedeployRequest() {} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java index 6e6d107..5669e5d 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java @@ -1,10 +1,22 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerStack { + + @JsonProperty("Id") private Long id; + + @JsonProperty("Name") private String name; + + @JsonProperty("EndpointId") private Long endpointId; - private String status; + + @JsonProperty("Status") + private int status; + + @JsonProperty("ResourceControl") private String resourceControl; public PortainerStack() {} @@ -33,11 +45,11 @@ public class PortainerStack { this.endpointId = endpointId; } - public String getStatus() { + public int getStatus() { return status; } - public void setStatus(String status) { + public void setStatus(int status) { this.status = status; } diff --git a/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java new file mode 100644 index 0000000..1e7da28 --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java @@ -0,0 +1,223 @@ +package com.hithomelabs.common.portainer.client; + +import com.hithomelabs.common.portainer.client.exception.PortainerAuthenticationException; +import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; +import com.hithomelabs.common.portainer.model.PortainerAuthRequest; +import com.hithomelabs.common.portainer.model.PortainerAuthResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PortainerApiClientTest { + + @Mock + private RestTemplate restTemplate; + + private PortainerApiClient client; + + private static final String BASE_URL = "http://portainer.local:9000"; + + @BeforeEach + void setUp() { + client = new PortainerApiClient(restTemplate, BASE_URL); + } + + // -- authenticateWithPassword: success path -- + + @Test + void authenticateWithPassword_validCredentials_returnsAuthResponse() { + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("test-jwt-token"); + + ResponseEntity responseEntity = new ResponseEntity<>( + authResponse, HttpStatus.OK); + + when(restTemplate.exchange( + eq(BASE_URL + "/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthResponse result = client.authenticateWithPassword("admin", "password123"); + + assertNotNull(result); + assertEquals("test-jwt-token", result.getJwt()); + verify(restTemplate).exchange( + eq(BASE_URL + "/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class)); + } + + // -- authenticateWithPassword: input validation -- + + @Test + void authenticateWithPassword_nullUsername_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword(null, "password")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_blankUsername_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword(" ", "password")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_nullPassword_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", null)); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_blankPassword_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", " ")); + verifyNoInteractions(restTemplate); + } + + // -- authenticateWithPassword: HTTP error handling -- + + @Test + void authenticateWithPassword_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Bad credentials".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + PortainerAuthenticationException ex = assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "wrong")); + assertTrue(ex.getMessage().contains("401")); + } + + @Test + void authenticateWithPassword_http403_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(HttpClientErrorException.create( + HttpStatus.FORBIDDEN, + "Forbidden", + HttpHeaders.EMPTY, + "Forbidden".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "wrong")); + } + + @Test + void authenticateWithPassword_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.authenticateWithPassword("admin", "password")); + assertTrue(ex.getMessage().contains(BASE_URL)); + assertTrue(ex.getMessage().contains("Connection refused")); + } + + @Test + void authenticateWithPassword_nonOkStatus_throwsAuthenticationException() { + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("token"); + // Return a non-OK status with null body to trigger the status check path + ResponseEntity responseEntity = new ResponseEntity<>( + null, HttpStatus.INTERNAL_SERVER_ERROR); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthenticationException ex = assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "password")); + assertTrue(ex.getMessage().contains("500")); + } + + // -- authenticateWithApiKey: input validation -- + + @Test + void authenticateWithApiKey_nullKey_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithApiKey(null)); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithApiKey_blankKey_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithApiKey(" ")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithApiKey_validKey_setsApiKey() { + client.authenticateWithApiKey("ptr_abc123def456"); + // No exception expected; we verify it doesn't throw + // We can test this by calling a subsequent method that uses the API key + assertDoesNotThrow(() -> client.authenticateWithApiKey("ptr_abc123def456")); + } + + // -- Constructor: trailing slash handling -- + + @Test + void constructor_withTrailingSlash_stripsIt() { + PortainerApiClient clientWithSlash = new PortainerApiClient(restTemplate, "http://portainer.local:9000/"); + // authenticateWithPassword should use the stripped URL + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("token"); + ResponseEntity responseEntity = new ResponseEntity<>( + authResponse, HttpStatus.OK); + + when(restTemplate.exchange( + eq("http://portainer.local:9000/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthResponse result = clientWithSlash.authenticateWithPassword("admin", "pass"); + assertNotNull(result); + assertEquals("token", result.getJwt()); + } + + @Test + void constructor_withNullBaseUrl_usesEmptyString() { + PortainerApiClient clientNullUrl = new PortainerApiClient(restTemplate, null); + // Should not throw; the client is created with an empty base URL + assertDoesNotThrow(() -> clientNullUrl.authenticateWithApiKey("key")); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java b/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java new file mode 100644 index 0000000..530c065 --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java @@ -0,0 +1,107 @@ +package com.hithomelabs.common.registry.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ImageReferenceTest { + + @Test + void parse_withValidImage_returnsImageReference() { + ImageReference ref = ImageReference.parse("192.168.0.100:8928/hithomelabs/cftunnels:1.2.3"); + + assertNotNull(ref); + assertEquals("192.168.0.100:8928", ref.getRegistry()); + assertEquals("hithomelabs/cftunnels", ref.getRepository()); + assertEquals("1.2.3", ref.getTag()); + assertEquals("192.168.0.100:8928/hithomelabs/cftunnels:1.2.3", ref.getFullImage()); + } + + @Test + void parse_withRegistryPortAndMultiLevelRepository_returnsImageReference() { + ImageReference ref = ImageReference.parse("registry.example.com:5000/namespace/sub/image:latest"); + + assertNotNull(ref); + assertEquals("registry.example.com:5000", ref.getRegistry()); + assertEquals("namespace/sub/image", ref.getRepository()); + assertEquals("latest", ref.getTag()); + } + + @Test + void parse_withSimpleRegistryPath_returnsImageReference() { + ImageReference ref = ImageReference.parse("docker.io/library/nginx:1.25"); + + assertNotNull(ref); + assertEquals("docker.io", ref.getRegistry()); + assertEquals("library/nginx", ref.getRepository()); + assertEquals("1.25", ref.getTag()); + } + + @Test + void parse_withNumericTag_returnsImageReference() { + ImageReference ref = ImageReference.parse("myregistry.com/myapp:1"); + + assertNotNull(ref); + assertEquals("myregistry.com", ref.getRegistry()); + assertEquals("myapp", ref.getRepository()); + assertEquals("1", ref.getTag()); + } + + @Test + void parse_withTagContainingHyphenAndDot_returnsImageReference() { + ImageReference ref = ImageReference.parse("private.reg.io/team-app:3.2.1-rc1"); + + assertNotNull(ref); + assertEquals("private.reg.io", ref.getRegistry()); + assertEquals("team-app", ref.getRepository()); + assertEquals("3.2.1-rc1", ref.getTag()); + } + + @Test + void parse_withNullImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse(null)); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withBlankImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse(" ")); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withEmptyImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("")); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withMissingRegistry_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("nginx:latest")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withMissingTag_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com/repo")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withMissingColonSeparator_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com/repo:")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withOnlyRegistry_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com")); + assertTrue(ex.getMessage().contains("Invalid")); + } +}