diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d0a74..36b3cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Upcoming Version / (WIP) Improvements: * Stabilized consul registration and health checks +New functionality: +* [MW-1449](https://openlmis.atlassian.net/browse/MW-1449): Added Superset guest token endpoint for embedded dashboards. Dashboard reports now carry an optional `embeddedUuid` column referencing a Superset embedded dashboard. The new `/api/reports/superset/guest-token` endpoint exchanges an OpenLMIS user for a short-lived Superset guest token, gated by the `REPORTS_VIEW` right and a lookup against the dashboard's `embeddedUuid`. + 1.5.0 / 2025-11-27 ================== diff --git a/src/main/java/org/openlmis/report/domain/DashboardReport.java b/src/main/java/org/openlmis/report/domain/DashboardReport.java index 5779bb0..7d11df4 100644 --- a/src/main/java/org/openlmis/report/domain/DashboardReport.java +++ b/src/main/java/org/openlmis/report/domain/DashboardReport.java @@ -46,6 +46,9 @@ public class DashboardReport extends BaseEntity { @Column(columnDefinition = TEXT_COLUMN_DEFINITION, nullable = false) private String url; + @Column(columnDefinition = TEXT_COLUMN_DEFINITION) + private String embeddedUuid; + @Enumerated(EnumType.STRING) @Column(columnDefinition = TEXT_COLUMN_DEFINITION, nullable = false) private ReportType type; @@ -85,6 +88,7 @@ public static DashboardReport newInstance(DashboardReport.Importer importer) { public void updateFrom(DashboardReport.Importer importer) { this.name = importer.getName(); this.url = importer.getUrl(); + this.embeddedUuid = importer.getEmbeddedUuid(); this.type = importer.getType(); this.enabled = importer.isEnabled(); this.showOnHomePage = importer.isShowOnHomePage(); @@ -100,6 +104,7 @@ public void export(DashboardReport.Exporter exporter) { exporter.setId(id); exporter.setName(name); exporter.setUrl(url); + exporter.setEmbeddedUuid(embeddedUuid); exporter.setType(type); exporter.setEnabled(enabled); exporter.setShowOnHomePage(showOnHomePage); @@ -114,6 +119,8 @@ public interface Exporter { void setUrl(String url); + void setEmbeddedUuid(String embeddedUuid); + void setType(ReportType type); void setEnabled(boolean enabled); @@ -132,6 +139,8 @@ public interface Importer { String getUrl(); + String getEmbeddedUuid(); + ReportType getType(); boolean isEnabled(); diff --git a/src/main/java/org/openlmis/report/dto/DashboardReportDto.java b/src/main/java/org/openlmis/report/dto/DashboardReportDto.java index 9ad0626..93eafe1 100644 --- a/src/main/java/org/openlmis/report/dto/DashboardReportDto.java +++ b/src/main/java/org/openlmis/report/dto/DashboardReportDto.java @@ -39,6 +39,7 @@ public class DashboardReportDto implements Importer, Exporter { private UUID id; private String name; private String url; + private String embeddedUuid; private ReportType type; private boolean enabled; private boolean showOnHomePage; diff --git a/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java b/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java index 501d06c..5ec8bad 100644 --- a/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java +++ b/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java @@ -20,6 +20,7 @@ import org.openlmis.report.exception.DataRetrievalException; import org.openlmis.report.exception.NotFoundMessageException; import org.openlmis.report.exception.PermissionMessageException; +import org.openlmis.report.exception.ServerException; import org.openlmis.report.exception.ValidationMessageException; import org.openlmis.report.i18n.MessageService; import org.openlmis.report.utils.Message; @@ -85,6 +86,13 @@ public Message.LocalizedMessage handleNotFoundMessageException(NotFoundMessageEx return getLocalizedMessage(ex); } + @ExceptionHandler(ServerException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ResponseBody + public Message.LocalizedMessage handleServerException(ServerException ex) { + return getLocalizedMessage(ex); + } + /** * Logs an error message and returns an error response. * diff --git a/src/main/java/org/openlmis/report/exception/ServerException.java b/src/main/java/org/openlmis/report/exception/ServerException.java new file mode 100644 index 0000000..b397768 --- /dev/null +++ b/src/main/java/org/openlmis/report/exception/ServerException.java @@ -0,0 +1,33 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.exception; + +import org.openlmis.report.utils.Message; + +/** + * Exception representing a server-side failure that is safe to surface to the client + * as a localized message but should map to an HTTP 500 status. + */ +public class ServerException extends BaseMessageException { + + public ServerException(Message message) { + super(message); + } + + public ServerException(Message message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java b/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java new file mode 100644 index 0000000..12a61e3 --- /dev/null +++ b/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java @@ -0,0 +1,29 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.i18n; + +public class SupersetMessageKeys extends MessageKeys { + public static final String ERROR_SUPERSET_NOT_CONFIGURED = + "report.error.superset.notConfigured"; + public static final String ERROR_SUPERSET_LOGIN_FAILED = + "report.error.superset.login.failed"; + public static final String ERROR_SUPERSET_CSRF_FAILED = + "report.error.superset.csrf.failed"; + public static final String ERROR_SUPERSET_GUEST_TOKEN_FAILED = + "report.error.superset.guestToken.failed"; + public static final String ERROR_SUPERSET_DASHBOARD_NOT_FOUND = + "report.error.superset.dashboard.notFound"; +} diff --git a/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java b/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java index 5b418e3..14b9b42 100644 --- a/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java +++ b/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java @@ -31,6 +31,8 @@ public interface DashboardReportRepository boolean existsByName(String name); + boolean existsByEmbeddedUuid(String embeddedUuid); + List findByShowOnHomePage(boolean showOnHomePage); boolean existsByCategory_Id(UUID categoryId); diff --git a/src/main/java/org/openlmis/report/service/SupersetService.java b/src/main/java/org/openlmis/report/service/SupersetService.java new file mode 100644 index 0000000..0dc6175 --- /dev/null +++ b/src/main/java/org/openlmis/report/service/SupersetService.java @@ -0,0 +1,225 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.openlmis.report.exception.ServerException; +import org.openlmis.report.i18n.SupersetMessageKeys; +import org.openlmis.report.utils.Message; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +/** + * Service for communicating with the Superset API to obtain guest tokens + * for embedded dashboard access. + */ +@Service +public class SupersetService { + + @Value("${superset.url:}") + private String supersetUrl; + + @Value("${superset.admin.user:}") + private String adminUser; + + @Value("${superset.admin.password:}") + private String adminPassword; + + private final RestTemplate restTemplate = new RestTemplate(); + + private String cachedAccessToken; + private long tokenExpiresAt; + + /** + * Obtain a Superset guest token for the given embedded dashboard UUID. + * + * @param embeddedUuid the embedded UUID of the Superset dashboard + * @param username the username for the guest user + * @param firstName the first name for the guest user + * @param lastName the last name for the guest user + * @return the guest token string + */ + public String getGuestToken(String embeddedUuid, String username, + String firstName, String lastName) { + if (supersetUrl.isEmpty() || adminUser.isEmpty() || adminPassword.isEmpty()) { + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_NOT_CONFIGURED)); + } + try { + return requestGuestToken(embeddedUuid, username, firstName, lastName); + } catch (HttpClientErrorException ex) { + if (ex.getStatusCode().value() == 401) { + clearCachedToken(); + return requestGuestToken(embeddedUuid, username, firstName, lastName); + } + throw ex; + } + } + + private String requestGuestToken(String embeddedUuid, String username, + String firstName, String lastName) { + String accessToken = getAccessToken(); + + // Get CSRF token and session cookies + CsrfResult csrf = getCsrfToken(accessToken); + + // Build guest token request + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + accessToken); + headers.set("X-CSRFToken", csrf.token); + headers.set("Referer", supersetUrl + "/"); + if (csrf.cookies != null) { + headers.set("Cookie", csrf.cookies); + } + + Map user = new HashMap<>(); + user.put("username", username); + user.put("first_name", firstName); + user.put("last_name", lastName); + + Map resource = new HashMap<>(); + resource.put("type", "dashboard"); + resource.put("id", embeddedUuid); + + Map body = new HashMap<>(); + body.put("user", user); + body.put("resources", Collections.singletonList(resource)); + body.put("rls", Collections.emptyList()); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/guest_token/", + HttpMethod.POST, + request, + Map.class + ); + + Map responseBody = response.getBody(); + if (responseBody == null || !responseBody.containsKey("token")) { + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_GUEST_TOKEN_FAILED)); + } + return (String) responseBody.get("token"); + } + + private synchronized String getAccessToken() { + if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) { + return cachedAccessToken; + } + return login(); + } + + private String login() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("username", adminUser); + body.put("password", adminPassword); + body.put("provider", "db"); + body.put("refresh", "true"); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/login", + HttpMethod.POST, + request, + Map.class + ); + + Map loginResponse = response.getBody(); + if (loginResponse == null || !loginResponse.containsKey("access_token")) { + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_LOGIN_FAILED)); + } + cachedAccessToken = (String) loginResponse.get("access_token"); + // Cache token for 4 minutes (Superset default expiry is 5 minutes) + tokenExpiresAt = System.currentTimeMillis() + (4 * 60 * 1000); + + return cachedAccessToken; + } + + private CsrfResult getCsrfToken(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/csrf_token/", + HttpMethod.GET, + request, + Map.class + ); + + Map csrfBody = response.getBody(); + if (csrfBody == null || !csrfBody.containsKey("result")) { + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_CSRF_FAILED)); + } + String csrfToken = (String) csrfBody.get("result"); + + // Extract Set-Cookie headers to send back with the guest token request + List setCookies = response.getHeaders().get(HttpHeaders.SET_COOKIE); + String cookies = null; + if (setCookies != null && !setCookies.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < setCookies.size(); i++) { + if (i > 0) { + sb.append("; "); + } + // Extract just the cookie name=value part (before any ;) + String cookie = setCookies.get(i); + int semicolonIdx = cookie.indexOf(';'); + if (semicolonIdx > 0) { + sb.append(cookie.substring(0, semicolonIdx)); + } else { + sb.append(cookie); + } + } + cookies = sb.toString(); + } + + return new CsrfResult(csrfToken, cookies); + } + + private synchronized void clearCachedToken() { + cachedAccessToken = null; + tokenExpiresAt = 0; + } + + /** + * Simple holder for CSRF token and associated session cookies. + */ + private static class CsrfResult { + final String token; + final String cookies; + + CsrfResult(String token, String cookies) { + this.token = token; + this.cookies = cookies; + } + } +} diff --git a/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java new file mode 100644 index 0000000..1ad633b --- /dev/null +++ b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java @@ -0,0 +1,74 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.web; + +import java.util.Collections; +import java.util.Map; +import org.openlmis.report.exception.NotFoundMessageException; +import org.openlmis.report.i18n.SupersetMessageKeys; +import org.openlmis.report.repository.DashboardReportRepository; +import org.openlmis.report.service.PermissionService; +import org.openlmis.report.service.SupersetService; +import org.openlmis.report.utils.Message; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Controller +@RequestMapping("/api/reports/superset") +public class SupersetGuestTokenController extends BaseController { + + @Autowired + private SupersetService supersetService; + + @Autowired + private DashboardReportRepository dashboardReportRepository; + + @Autowired + private PermissionService permissionService; + + /** + * Get a Superset guest token for embedding a dashboard. + * + * @param embeddedUuid the embedded UUID of the Superset dashboard + * @return a map containing the guest token + */ + @GetMapping("/guest-token") + @ResponseStatus(HttpStatus.OK) + @ResponseBody + public Map getGuestToken(@RequestParam String embeddedUuid) { + permissionService.canViewReports(); + + if (!dashboardReportRepository.existsByEmbeddedUuid(embeddedUuid)) { + throw new NotFoundMessageException( + new Message(SupersetMessageKeys.ERROR_SUPERSET_DASHBOARD_NOT_FOUND, embeddedUuid)); + } + + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + String token = supersetService.getGuestToken( + embeddedUuid, username, username, username + ); + + return Collections.singletonMap("token", token); + } +} diff --git a/src/main/resources/api-definition.yaml b/src/main/resources/api-definition.yaml index b56aaeb..7fbf29b 100644 --- a/src/main/resources/api-definition.yaml +++ b/src/main/resources/api-definition.yaml @@ -423,6 +423,25 @@ resourceTypes: body: application/json: schema: localizedMessage + /superset: + /guest-token: + displayName: Superset guest token + get: + is: [ secured ] + description: Get a Superset guest token for embedding a dashboard. + queryParameters: + embeddedUuid: + displayName: embeddedUuid + type: string + required: true + responses: + 200: + body: + application/json: + 401: + body: + application/json: + schema: localizedMessage /dashboardReports: displayName: Dashboard reports get: diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 97cbeb8..5003c2e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,3 +58,7 @@ referencedata.url=${BASE_URL} notification.url=${BASE_URL} requisition.url=${BASE_URL} fulfillment.url=${BASE_URL} + +superset.url=${SUPERSET_URL:} +superset.admin.user=${SUPERSET_ADMIN_USER:} +superset.admin.password=${SUPERSET_ADMIN_PASSWORD:} diff --git a/src/main/resources/db/migration/20260525141933259__add_embedded_uuid_to_dashboard_reports.sql b/src/main/resources/db/migration/20260525141933259__add_embedded_uuid_to_dashboard_reports.sql new file mode 100644 index 0000000..a22a3e9 --- /dev/null +++ b/src/main/resources/db/migration/20260525141933259__add_embedded_uuid_to_dashboard_reports.sql @@ -0,0 +1 @@ +ALTER TABLE report.dashboard_reports ADD COLUMN IF NOT EXISTS embeddeduuid text; diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index c1501b2..fa4a5bf 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -17,3 +17,9 @@ report.error.reportCategory.name.duplicated=Error. Report category name duplicat report.error.reportCategory.id.mismatch=Error. Report category id mismatch. report.error.reportCategory.notFound=Report category with id {0} not found. report.error.reportCategory.already.assigned=Report category cannot be deleted since it is assigned. {0} + +report.error.superset.notConfigured=Superset integration is not configured on this server. +report.error.superset.login.failed=Failed to authenticate against Superset. +report.error.superset.csrf.failed=Failed to obtain a CSRF token from Superset. +report.error.superset.guestToken.failed=Failed to obtain a Superset guest token. +report.error.superset.dashboard.notFound=No dashboard report is configured for the given embedded UUID. diff --git a/src/test/java/org/openlmis/report/service/SupersetServiceTest.java b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java new file mode 100644 index 0000000..ea47461 --- /dev/null +++ b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java @@ -0,0 +1,274 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.openlmis.report.exception.ServerException; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@RunWith(MockitoJUnitRunner.class) +public class SupersetServiceTest { + + private static final String SUPERSET_URL = "http://superset:8088"; + private static final String ADMIN_USER = "admin"; + private static final String ADMIN_PASSWORD = "secret"; + private static final String EMBEDDED_UUID = "test-dashboard-uuid"; + private static final String USERNAME = "testuser"; + private static final String ACCESS_TOKEN = "mock-access-token"; + private static final String CSRF_TOKEN = "mock-csrf-token"; + private static final String GUEST_TOKEN = "mock-guest-token"; + private static final String LOGIN_URL = + SUPERSET_URL + "/api/v1/security/login"; + private static final String CSRF_URL = + SUPERSET_URL + "/api/v1/security/csrf_token/"; + private static final String GUEST_TOKEN_URL = + SUPERSET_URL + "/api/v1/security/guest_token/"; + + @Mock + private RestTemplate restTemplate; + + private SupersetService supersetService; + + @Before + public void setUp() { + supersetService = new SupersetService(); + ReflectionTestUtils.setField(supersetService, "restTemplate", restTemplate); + ReflectionTestUtils.setField(supersetService, "supersetUrl", SUPERSET_URL); + ReflectionTestUtils.setField(supersetService, "adminUser", ADMIN_USER); + ReflectionTestUtils.setField(supersetService, "adminPassword", ADMIN_PASSWORD); + ReflectionTestUtils.setField(supersetService, "cachedAccessToken", null); + ReflectionTestUtils.setField(supersetService, "tokenExpiresAt", 0L); + } + + @Test + public void shouldLoginAndReturnGuestToken() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + mockGuestTokenResponse(); + + // when + String token = supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then + assertEquals(GUEST_TOKEN, token); + } + + @Test + public void shouldUseCachedTokenOnSecondCall() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + mockGuestTokenResponse(); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then - login is called only once, but CSRF and guest_token are called twice each + verify(restTemplate, times(1)).exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + verify(restTemplate, times(2)).exchange( + eq(CSRF_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + ); + verify(restTemplate, times(2)).exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + } + + @Test + public void shouldRetryOnUnauthorized() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + + // First guest_token call throws 401, subsequent calls succeed + Map guestTokenBody = new HashMap<>(); + guestTokenBody.put("token", GUEST_TOKEN); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )) + .thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED)) + .thenReturn(new ResponseEntity<>(guestTokenBody, HttpStatus.OK)); + + // when + String token = supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then + assertEquals(GUEST_TOKEN, token); + // login should be called twice: once initially and once after 401 retry + verify(restTemplate, times(2)).exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + } + + @Test + public void shouldThrowWhenSupersetIsNotConfigured() { + for (String field : new String[] {"supersetUrl", "adminUser", "adminPassword"}) { + setUp(); + ReflectionTestUtils.setField(supersetService, field, ""); + ServerException thrown = null; + try { + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } catch (ServerException ex) { + thrown = ex; + } + if (thrown == null) { + fail("Expected ServerException when " + field + " is empty"); + } + } + } + + @Test(expected = ServerException.class) + public void shouldThrowOnNullLoginResponse() { + // given + when(restTemplate.exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + + @Test(expected = ServerException.class) + public void shouldThrowOnNullGuestTokenResponse() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldExtractCookiesFromCsrfResponse() { + // given + mockLoginResponse(); + mockCsrfResponse(Arrays.asList("session=abc123; Path=/; HttpOnly", "csrftoken=xyz; Path=/")); + mockGuestTokenResponse(); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then - verify the guest token request includes cookies + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + captor.capture(), + eq(Map.class) + ); + + HttpEntity capturedRequest = captor.getValue(); + String cookieHeader = capturedRequest.getHeaders().getFirst("Cookie"); + assertEquals("session=abc123; csrftoken=xyz", cookieHeader); + } + + private void mockLoginResponse() { + Map loginBody = new HashMap<>(); + loginBody.put("access_token", ACCESS_TOKEN); + + when(restTemplate.exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(loginBody, HttpStatus.OK)); + } + + @SuppressWarnings("unchecked") + private void mockCsrfResponse(java.util.List setCookies) { + Map csrfBody = new HashMap<>(); + csrfBody.put("result", CSRF_TOKEN); + + HttpHeaders responseHeaders = new HttpHeaders(); + if (setCookies != null) { + for (String cookie : setCookies) { + responseHeaders.add(HttpHeaders.SET_COOKIE, cookie); + } + } + + when(restTemplate.exchange( + eq(CSRF_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(csrfBody, responseHeaders, HttpStatus.OK)); + } + + private void mockGuestTokenResponse() { + Map guestTokenBody = new HashMap<>(); + guestTokenBody.put("token", GUEST_TOKEN); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(guestTokenBody, HttpStatus.OK)); + } +} diff --git a/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java b/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java index 9359565..8516105 100644 --- a/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java +++ b/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java @@ -24,6 +24,7 @@ public class DashboardReportDataBuilder { private final UUID id = UUID.randomUUID(); private String name = RandomStringUtils.random(6); private String url = "http://example.com"; + private String embeddedUuid = null; private ReportType type = ReportType.SUPERSET; private boolean enabled = true; private boolean showOnHomePage = false; @@ -80,6 +81,7 @@ public DashboardReport buildAsNew() { return new DashboardReport( name, url, + embeddedUuid, type, enabled, showOnHomePage, diff --git a/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java new file mode 100644 index 0000000..14c5e1d --- /dev/null +++ b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java @@ -0,0 +1,109 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.web; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.openlmis.report.exception.NotFoundMessageException; +import org.openlmis.report.exception.PermissionMessageException; +import org.openlmis.report.repository.DashboardReportRepository; +import org.openlmis.report.service.PermissionService; +import org.openlmis.report.service.SupersetService; +import org.openlmis.report.utils.Message; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SupersetGuestTokenControllerTest { + + private static final String EMBEDDED_UUID = "test-dashboard-uuid"; + private static final String USERNAME = "testuser"; + private static final String GUEST_TOKEN = "mock-guest-token"; + + @Mock + private SupersetService supersetService; + + @Mock + private DashboardReportRepository dashboardReportRepository; + + @Mock + private PermissionService permissionService; + + @InjectMocks + private SupersetGuestTokenController controller; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USERNAME, "password") + ); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void shouldReturnGuestToken() { + when(dashboardReportRepository.existsByEmbeddedUuid(EMBEDDED_UUID)).thenReturn(true); + when(supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME)) + .thenReturn(GUEST_TOKEN); + + Map result = controller.getGuestToken(EMBEDDED_UUID); + + assertEquals(GUEST_TOKEN, result.get("token")); + assertEquals(1, result.size()); + verify(permissionService).canViewReports(); + } + + @Test(expected = NotFoundMessageException.class) + public void shouldThrowNotFoundWhenDashboardDoesNotExist() { + when(dashboardReportRepository.existsByEmbeddedUuid(EMBEDDED_UUID)).thenReturn(false); + + try { + controller.getGuestToken(EMBEDDED_UUID); + } finally { + verify(supersetService, never()).getGuestToken( + EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + } + + @Test(expected = PermissionMessageException.class) + public void shouldThrowWhenUserLacksReportsViewRight() { + doThrow(new PermissionMessageException(new Message("report.error.noPermission"))) + .when(permissionService).canViewReports(); + + try { + controller.getGuestToken(EMBEDDED_UUID); + } finally { + verify(dashboardReportRepository, never()).existsByEmbeddedUuid(EMBEDDED_UUID); + verify(supersetService, never()).getGuestToken( + EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + } +}