diff --git a/TODO.md b/TODO.md index 1ac63d0..0e2a43b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,4 @@ -- Add test for sync -- Add test for weight get +- Add test for access token refresh +- Add test for sync forbidden +- Split Weight controller into Weigth controller and withings controller - Adjust the UI diff --git a/server/src/main/java/mucsi96/traininglog/core/AppControllerAdvice.java b/server/src/main/java/mucsi96/traininglog/core/AppControllerAdvice.java index 90b2bda..73ea50a 100644 --- a/server/src/main/java/mucsi96/traininglog/core/AppControllerAdvice.java +++ b/server/src/main/java/mucsi96/traininglog/core/AppControllerAdvice.java @@ -13,7 +13,7 @@ @ControllerAdvice public class AppControllerAdvice { @ExceptionHandler(ClientAuthorizationRequiredException.class) - public ResponseEntity handleClientAuthorizationRequired( + public ResponseEntity> handleClientAuthorizationRequired( ClientAuthorizationRequiredException ex) { String oauth2LoginUrl = ServletUriComponentsBuilder.fromCurrentServletMapping().path( OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/" diff --git a/server/src/main/java/mucsi96/traininglog/configuration/SecurityConfiguration.java b/server/src/main/java/mucsi96/traininglog/core/SecurityConfiguration.java similarity index 96% rename from server/src/main/java/mucsi96/traininglog/configuration/SecurityConfiguration.java rename to server/src/main/java/mucsi96/traininglog/core/SecurityConfiguration.java index e6d53ec..f538864 100644 --- a/server/src/main/java/mucsi96/traininglog/configuration/SecurityConfiguration.java +++ b/server/src/main/java/mucsi96/traininglog/core/SecurityConfiguration.java @@ -1,4 +1,4 @@ -package mucsi96.traininglog.configuration; +package mucsi96.traininglog.core; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,7 +14,6 @@ import org.springframework.security.web.SecurityFilterChain; import io.github.mucsi96.kubetools.security.KubetoolsSecurityConfigurer; -import mucsi96.traininglog.core.RedirectToHomeRequestCache; import mucsi96.traininglog.oauth.AccessTokenResponseClient; import mucsi96.traininglog.oauth.AuthorizedClientManager; import mucsi96.traininglog.oauth.RefreshTokenResponseClient; diff --git a/server/src/main/java/mucsi96/traininglog/weight/Weight.java b/server/src/main/java/mucsi96/traininglog/weight/Weight.java index 2f3b137..a366aae 100644 --- a/server/src/main/java/mucsi96/traininglog/weight/Weight.java +++ b/server/src/main/java/mucsi96/traininglog/weight/Weight.java @@ -7,13 +7,18 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import lombok.NonNull; @Data @Entity @Builder +@AllArgsConstructor +@NoArgsConstructor(access= AccessLevel.PRIVATE, force=true) public class Weight { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/server/src/main/java/mucsi96/traininglog/withings/WithingsConfiguration.java b/server/src/main/java/mucsi96/traininglog/withings/WithingsConfiguration.java new file mode 100644 index 0000000..907e58c --- /dev/null +++ b/server/src/main/java/mucsi96/traininglog/withings/WithingsConfiguration.java @@ -0,0 +1,18 @@ +package mucsi96.traininglog.withings; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Data; + +@Data +@Configuration +@ConfigurationProperties(prefix = "withings") +public class WithingsConfiguration { + private WithingsApiConfiguration api; + + @Data + public static class WithingsApiConfiguration { + private String uri; + } +} diff --git a/server/src/main/java/mucsi96/traininglog/withings/WithingsService.java b/server/src/main/java/mucsi96/traininglog/withings/WithingsService.java index f26d8a6..3109570 100644 --- a/server/src/main/java/mucsi96/traininglog/withings/WithingsService.java +++ b/server/src/main/java/mucsi96/traininglog/withings/WithingsService.java @@ -1,7 +1,10 @@ package mucsi96.traininglog.withings; import java.time.Instant; -import java.util.Calendar; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; @@ -13,6 +16,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import lombok.RequiredArgsConstructor; import mucsi96.traininglog.weight.Weight; import mucsi96.traininglog.withings.data.GetMeasureResponse; import mucsi96.traininglog.withings.data.GetMeasureResponseBody; @@ -21,30 +25,22 @@ import mucsi96.traininglog.withings.oauth.WithingsClient; @Service +@RequiredArgsConstructor public class WithingsService { - int getStartDate() { - Calendar cal = Calendar.getInstance(); - cal.set(Calendar.HOUR_OF_DAY, 0); - cal.set(Calendar.MINUTE, 0); - cal.set(Calendar.SECOND, 0); - return (int) (cal.getTimeInMillis() / 1000); - } - - int getEndDate() { - Calendar cal = Calendar.getInstance(); - return (int) (cal.getTimeInMillis() / 1000); - } + private final WithingsConfiguration withingsConfiguration; private String getMeasureUrl() { + long startTime = LocalDateTime.of(LocalDate.now(), LocalTime.MIN).toInstant(ZoneOffset.UTC).getEpochSecond(); + long endTime = LocalDateTime.of(LocalDate.now(), LocalTime.MAX).toInstant(ZoneOffset.UTC).getEpochSecond(); return UriComponentsBuilder - .fromHttpUrl("https://wbsapi.withings.net") + .fromHttpUrl(withingsConfiguration.getApi().getUri()) .path("/measure") .queryParam("action", "getmeas") .queryParam("meastype", 1) .queryParam("category", 1) - .queryParam("startdate", getStartDate()) - .queryParam("enddate", getEndDate()) + .queryParam("startdate", startTime) + .queryParam("enddate", endTime) .build() .encode() .toUriString(); diff --git a/server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/server/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..5f18a88 --- /dev/null +++ b/server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,5 @@ +{"properties": [{ + "name": "withings", + "type": "mucsi96.traininglog.withings.WithingsConfiguration", + "description": "Configuration for Withings API" +}]} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 406f51b..d230177 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -53,3 +53,6 @@ logging: springdoc: swagger-ui: path: / +withings: + api: + uri: https://wbsapi.withings.net diff --git a/server/src/test/java/mucsi96/traininglog/WeightControllerTests.java b/server/src/test/java/mucsi96/traininglog/WeightControllerTests.java index d1f09f1..5acf30e 100644 --- a/server/src/test/java/mucsi96/traininglog/WeightControllerTests.java +++ b/server/src/test/java/mucsi96/traininglog/WeightControllerTests.java @@ -8,6 +8,11 @@ import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; @@ -36,6 +41,8 @@ import lombok.RequiredArgsConstructor; import mucsi96.traininglog.model.TestAuthorizedClient; import mucsi96.traininglog.repository.TestAuthorizedClientRepository; +import mucsi96.traininglog.weight.Weight; +import mucsi96.traininglog.weight.WeightRepository; import mucsi96.traininglog.withings.oauth.WithingsClient; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -43,16 +50,15 @@ @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) public class WeightControllerTests extends BaseIntegrationTest { - // OAuth2AuthorizationCodeAuthenticationProvider - private final MockMvc mockMvc; private final TestAuthorizedClientRepository authorizedClientRepository; + private final WeightRepository weightRepository; @LocalServerPort private int port; @RegisterExtension - static WireMockExtension withingsServer = WireMockExtension.newInstance() + static WireMockExtension mockWithingsServer = WireMockExtension.newInstance() .options(wireMockConfig().dynamicPort()) .build(); @@ -60,20 +66,38 @@ public class WeightControllerTests extends BaseIntegrationTest { static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("spring.security.oauth2.client.provider.withings.authorization-uri", - () -> withingsServer.baseUrl() + "/oauth2_user/authorize2"); + () -> mockWithingsServer.baseUrl() + "/oauth2_user/authorize2"); registry.add( "spring.security.oauth2.client.provider.withings.token-uri", - () -> withingsServer.baseUrl() + "/v2/oauth2"); + () -> mockWithingsServer.baseUrl() + "/v2/oauth2"); registry.add("spring.security.oauth2.client.registration.withings-client.client-id", () -> "test-withings-client-id"); registry.add("spring.security.oauth2.client.registration.withings-client.client-secret", () -> "test-withings-client-secret"); + registry.add("withings.api.uri", () -> mockWithingsServer.baseUrl()); } @AfterEach void afterEach() { authorizedClientRepository.deleteAll(); + weightRepository.deleteAll(); + } + + private void authorizeWithingsOAuth2Client() { + TestAuthorizedClient authorizedClient = TestAuthorizedClient.builder() + .clientRegistrationId("withings-client") + .principalName("rob") + .accessTokenType("Bearer") + .accessTokenValue("test-access-token".getBytes(StandardCharsets.UTF_8)) + .accessTokenIssuedAt(LocalDateTime.now()) + .accessTokenExpiresAt(LocalDateTime.now().plusDays(1)) + .accessTokenScopes("user.metrics") + .refreshTokenValue("test-refresh-token".getBytes(StandardCharsets.UTF_8)) + .refreshTokenIssuedAt(LocalDateTime.now()) + .build(); + + authorizedClientRepository.save(authorizedClient); } @Test @@ -86,12 +110,40 @@ public void returns_not_authorized_if_no_preauth_headers_are_sent() throws Excep assertThat(response.getStatus()).isEqualTo(401); } + @Test + public void returns_forbidden_if_user_has_no_user_role() throws Exception { + MockHttpServletResponse response = mockMvc + .perform( + get("/weight") + .headers(getAuthHeaders("guest"))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + public void returns_weight_from_database() throws Exception { + Weight weight = Weight.builder() + .value(83.5) + .createdAt(Instant.now()) + .build(); + weightRepository.save(weight); + + MockHttpServletResponse response = mockMvc + .perform( + get("/weight") + .headers(getAuthHeaders("user"))) + .andReturn().getResponse(); + + assertThat(JsonPath.parse(response.getContentAsString()).read("$.weight", Double.class)).isEqualTo(83.5); + } + @Test public void returns_not_authorized_if_authorized_client_is_not_found() throws Exception { MockHttpServletResponse response = mockMvc .perform( post("/weight/pull-from-withings") - .headers(getAuthHeaders("guest"))) + .headers(getAuthHeaders("user"))) .andReturn().getResponse(); assertThat(response.getStatus()).isEqualTo(401); @@ -109,7 +161,7 @@ public void redirects_to_withings_request_authorization_page() throws Exception assertThat(response.getStatus()).isEqualTo(302); URI redirectUrl = new URI(response.getRedirectedUrl()); assertThat(redirectUrl).hasHost("localhost"); - assertThat(redirectUrl).hasPort(withingsServer.getPort()); + assertThat(redirectUrl).hasPort(mockWithingsServer.getPort()); assertThat(redirectUrl).hasPath("/oauth2_user/authorize2"); assertThat(redirectUrl).hasParameter(OAuth2ParameterNames.RESPONSE_TYPE, "code"); assertThat(redirectUrl).hasParameter(OAuth2ParameterNames.CLIENT_ID, "test-withings-client-id"); @@ -121,7 +173,7 @@ public void redirects_to_withings_request_authorization_page() throws Exception @Test public void requests_withings_access_token_after_consent_is_granted() throws Exception { - withingsServer.stubFor(WireMock.post("/v2/oauth2").willReturn( + mockWithingsServer.stubFor(WireMock.post("/v2/oauth2").willReturn( WireMock.aResponse() .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .withBodyFile("withings-authorize.json"))); @@ -147,7 +199,8 @@ public void requests_withings_access_token_after_consent_is_granted() throws Exc assertThat(response2.getStatus()).isEqualTo(302); assertThat(response2.getRedirectedUrl()).isEqualTo("http://localhost/"); - List requests = withingsServer.findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/v2/oauth2"))); + List requests = mockWithingsServer + .findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/v2/oauth2"))); assertThat(requests).hasSize(1); URI uri = new URI("?" + requests.get(0).getBodyAsString()); @@ -155,12 +208,39 @@ public void requests_withings_access_token_after_consent_is_granted() throws Exc assertThat(authorizedClient.isPresent()).isTrue(); assertThat(authorizedClient.get().getPrincipalName()).isEqualTo("rob"); - assertThat(new String(authorizedClient.get().getAccessTokenValue(), "UTF-8")).isEqualTo("test-access-token"); - assertThat(new String(authorizedClient.get().getRefreshTokenValue(), "UTF-8")).isEqualTo("test-refresh-token"); + assertThat(new String(authorizedClient.get().getAccessTokenValue(), StandardCharsets.UTF_8)) + .isEqualTo("test-access-token"); + assertThat(new String(authorizedClient.get().getRefreshTokenValue(), StandardCharsets.UTF_8)) + .isEqualTo("test-refresh-token"); assertThat(uri).hasParameter(OAuth2ParameterNames.GRANT_TYPE, "authorization_code"); assertThat(uri).hasParameter(OAuth2ParameterNames.CODE, "test-authorization-code"); assertThat(uri).hasParameter("action", "requesttoken"); assertThat(uri).hasParameter(OAuth2ParameterNames.CLIENT_ID, "test-withings-client-id"); assertThat(uri).hasParameter(OAuth2ParameterNames.CLIENT_SECRET, "test-withings-client-secret"); } + + @Test + public void pulls_todays_weight_from_withings_to_database() throws Exception { + authorizeWithingsOAuth2Client(); + long startTime = LocalDateTime.of(LocalDate.now(), LocalTime.MIN).toInstant(ZoneOffset.UTC).getEpochSecond(); + long endTime = LocalDateTime.of(LocalDate.now(), LocalTime.MAX).toInstant(ZoneOffset.UTC).getEpochSecond(); + mockWithingsServer.stubFor(WireMock + .post(String.format("/measure?action=getmeas&meastype=1&category=1&startdate=%s&enddate=%s", + startTime, endTime)) + .willReturn( + WireMock.aResponse() + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBodyFile("withings-measure.json"))); + MockHttpServletResponse response = mockMvc + .perform( + post("/weight/pull-from-withings") + .headers(getAuthHeaders("user"))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(200); + Optional weight = weightRepository.findAll().stream().findFirst(); + assertThat(weight.isPresent()).isTrue(); + assertThat(weight.get().getValue()).isEqualTo(65.75); + assertThat(weight.get().getCreatedAt().getEpochSecond()).isEqualTo(1594245600L); + } } diff --git a/server/src/test/java/mucsi96/traininglog/model/TestAuthorizedClient.java b/server/src/test/java/mucsi96/traininglog/model/TestAuthorizedClient.java index 3023256..1b0a77c 100644 --- a/server/src/test/java/mucsi96/traininglog/model/TestAuthorizedClient.java +++ b/server/src/test/java/mucsi96/traininglog/model/TestAuthorizedClient.java @@ -1,19 +1,18 @@ package mucsi96.traininglog.model; -import org.hibernate.annotations.JdbcType; -import org.hibernate.annotations.Type; -import org.hibernate.type.descriptor.jdbc.VarbinaryJdbcType; +import java.time.LocalDateTime; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data +@Builder() @Entity @Table(name = "oauth2_authorized_client") @AllArgsConstructor @@ -24,9 +23,9 @@ public class TestAuthorizedClient { private String principalName; private String accessTokenType; private byte[] accessTokenValue; - private String accessTokenIssuedAt; - private String accessTokenExpiresAt; + private LocalDateTime accessTokenIssuedAt; + private LocalDateTime accessTokenExpiresAt; private String accessTokenScopes; private byte[] refreshTokenValue; - private String refreshTokenIssuedAt; + private LocalDateTime refreshTokenIssuedAt; } diff --git a/server/src/test/resources/__files/withings-measure.json b/server/src/test/resources/__files/withings-measure.json new file mode 100644 index 0000000..b5bcec4 --- /dev/null +++ b/server/src/test/resources/__files/withings-measure.json @@ -0,0 +1,32 @@ +{ + "status": 0, + "body": { + "updatetime": "string", + "timezone": "string", + "measuregrps": [ + { + "grpid": 12, + "attrib": 1, + "date": 1594245600, + "created": 1594246600, + "modified": 1594257200, + "category": 1594257200, + "deviceid": "892359876fd8805ac45bab078c4828692f0276b1", + "measures": [ + { + "value": 65750, + "type": 1, + "unit": -3, + "algo": 3425, + "fm": 1, + "fw": 1000 + } + ], + "comment": "A measurement comment", + "timezone": "Europe/Paris" + } + ], + "more": 0, + "offset": 0 + } +}