From 3bd63ee8aeafa3b1d4ac5f6aec6054a5b2fa75c6 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Mon, 20 Apr 2026 11:58:13 +0200 Subject: [PATCH 01/18] [backend/frontend] feat(xtmhub): connectivity check from api with tenant --- .../java/io/openaev/api/xtmhub/XtmHubApi.java | 10 +- .../service/PlatformSettingsService.java | 14 + .../java/io/openaev/xtmhub/XtmHubClient.java | 53 +-- .../java/io/openaev/xtmhub/XtmHubService.java | 64 ++-- .../io/openaev/xtmhub/XtmHubServiceTest.java | 312 +++++++----------- .../src/actions/xtmhub/xtmhub-actions.ts | 2 +- .../experience/xtm_hub/XtmHubSettings.tsx | 7 +- openaev-front/src/utils/tenant-url-helper.ts | 1 - 8 files changed, 208 insertions(+), 255 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java index 218b0695f10..9fc2ff86c49 100644 --- a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java +++ b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java @@ -6,7 +6,6 @@ import io.openaev.database.model.Action; import io.openaev.database.model.ResourceType; import io.openaev.rest.helper.RestBehavior; -import io.openaev.rest.settings.response.PlatformSettings; import io.openaev.xtmhub.XtmHubService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -81,17 +80,18 @@ public void unregister() { } @PostMapping( - value = XTMHUB_URI + "/refresh-connectivity", + value = {XTMHUB_URI + "/refresh-connectivity", TENANT_XTMHUB_URI + "/refresh-connectivity"}, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation( summary = "Refresh connectivity with XTM Hub", description = "Refresh status in settings and version in XTM Hub") @ApiResponses({@ApiResponse(responseCode = "200", description = "Successful refresh")}) - @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.PLATFORM_SETTING) + @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.XTM_HUB_REGISTRATION) @Transactional(rollbackFor = Exception.class) - public PlatformSettings refreshConnectivity() { - return this.xtmHubService.refreshConnectivity(); + public XtmHubRegistrationOutput refreshConnectivity() { + return xtmHubRegistrationMapper.toXtmHubRegistrationOutput( + this.xtmHubService.refreshConnectivity()); } @PutMapping(value = XTMHUB_URI + "/auto-register", consumes = MediaType.APPLICATION_JSON_VALUE) diff --git a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java index c4d806223df..28ed22ac842 100644 --- a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java +++ b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java @@ -612,6 +612,20 @@ public PlatformSettings updateXTMHubRegistration( return findSettings(); } + public void updateXTMHubEmailNotification(boolean shouldSendConnectivityEmail) { + Optional current = + this.settingRepository.findByKey(XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL.key()); + boolean currentValue = current.map(s -> Boolean.parseBoolean(s.getValue())).orElse(true); + if (currentValue != shouldSendConnectivityEmail) { + Setting setting = + resolve( + current, + XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL.key(), + String.valueOf(shouldSendConnectivityEmail)); + settingRepository.save(setting); + } + } + public PlatformSettings deleteXTMHubRegistration() { Map dbSettings = mapOfSettings(fromIterable(this.settingRepository.findAll())); diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java index 8a99469f0aa..b9d2ce381eb 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java @@ -59,15 +59,16 @@ public Boolean contactUs(String message, String token, String platformId) { } } - public XtmHubConnectivityStatus refreshRegistrationStatus( - String platformId, String platformVersion, String token) { + public XtmHubConnectivityStatus refreshRegistrationStatusSingleTenant( + String platformId, String platformVersion, String token, String url, String tenantId) { try (CloseableHttpClient httpClient = httpClientFactory.httpClientCustom()) { HttpPost httpPost = new HttpPost(this.graphqlEndpoint); httpPost.addHeader("Accept", "application/json"); httpPost.addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE); httpPost.addHeader(ACCEPT, APPLICATION_JSON_VALUE); - StringEntity httpBody = buildRefreshStatusBody(platformId, platformVersion, token); + StringEntity httpBody = + buildRefreshStatusSingleTenantBody(platformId, platformVersion, token, url, tenantId); httpPost.setEntity(httpBody); return httpClient.execute(httpPost, this::parseResponseAsConnectivityStatus); } catch (Exception e) { @@ -138,30 +139,32 @@ mutation ContactUsXTMHub($message: String!) { } @NotNull - private StringEntity buildRefreshStatusBody( - String platformId, String platformVersion, String token) { + private StringEntity buildRefreshStatusSingleTenantBody( + String platformId, String platformVersion, String token, String url, String tenantId) { String mutationBody = String.format( """ - { - "query": " - mutation RefreshPlatformRegistrationConnectivityStatus($input: RefreshPlatformRegistrationConnectivityStatusInput!) { - refreshPlatformRegistrationConnectivityStatus(input: $input) { - status - } - } - ", - "variables": { - "input": { - "platformId": "%s", - "platformVersion": "%s", - "token": "%s", - "platformIdentifier": "%s" - } - } - } - """, - platformId, platformVersion, token, platformIdentifier); + { + "query": " + mutation refreshPlatformRegistrationConnectivityStatusSingleTenant($input: RefreshPlatformRegistrationConnectivityStatusSingleTenantInput!) { + refreshPlatformRegistrationConnectivityStatusSingleTenant(input: $input) { + status + } + } + ", + "variables": { + "input": { + "platformId": "%s", + "platformVersion": "%s", + "token": "%s", + "platformIdentifier": "%s", + "url": "%s", + "tenantId": "%s" + } + } + } + """, + platformId, platformVersion, token, platformIdentifier, url, tenantId); JsonElement element = JsonParser.parseString(mutationBody); return new StringEntity(element.toString()); @@ -213,7 +216,7 @@ private XtmHubConnectivityStatus parseResponseAsConnectivityStatus(ClassicHttpRe .getAsJsonObject() .get("data") .getAsJsonObject() - .get("refreshPlatformRegistrationConnectivityStatus") + .get("refreshPlatformRegistrationConnectivityStatusSingleTenant") .getAsJsonObject() .get("status") .getAsString(); diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index 17c44d6c391..d3a018bc4e5 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -14,7 +14,6 @@ import java.util.Optional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -71,21 +70,24 @@ public void unregister() { tenantXtmHubRegistrationRepository.deleteByTenantId(TenantContext.getCurrentTenant()); } - public PlatformSettings refreshConnectivity() { - PlatformSettings settings = platformSettingsService.findSettings(); - if (!isRegisteredWithXtmHub(settings)) { - return settings; + public TenantXtmHubRegistration refreshConnectivity() { + Optional registration = getRegistration(); + + if (registration.isEmpty()) { + return null; } - ConnectivityCheckResult checkResult = checkConnectivityStatus(settings); - if (checkResult.status == XtmHubConnectivityStatus.NOT_FOUND) { + PlatformSettings settings = platformSettingsService.findSettings(); + ConnectivityCheckResult checkResult = checkConnectivityStatus(settings, registration.get()); + if (checkResult.status() == XtmHubConnectivityStatus.NOT_FOUND) { log.warn("Platform was not found on XTM Hub"); - return platformSettingsService.deleteXTMHubRegistration(); + platformSettingsService.deleteXTMHubRegistration(); + return null; } handleConnectivityLossNotification(settings, checkResult); - return updateRegistrationStatus(settings, checkResult); + return updateRegistrationStatus(settings, registration.get(), checkResult); } private TenantXtmHubRegistration findOrCreateRegistration() { @@ -94,16 +96,19 @@ private TenantXtmHubRegistration findOrCreateRegistration() { .orElse(new TenantXtmHubRegistration()); } - private boolean isRegisteredWithXtmHub(PlatformSettings settings) { - return StringUtils.isNotBlank(settings.getXtmHubToken()); - } + private ConnectivityCheckResult checkConnectivityStatus( + PlatformSettings settings, TenantXtmHubRegistration registration) { + String url = settings.getPlatformBaseUrl() + "/" + TenantContext.getCurrentTenant(); - private ConnectivityCheckResult checkConnectivityStatus(PlatformSettings settings) { XtmHubConnectivityStatus status = - xtmHubClient.refreshRegistrationStatus( - settings.getPlatformId(), settings.getPlatformVersion(), settings.getXtmHubToken()); + xtmHubClient.refreshRegistrationStatusSingleTenant( + settings.getPlatformId(), + settings.getPlatformVersion(), + registration.getToken(), + url, + TenantContext.getCurrentTenant()); - LocalDateTime lastCheck = parseLastConnectivityCheck(settings); + LocalDateTime lastCheck = parseLastConnectivityCheck(registration); return new ConnectivityCheckResult(status, lastCheck); } @@ -115,9 +120,9 @@ public Boolean contactUs(String message) { return xtmHubClient.contactUs(message, token, platformId); } - private LocalDateTime parseLastConnectivityCheck(PlatformSettings settings) { - String lastCheckStr = settings.getXtmHubLastConnectivityCheck(); - return lastCheckStr != null ? LocalDateTime.parse(lastCheckStr) : LocalDateTime.now(); + private LocalDateTime parseLastConnectivityCheck(TenantXtmHubRegistration registration) { + LocalDateTime lastCheck = registration.getLastConnectivityCheck(); + return lastCheck != null ? lastCheck : LocalDateTime.now(); } private void handleConnectivityLossNotification( @@ -145,8 +150,10 @@ private boolean isEmailNotificationEnabled(PlatformSettings settings) { && xtmHubConfig.getConnectivityEmailEnable(); } - private PlatformSettings updateRegistrationStatus( - PlatformSettings settings, ConnectivityCheckResult checkResult) { + private TenantXtmHubRegistration updateRegistrationStatus( + PlatformSettings settings, + TenantXtmHubRegistration registration, + ConnectivityCheckResult checkResult) { XtmHubRegistrationStatus newStatus = checkResult.status() == XtmHubConnectivityStatus.ACTIVE @@ -161,18 +168,11 @@ private PlatformSettings updateRegistrationStatus( boolean shouldKeepEmailNotificationEnabled = !shouldSendConnectivityLossEmail(settings, checkResult); - return platformSettingsService.updateXTMHubRegistration( - settings.getXtmHubToken(), - parseRegistrationDate(settings), - newStatus, - new XtmHubRegistererRecord( - settings.getXtmHubRegistrationUserId(), settings.getXtmHubRegistrationUserName()), - updatedLastCheck, - shouldKeepEmailNotificationEnabled); - } + platformSettingsService.updateXTMHubEmailNotification(shouldKeepEmailNotificationEnabled); + registration.setRegistrationStatus(newStatus); + registration.setLastConnectivityCheck(updatedLastCheck); - private LocalDateTime parseRegistrationDate(PlatformSettings settings) { - return LocalDateTime.parse(settings.getXtmHubRegistrationDate()); + return tenantXtmHubRegistrationRepository.save(registration); } /** Encapsulates the result of a connectivity check */ diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index b397d630426..4458f8070d1 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -23,9 +23,11 @@ import io.openaev.utilstest.DefaultTenantExtension; import io.openaev.xtmhub.config.XtmHubConfig; import java.time.LocalDateTime; +import java.util.Optional; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockserver.integration.ClientAndServer; @@ -78,10 +80,10 @@ void setUp() { // lenient: some tests (blank/null token) never reach the HTTP call lenient().when(httpClientFactory.httpClientCustom()).thenReturn(HttpClients.createDefault()); - // findOrCreateRegistration — return empty so the entity is freshly created + // Default: no registration found lenient() .when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(java.util.Optional.empty()); + .thenReturn(Optional.empty()); XtmHubClient xtmHubClient = new XtmHubClient(xtmHubConfig, httpClientFactory, platformSettingsService); @@ -115,7 +117,7 @@ private void whenHubReturnsConnectivityStatus(String status) { .withStatusCode(200) .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) .withBody( - "{\"data\":{\"refreshPlatformRegistrationConnectivityStatus\":{\"status\":\"%s\"}}}" + "{\"data\":{\"refreshPlatformRegistrationConnectivityStatusSingleTenant\":{\"status\":\"%s\"}}}" .formatted(status))); } @@ -172,7 +174,7 @@ private JsonObject verifyAutoRegisterRequest(String token, String platformId) { /** Verifies refresh-connectivity GraphQL request headers and body. */ private void verifyRefreshConnectivityRequest( - String platformId, String platformVersion, String token) { + String platformId, String platformVersion, String token, String platformBaseUrl) { JsonObject body = verifySingleGraphqlPostRequestAndGetBody(graphqlPostRequestMatcher()); assertThat(body.get("query").getAsString()) .contains("refreshPlatformRegistrationConnectivityStatus"); @@ -182,6 +184,8 @@ private void verifyRefreshConnectivityRequest( assertThat(input.get("platformVersion").getAsString()).isEqualTo(platformVersion); assertThat(input.get("token").getAsString()).isEqualTo(token); assertThat(input.get("platformIdentifier").getAsString()).isEqualTo("openaev"); + assertThat(input.get("url").getAsString()) + .isEqualTo(platformBaseUrl + "/" + Tenant.DEFAULT_TENANT_UUID); } /** Asserts that no HTTP request was made to the hub at all. */ @@ -189,72 +193,62 @@ private void verifyNoRequestSentToHub() { assertThat(mockServer.retrieveRecordedRequests(request())).isEmpty(); } + /** Builds a TenantXtmHubRegistration with the given token and lastConnectivityCheck. */ + private TenantXtmHubRegistration buildRegistration(String token, LocalDateTime lastCheck) { + TenantXtmHubRegistration registration = new TenantXtmHubRegistration(); + registration.setToken(token); + registration.setRegistrationDate(registrationDate); + registration.setRegistrationUserId("user-123"); + registration.setRegistrationUserName("John Doe"); + registration.setLastConnectivityCheck(lastCheck); + return registration; + } + // ===================================================================== // refreshConnectivity tests // ===================================================================== @Test - @DisplayName("Should call XTM Hub refresh endpoint when token is present") - void refreshConnectivity_WhenTokenIsPresent_ShouldCallXtmHub() { + @DisplayName("Should call XTM Hub refresh endpoint when registration is present") + void refreshConnectivity_WhenRegistrationIsPresent_ShouldCallXtmHub() { // Given String token = "valid-token"; String platformId = "platform-123"; String platformVersion = "1.0.0"; + String platformBaseUrl = "http://localhost"; LocalDateTime lastCheck = now.minusHours(1); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId(platformId); mockSettings.setPlatformVersion(platformVersion); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl(platformBaseUrl); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(new PlatformSettings()); whenHubReturnsConnectivityStatus("active"); // When xtmHubService.refreshConnectivity(); // Then - verifyRefreshConnectivityRequest(platformId, platformVersion, token); - } - - @Test - @DisplayName("Should return settings unchanged when XTM Hub token is blank") - void refreshConnectivity_WhenTokenIsBlank_ShouldReturnSettingsUnchanged() { - // Given - mockSettings.setXtmHubToken(""); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - - // When - PlatformSettings result = xtmHubService.refreshConnectivity(); - - // Then - assertEquals(mockSettings, result); - verifyNoRequestSentToHub(); - verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService, never()) - .updateXTMHubRegistration(any(), any(), any(), any(), any(), any()); + verifyRefreshConnectivityRequest(platformId, platformVersion, token, platformBaseUrl); } @Test - @DisplayName("Should return settings unchanged when XTM Hub token is null") - void refreshConnectivity_WhenTokenIsNull_ShouldReturnSettingsUnchanged() { - // Given - mockSettings.setXtmHubToken(null); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); + @DisplayName("Should return null when no registration exists") + void refreshConnectivity_WhenRegistrationIsAbsent_ShouldReturnNull() { + // Given — repository returns empty by default (setUp) // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(mockSettings, result); + assertNull(result); verifyNoRequestSentToHub(); verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); } @Test @@ -265,9 +259,13 @@ void refreshConnectivity_WhenPlatformIsNotFound_ShouldRemoveRegistration() { String platformId = "platform-123"; String platformVersion = "1.0.0"; - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration(token, now.minusHours(1)); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId(platformId); mockSettings.setPlatformVersion(platformVersion); + mockSettings.setPlatformBaseUrl("http://localhost"); when(platformSettingsService.findSettings()).thenReturn(mockSettings); whenHubReturnsConnectivityStatus("not_found"); @@ -285,47 +283,33 @@ void refreshConnectivity_WhenPlatformIsNotFound_ShouldRemoveRegistration() { void refreshConnectivity_WhenConnectivityIsActive_ShouldUpdateAsRegistered() { // Given String token = "valid-token"; - String platformId = "platform-123"; - String platformVersion = "1.0.0"; - String userId = "user-123"; - String userName = "John Doe"; LocalDateTime lastCheck = now.minusHours(12); - mockSettings.setXtmHubToken(token); - mockSettings.setPlatformId(platformId); - mockSettings.setPlatformVersion(platformVersion); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId(userId); - mockSettings.setXtmHubRegistrationUserName(userName); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); - PlatformSettings updatedSettings = new PlatformSettings(); + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.REGISTERED), - eq(new XtmHubRegistererRecord(userId, userName)), - any(LocalDateTime.class), - eq(true))) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("active"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.REGISTERED), - eq(new XtmHubRegistererRecord(userId, userName)), - any(LocalDateTime.class), - eq(true)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertEquals(captor.getValue(), result); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.REGISTERED); + + verify(platformSettingsService).updateXTMHubEmailNotification(true); verifyNoInteractions(xtmHubEmailService); } @@ -337,35 +321,32 @@ void refreshConnectivity_WhenConnectivityLostLessThan24Hours_ShouldNotSendEmail( String token = "valid-token"; LocalDateTime lastCheck = now.minusHours(12); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.LOST_CONNECTIVITY), - eq(new XtmHubRegistererRecord("user-123", "John Doe")), - eq(lastCheck), - eq(true)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertEquals(captor.getValue(), result); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + assertThat(captor.getValue().getLastConnectivityCheck()).isEqualTo(lastCheck); + + verify(platformSettingsService).updateXTMHubEmailNotification(true); verifyNoInteractions(xtmHubEmailService); } @@ -374,30 +355,26 @@ void refreshConnectivity_WhenConnectivityLostLessThan24Hours_ShouldNotSendEmail( void refreshConnectivity_WhenEmailDisabledFromConfig_ShouldNotSendEmail() { // Given xtmHubConfig.setConnectivityEmailEnable(false); - String token = "valid-token"; LocalDateTime lastCheck = now.minusHours(25); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); + assertNotNull(result); verifyNoInteractions(xtmHubEmailService); } @@ -406,158 +383,117 @@ void refreshConnectivity_WhenEmailDisabledFromConfig_ShouldNotSendEmail() { "Should send connectivity email when connectivity is lost for more than 24 hours and email sending is enabled") void refreshConnectivity_WhenConnectivityLostMoreThan24HoursAndEmailEnabled_ShouldSendEmail() { // Given - // xtmHubConfig.connectivityEmailEnable is already true from setUp - String token = "valid-token"; LocalDateTime lastCheck = now.minusHours(25); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); + assertNotNull(result); verify(xtmHubEmailService).sendLostConnectivityEmail(); - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.LOST_CONNECTIVITY), - eq(new XtmHubRegistererRecord("user-123", "John Doe")), - eq(lastCheck), - eq(false)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + + verify(platformSettingsService).updateXTMHubEmailNotification(false); } @Test @DisplayName("Should not send email when connectivity is lost but email sending is disabled") void refreshConnectivity_WhenConnectivityLostButEmailDisabled_ShouldNotSendEmail() { // Given - String token = "valid-token"; LocalDateTime lastCheck = now.minusHours(25); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("false"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); + assertNotNull(result); verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.LOST_CONNECTIVITY), - eq(new XtmHubRegistererRecord("user-123", "John Doe")), - eq(lastCheck), - eq(true)); + verify(platformSettingsService).updateXTMHubEmailNotification(true); } @Test @DisplayName("Should handle null lastConnectivityCheck by using current time") void refreshConnectivity_WhenLastConnectivityCheckIsNull_ShouldUseCurrentTime() { // Given - String token = "valid-token"; + TenantXtmHubRegistration registration = buildRegistration("valid-token", null); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); - mockSettings.setXtmHubToken(token); mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(null); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); - verifyNoInteractions( - xtmHubEmailService); // Should not send email as it's considered first check - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.LOST_CONNECTIVITY), - eq(new XtmHubRegistererRecord("user-123", "John Doe")), - any(LocalDateTime.class), - eq(true)); + assertNotNull(result); + verifyNoInteractions(xtmHubEmailService); // Not sent as it's considered first check + verify(platformSettingsService).updateXTMHubEmailNotification(true); } @Test @DisplayName("Should handle exactly 24 hours difference") void refreshConnectivity_WhenExactly24HoursPassed_ShouldSendEmail() { // Given - // xtmHubConfig.connectivityEmailEnable is already true from setUp - String token = "valid-token"; LocalDateTime lastCheck = now.minusHours(24); - mockSettings.setXtmHubToken(token); + TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setXtmHubRegistrationDate(registrationDate.toString()); - mockSettings.setXtmHubRegistrationUserId("user-123"); - mockSettings.setXtmHubRegistrationUserName("John Doe"); - mockSettings.setXtmHubLastConnectivityCheck(lastCheck.toString()); + mockSettings.setPlatformBaseUrl("http://localhost"); mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - PlatformSettings updatedSettings = new PlatformSettings(); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(platformSettingsService.updateXTMHubRegistration(any(), any(), any(), any(), any(), any())) - .thenReturn(updatedSettings); whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // When - PlatformSettings result = xtmHubService.refreshConnectivity(); + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); // Then - assertEquals(updatedSettings, result); + assertNotNull(result); verify(xtmHubEmailService).sendLostConnectivityEmail(); - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - eq(registrationDate), - eq(XtmHubRegistrationStatus.LOST_CONNECTIVITY), - eq(new XtmHubRegistererRecord("user-123", "John Doe")), - eq(lastCheck), - eq(false)); + verify(platformSettingsService).updateXTMHubEmailNotification(false); } // ===================================================================== @@ -630,7 +566,7 @@ void autoRegister_WithEnterpriseStandardLicense_ShouldUseEEContract() { } @Test - @DisplayName("Should update registration status when auto-register succeeds") + @DisplayName("Should update registration entity when auto-register succeeds") void autoRegister_WhenSuccessful_ShouldUpdateRegistrationStatus() { // Given String token = "valid-token"; diff --git a/openaev-front/src/actions/xtmhub/xtmhub-actions.ts b/openaev-front/src/actions/xtmhub/xtmhub-actions.ts index da755da2638..87e7caa3b00 100644 --- a/openaev-front/src/actions/xtmhub/xtmhub-actions.ts +++ b/openaev-front/src/actions/xtmhub/xtmhub-actions.ts @@ -71,7 +71,7 @@ export const unregisterPlatform = (registrationId: string) => (dispatch: Dispatc export const refreshConnectivity = () => (dispatch: Dispatch) => { const uri = `${XTM_HUB_URI}/refresh-connectivity`; return postReferential( - schema.platformParameters, + schema.tenantXtmHubRegistration, uri, {}, undefined, diff --git a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubSettings.tsx b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubSettings.tsx index 686e241d676..cd8681bf523 100644 --- a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubSettings.tsx +++ b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubSettings.tsx @@ -1,7 +1,7 @@ import { Paper, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import type React from 'react'; -import { useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import type { LoggedHelper } from '../../../../../actions/helper'; import { fetchXtmHubRegistration, refreshConnectivity } from '../../../../../actions/xtmhub/xtmhub-actions'; @@ -10,7 +10,7 @@ import { useHelper } from '../../../../../store'; import { type PlatformSettings, type XtmHubRegistrationOutput } from '../../../../../utils/api-types'; import { useAppDispatch } from '../../../../../utils/hooks'; import useAuth from '../../../../../utils/hooks/useAuth'; -import { Can } from '../../../../../utils/permissions/permissionsContext'; +import { AbilityContext, Can } from '../../../../../utils/permissions/permissionsContext'; import { ACTIONS, SUBJECTS } from '../../../../../utils/permissions/types'; import XtmHubRegisteredSection from './XtmHubRegisteredSection'; import XtmHubTab from './XtmHubTab'; @@ -23,6 +23,7 @@ const XtmHubSettings: React.FC = () => { const registration: XtmHubRegistrationOutput | null = useHelper((helper: LoggedHelper) => helper.getXtmHubRegistration()); const { settings }: { settings: PlatformSettings } = useHelper((helper: LoggedHelper) => ({ settings: helper.getPlatformSettings() })); const dispatch = useAppDispatch(); + const ability = useContext(AbilityContext); const hasFetchedRegistration = useRef(false); const hasRefreshedConnectivity = useRef(false); @@ -33,7 +34,7 @@ const XtmHubSettings: React.FC = () => { }, []); useEffect(() => { - if (!registration?.tenant_xtmhub_registration_token || hasRefreshedConnectivity.current) { + if (!registration?.tenant_xtmhub_registration_token || hasRefreshedConnectivity.current || ability.cannot(ACTIONS.MANAGE, SUBJECTS.TENANT_SETTINGS)) { return; } diff --git a/openaev-front/src/utils/tenant-url-helper.ts b/openaev-front/src/utils/tenant-url-helper.ts index 732afd78d5d..bcde4fabbf3 100644 --- a/openaev-front/src/utils/tenant-url-helper.ts +++ b/openaev-front/src/utils/tenant-url-helper.ts @@ -116,7 +116,6 @@ const TENANT_EXEMPT_PREFIXES = [ '/api/platform-roles', '/api/platform-users', '/api/capabilities', - '/api/xtmhub/refresh-connectivity', '/api/xtmhub/contact-us', '/api/xtmhub/auto-register', '/api/xtm-composer', From 3aeadfca1465df8ebce353065ad3e3dd4f96e54b Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Mon, 20 Apr 2026 18:05:07 +0200 Subject: [PATCH 02/18] [backend] feat(xtmhub): connectivity check for xtmhub collector --- .../xtmhub/TenantRegistrationDetails.java | 3 + .../java/io/openaev/xtmhub/XtmHubClient.java | 112 +- .../java/io/openaev/xtmhub/XtmHubService.java | 66 +- .../XtmHubConnectivityCollectorService.java | 2 +- .../io/openaev/xtmhub/XtmHubServiceTest.java | 1043 ++++++++++------- 5 files changed, 758 insertions(+), 468 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java b/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java new file mode 100644 index 00000000000..69b3b5b6898 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java @@ -0,0 +1,3 @@ +package io.openaev.xtmhub; + +public record TenantRegistrationDetails(String token, String url) {} diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java index b9d2ce381eb..f7a378dfc88 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java @@ -4,6 +4,7 @@ import static org.apache.hc.core5.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -12,6 +13,8 @@ import io.openaev.service.PlatformSettingsService; import io.openaev.xtmhub.config.XtmHubConfig; import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.hc.client5.http.classic.methods.HttpPost; @@ -78,6 +81,24 @@ public XtmHubConnectivityStatus refreshRegistrationStatusSingleTenant( } } + public Map refreshRegistrationStatusAllTenants( + String platformId, String platformVersion, Map tenants) { + try (CloseableHttpClient httpClient = httpClientFactory.httpClientCustom()) { + HttpPost httpPost = new HttpPost(this.graphqlEndpoint); + httpPost.addHeader("Accept", "application/json"); + httpPost.addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE); + httpPost.addHeader(ACCEPT, APPLICATION_JSON_VALUE); + + StringEntity httpBody = + buildRefreshStatusAllTenantsBody(platformId, platformVersion, tenants); + httpPost.setEntity(httpBody); + return httpClient.execute(httpPost, this::parseAllTenantsResponseAsConnectivityStatus); + } catch (Exception e) { + log.error("XTM Hub is unreachable on {}: {}", config.getApiUrl(), e.getMessage(), e); + return Map.of(); + } + } + public boolean autoRegister( String token, String platformContract, @@ -170,6 +191,38 @@ mutation refreshPlatformRegistrationConnectivityStatusSingleTenant($input: Refre return new StringEntity(element.toString()); } + @NotNull + private StringEntity buildRefreshStatusAllTenantsBody( + String platformId, String platformVersion, Map tenants) { + + JsonArray tenantsArray = new JsonArray(); + tenants.forEach( + (tenantId, details) -> { + JsonObject tenantToken = new JsonObject(); + tenantToken.addProperty("tenantId", tenantId); + tenantToken.addProperty("token", details.token()); + tenantToken.addProperty("url", details.url()); + tenantsArray.add(tenantToken); + }); + + JsonObject input = new JsonObject(); + input.addProperty("platformId", platformId); + input.addProperty("platformVersion", platformVersion); + input.addProperty("platformIdentifier", platformIdentifier); + input.add("tenants", tenantsArray); + + JsonObject variables = new JsonObject(); + variables.add("input", input); + + JsonObject body = new JsonObject(); + body.addProperty( + "query", + "mutation refreshPlatformRegistrationConnectivityStatusAllTenants($input: RefreshPlatformRegistrationConnectivityStatusAllTenantsInput!) { refreshPlatformRegistrationConnectivityStatusAllTenants(input: $input) { statuses { tenantId status } } }"); + body.add("variables", variables); + + return new StringEntity(body.toString()); + } + @NotNull private StringEntity buildAutoRegisterBody( String platformContract, @@ -202,11 +255,20 @@ private StringEntity buildAutoRegisterBody( return new StringEntity(body.toString()); } + private XtmHubConnectivityStatus toConnectivityStatus(String status) { + if (status.equals(XtmHubConnectivityStatus.ACTIVE.label)) { + return XtmHubConnectivityStatus.ACTIVE; + } + if (status.equals(XtmHubConnectivityStatus.NOT_FOUND.label)) { + return XtmHubConnectivityStatus.NOT_FOUND; + } + return XtmHubConnectivityStatus.INACTIVE; + } + private XtmHubConnectivityStatus parseResponseAsConnectivityStatus(ClassicHttpResponse response) { if (response.getCode() != HttpStatus.SC_OK) { return XtmHubConnectivityStatus.INACTIVE; } - try { HttpEntity entity = response.getEntity(); String responseString = EntityUtils.toString(entity, "UTF-8"); @@ -220,22 +282,50 @@ private XtmHubConnectivityStatus parseResponseAsConnectivityStatus(ClassicHttpRe .getAsJsonObject() .get("status") .getAsString(); - if (status.equals(XtmHubConnectivityStatus.ACTIVE.label)) { - return XtmHubConnectivityStatus.ACTIVE; - } - - if (status.equals(XtmHubConnectivityStatus.NOT_FOUND.label)) { - return XtmHubConnectivityStatus.NOT_FOUND; - } - - return XtmHubConnectivityStatus.INACTIVE; + return toConnectivityStatus(status); } catch (Exception e) { log.warn("Error occurred while parsing XTM Hub connectivity response: {}", e.getMessage(), e); - return XtmHubConnectivityStatus.INACTIVE; } } + private Map parseAllTenantsResponseAsConnectivityStatus( + ClassicHttpResponse response) { + if (response.getCode() != HttpStatus.SC_OK) { + return Map.of(); + } + try { + HttpEntity entity = response.getEntity(); + String responseString = EntityUtils.toString(entity, "UTF-8"); + JsonElement jsonResponse = JsonParser.parseString(responseString); + JsonArray statuses = + jsonResponse + .getAsJsonObject() + .get("data") + .getAsJsonObject() + .get("refreshPlatformRegistrationConnectivityStatusAllTenants") + .getAsJsonObject() + .get("statuses") + .getAsJsonArray(); + + Map result = new HashMap<>(); + statuses.forEach( + element -> { + JsonObject tenantStatus = element.getAsJsonObject(); + String tenantId = tenantStatus.get("tenantId").getAsString(); + String status = tenantStatus.get("status").getAsString(); + result.put(tenantId, toConnectivityStatus(status)); + }); + return result; + } catch (Exception e) { + log.warn( + "Error occurred while parsing XTM Hub all-tenants connectivity response: {}", + e.getMessage(), + e); + return Map.of(); + } + } + private boolean parseResponseAsSuccess(ClassicHttpResponse response) { if (response.getCode() != HttpStatus.SC_OK) { return false; diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index d3a018bc4e5..bd8b55a3e31 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -11,6 +11,10 @@ import io.openaev.xtmhub.config.XtmHubConfig; import jakarta.validation.constraints.NotBlank; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -85,9 +89,52 @@ public TenantXtmHubRegistration refreshConnectivity() { return null; } - handleConnectivityLossNotification(settings, checkResult); + return updateRegistrationStatus(registration.get(), checkResult); + } + + public void refreshConnectivityAllTenants() { + PlatformSettings settings = platformSettingsService.findSettings(); + + List registrations = + new ArrayList<>(tenantXtmHubRegistrationRepository.findAll()); + + if (registrations.isEmpty()) { + return; + } + + Map tenants = new HashMap<>(); + for (TenantXtmHubRegistration registration : registrations) { + String tenantId = registration.getTenant().getId(); + String url = settings.getPlatformBaseUrl() + "/" + tenantId; + tenants.put(tenantId, new TenantRegistrationDetails(registration.getToken(), url)); + } + + Map statuses = + xtmHubClient.refreshRegistrationStatusAllTenants( + settings.getPlatformId(), settings.getPlatformVersion(), tenants); + + for (TenantXtmHubRegistration registration : registrations) { + TenantContext.setCurrentTenant(registration.getTenant().getId()); + + XtmHubConnectivityStatus status = + statuses.getOrDefault( + registration.getTenant().getId(), XtmHubConnectivityStatus.INACTIVE); + + if (status == XtmHubConnectivityStatus.NOT_FOUND) { + log.warn( + "Platform was not found on XTM Hub for tenant {}", registration.getTenant().getId()); + tenantXtmHubRegistrationRepository.deleteByTenantId(registration.getTenant().getId()); + continue; + } - return updateRegistrationStatus(settings, registration.get(), checkResult); + ConnectivityCheckResult checkResult = + new ConnectivityCheckResult(status, parseLastConnectivityCheck(registration)); + + handleConnectivityLossNotification(settings, checkResult); + updateEmailNotificationFlag(settings, checkResult); + updateRegistrationStatus(registration, checkResult); + } + TenantContext.clearCurrentTenant(); } private TenantXtmHubRegistration findOrCreateRegistration() { @@ -151,9 +198,7 @@ private boolean isEmailNotificationEnabled(PlatformSettings settings) { } private TenantXtmHubRegistration updateRegistrationStatus( - PlatformSettings settings, - TenantXtmHubRegistration registration, - ConnectivityCheckResult checkResult) { + TenantXtmHubRegistration registration, ConnectivityCheckResult checkResult) { XtmHubRegistrationStatus newStatus = checkResult.status() == XtmHubConnectivityStatus.ACTIVE @@ -165,16 +210,19 @@ private TenantXtmHubRegistration updateRegistrationStatus( ? LocalDateTime.now() : checkResult.lastCheck(); - boolean shouldKeepEmailNotificationEnabled = - !shouldSendConnectivityLossEmail(settings, checkResult); - - platformSettingsService.updateXTMHubEmailNotification(shouldKeepEmailNotificationEnabled); registration.setRegistrationStatus(newStatus); registration.setLastConnectivityCheck(updatedLastCheck); return tenantXtmHubRegistrationRepository.save(registration); } + private void updateEmailNotificationFlag( + PlatformSettings settings, ConnectivityCheckResult checkResult) { + boolean shouldKeepEmailNotificationEnabled = + !shouldSendConnectivityLossEmail(settings, checkResult); + platformSettingsService.updateXTMHubEmailNotification(shouldKeepEmailNotificationEnabled); + } + /** Encapsulates the result of a connectivity check */ private record ConnectivityCheckResult( XtmHubConnectivityStatus status, LocalDateTime lastCheck) {} diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorService.java b/openaev-api/src/main/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorService.java index 4674dfa379d..f7679667332 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorService.java @@ -11,6 +11,6 @@ public class XtmHubConnectivityCollectorService implements Runnable { @Override public void run() { - xtmHubService.refreshConnectivity(); + xtmHubService.refreshConnectivityAllTenants(); } } diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index 4458f8070d1..e6fd31640f8 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -9,9 +9,11 @@ import static org.mockserver.model.HttpResponse.response; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import io.openaev.authorisation.HttpClientFactory; +import io.openaev.context.TenantContext; import io.openaev.database.model.Tenant; import io.openaev.database.model.TenantXtmHubRegistration; import io.openaev.database.repository.TenantXtmHubRegistrationRepository; @@ -23,6 +25,8 @@ import io.openaev.utilstest.DefaultTenantExtension; import io.openaev.xtmhub.config.XtmHubConfig; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import java.util.Optional; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.junit.jupiter.api.*; @@ -201,467 +205,612 @@ private TenantXtmHubRegistration buildRegistration(String token, LocalDateTime l registration.setRegistrationUserId("user-123"); registration.setRegistrationUserName("John Doe"); registration.setLastConnectivityCheck(lastCheck); + registration.setTenant(new Tenant(TenantContext.getCurrentTenant())); return registration; } - // ===================================================================== - // refreshConnectivity tests - // ===================================================================== - - @Test - @DisplayName("Should call XTM Hub refresh endpoint when registration is present") - void refreshConnectivity_WhenRegistrationIsPresent_ShouldCallXtmHub() { - // Given - String token = "valid-token"; - String platformId = "platform-123"; - String platformVersion = "1.0.0"; - String platformBaseUrl = "http://localhost"; - LocalDateTime lastCheck = now.minusHours(1); - - TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId(platformId); - mockSettings.setPlatformVersion(platformVersion); - mockSettings.setPlatformBaseUrl(platformBaseUrl); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("active"); - - // When - xtmHubService.refreshConnectivity(); - - // Then - verifyRefreshConnectivityRequest(platformId, platformVersion, token, platformBaseUrl); - } - - @Test - @DisplayName("Should return null when no registration exists") - void refreshConnectivity_WhenRegistrationIsAbsent_ShouldReturnNull() { - // Given — repository returns empty by default (setUp) - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNull(result); - verifyNoRequestSentToHub(); - verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); - } - - @Test - @DisplayName("Should remove XTM Hub registration when platform is not found in the hub") - void refreshConnectivity_WhenPlatformIsNotFound_ShouldRemoveRegistration() { - // Given - String token = "valid-token"; - String platformId = "platform-123"; - String platformVersion = "1.0.0"; - - TenantXtmHubRegistration registration = buildRegistration(token, now.minusHours(1)); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId(platformId); - mockSettings.setPlatformVersion(platformVersion); - mockSettings.setPlatformBaseUrl("http://localhost"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("not_found"); - - // When - xtmHubService.refreshConnectivity(); - - // Then - verify(platformSettingsService).deleteXTMHubRegistration(); - verifyNoInteractions(xtmHubEmailService); - } - - @Test - @DisplayName("Should update registration as REGISTERED when connectivity is ACTIVE") - void refreshConnectivity_WhenConnectivityIsActive_ShouldUpdateAsRegistered() { - // Given - String token = "valid-token"; - LocalDateTime lastCheck = now.minusHours(12); - - TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("active"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - ArgumentCaptor captor = - ArgumentCaptor.forClass(TenantXtmHubRegistration.class); - verify(tenantXtmHubRegistrationRepository).save(captor.capture()); - assertEquals(captor.getValue(), result); - assertThat(captor.getValue().getRegistrationStatus()) - .isEqualTo(XtmHubRegistrationStatus.REGISTERED); - - verify(platformSettingsService).updateXTMHubEmailNotification(true); - verifyNoInteractions(xtmHubEmailService); - } - - @Test - @DisplayName( - "Should update registration as LOST_CONNECTIVITY when connectivity is not ACTIVE and not send email if less than 24 hours") - void refreshConnectivity_WhenConnectivityLostLessThan24Hours_ShouldNotSendEmail() { - // Given - String token = "valid-token"; - LocalDateTime lastCheck = now.minusHours(12); - - TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - ArgumentCaptor captor = - ArgumentCaptor.forClass(TenantXtmHubRegistration.class); - verify(tenantXtmHubRegistrationRepository).save(captor.capture()); - assertEquals(captor.getValue(), result); - assertThat(captor.getValue().getRegistrationStatus()) - .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); - assertThat(captor.getValue().getLastConnectivityCheck()).isEqualTo(lastCheck); - - verify(platformSettingsService).updateXTMHubEmailNotification(true); - verifyNoInteractions(xtmHubEmailService); - } - - @Test - @DisplayName("Should not send connectivity email when email is disabled from configuration") - void refreshConnectivity_WhenEmailDisabledFromConfig_ShouldNotSendEmail() { - // Given - xtmHubConfig.setConnectivityEmailEnable(false); - LocalDateTime lastCheck = now.minusHours(25); - - TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNotNull(result); - verifyNoInteractions(xtmHubEmailService); - } - - @Test - @DisplayName( - "Should send connectivity email when connectivity is lost for more than 24 hours and email sending is enabled") - void refreshConnectivity_WhenConnectivityLostMoreThan24HoursAndEmailEnabled_ShouldSendEmail() { - // Given - LocalDateTime lastCheck = now.minusHours(25); - - TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNotNull(result); - verify(xtmHubEmailService).sendLostConnectivityEmail(); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(TenantXtmHubRegistration.class); - verify(tenantXtmHubRegistrationRepository).save(captor.capture()); - assertThat(captor.getValue().getRegistrationStatus()) - .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); - - verify(platformSettingsService).updateXTMHubEmailNotification(false); - } - - @Test - @DisplayName("Should not send email when connectivity is lost but email sending is disabled") - void refreshConnectivity_WhenConnectivityLostButEmailDisabled_ShouldNotSendEmail() { - // Given - LocalDateTime lastCheck = now.minusHours(25); - - TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("false"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNotNull(result); - verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService).updateXTMHubEmailNotification(true); - } - - @Test - @DisplayName("Should handle null lastConnectivityCheck by using current time") - void refreshConnectivity_WhenLastConnectivityCheckIsNull_ShouldUseCurrentTime() { - // Given - TenantXtmHubRegistration registration = buildRegistration("valid-token", null); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNotNull(result); - verifyNoInteractions(xtmHubEmailService); // Not sent as it's considered first check - verify(platformSettingsService).updateXTMHubEmailNotification(true); - } - - @Test - @DisplayName("Should handle exactly 24 hours difference") - void refreshConnectivity_WhenExactly24HoursPassed_ShouldSendEmail() { - // Given - LocalDateTime lastCheck = now.minusHours(24); - - TenantXtmHubRegistration registration = buildRegistration("valid-token", lastCheck); - when(tenantXtmHubRegistrationRepository.findByTenantId(any())) - .thenReturn(Optional.of(registration)); - - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformVersion("1.0.0"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setXtmHubShouldSendConnectivityEmail("true"); - - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - whenHubReturnsConnectivityStatus("inactive"); - when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - // When - TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); - - // Then - assertNotNull(result); - verify(xtmHubEmailService).sendLostConnectivityEmail(); - verify(platformSettingsService).updateXTMHubEmailNotification(false); - } - - // ===================================================================== - // autoRegister tests - // ===================================================================== - - @Test - @DisplayName("Should compute contract level as CE for non-enterprise license") - void autoRegister_WithNonEnterpriseLicense_ShouldUseCEContract() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(false); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn(1L); - whenHubAutoRegisters(true); - - // When - xtmHubService.autoRegister(token); - - // Then - JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); - assertThat(input.getAsJsonObject("platform").get("contract").getAsString()).isEqualTo("CE"); + /** + * Stubs MockServer to return connectivity statuses for multiple tenants from the all-tenants + * mutation. The map key is tenantId, value is the status label. + */ + private void whenHubReturnsAllTenantsConnectivityStatuses(Map tenantStatuses) { + StringBuilder statuses = new StringBuilder(); + tenantStatuses.forEach( + (tenantId, status) -> { + if (!statuses.isEmpty()) statuses.append(","); + statuses.append("{\"tenantId\":\"%s\",\"status\":\"%s\"}".formatted(tenantId, status)); + }); + mockServer + .when(request().withMethod("POST").withPath(GRAPHQL_PATH)) + .respond( + response() + .withStatusCode(200) + .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .withBody( + "{\"data\":{\"refreshPlatformRegistrationConnectivityStatusAllTenants\":{\"statuses\":[%s]}}}" + .formatted(statuses))); } - @Test - @DisplayName("Should compute contract level as trial for enterprise trial license") - void autoRegister_WithEnterpriseTrialLicense_ShouldUseTrialContract() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(true); - license.setType(LicenseTypeEnum.trial); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn(1L); - whenHubAutoRegisters(true); - - // When - xtmHubService.autoRegister(token); - - // Then - JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); - assertThat(input.getAsJsonObject("platform").get("contract").getAsString()).isEqualTo("trial"); + @Nested + @DisplayName("refreshConnectivity") + class RefreshConnectivity { + + @Test + @DisplayName("Should call XTM Hub refresh endpoint when registration is present") + void whenRegistrationIsPresent_ShouldCallXtmHub() { + // Given + String token = "valid-token"; + String platformId = "platform-123"; + String platformVersion = "1.0.0"; + String platformBaseUrl = "http://localhost"; + LocalDateTime lastCheck = now.minusHours(1); + + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + + mockSettings.setPlatformId(platformId); + mockSettings.setPlatformVersion(platformVersion); + mockSettings.setPlatformBaseUrl(platformBaseUrl); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + whenHubReturnsConnectivityStatus("active"); + + // When + xtmHubService.refreshConnectivity(); + + // Then + verifyRefreshConnectivityRequest(platformId, platformVersion, token, platformBaseUrl); + } + + @Test + @DisplayName("Should return null when no registration exists") + void whenRegistrationIsAbsent_ShouldReturnNull() { + // Given — repository returns empty by default (setUp) + + // When + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); + + // Then + assertNull(result); + verifyNoRequestSentToHub(); + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + } + + @Test + @DisplayName("Should remove XTM Hub registration when platform is not found in the hub") + void whenPlatformIsNotFound_ShouldRemoveRegistration() { + // Given + String token = "valid-token"; + String platformId = "platform-123"; + String platformVersion = "1.0.0"; + + TenantXtmHubRegistration registration = buildRegistration(token, now.minusHours(1)); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + + mockSettings.setPlatformId(platformId); + mockSettings.setPlatformVersion(platformVersion); + mockSettings.setPlatformBaseUrl("http://localhost"); + + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + whenHubReturnsConnectivityStatus("not_found"); + + // When + xtmHubService.refreshConnectivity(); + + // Then + verify(platformSettingsService).deleteXTMHubRegistration(); + verifyNoInteractions(xtmHubEmailService); + } + + @Test + @DisplayName("Should update registration as REGISTERED when connectivity is ACTIVE") + void whenConnectivityIsActive_ShouldUpdateAsRegistered() { + // Given + String token = "valid-token"; + LocalDateTime lastCheck = now.minusHours(12); + + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + whenHubReturnsConnectivityStatus("active"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + // When + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); + + // Then + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertEquals(captor.getValue(), result); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.REGISTERED); + + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + verifyNoInteractions(xtmHubEmailService); + } + + @Test + @DisplayName("Should update registration as LOST_CONNECTIVITY when connectivity is inactive") + void whenConnectivityIsInactive_ShouldUpdateAsLostConnectivity() { + // Given + String token = "valid-token"; + LocalDateTime lastCheck = now.minusHours(12); + + TenantXtmHubRegistration registration = buildRegistration(token, lastCheck); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + // When + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); + + // Then + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertEquals(captor.getValue(), result); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + assertThat(captor.getValue().getLastConnectivityCheck()).isEqualTo(lastCheck); + + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + } + + @Test + @DisplayName("Should handle null lastConnectivityCheck by using current time") + void whenLastConnectivityCheckIsNull_ShouldUseCurrentTime() { + // Given + TenantXtmHubRegistration registration = buildRegistration("valid-token", null); + when(tenantXtmHubRegistrationRepository.findByTenantId(any())) + .thenReturn(Optional.of(registration)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + whenHubReturnsConnectivityStatus("inactive"); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + // When + TenantXtmHubRegistration result = xtmHubService.refreshConnectivity(); + + // Then + assertNotNull(result); + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + } } - @Test - @DisplayName("Should compute contract level as EE for enterprise license") - void autoRegister_WithEnterpriseStandardLicense_ShouldUseEEContract() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(true); - license.setType(LicenseTypeEnum.standard); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn(1L); - whenHubAutoRegisters(true); - - // When - xtmHubService.autoRegister(token); - - // Then - JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); - assertThat(input.getAsJsonObject("platform").get("contract").getAsString()).isEqualTo("EE"); + /** Returns the parsed GraphQL body for the all-tenants request. */ + private JsonObject getAllTenantsRequestBody() { + var recorded = + mockServer.retrieveRecordedRequests(request().withMethod("POST").withPath(GRAPHQL_PATH)); + assertThat(recorded).hasSize(1); + return JsonParser.parseString(recorded[0].getBodyAsString()).getAsJsonObject(); } - @Test - @DisplayName("Should update registration entity when auto-register succeeds") - void autoRegister_WhenSuccessful_ShouldUpdateRegistrationStatus() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(true); - license.setType(LicenseTypeEnum.trial); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformName("Test Platform"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setPlatformVersion("1.0.0"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn(1L); - whenHubAutoRegisters(true); - - // When - xtmHubService.autoRegister(token); - - // Then - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - any(LocalDateTime.class), - eq(XtmHubRegistrationStatus.REGISTERED), - isNull(), - isNull(), - eq(false)); + @Nested + @DisplayName("refreshConnectivityAllTenants") + class RefreshConnectivityAllTenants { + + @Test + @DisplayName("Should do nothing when no registrations exist") + void whenNoRegistrations_ShouldDoNothing() { + // Given + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of()); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + verifyNoRequestSentToHub(); + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + verify(tenantXtmHubRegistrationRepository, never()).save(any()); + } + + @Test + @DisplayName("Should save active tenants as REGISTERED and delete NOT_FOUND tenants") + void whenMixedStatuses_ShouldSaveActiveAndDeleteNotFound() { + // Given + TenantContext.setCurrentTenant("tenant-active"); + TenantXtmHubRegistration activeReg = buildRegistration("token-1", now.minusHours(1)); + TenantContext.setCurrentTenant("tenant-not-found"); + TenantXtmHubRegistration notFoundReg = buildRegistration("token-2", now.minusHours(1)); + String activeTenantId = activeReg.getTenant().getId(); + String notFoundTenantId = notFoundReg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()) + .thenReturn(List.of(activeReg, notFoundReg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses( + Map.of(activeTenantId, "active", notFoundTenantId, "not_found")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + verify(tenantXtmHubRegistrationRepository).deleteByTenantId(notFoundTenantId); + verify(tenantXtmHubRegistrationRepository, never()).deleteByTenantId(activeTenantId); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository, times(1)).save(captor.capture()); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.REGISTERED); + + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, times(1)).updateXTMHubEmailNotification(true); + } + + @Test + @DisplayName( + "Should send email and update flag when tenant lost connectivity for more than 24h") + void whenTenantLostConnectivityMoreThan24h_ShouldSendEmail() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + LocalDateTime lastCheck = now.minusHours(25); + TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "inactive")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + verify(xtmHubEmailService).sendLostConnectivityEmail(); + verify(platformSettingsService).updateXTMHubEmailNotification(false); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + assertThat(captor.getValue().getLastConnectivityCheck()).isEqualTo(lastCheck); + } + + @Test + @DisplayName("Should not send email when tenant lost connectivity for less than 24h") + void whenTenantLostConnectivityLessThan24h_ShouldNotSendEmail() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + LocalDateTime lastCheck = now.minusHours(12); + TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "inactive")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService).updateXTMHubEmailNotification(true); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + } + + @Test + @DisplayName("Should not send email when connectivity is lost but email sending is disabled") + void whenEmailSendingIsDisabled_ShouldNotSendEmail() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + LocalDateTime lastCheck = now.minusHours(25); + TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("false"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "inactive")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService).updateXTMHubEmailNotification(true); + } + + @Test + @DisplayName("Should default to INACTIVE when hub does not return a status for a tenant") + void whenHubMissesTenant_ShouldDefaultToInactive() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(1)); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + // Hub returns statuses for a different tenant only + whenHubReturnsAllTenantsConnectivityStatuses(Map.of("other-tenant", "active")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + assertThat(captor.getValue().getRegistrationStatus()) + .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); + } + + @Test + @DisplayName("Should send correct url per tenant in the GraphQL request body") + void whenRegistrationsExist_ShouldSendCorrectUrlPerTenant() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg1 = buildRegistration("token-1", now.minusHours(1)); + TenantContext.setCurrentTenant("tenant-2"); + TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(1)); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses( + Map.of("tenant-1", "active", "tenant-2", "active")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then + JsonArray tenants = + getAllTenantsRequestBody() + .getAsJsonObject("variables") + .getAsJsonObject("input") + .getAsJsonArray("tenants"); + + assertThat(tenants).hasSize(2); + tenants.forEach( + element -> { + JsonObject entry = element.getAsJsonObject(); + String tenantId = entry.get("tenantId").getAsString(); + String expectedUrl = "http://localhost/" + tenantId; + assertThat(entry.get("url").getAsString()).isEqualTo(expectedUrl); + }); + } } - @Test - @DisplayName("Should send correct platform payload to XTM Hub") - void autoRegister_WhenSuccessful_ShouldSendCorrectPayloadToHub() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(true); - license.setType(LicenseTypeEnum.trial); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformName("Test Platform"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setPlatformVersion("1.0.0"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn(1L); - whenHubAutoRegisters(true); - - // When - xtmHubService.autoRegister(token); - - // Then - JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); - JsonObject platform = input.getAsJsonObject("platform"); - assertThat(platform.get("contract").getAsString()).isEqualTo("trial"); - assertThat(platform.get("id").getAsString()).isEqualTo("platform-123"); - assertThat(platform.get("title").getAsString()).isEqualTo("Test Platform"); - assertThat(platform.get("url").getAsString()).isEqualTo("http://localhost"); - assertThat(platform.get("version").getAsString()).isEqualTo("1.0.0"); - assertThat(input.get("existing_users_count").getAsLong()).isEqualTo(1L); + @Nested + @DisplayName("autoRegister") + class AutoRegister { + + @Test + @DisplayName("Should compute contract level as CE for non-enterprise license") + void withNonEnterpriseLicense_ShouldUseCEContract() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(false); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn(1L); + whenHubAutoRegisters(true); + + // When + xtmHubService.autoRegister(token); + + // Then + JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); + assertThat(input.getAsJsonObject("platform").get("contract").getAsString()).isEqualTo("CE"); + } + + @Test + @DisplayName("Should compute contract level as trial for enterprise trial license") + void withEnterpriseTrialLicense_ShouldUseTrialContract() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(true); + license.setType(LicenseTypeEnum.trial); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn(1L); + whenHubAutoRegisters(true); + + // When + xtmHubService.autoRegister(token); + + // Then + JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); + assertThat(input.getAsJsonObject("platform").get("contract").getAsString()) + .isEqualTo("trial"); + } + + @Test + @DisplayName("Should compute contract level as EE for enterprise license") + void withEnterpriseStandardLicense_ShouldUseEEContract() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(true); + license.setType(LicenseTypeEnum.standard); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn(1L); + whenHubAutoRegisters(true); + + // When + xtmHubService.autoRegister(token); + + // Then + JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); + assertThat(input.getAsJsonObject("platform").get("contract").getAsString()).isEqualTo("EE"); + } + + @Test + @DisplayName("Should update registration entity when auto-register succeeds") + void whenSuccessful_ShouldUpdateRegistrationStatus() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(true); + license.setType(LicenseTypeEnum.trial); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformName("Test Platform"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setPlatformVersion("1.0.0"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn(1L); + whenHubAutoRegisters(true); + + // When + xtmHubService.autoRegister(token); + + // Then + verify(platformSettingsService) + .updateXTMHubRegistration( + eq(token), + any(LocalDateTime.class), + eq(XtmHubRegistrationStatus.REGISTERED), + isNull(), + isNull(), + eq(false)); + } + + @Test + @DisplayName("Should send correct platform payload to XTM Hub") + void whenSuccessful_ShouldSendCorrectPayloadToHub() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(true); + license.setType(LicenseTypeEnum.trial); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformName("Test Platform"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setPlatformVersion("1.0.0"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn(1L); + whenHubAutoRegisters(true); + + // When + xtmHubService.autoRegister(token); + + // Then + JsonObject input = verifyAutoRegisterRequest(token, "platform-123"); + JsonObject platform = input.getAsJsonObject("platform"); + assertThat(platform.get("contract").getAsString()).isEqualTo("trial"); + assertThat(platform.get("id").getAsString()).isEqualTo("platform-123"); + assertThat(platform.get("title").getAsString()).isEqualTo("Test Platform"); + assertThat(platform.get("url").getAsString()).isEqualTo("http://localhost"); + assertThat(platform.get("version").getAsString()).isEqualTo("1.0.0"); + assertThat(input.get("existing_users_count").getAsLong()).isEqualTo(1L); + } + + @Test + @DisplayName("Should throw BAD_GATEWAY when XtmHub client returns false") + void whenClientReturnsFalse_ShouldThrowBadGateway() { + // Given + String token = "valid-token"; + License license = new License(); + license.setLicenseEnterprise(false); + mockSettings.setPlatformLicense(license); + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformName("Test Platform"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setPlatformVersion("1.0.0"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + when(userService.globalCount()).thenReturn((long) 1); + whenHubAutoRegisters(false); + + // When + ResponseStatusException exception = + assertThrows(ResponseStatusException.class, () -> xtmHubService.autoRegister(token)); + + // Then + assertEquals(HttpStatus.BAD_GATEWAY, exception.getStatusCode()); + assertNotNull(exception.getReason()); + assertTrue(exception.getReason().contains("Failed to register")); + + verify(tenantXtmHubRegistrationRepository, never()).save(any(TenantXtmHubRegistration.class)); + } } - @Test - @DisplayName("Should throw BAD_GATEWAY when XtmHub client returns false") - void autoRegister_WhenClientReturnsFalse_ShouldThrowBadGateway() { - // Given - String token = "valid-token"; - License license = new License(); - license.setLicenseEnterprise(false); - mockSettings.setPlatformLicense(license); - mockSettings.setPlatformId("platform-123"); - mockSettings.setPlatformName("Test Platform"); - mockSettings.setPlatformBaseUrl("http://localhost"); - mockSettings.setPlatformVersion("1.0.0"); - when(platformSettingsService.findSettings()).thenReturn(mockSettings); - when(userService.globalCount()).thenReturn((long) 1); - whenHubAutoRegisters(false); - - // When - ResponseStatusException exception = - assertThrows(ResponseStatusException.class, () -> xtmHubService.autoRegister(token)); - - // Then - assertEquals(HttpStatus.BAD_GATEWAY, exception.getStatusCode()); - assertNotNull(exception.getReason()); - assertTrue(exception.getReason().contains("Failed to register")); - - verify(tenantXtmHubRegistrationRepository, never()).save(any(TenantXtmHubRegistration.class)); - } + @Nested + @DisplayName("unregister") + class Unregister { - @Test - @DisplayName("Should delete tenant registration when unregister is called") - void unregister_ShouldDeleteTenantRegistration() { - // When - xtmHubService.unregister(); + @Test + @DisplayName("Should delete tenant registration") + void shouldDeleteTenantRegistration() { + // When + xtmHubService.unregister(); - // Then - verify(tenantXtmHubRegistrationRepository).deleteByTenantId(Tenant.DEFAULT_TENANT_UUID); + // Then + verify(tenantXtmHubRegistrationRepository).deleteByTenantId(Tenant.DEFAULT_TENANT_UUID); + } } } From 4aece9a9ec2cbfdc2568ae01b28ed302e7ad70aa Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Wed, 22 Apr 2026 10:08:22 +0200 Subject: [PATCH 03/18] [backend] feat(xtmhub): Also send connectivity lost mail to users managing tenants --- .../src/main/java/io/openaev/xtmhub/XtmHubEmailService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubEmailService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubEmailService.java index d56a33542a8..1740e8c959f 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubEmailService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubEmailService.java @@ -75,7 +75,10 @@ private String createBodyContent(String baseUrl) { private List findUsersAbleToManageSettings() { List capabilities = - List.of(Capability.MANAGE_PLATFORM_SETTINGS.toString(), Capability.BYPASS.toString()); + List.of( + Capability.MANAGE_TENANT_SETTINGS.toString(), + Capability.MANAGE_PLATFORM_SETTINGS.toString(), + Capability.BYPASS.toString()); return userRepository.adminsOrUsersHavingCapabilities(capabilities); } From 18f899e18e75613aa62a26517c8ff3088e6516be Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Wed, 22 Apr 2026 13:32:45 +0200 Subject: [PATCH 04/18] [backend/frontend] fix: copilot feedback --- .../java/io/openaev/api/xtmhub/XtmHubApi.java | 16 ++++-- .../service/PlatformSettingsService.java | 25 --------- .../java/io/openaev/xtmhub/XtmHubService.java | 39 +++++++------- .../io/openaev/xtmhub/XtmHubServiceTest.java | 2 +- .../src/actions/xtmhub/xtmhub-actions.ts | 52 ++++++++++--------- 5 files changed, 62 insertions(+), 72 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java index 9fc2ff86c49..9cdfe6e80a3 100644 --- a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java +++ b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -86,12 +87,19 @@ public void unregister() { @Operation( summary = "Refresh connectivity with XTM Hub", description = "Refresh status in settings and version in XTM Hub") - @ApiResponses({@ApiResponse(responseCode = "200", description = "Successful refresh")}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Successful refresh"), + @ApiResponse( + responseCode = "204", + description = "No registration found or platform not found on XTM Hub") + }) @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.XTM_HUB_REGISTRATION) @Transactional(rollbackFor = Exception.class) - public XtmHubRegistrationOutput refreshConnectivity() { - return xtmHubRegistrationMapper.toXtmHubRegistrationOutput( - this.xtmHubService.refreshConnectivity()); + public ResponseEntity refreshConnectivity() { + return Optional.ofNullable(this.xtmHubService.refreshConnectivity()) + .map(xtmHubRegistrationMapper::toXtmHubRegistrationOutput) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); } @PutMapping(value = XTMHUB_URI + "/auto-register", consumes = MediaType.APPLICATION_JSON_VALUE) diff --git a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java index 28ed22ac842..23cb710e259 100644 --- a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java +++ b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java @@ -626,31 +626,6 @@ public void updateXTMHubEmailNotification(boolean shouldSendConnectivityEmail) { } } - public PlatformSettings deleteXTMHubRegistration() { - Map dbSettings = mapOfSettings(fromIterable(this.settingRepository.findAll())); - - List keys = - Arrays.asList( - XTM_HUB_TOKEN.key(), - XTM_HUB_REGISTRATION_DATE.key(), - XTM_HUB_REGISTRATION_STATUS.key(), - XTM_HUB_REGISTRATION_USER_ID.key(), - XTM_HUB_REGISTRATION_USER_NAME.key(), - XTM_HUB_LAST_CONNECTIVITY_CHECK.key(), - XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL.key()); - - List toDelete = new ArrayList<>(); - keys.forEach( - settingsKey -> { - if (dbSettings.containsKey(settingsKey)) { - toDelete.add(dbSettings.get(settingsKey).getId()); - } - }); - - this.settingRepository.deleteByIdsNative(toDelete); - return findSettings(); - } - // -- PLATFORM MESSAGE -- public void cleanMessage(@NotBlank final BannerMessage.BANNER_KEYS banner) { diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index bd8b55a3e31..6bd5ec0a67c 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -85,7 +85,7 @@ public TenantXtmHubRegistration refreshConnectivity() { ConnectivityCheckResult checkResult = checkConnectivityStatus(settings, registration.get()); if (checkResult.status() == XtmHubConnectivityStatus.NOT_FOUND) { log.warn("Platform was not found on XTM Hub"); - platformSettingsService.deleteXTMHubRegistration(); + tenantXtmHubRegistrationRepository.deleteByTenantId(TenantContext.getCurrentTenant()); return null; } @@ -113,28 +113,31 @@ public void refreshConnectivityAllTenants() { xtmHubClient.refreshRegistrationStatusAllTenants( settings.getPlatformId(), settings.getPlatformVersion(), tenants); - for (TenantXtmHubRegistration registration : registrations) { - TenantContext.setCurrentTenant(registration.getTenant().getId()); + try { + for (TenantXtmHubRegistration registration : registrations) { + TenantContext.setCurrentTenant(registration.getTenant().getId()); - XtmHubConnectivityStatus status = - statuses.getOrDefault( - registration.getTenant().getId(), XtmHubConnectivityStatus.INACTIVE); + XtmHubConnectivityStatus status = + statuses.getOrDefault( + registration.getTenant().getId(), XtmHubConnectivityStatus.INACTIVE); - if (status == XtmHubConnectivityStatus.NOT_FOUND) { - log.warn( - "Platform was not found on XTM Hub for tenant {}", registration.getTenant().getId()); - tenantXtmHubRegistrationRepository.deleteByTenantId(registration.getTenant().getId()); - continue; - } + if (status == XtmHubConnectivityStatus.NOT_FOUND) { + log.warn( + "Platform was not found on XTM Hub for tenant {}", registration.getTenant().getId()); + tenantXtmHubRegistrationRepository.deleteByTenantId(registration.getTenant().getId()); + continue; + } - ConnectivityCheckResult checkResult = - new ConnectivityCheckResult(status, parseLastConnectivityCheck(registration)); + ConnectivityCheckResult checkResult = + new ConnectivityCheckResult(status, parseLastConnectivityCheck(registration)); - handleConnectivityLossNotification(settings, checkResult); - updateEmailNotificationFlag(settings, checkResult); - updateRegistrationStatus(registration, checkResult); + handleConnectivityLossNotification(settings, checkResult); + updateEmailNotificationFlag(settings, checkResult); + updateRegistrationStatus(registration, checkResult); + } + } finally { + TenantContext.clearCurrentTenant(); } - TenantContext.clearCurrentTenant(); } private TenantXtmHubRegistration findOrCreateRegistration() { diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index e6fd31640f8..92cad6dc2c2 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -300,7 +300,7 @@ void whenPlatformIsNotFound_ShouldRemoveRegistration() { xtmHubService.refreshConnectivity(); // Then - verify(platformSettingsService).deleteXTMHubRegistration(); + verify(tenantXtmHubRegistrationRepository).deleteByTenantId(any()); verifyNoInteractions(xtmHubEmailService); } diff --git a/openaev-front/src/actions/xtmhub/xtmhub-actions.ts b/openaev-front/src/actions/xtmhub/xtmhub-actions.ts index 87e7caa3b00..bdc23bae788 100644 --- a/openaev-front/src/actions/xtmhub/xtmhub-actions.ts +++ b/openaev-front/src/actions/xtmhub/xtmhub-actions.ts @@ -3,7 +3,7 @@ import type { Dispatch } from 'redux'; import * as Constants from '../../constants/ActionTypes'; import { store } from '../../store'; -import { postReferential, putReferential, simpleCall, simplePutCall } from '../../utils/Action'; +import { putReferential, simpleCall, simplePostCall, simplePutCall } from '../../utils/Action'; import * as schema from '../Schema'; const XTM_HUB_URI = '/api/xtmhub'; @@ -23,26 +23,33 @@ const clearStaleRegistrations = (dispatch: Dispatch) => { } }; +const handleRegistrationResponse = (dispatch: Dispatch) => (response: { + status: number; + data: unknown; +}) => { + if (response.status === 204 || !response.data) { + clearStaleRegistrations(dispatch); + } else { + dispatch({ + type: Constants.DATA_FETCH_SUCCESS, + payload: normalize(response.data, schema.tenantXtmHubRegistration), + }); + } +}; + +const handleRegistrationError = (dispatch: Dispatch) => (error: unknown) => { + dispatch({ + type: Constants.DATA_FETCH_ERROR, + payload: error, + }); +}; + export const fetchXtmHubRegistration = () => (dispatch: Dispatch) => { const uri = `${XTM_HUB_URI}/registration`; dispatch({ type: Constants.DATA_FETCH_SUBMITTED }); return simpleCall(uri, undefined, false) - .then((response) => { - if (response.status === 204 || !response.data) { - clearStaleRegistrations(dispatch); - } else { - dispatch({ - type: Constants.DATA_FETCH_SUCCESS, - payload: normalize(response.data, schema.tenantXtmHubRegistration), - }); - } - }) - .catch((error) => { - dispatch({ - type: Constants.DATA_FETCH_ERROR, - payload: error, - }); - }); + .then(handleRegistrationResponse(dispatch)) + .catch(handleRegistrationError(dispatch)); }; export const registerPlatform = (token: string) => (dispatch: Dispatch) => { @@ -70,11 +77,8 @@ export const unregisterPlatform = (registrationId: string) => (dispatch: Dispatc export const refreshConnectivity = () => (dispatch: Dispatch) => { const uri = `${XTM_HUB_URI}/refresh-connectivity`; - return postReferential( - schema.tenantXtmHubRegistration, - uri, - {}, - undefined, - false, - )(dispatch); + dispatch({ type: Constants.DATA_FETCH_SUBMITTED }); + return simplePostCall(uri, {}, undefined, true, false) + .then(handleRegistrationResponse(dispatch)) + .catch(handleRegistrationError(dispatch)); }; From 5ab0f70ead9a9bdf01158113797a55c883686524 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Wed, 22 Apr 2026 15:50:10 +0200 Subject: [PATCH 05/18] [backend] feat(xtmhub): send connectivity loss email if all tenants have lost connectivity --- .../java/io/openaev/xtmhub/XtmHubService.java | 41 +++-- .../io/openaev/xtmhub/XtmHubServiceTest.java | 150 +++++++++++++++++- 2 files changed, 172 insertions(+), 19 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index 6bd5ec0a67c..a9469aca679 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -113,6 +113,8 @@ public void refreshConnectivityAllTenants() { xtmHubClient.refreshRegistrationStatusAllTenants( settings.getPlatformId(), settings.getPlatformVersion(), tenants); + List allCheckResults = new ArrayList<>(); + try { for (TenantXtmHubRegistration registration : registrations) { TenantContext.setCurrentTenant(registration.getTenant().getId()); @@ -131,13 +133,14 @@ public void refreshConnectivityAllTenants() { ConnectivityCheckResult checkResult = new ConnectivityCheckResult(status, parseLastConnectivityCheck(registration)); - handleConnectivityLossNotification(settings, checkResult); - updateEmailNotificationFlag(settings, checkResult); + allCheckResults.add(checkResult); updateRegistrationStatus(registration, checkResult); } } finally { TenantContext.clearCurrentTenant(); } + + handleConnectivityLossNotification(settings, allCheckResults); } private TenantXtmHubRegistration findOrCreateRegistration() { @@ -176,19 +179,34 @@ private LocalDateTime parseLastConnectivityCheck(TenantXtmHubRegistration regist } private void handleConnectivityLossNotification( - PlatformSettings settings, ConnectivityCheckResult checkResult) { + PlatformSettings settings, List checkResults) { + if (checkResults.isEmpty()) { + return; + } + + boolean connectivityRestored = + checkResults.stream().anyMatch(r -> r.status() == XtmHubConnectivityStatus.ACTIVE); - if (shouldSendConnectivityLossEmail(settings, checkResult)) { + if (connectivityRestored) { + platformSettingsService.updateXTMHubEmailNotification(true); + return; + } + + if (shouldSendConnectivityLossEmail(settings, checkResults)) { + platformSettingsService.updateXTMHubEmailNotification(false); xtmHubEmailService.sendLostConnectivityEmail(); } } private boolean shouldSendConnectivityLossEmail( - PlatformSettings settings, ConnectivityCheckResult checkResult) { + PlatformSettings settings, List checkResults) { - return checkResult.status() != XtmHubConnectivityStatus.ACTIVE - && hasConnectivityBeenLostForTooLong(checkResult.lastCheck()) - && isEmailNotificationEnabled(settings); + return isEmailNotificationEnabled(settings) + && checkResults.stream() + .allMatch( + r -> + r.status() != XtmHubConnectivityStatus.ACTIVE + && hasConnectivityBeenLostForTooLong(r.lastCheck())); } private boolean hasConnectivityBeenLostForTooLong(LocalDateTime lastCheck) { @@ -219,13 +237,6 @@ private TenantXtmHubRegistration updateRegistrationStatus( return tenantXtmHubRegistrationRepository.save(registration); } - private void updateEmailNotificationFlag( - PlatformSettings settings, ConnectivityCheckResult checkResult) { - boolean shouldKeepEmailNotificationEnabled = - !shouldSendConnectivityLossEmail(settings, checkResult); - platformSettingsService.updateXTMHubEmailNotification(shouldKeepEmailNotificationEnabled); - } - /** Encapsulates the result of a connectivity check */ private record ConnectivityCheckResult( XtmHubConnectivityStatus status, LocalDateTime lastCheck) {} diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index 92cad6dc2c2..c3920adb3db 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -527,9 +527,9 @@ void whenTenantLostConnectivityLessThan24h_ShouldNotSendEmail() { // When xtmHubService.refreshConnectivityAllTenants(); - // Then + // Then — connectivity is still lost, threshold not reached: flag is not touched verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService).updateXTMHubEmailNotification(true); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); ArgumentCaptor captor = ArgumentCaptor.forClass(TenantXtmHubRegistration.class); @@ -561,9 +561,9 @@ void whenEmailSendingIsDisabled_ShouldNotSendEmail() { // When xtmHubService.refreshConnectivityAllTenants(); - // Then + // Then — connectivity is still lost, flag already false: flag is not touched verifyNoInteractions(xtmHubEmailService); - verify(platformSettingsService).updateXTMHubEmailNotification(true); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); } @Test @@ -596,6 +596,148 @@ void whenHubMissesTenant_ShouldDefaultToInactive() { .isEqualTo(XtmHubRegistrationStatus.LOST_CONNECTIVITY); } + @Test + @DisplayName( + "Should send a single email when ALL tenants have lost connectivity for more than 24h") + void whenAllTenantsLostConnectivityMoreThan24h_ShouldSendEmailOnce() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg1 = buildRegistration("token-1", now.minusHours(25)); + TenantContext.setCurrentTenant("tenant-2"); + TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(30)); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses( + Map.of("tenant-1", "inactive", "tenant-2", "inactive")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — email sent exactly once, not once per tenant + verify(xtmHubEmailService, times(1)).sendLostConnectivityEmail(); + verify(platformSettingsService).updateXTMHubEmailNotification(false); + } + + @Test + @DisplayName( + "Should not send email when only some tenants have lost connectivity — not all of them") + void whenOnlySomeTenantsLostConnectivity_ShouldNotSendEmail() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg1 = buildRegistration("token-1", now.minusHours(25)); + TenantContext.setCurrentTenant("tenant-2"); + TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(1)); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses( + Map.of("tenant-1", "inactive", "tenant-2", "active")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — one tenant is still active, so no email should be sent + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService).updateXTMHubEmailNotification(true); + } + + @Test + @DisplayName( + "Should not send email again and not reset flag when all tenants are still lost and email was already sent") + void whenAllTenantsStillLostAndEmailAlreadySent_ShouldNotSendEmailAgain() { + // Given — flag=false simulates the email was already sent on a previous run + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("false"); // already sent, flag disabled + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "inactive")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — no new email, and the flag is not touched (connectivity is still lost) + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + } + + @Test + @DisplayName("Should reset the email flag when connectivity is restored after having been lost") + void whenConnectivityRestoredAfterLoss_ShouldResetEmailFlag() { + // Given — flag=false simulates the email was sent on a previous run + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("false"); // was disabled after loss + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "active")); // now restored + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — flag re-armed, no email + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService).updateXTMHubEmailNotification(true); + } + + @Test + @DisplayName("Should send email when no registrations exist after filtering NOT_FOUND") + void whenAllRegistrationsAreNotFound_ShouldNotSendEmail() { + // Given + TenantContext.setCurrentTenant("tenant-1"); + TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); + String tenantId = reg.getTenant().getId(); + + when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of(tenantId, "not_found")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — registration deleted, checkResults is empty, no email + verify(tenantXtmHubRegistrationRepository).deleteByTenantId(tenantId); + verifyNoInteractions(xtmHubEmailService); + verify(platformSettingsService, never()).updateXTMHubEmailNotification(anyBoolean()); + } + @Test @DisplayName("Should send correct url per tenant in the GraphQL request body") void whenRegistrationsExist_ShouldSendCorrectUrlPerTenant() { From 7ab65c4c1a4580cbe43c394cbc78fa7ea09b8f38 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Wed, 22 Apr 2026 16:25:55 +0200 Subject: [PATCH 06/18] [backend] feat(xtmhub): collector refresh connectivity only for non deleted tenants. --- .../java/io/openaev/xtmhub/XtmHubService.java | 2 +- .../io/openaev/xtmhub/XtmHubServiceTest.java | 64 +++++++++++++++---- .../TenantXtmHubRegistrationRepository.java | 5 ++ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index a9469aca679..fe2ebda3185 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -96,7 +96,7 @@ public void refreshConnectivityAllTenants() { PlatformSettings settings = platformSettingsService.findSettings(); List registrations = - new ArrayList<>(tenantXtmHubRegistrationRepository.findAll()); + new ArrayList<>(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()); if (registrations.isEmpty()) { return; diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index c3920adb3db..8cea2a992b6 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -415,7 +415,7 @@ class RefreshConnectivityAllTenants { @DisplayName("Should do nothing when no registrations exist") void whenNoRegistrations_ShouldDoNothing() { // Given - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of()); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of()); // When xtmHubService.refreshConnectivityAllTenants(); @@ -438,7 +438,7 @@ void whenMixedStatuses_ShouldSaveActiveAndDeleteNotFound() { String activeTenantId = activeReg.getTenant().getId(); String notFoundTenantId = notFoundReg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()) + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()) .thenReturn(List.of(activeReg, notFoundReg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -478,7 +478,7 @@ void whenTenantLostConnectivityMoreThan24h_ShouldSendEmail() { TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -513,7 +513,7 @@ void whenTenantLostConnectivityLessThan24h_ShouldNotSendEmail() { TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -547,7 +547,7 @@ void whenEmailSendingIsDisabled_ShouldNotSendEmail() { TenantXtmHubRegistration reg = buildRegistration("token-1", lastCheck); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -573,7 +573,7 @@ void whenHubMissesTenant_ShouldDefaultToInactive() { TenantContext.setCurrentTenant("tenant-1"); TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(1)); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -606,7 +606,8 @@ void whenAllTenantsLostConnectivityMoreThan24h_ShouldSendEmailOnce() { TenantContext.setCurrentTenant("tenant-2"); TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(30)); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()) + .thenReturn(List.of(reg1, reg2)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -636,7 +637,8 @@ void whenOnlySomeTenantsLostConnectivity_ShouldNotSendEmail() { TenantContext.setCurrentTenant("tenant-2"); TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(1)); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()) + .thenReturn(List.of(reg1, reg2)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -665,7 +667,7 @@ void whenAllTenantsStillLostAndEmailAlreadySent_ShouldNotSendEmailAgain() { TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -692,7 +694,7 @@ void whenConnectivityRestoredAfterLoss_ShouldResetEmailFlag() { TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -719,7 +721,7 @@ void whenAllRegistrationsAreNotFound_ShouldNotSendEmail() { TenantXtmHubRegistration reg = buildRegistration("token-1", now.minusHours(30)); String tenantId = reg.getTenant().getId(); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()).thenReturn(List.of(reg)); mockSettings.setPlatformId("platform-123"); mockSettings.setPlatformVersion("1.0.0"); @@ -747,7 +749,8 @@ void whenRegistrationsExist_ShouldSendCorrectUrlPerTenant() { TenantContext.setCurrentTenant("tenant-2"); TenantXtmHubRegistration reg2 = buildRegistration("token-2", now.minusHours(1)); - when(tenantXtmHubRegistrationRepository.findAll()).thenReturn(List.of(reg1, reg2)); + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()) + .thenReturn(List.of(reg1, reg2)); when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockSettings.setPlatformId("platform-123"); @@ -778,6 +781,43 @@ void whenRegistrationsExist_ShouldSendCorrectUrlPerTenant() { assertThat(entry.get("url").getAsString()).isEqualTo(expectedUrl); }); } + + @Test + @DisplayName("Should ignore soft-deleted tenants and not call hub for them") + void whenTenantIsSoftDeleted_ShouldNotBeIncludedInRefresh() { + // Given — the repository already excludes soft-deleted tenants, + // so only the non-deleted registration is returned. + TenantContext.setCurrentTenant("tenant-active"); + TenantXtmHubRegistration activeReg = buildRegistration("token-active", now.minusHours(1)); + + when(tenantXtmHubRegistrationRepository.findAllByTenantNotDeleted()) + .thenReturn(List.of(activeReg)); + when(tenantXtmHubRegistrationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockSettings.setPlatformId("platform-123"); + mockSettings.setPlatformVersion("1.0.0"); + mockSettings.setPlatformBaseUrl("http://localhost"); + mockSettings.setXtmHubShouldSendConnectivityEmail("true"); + when(platformSettingsService.findSettings()).thenReturn(mockSettings); + + whenHubReturnsAllTenantsConnectivityStatuses(Map.of("tenant-active", "active")); + + // When + xtmHubService.refreshConnectivityAllTenants(); + + // Then — only 1 tenant sent to hub (soft-deleted tenant is absent from the payload) + JsonArray tenants = + getAllTenantsRequestBody() + .getAsJsonObject("variables") + .getAsJsonObject("input") + .getAsJsonArray("tenants"); + assertThat(tenants).hasSize(1); + assertThat(tenants.get(0).getAsJsonObject().get("tenantId").getAsString()) + .isEqualTo("tenant-active"); + + verify(tenantXtmHubRegistrationRepository, times(1)).save(any()); + verify(tenantXtmHubRegistrationRepository, never()).deleteByTenantId("tenant-deleted"); + } } @Nested diff --git a/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java index fefb4c41f52..0b9eded2fa6 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java @@ -1,9 +1,11 @@ package io.openaev.database.repository; import io.openaev.database.model.TenantXtmHubRegistration; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,9 @@ public interface TenantXtmHubRegistrationRepository Optional findByTenantId(String tenantId); + @Query("SELECT r FROM TenantXtmHubRegistration r WHERE r.tenant.deletedAt IS NULL") + List findAllByTenantNotDeleted(); + @Transactional void deleteByTenantId(String tenantId); } From 6bdb4731608556ccb1693620afb59a91280bde1a Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 10:00:58 +0200 Subject: [PATCH 07/18] [backend] fix(multi-tenant): we should not register platform with / to avoid issues with one click deploy. --- .../settings/experience/xtm_hub/XtmHubTab.test.tsx | 6 +++--- .../components/settings/experience/xtm_hub/XtmHubTab.tsx | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx index 9c2f5a21707..6816710b1a2 100644 --- a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx +++ b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx @@ -170,7 +170,7 @@ const buildRegistrationParams = (overrides: Record = {}) => new URLSearchParams({ tenant_id: TENANT.tenant_id, platform_id: DEFAULT_SETTINGS.platform_id!, - platform_url: `${window.location.origin}/${TENANT.tenant_id}/`, + platform_url: `${window.location.origin}/${TENANT.tenant_id}`, platform_title: DEFAULT_SETTINGS.platform_name!, platform_contract: 'EE', platform_version: DEFAULT_SETTINGS.platform_version!, @@ -380,7 +380,7 @@ describe('XtmHubTab', () => { it('platform_url is the full origin + tenant path when a tenant is set', () => { renderXtmHubTab({ registrationStatus: null }); const platformUrl = new URL(getExternalTabArgs().url).searchParams.get('platform_url'); - expect(platformUrl).toBe(`${window.location.origin}/${TENANT.tenant_id}/`); + expect(platformUrl).toBe(`${window.location.origin}/${TENANT.tenant_id}`); }); it('platform_url uses the default tenant uuid when no tenant is set', () => { @@ -389,7 +389,7 @@ describe('XtmHubTab', () => { currentUserTenant: null, }); const platformUrl = new URL(getExternalTabArgs().url).searchParams.get('platform_url'); - expect(platformUrl).toBe(`${window.location.origin}/${DEFAULT_TENANT_UUID}/`); + expect(platformUrl).toBe(`${window.location.origin}/${DEFAULT_TENANT_UUID}`); }); it('uses CE contract when license is not validated', () => { diff --git a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx index eef85a2e1be..ace5cd8a8b8 100644 --- a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx +++ b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx @@ -10,7 +10,7 @@ import { isDemoInstance, MESSAGING$, XTM_HUB_DEFAULT_URL } from '../../../../../ import { useAppDispatch } from '../../../../../utils/hooks'; import useAuth from '../../../../../utils/hooks/useAuth'; import useExternalTab from '../../../../../utils/hooks/useExternalTab'; -import { buildTenantUrl, DEFAULT_TENANT_UUID } from '../../../../../utils/tenant-url-helper'; +import { DEFAULT_TENANT_UUID } from '../../../../../utils/tenant-url-helper'; import GradientButton from '../../../common/GradientButton'; import XtmHubConfirmationDialog from './XtmHubConfirmationDialog'; import XtmHubProcessDialog from './XtmHubProcessDialog'; @@ -53,7 +53,8 @@ const XtmHubTab: React.FC = () => { }; const platformInformation = { ...platformIdentifiers, - platform_url: `${window.location.origin}${buildTenantUrl(platformIdentifiers.tenant_id, '/')}`, + // platform_url should not end with / to avoid issues with one click deploy + platform_url: `${window.location.origin}/${platformIdentifiers.tenant_id}`, platform_title: settings?.platform_name ?? 'OpenAEV Platform', platform_contract: isEnterpriseEdition ? 'EE' : 'CE', platform_version: settings?.platform_version ?? '', From 4eacadde5baed85cdc0a4a81865413997a389b0f Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 10:26:23 +0200 Subject: [PATCH 08/18] [backend] feat(multi-tenant): autoregister default tenant --- .../java/io/openaev/api/xtmhub/XtmHubApi.java | 2 +- .../java/io/openaev/xtmhub/XtmHubClient.java | 4 ++++ .../java/io/openaev/xtmhub/XtmHubService.java | 9 +++++++-- .../io/openaev/xtmhub/XtmHubServiceTest.java | 17 +++++++++-------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java index 9cdfe6e80a3..a66d840e5bb 100644 --- a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java +++ b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java @@ -112,7 +112,7 @@ public ResponseEntity refreshConnectivity() { @ApiResponse(responseCode = "502", description = "Registration failed on XTM Hub call"), @ApiResponse(responseCode = "500", description = "Internal error") }) - @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.PLATFORM_SETTING) + @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.XTM_HUB_REGISTRATION) @Transactional(rollbackFor = Exception.class) public void autoRegister(@Valid @RequestBody XtmHubRegisterInput input) { this.xtmHubService.autoRegister(input.getToken()); diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java index f7a378dfc88..47fce7e1bb0 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java @@ -106,6 +106,7 @@ public boolean autoRegister( String platformTitle, String platformUrl, String platformVersion, + String tenantId, Long usersCount) { PlatformSettings settings = platformSettingsService.findSettings(); @@ -123,6 +124,7 @@ public boolean autoRegister( platformTitle, platformUrl, platformVersion, + tenantId, usersCount); httpPost.setEntity(httpBody); return httpClient.execute(httpPost, this::parseResponseAsSuccess); @@ -230,6 +232,7 @@ private StringEntity buildAutoRegisterBody( String platformTitle, String platformUrl, String platformVersion, + String tenantId, Long usersCount) { JsonObject platform = new JsonObject(); @@ -238,6 +241,7 @@ private StringEntity buildAutoRegisterBody( platform.addProperty("title", platformTitle); platform.addProperty("url", platformUrl); platform.addProperty("version", platformVersion); + platform.addProperty("tenantId", tenantId); JsonObject input = new JsonObject(); input.add("platform", platform); diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index fe2ebda3185..5a67c690b3a 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -62,12 +62,17 @@ public void autoRegister(@NotBlank final String token) { settings.getPlatformName(), settings.getPlatformBaseUrl(), settings.getPlatformVersion(), + TenantContext.getCurrentTenant(), usersCount)) { throw new ResponseStatusException( HttpStatus.BAD_GATEWAY, "Failed to register the platform on XtmHub"); } - this.platformSettingsService.updateXTMHubRegistration( - token, LocalDateTime.now(), XtmHubRegistrationStatus.REGISTERED, null, null, false); + TenantXtmHubRegistration registration = findOrCreateRegistration(); + registration.setToken(token); + registration.setRegistrationDate(LocalDateTime.now()); + registration.setRegistrationStatus(XtmHubRegistrationStatus.REGISTERED); + registration.setLastConnectivityCheck(LocalDateTime.now()); + tenantXtmHubRegistrationRepository.save(registration); } public void unregister() { diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index 8cea2a992b6..6027610f502 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -911,14 +911,14 @@ void whenSuccessful_ShouldUpdateRegistrationStatus() { xtmHubService.autoRegister(token); // Then - verify(platformSettingsService) - .updateXTMHubRegistration( - eq(token), - any(LocalDateTime.class), - eq(XtmHubRegistrationStatus.REGISTERED), - isNull(), - isNull(), - eq(false)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(TenantXtmHubRegistration.class); + verify(tenantXtmHubRegistrationRepository).save(captor.capture()); + TenantXtmHubRegistration saved = captor.getValue(); + assertThat(saved.getToken()).isEqualTo(token); + assertThat(saved.getRegistrationStatus()).isEqualTo(XtmHubRegistrationStatus.REGISTERED); + assertThat(saved.getRegistrationDate()).isNotNull(); + assertThat(saved.getLastConnectivityCheck()).isNotNull(); } @Test @@ -949,6 +949,7 @@ void whenSuccessful_ShouldSendCorrectPayloadToHub() { assertThat(platform.get("title").getAsString()).isEqualTo("Test Platform"); assertThat(platform.get("url").getAsString()).isEqualTo("http://localhost"); assertThat(platform.get("version").getAsString()).isEqualTo("1.0.0"); + assertThat(platform.get("tenantId").getAsString()).isEqualTo(Tenant.DEFAULT_TENANT_UUID); assertThat(input.get("existing_users_count").getAsLong()).isEqualTo(1L); } From 65ef43ea5a4e95c6513d276c28b1900df05d6550 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 12:02:19 +0200 Subject: [PATCH 09/18] [backend] feat(multi-tenant): use registration entity for contact us --- .../main/java/io/openaev/xtmhub/XtmHubService.java | 12 +++++++++--- .../src/test/java/io/openaev/rest/XtmHubApiTest.java | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index 5a67c690b3a..675989ecdd2 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -1,6 +1,7 @@ package io.openaev.xtmhub; import io.openaev.context.TenantContext; +import io.openaev.database.model.Tenant; import io.openaev.database.model.TenantXtmHubRegistration; import io.openaev.database.model.User; import io.openaev.database.repository.TenantXtmHubRegistrationRepository; @@ -172,9 +173,14 @@ private ConnectivityCheckResult checkConnectivityStatus( } public Boolean contactUs(String message) { - PlatformSettings settings = platformSettingsService.findSettings(); - String token = settings.getXtmHubToken(); - String platformId = settings.getPlatformId(); + Optional registration = + tenantXtmHubRegistrationRepository.findByTenantId(Tenant.DEFAULT_TENANT_UUID); + if (registration.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Default tenant is not registered on XtmHub"); + } + String token = registration.get().getToken(); + String platformId = platformSettingsService.findSettings().getPlatformId(); return xtmHubClient.contactUs(message, token, platformId); } diff --git a/openaev-api/src/test/java/io/openaev/rest/XtmHubApiTest.java b/openaev-api/src/test/java/io/openaev/rest/XtmHubApiTest.java index cf5d939f1a4..8d89df23aac 100644 --- a/openaev-api/src/test/java/io/openaev/rest/XtmHubApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/XtmHubApiTest.java @@ -201,6 +201,14 @@ public void whenContactUsSendMessage() throws Exception { XtmHubContactUsInput input = new XtmHubContactUsInput(); input.setMessage(message); + Tenant defaultTenant = entityManager.find(Tenant.class, Tenant.DEFAULT_TENANT_UUID); + TenantXtmHubRegistration registration = new TenantXtmHubRegistration(); + registration.setToken("contact-us-token"); + registration.setRegistrationStatus(XtmHubRegistrationStatus.REGISTERED); + registration.setTenant(defaultTenant); + tenantXtmHubRegistrationRepository.save(registration); + entityManager.flush(); + when(xtmHubClient.contactUs(any(), any(), any())).thenReturn(true); String response = From 4b72b1abf7e1856e0c6dc02929b7b0430985110d Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 15:55:19 +0200 Subject: [PATCH 10/18] [backend] feat(multi-tenant): clean up xtmhub registration from platform settings --- .../V4_99__Remove_xtmhub_settings_keys.java | 28 ++++++++++ .../settings/response/PlatformSettings.java | 24 --------- .../service/PlatformSettingsService.java | 53 ------------------- openaev-front/src/utils/api-types.d.ts | 12 ----- .../openaev/database/model/SettingKeys.java | 6 --- 5 files changed, 28 insertions(+), 95 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/migration/V4_99__Remove_xtmhub_settings_keys.java diff --git a/openaev-api/src/main/java/io/openaev/migration/V4_99__Remove_xtmhub_settings_keys.java b/openaev-api/src/main/java/io/openaev/migration/V4_99__Remove_xtmhub_settings_keys.java new file mode 100644 index 00000000000..7a4fac99ae0 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/migration/V4_99__Remove_xtmhub_settings_keys.java @@ -0,0 +1,28 @@ +package io.openaev.migration; + +import java.sql.Statement; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +@Component +public class V4_99__Remove_xtmhub_settings_keys extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute( + """ + DELETE FROM parameters + WHERE parameter_key IN ( + 'xtm_hub_token', + 'xtm_hub_registration_date', + 'xtm_hub_registration_status', + 'xtm_hub_registration_user_id', + 'xtm_hub_registration_user_name', + 'xtm_hub_last_connectivity_check' + ); + """); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/settings/response/PlatformSettings.java b/openaev-api/src/main/java/io/openaev/rest/settings/response/PlatformSettings.java index 14c59e2cc26..852f7d0d169 100644 --- a/openaev-api/src/main/java/io/openaev/rest/settings/response/PlatformSettings.java +++ b/openaev-api/src/main/java/io/openaev/rest/settings/response/PlatformSettings.java @@ -283,30 +283,6 @@ public String getDefaultMailerName() { @Schema(description = "True if xtmhub backend is reachable") private Boolean xtmHubReachable; - @JsonProperty("xtm_hub_token") - @Schema(description = "XTM Hub token") - private String xtmHubToken; - - @JsonProperty("xtm_hub_registration_status") - @Schema(description = "XTM Hub registration status") - private String xtmHubRegistrationStatus; - - @JsonProperty("xtm_hub_registration_date") - @Schema(description = "XTM Hub registration date") - private String xtmHubRegistrationDate; - - @JsonProperty("xtm_hub_registration_user_id") - @Schema(description = "XTM Hub registration user id") - private String xtmHubRegistrationUserId; - - @JsonProperty("xtm_hub_registration_user_name") - @Schema(description = "XTM Hub registration user name") - private String xtmHubRegistrationUserName; - - @JsonProperty("xtm_hub_last_connectivity_check") - @Schema(description = "XTM Hub last connectivity check") - private String xtmHubLastConnectivityCheck; - @JsonProperty("xtm_hub_should_send_connectivity_email") @Schema(description = "XTM Hub should send connectivity email") private String xtmHubShouldSendConnectivityEmail; diff --git a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java index 23cb710e259..7a36850d221 100644 --- a/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java +++ b/openaev-api/src/main/java/io/openaev/service/PlatformSettingsService.java @@ -24,14 +24,11 @@ import io.openaev.rest.settings.response.PlatformSettings; import io.openaev.rest.stream.ai.AiConfig; import io.openaev.xtmhub.XtmHubConnectivityService; -import io.openaev.xtmhub.XtmHubRegistererRecord; -import io.openaev.xtmhub.XtmHubRegistrationStatus; import io.openaev.xtmhub.config.XtmHubConfig; import io.openaev.xtmone.XtmOneConfig; import jakarta.annotation.Resource; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -331,17 +328,6 @@ public PlatformSettings findSettings() { platformSettings.setXtmHubEnable(xtmHubConfig.getEnable()); platformSettings.setXtmHubUrl(xtmHubConfig.getUrl()); platformSettings.setXtmHubReachable(xtmHubConnectivityService.isReachable()); - platformSettings.setXtmHubToken(getValueFromMapOfSettings(dbSettings, XTM_HUB_TOKEN.key())); - platformSettings.setXtmHubRegistrationStatus( - getValueFromMapOfSettings(dbSettings, XTM_HUB_REGISTRATION_STATUS.key())); - platformSettings.setXtmHubRegistrationDate( - getValueFromMapOfSettings(dbSettings, XTM_HUB_REGISTRATION_DATE.key())); - platformSettings.setXtmHubRegistrationUserId( - getValueFromMapOfSettings(dbSettings, XTM_HUB_REGISTRATION_USER_ID.key())); - platformSettings.setXtmHubRegistrationUserName( - getValueFromMapOfSettings(dbSettings, XTM_HUB_REGISTRATION_USER_NAME.key())); - platformSettings.setXtmHubLastConnectivityCheck( - getValueFromMapOfSettings(dbSettings, XTM_HUB_LAST_CONNECTIVITY_CHECK.key())); platformSettings.setXtmHubShouldSendConnectivityEmail( getValueFromMapOfSettings(dbSettings, XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL.key())); return platformSettings; @@ -573,45 +559,6 @@ public Setting saveSetting(String key, String value) { return settingRepository.save(setting); } - public PlatformSettings updateXTMHubRegistration( - String token, - LocalDateTime registrationDate, - XtmHubRegistrationStatus registrationStatus, - XtmHubRegistererRecord registerer, - LocalDateTime lastConnectivityCheck, - Boolean shouldSendConnectivityEmail) { - Map dbSettings = mapOfSettings(fromIterable(this.settingRepository.findAll())); - - Map xtmhubSettingsMap = new HashMap<>(); - xtmhubSettingsMap.put(XTM_HUB_TOKEN, token); - xtmhubSettingsMap.put( - XTM_HUB_REGISTRATION_DATE, registrationDate != null ? registrationDate.toString() : null); - xtmhubSettingsMap.put(XTM_HUB_REGISTRATION_STATUS, registrationStatus.label); - xtmhubSettingsMap.put( - XTM_HUB_REGISTRATION_USER_ID, registerer != null ? registerer.id() : null); - xtmhubSettingsMap.put( - XTM_HUB_REGISTRATION_USER_NAME, registerer != null ? registerer.name() : null); - xtmhubSettingsMap.put( - XTM_HUB_LAST_CONNECTIVITY_CHECK, - lastConnectivityCheck != null ? lastConnectivityCheck.toString() : null); - xtmhubSettingsMap.put( - XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL, - shouldSendConnectivityEmail != null ? shouldSendConnectivityEmail.toString() : null); - - List settingsToSave = new ArrayList<>(); - - xtmhubSettingsMap.forEach( - (settingKey, value) -> { - if (value != null) { - settingsToSave.add(resolveFromMap(dbSettings, settingKey.key(), value)); - } - }); - - settingRepository.saveAll(settingsToSave); - - return findSettings(); - } - public void updateXTMHubEmailNotification(boolean shouldSendConnectivityEmail) { Optional current = this.settingRepository.findByKey(XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL.key()); diff --git a/openaev-front/src/utils/api-types.d.ts b/openaev-front/src/utils/api-types.d.ts index f6417c669ad..033c5b72c1f 100644 --- a/openaev-front/src/utils/api-types.d.ts +++ b/openaev-front/src/utils/api-types.d.ts @@ -6352,22 +6352,10 @@ export interface PlatformSettings { telemetry_manager_enable?: boolean; /** True if connection with XTM Hub is enabled */ xtm_hub_enable?: boolean; - /** XTM Hub last connectivity check */ - xtm_hub_last_connectivity_check?: string; /** True if xtmhub backend is reachable */ xtm_hub_reachable?: boolean; - /** XTM Hub registration date */ - xtm_hub_registration_date?: string; - /** XTM Hub registration status */ - xtm_hub_registration_status?: string; - /** XTM Hub registration user id */ - xtm_hub_registration_user_id?: string; - /** XTM Hub registration user name */ - xtm_hub_registration_user_name?: string; /** XTM Hub should send connectivity email */ xtm_hub_should_send_connectivity_email?: string; - /** XTM Hub token */ - xtm_hub_token?: string; /** Url of XTM Hub */ xtm_hub_url?: string; /** True if connection with OpenCTI is enabled */ diff --git a/openaev-model/src/main/java/io/openaev/database/model/SettingKeys.java b/openaev-model/src/main/java/io/openaev/database/model/SettingKeys.java index c9e35636270..c62f3900285 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/SettingKeys.java +++ b/openaev-model/src/main/java/io/openaev/database/model/SettingKeys.java @@ -17,12 +17,6 @@ public enum SettingKeys { PLATFORM_BANNER("platform_banner", ""), PLATFORM_INSTANCE("instance_id", ""), PLATFORM_INSTANCE_CREATION("instance_creation_date", ""), - XTM_HUB_TOKEN("xtm_hub_token", ""), - XTM_HUB_REGISTRATION_DATE("xtm_hub_registration_date", ""), - XTM_HUB_REGISTRATION_STATUS("xtm_hub_registration_status", ""), - XTM_HUB_REGISTRATION_USER_ID("xtm_hub_registration_user_id", ""), - XTM_HUB_REGISTRATION_USER_NAME("xtm_hub_registration_user_name", ""), - XTM_HUB_LAST_CONNECTIVITY_CHECK("xtm_hub_last_connectivity_check", ""), XTM_HUB_SHOULD_SEND_CONNECTIVITY_EMAIL("xtm_hub_should_send_connectivity_email", "true"), XTM_COMPOSER_ID("xtm_composer_id", ""), From 98b056e25b317cbd360b5a315f5cd080125ba514 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 16:22:44 +0200 Subject: [PATCH 11/18] [backend] fix(multi-tenant): fix dot color issue when platform is registered --- openaev-front/src/admin/components/nav/TopBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-front/src/admin/components/nav/TopBar.tsx b/openaev-front/src/admin/components/nav/TopBar.tsx index 588bb9bf1dd..763024116c3 100644 --- a/openaev-front/src/admin/components/nav/TopBar.tsx +++ b/openaev-front/src/admin/components/nav/TopBar.tsx @@ -286,7 +286,7 @@ const TopBar: FunctionComponent = () => { rel="noreferrer" onClick={handleCloseXtm} > - + {xtmhubBadgeImg} From 2c2c9943ab815df8fdbc310bac49ad7dabda497e Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Thu, 23 Apr 2026 16:53:55 +0200 Subject: [PATCH 12/18] [backend] doc(xtmhub): fix auto-register endpoint description --- openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java index a66d840e5bb..853ca7328b9 100644 --- a/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java +++ b/openaev-api/src/main/java/io/openaev/api/xtmhub/XtmHubApi.java @@ -105,8 +105,7 @@ public ResponseEntity refreshConnectivity() { @PutMapping(value = XTMHUB_URI + "/auto-register", consumes = MediaType.APPLICATION_JSON_VALUE) @Operation( summary = "Autoregister OpenAEV into XTM Hub", - description = - "Register platform on xtmhub and Save registration data into settings from XTM Hub registration") + description = "Register platform on xtmhub and Save registration data") @ApiResponses({ @ApiResponse(responseCode = "204", description = "Successful registration"), @ApiResponse(responseCode = "502", description = "Registration failed on XTM Hub call"), From 50ebac55afe43baad4bd7e8cf65eb78a8444842a Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Mon, 27 Apr 2026 17:48:22 +0200 Subject: [PATCH 13/18] [backend/frontend] feat(xtmhub): send tenant name in register and refresh connectivity (#5615) --- .../xtmhub/TenantRegistrationDetails.java | 2 +- .../java/io/openaev/xtmhub/XtmHubClient.java | 28 +++++++++++++++---- .../java/io/openaev/xtmhub/XtmHubService.java | 15 ++++++++-- .../io/openaev/xtmhub/XtmHubServiceTest.java | 16 +++++++++-- .../settings/experience/xtm_hub/XtmHubTab.tsx | 5 ++-- .../TenantXtmHubRegistrationRepository.java | 2 +- 6 files changed, 53 insertions(+), 15 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java b/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java index 69b3b5b6898..51b2dcfa823 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/TenantRegistrationDetails.java @@ -1,3 +1,3 @@ package io.openaev.xtmhub; -public record TenantRegistrationDetails(String token, String url) {} +public record TenantRegistrationDetails(String token, String url, String tenantName) {} diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java index 47fce7e1bb0..6e74b70c1e8 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java @@ -63,7 +63,12 @@ public Boolean contactUs(String message, String token, String platformId) { } public XtmHubConnectivityStatus refreshRegistrationStatusSingleTenant( - String platformId, String platformVersion, String token, String url, String tenantId) { + String platformId, + String platformVersion, + String token, + String url, + String tenantId, + String tenantName) { try (CloseableHttpClient httpClient = httpClientFactory.httpClientCustom()) { HttpPost httpPost = new HttpPost(this.graphqlEndpoint); httpPost.addHeader("Accept", "application/json"); @@ -71,12 +76,12 @@ public XtmHubConnectivityStatus refreshRegistrationStatusSingleTenant( httpPost.addHeader(ACCEPT, APPLICATION_JSON_VALUE); StringEntity httpBody = - buildRefreshStatusSingleTenantBody(platformId, platformVersion, token, url, tenantId); + buildRefreshStatusSingleTenantBody( + platformId, platformVersion, token, url, tenantId, tenantName); httpPost.setEntity(httpBody); return httpClient.execute(httpPost, this::parseResponseAsConnectivityStatus); } catch (Exception e) { log.error("XTM Hub is unreachable on {}: {}", config.getApiUrl(), e.getMessage(), e); - return XtmHubConnectivityStatus.INACTIVE; } } @@ -107,6 +112,7 @@ public boolean autoRegister( String platformUrl, String platformVersion, String tenantId, + String tenantName, Long usersCount) { PlatformSettings settings = platformSettingsService.findSettings(); @@ -125,6 +131,7 @@ public boolean autoRegister( platformUrl, platformVersion, tenantId, + tenantName, usersCount); httpPost.setEntity(httpBody); return httpClient.execute(httpPost, this::parseResponseAsSuccess); @@ -163,7 +170,12 @@ mutation ContactUsXTMHub($message: String!) { @NotNull private StringEntity buildRefreshStatusSingleTenantBody( - String platformId, String platformVersion, String token, String url, String tenantId) { + String platformId, + String platformVersion, + String token, + String url, + String tenantId, + String tenantName) { String mutationBody = String.format( """ @@ -182,12 +194,13 @@ mutation refreshPlatformRegistrationConnectivityStatusSingleTenant($input: Refre "token": "%s", "platformIdentifier": "%s", "url": "%s", - "tenantId": "%s" + "tenantId": "%s", + "tenantName": "%s" } } } """, - platformId, platformVersion, token, platformIdentifier, url, tenantId); + platformId, platformVersion, token, platformIdentifier, url, tenantId, tenantName); JsonElement element = JsonParser.parseString(mutationBody); return new StringEntity(element.toString()); @@ -202,6 +215,7 @@ private StringEntity buildRefreshStatusAllTenantsBody( (tenantId, details) -> { JsonObject tenantToken = new JsonObject(); tenantToken.addProperty("tenantId", tenantId); + tenantToken.addProperty("tenantName", details.tenantName()); tenantToken.addProperty("token", details.token()); tenantToken.addProperty("url", details.url()); tenantsArray.add(tenantToken); @@ -233,6 +247,7 @@ private StringEntity buildAutoRegisterBody( String platformUrl, String platformVersion, String tenantId, + String tenantName, Long usersCount) { JsonObject platform = new JsonObject(); @@ -242,6 +257,7 @@ private StringEntity buildAutoRegisterBody( platform.addProperty("url", platformUrl); platform.addProperty("version", platformVersion); platform.addProperty("tenantId", tenantId); + platform.addProperty("tenantName", tenantName); JsonObject input = new JsonObject(); input.add("platform", platform); diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index 675989ecdd2..d0099fbecce 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -4,6 +4,7 @@ import io.openaev.database.model.Tenant; import io.openaev.database.model.TenantXtmHubRegistration; import io.openaev.database.model.User; +import io.openaev.database.repository.TenantRepository; import io.openaev.database.repository.TenantXtmHubRegistrationRepository; import io.openaev.rest.settings.response.PlatformSettings; import io.openaev.service.PlatformSettingsService; @@ -35,6 +36,7 @@ public class XtmHubService { private final XtmHubClient xtmHubClient; private final XtmHubEmailService xtmHubEmailService; private final TenantXtmHubRegistrationRepository tenantXtmHubRegistrationRepository; + private final TenantRepository tenantRepository; public Optional getRegistration() { return tenantXtmHubRegistrationRepository.findByTenantId(TenantContext.getCurrentTenant()); @@ -56,6 +58,8 @@ public TenantXtmHubRegistration register(@NotBlank final String token) { public void autoRegister(@NotBlank final String token) { PlatformSettings settings = platformSettingsService.findSettings(); Long usersCount = userService.globalCount(); + String tenantId = TenantContext.getCurrentTenant(); + String tenantName = tenantRepository.findById(tenantId).map(Tenant::getName).orElse(tenantId); if (!xtmHubClient.autoRegister( token, LicenseUtils.computeXtmHubContractLevel(settings.getPlatformLicense()), @@ -63,7 +67,8 @@ public void autoRegister(@NotBlank final String token) { settings.getPlatformName(), settings.getPlatformBaseUrl(), settings.getPlatformVersion(), - TenantContext.getCurrentTenant(), + tenantId, + tenantName, usersCount)) { throw new ResponseStatusException( HttpStatus.BAD_GATEWAY, "Failed to register the platform on XtmHub"); @@ -111,8 +116,10 @@ public void refreshConnectivityAllTenants() { Map tenants = new HashMap<>(); for (TenantXtmHubRegistration registration : registrations) { String tenantId = registration.getTenant().getId(); + String tenantName = registration.getTenant().getName(); String url = settings.getPlatformBaseUrl() + "/" + tenantId; - tenants.put(tenantId, new TenantRegistrationDetails(registration.getToken(), url)); + tenants.put( + tenantId, new TenantRegistrationDetails(registration.getToken(), url, tenantName)); } Map statuses = @@ -158,6 +165,7 @@ private TenantXtmHubRegistration findOrCreateRegistration() { private ConnectivityCheckResult checkConnectivityStatus( PlatformSettings settings, TenantXtmHubRegistration registration) { String url = settings.getPlatformBaseUrl() + "/" + TenantContext.getCurrentTenant(); + String tenantName = registration.getTenant().getName(); XtmHubConnectivityStatus status = xtmHubClient.refreshRegistrationStatusSingleTenant( @@ -165,7 +173,8 @@ private ConnectivityCheckResult checkConnectivityStatus( settings.getPlatformVersion(), registration.getToken(), url, - TenantContext.getCurrentTenant()); + TenantContext.getCurrentTenant(), + tenantName); LocalDateTime lastCheck = parseLastConnectivityCheck(registration); diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java index 6027610f502..35d449de484 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java @@ -16,6 +16,7 @@ import io.openaev.context.TenantContext; import io.openaev.database.model.Tenant; import io.openaev.database.model.TenantXtmHubRegistration; +import io.openaev.database.repository.TenantRepository; import io.openaev.database.repository.TenantXtmHubRegistrationRepository; import io.openaev.ee.License; import io.openaev.ee.LicenseTypeEnum; @@ -54,6 +55,7 @@ class XtmHubServiceTest { @Mock private XtmHubEmailService xtmHubEmailService; @Mock private HttpClientFactory httpClientFactory; @Mock private TenantXtmHubRegistrationRepository tenantXtmHubRegistrationRepository; + @Mock private TenantRepository tenantRepository; private XtmHubConfig xtmHubConfig; private XtmHubService xtmHubService; @@ -88,6 +90,8 @@ void setUp() { lenient() .when(tenantXtmHubRegistrationRepository.findByTenantId(any())) .thenReturn(Optional.empty()); + // Default: tenant found with default name + lenient().when(tenantRepository.findById(any())).thenReturn(Optional.of(new Tenant())); XtmHubClient xtmHubClient = new XtmHubClient(xtmHubConfig, httpClientFactory, platformSettingsService); @@ -100,7 +104,8 @@ void setUp() { xtmHubConfig, xtmHubClient, xtmHubEmailService, - tenantXtmHubRegistrationRepository); + tenantXtmHubRegistrationRepository, + tenantRepository); } @AfterEach @@ -190,6 +195,7 @@ private void verifyRefreshConnectivityRequest( assertThat(input.get("platformIdentifier").getAsString()).isEqualTo("openaev"); assertThat(input.get("url").getAsString()) .isEqualTo(platformBaseUrl + "/" + Tenant.DEFAULT_TENANT_UUID); + assertThat(input.get("tenantName").getAsString()).isEqualTo("Default Tenant"); } /** Asserts that no HTTP request was made to the hub at all. */ @@ -205,7 +211,9 @@ private TenantXtmHubRegistration buildRegistration(String token, LocalDateTime l registration.setRegistrationUserId("user-123"); registration.setRegistrationUserName("John Doe"); registration.setLastConnectivityCheck(lastCheck); - registration.setTenant(new Tenant(TenantContext.getCurrentTenant())); + Tenant tenant = new Tenant(TenantContext.getCurrentTenant()); + tenant.setName("Default Tenant"); + registration.setTenant(tenant); return registration; } @@ -936,6 +944,9 @@ void whenSuccessful_ShouldSendCorrectPayloadToHub() { mockSettings.setPlatformVersion("1.0.0"); when(platformSettingsService.findSettings()).thenReturn(mockSettings); when(userService.globalCount()).thenReturn(1L); + Tenant tenant = new Tenant(); + tenant.setName("My Tenant"); + when(tenantRepository.findById(Tenant.DEFAULT_TENANT_UUID)).thenReturn(Optional.of(tenant)); whenHubAutoRegisters(true); // When @@ -950,6 +961,7 @@ void whenSuccessful_ShouldSendCorrectPayloadToHub() { assertThat(platform.get("url").getAsString()).isEqualTo("http://localhost"); assertThat(platform.get("version").getAsString()).isEqualTo("1.0.0"); assertThat(platform.get("tenantId").getAsString()).isEqualTo(Tenant.DEFAULT_TENANT_UUID); + assertThat(platform.get("tenantName").getAsString()).isEqualTo("My Tenant"); assertThat(input.get("existing_users_count").getAsLong()).isEqualTo(1L); } diff --git a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx index ace5cd8a8b8..2c201d5e4cb 100644 --- a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx +++ b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx @@ -10,7 +10,7 @@ import { isDemoInstance, MESSAGING$, XTM_HUB_DEFAULT_URL } from '../../../../../ import { useAppDispatch } from '../../../../../utils/hooks'; import useAuth from '../../../../../utils/hooks/useAuth'; import useExternalTab from '../../../../../utils/hooks/useExternalTab'; -import { DEFAULT_TENANT_UUID } from '../../../../../utils/tenant-url-helper'; +import {DEFAULT_TENANT_UUID, getCurrentTenantId} from '../../../../../utils/tenant-url-helper'; import GradientButton from '../../../common/GradientButton'; import XtmHubConfirmationDialog from './XtmHubConfirmationDialog'; import XtmHubProcessDialog from './XtmHubProcessDialog'; @@ -48,7 +48,7 @@ const XtmHubTab: React.FC = () => { const isRegistered = registration?.tenant_xtmhub_registration_status === 'REGISTERED'; const platformIdentifiers = { - tenant_id: currentUserTenant?.tenant_id ?? DEFAULT_TENANT_UUID, + tenant_id: getCurrentTenantId(), platform_id: settings?.platform_id ?? '', }; const platformInformation = { @@ -58,6 +58,7 @@ const XtmHubTab: React.FC = () => { platform_title: settings?.platform_name ?? 'OpenAEV Platform', platform_contract: isEnterpriseEdition ? 'EE' : 'CE', platform_version: settings?.platform_version ?? '', + tenant_name: currentUserTenant?.tenant_name ?? '', }; const queryPlatformIdentifiers = new URLSearchParams( platformIdentifiers, diff --git a/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java index 0b9eded2fa6..2efacd81be9 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/TenantXtmHubRegistrationRepository.java @@ -16,7 +16,7 @@ public interface TenantXtmHubRegistrationRepository Optional findByTenantId(String tenantId); - @Query("SELECT r FROM TenantXtmHubRegistration r WHERE r.tenant.deletedAt IS NULL") + @Query("SELECT r FROM TenantXtmHubRegistration r JOIN FETCH r.tenant t WHERE t.deletedAt IS NULL") List findAllByTenantNotDeleted(); @Transactional From b5586de66b75766880e327d9b4362e83e931dc8a Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Tue, 28 Apr 2026 10:09:58 +0200 Subject: [PATCH 14/18] [frontend] chore: lint (#5615) --- .../admin/components/settings/experience/xtm_hub/XtmHubTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx index 2c201d5e4cb..478a91c0b8b 100644 --- a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx +++ b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx @@ -10,7 +10,7 @@ import { isDemoInstance, MESSAGING$, XTM_HUB_DEFAULT_URL } from '../../../../../ import { useAppDispatch } from '../../../../../utils/hooks'; import useAuth from '../../../../../utils/hooks/useAuth'; import useExternalTab from '../../../../../utils/hooks/useExternalTab'; -import {DEFAULT_TENANT_UUID, getCurrentTenantId} from '../../../../../utils/tenant-url-helper'; +import { getCurrentTenantId } from '../../../../../utils/tenant-url-helper'; import GradientButton from '../../../common/GradientButton'; import XtmHubConfirmationDialog from './XtmHubConfirmationDialog'; import XtmHubProcessDialog from './XtmHubProcessDialog'; From 9c437ca4fd35cc4bd12dae73196b0260748742d1 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Tue, 28 Apr 2026 10:43:56 +0200 Subject: [PATCH 15/18] [frontend] chore: update tests (#5615) --- .../settings/experience/xtm_hub/XtmHubTab.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx index 6816710b1a2..6c43b280e17 100644 --- a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx +++ b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx @@ -9,15 +9,17 @@ import { type PlatformSettings, type TenantOutput, type User } from '../../../.. import type * as EnvironmentModule from '../../../../../../utils/Environment'; import { isDemoInstance, XTM_HUB_DEFAULT_URL } from '../../../../../../utils/Environment'; import { UserContext, type UserContextType } from '../../../../../../utils/hooks/useAuth'; +import type * as TenantUrlHelperModule from '../../../../../../utils/tenant-url-helper'; // -- MODULE MOCKS -- -const { mockOpenTab, mockCloseTab, mockFocusTab, mockDispatch, mockNotifySuccess } = vi.hoisted(() => ({ +const { mockOpenTab, mockCloseTab, mockFocusTab, mockDispatch, mockNotifySuccess, mockGetCurrentTenantId } = vi.hoisted(() => ({ mockOpenTab: vi.fn(), mockCloseTab: vi.fn(), mockFocusTab: vi.fn(), mockDispatch: vi.fn(), mockNotifySuccess: vi.fn(), + mockGetCurrentTenantId: vi.fn(() => 'tenant-abc'), })); vi.mock('../../../../../../utils/hooks/useExternalTab', () => ({ @@ -49,6 +51,14 @@ vi.mock('../../../../../../utils/Environment', async (importOriginal) => { }; }); +vi.mock('../../../../../../utils/tenant-url-helper', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getCurrentTenantId: mockGetCurrentTenantId, + }; +}); + // -- SUB-COMPONENT MOCKS -- vi.mock('../../../../../../admin/components/settings/experience/xtm_hub/XtmHubProcessInstructions', () => ({ default: ({ onContinue }: { onContinue: () => void }) => ( @@ -174,6 +184,7 @@ const buildRegistrationParams = (overrides: Record = {}) => platform_title: DEFAULT_SETTINGS.platform_name!, platform_contract: 'EE', platform_version: DEFAULT_SETTINGS.platform_version!, + tenant_name: TENANT.tenant_name, ...overrides, }); @@ -384,6 +395,7 @@ describe('XtmHubTab', () => { }); it('platform_url uses the default tenant uuid when no tenant is set', () => { + mockGetCurrentTenantId.mockReturnValueOnce(DEFAULT_TENANT_UUID); renderXtmHubTab({ registrationStatus: null, currentUserTenant: null, From 885588bb684e20e08a7790a0d6655640ca3dada1 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Wed, 29 Apr 2026 10:07:25 +0200 Subject: [PATCH 16/18] [frontend] fix: merge issue (#5615) --- .../settings/experience/xtm_hub/XtmHubTab.test.tsx | 8 +++++--- .../components/settings/experience/xtm_hub/XtmHubTab.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx index 0bb87767a31..e98d64cb9a6 100644 --- a/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx +++ b/openaev-front/src/__tests__/admin/components/settings/experience/xtm_hub/XtmHubTab.test.tsx @@ -9,7 +9,7 @@ import { type PlatformSettings, type TenantOutput, type User } from '../../../.. import type * as EnvironmentModule from '../../../../../../utils/Environment'; import { isDemoInstance, XTM_HUB_DEFAULT_URL } from '../../../../../../utils/Environment'; import { UserContext, type UserContextType } from '../../../../../../utils/hooks/useAuth'; -import type * as TenantUrlHelperModule from '../../../../../../utils/tenant-url-helper'; +import type * as UrlHelperModule from '../../../../../../utils/url-helper'; // -- MODULE MOCKS -- @@ -51,8 +51,8 @@ vi.mock('../../../../../../utils/Environment', async (importOriginal) => { }; }); -vi.mock('../../../../../../utils/tenant-url-helper', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../../../../../../utils/url-helper', async (importOriginal) => { + const original = await importOriginal(); return { ...original, getCurrentTenantId: mockGetCurrentTenantId, @@ -208,6 +208,8 @@ describe('XtmHubTab', () => { afterEach(() => { cleanup(); vi.clearAllMocks(); + // Restore default mock implementations after each test + mockGetCurrentTenantId.mockImplementation(() => 'tenant-abc'); // Ensure the demo mode mock is reset between tests vi.mocked(isDemoInstance).mockReturnValue(false); }); diff --git a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx index 478a91c0b8b..15d02fc0e03 100644 --- a/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx +++ b/openaev-front/src/admin/components/settings/experience/xtm_hub/XtmHubTab.tsx @@ -10,7 +10,7 @@ import { isDemoInstance, MESSAGING$, XTM_HUB_DEFAULT_URL } from '../../../../../ import { useAppDispatch } from '../../../../../utils/hooks'; import useAuth from '../../../../../utils/hooks/useAuth'; import useExternalTab from '../../../../../utils/hooks/useExternalTab'; -import { getCurrentTenantId } from '../../../../../utils/tenant-url-helper'; +import { getCurrentTenantId } from '../../../../../utils/url-helper'; import GradientButton from '../../../common/GradientButton'; import XtmHubConfirmationDialog from './XtmHubConfirmationDialog'; import XtmHubProcessDialog from './XtmHubProcessDialog'; From 447c6e40cda3a2cd8a2d0e2df439d0fea705d5d4 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Tue, 5 May 2026 10:09:24 +0200 Subject: [PATCH 17/18] [backend] feat(multi-tenant): use bypassRls and add test (#5560) --- .../java/io/openaev/xtmhub/XtmHubService.java | 2 + ...tmHubConnectivityCollectorServiceTest.java | 131 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java diff --git a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java index c0c4bf6c395..bca5bdc6497 100644 --- a/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java +++ b/openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java @@ -1,5 +1,6 @@ package io.openaev.xtmhub; +import io.openaev.aop.BypassRls; import io.openaev.context.TenantContext; import io.openaev.database.model.Tenant; import io.openaev.database.model.TenantXtmHubRegistration; @@ -107,6 +108,7 @@ public TenantXtmHubRegistration refreshConnectivity() { return updateRegistrationStatus(registration.get(), checkResult); } + @BypassRls public void refreshConnectivityAllTenants() { PlatformSettings settings = platformSettingsService.findSettings(); diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java new file mode 100644 index 00000000000..3daefe38999 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java @@ -0,0 +1,131 @@ +package io.openaev.xtmhub.collector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.openaev.IntegrationTest; +import io.openaev.context.TenantContext; +import io.openaev.database.model.Tenant; +import io.openaev.database.model.TenantXtmHubRegistration; +import io.openaev.database.repository.TenantXtmHubRegistrationRepository; +import io.openaev.utils.fixtures.tenants.TenantComposer; +import io.openaev.utils.fixtures.tenants.TenantFixture; +import io.openaev.utils.mockUser.WithMockUser; +import io.openaev.utilstest.DefaultTenantExtension; +import io.openaev.xtmhub.TenantRegistrationDetails; +import io.openaev.xtmhub.XtmHubClient; +import io.openaev.xtmhub.XtmHubRegistrationStatus; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * Integration test for {@link XtmHubConnectivityCollectorService}. + */ +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@WithMockUser(isAdmin = true) +@ExtendWith(DefaultTenantExtension.class) +@DisplayName("XtmHubConnectivityCollectorService integration tests") +class XtmHubConnectivityCollectorServiceTest extends IntegrationTest { + + @Autowired private XtmHubConnectivityCollectorService collectorService; + @Autowired private TenantXtmHubRegistrationRepository tenantXtmHubRegistrationRepository; + @Autowired private TenantComposer tenantComposer; + + @MockitoBean private XtmHubClient xtmHubClient; + + // Tracks registrationId → tenantId so cleanup can switch to the right context per row. + private final Map createdRegistrationIdToTenantId = new LinkedHashMap<>(); + private final List createdTenantIds = new ArrayList<>(); + + @AfterEach + void cleanup() { + // Delete each registration while the tenant context matches its tenant_id (required by RLS). + createdRegistrationIdToTenantId.forEach( + (registrationId, tenantId) -> { + TenantContext.setCurrentTenant(tenantId); + try { + tenantXtmHubRegistrationRepository.deleteById(registrationId); + } finally { + TenantContext.clearCurrentTenant(); + } + }); + createdTenantIds.forEach(tenantRepository::deleteById); + createdRegistrationIdToTenantId.clear(); + createdTenantIds.clear(); + } + + @Test + @DisplayName( + "Should call XTM Hub with only the 2 active tenant registrations," + + " excluding the one on a soft-deleted tenant") + void whenThreeRegistrationsOneOnDeletedTenant_ShouldCallHubWithOnlyTwoActiveTenants() { + // Given — create 3 tenants, 2 active and 1 soft-deleted + Tenant tenantA = createTenant("Tenant-A"); + Tenant tenantB = createTenant("Tenant-B"); + Tenant tenantDeleted = createTenant("Tenant-Deleted"); + tenantDeleted.setDeletedAt(Instant.now()); + tenantRepository.save(tenantDeleted); + + // Register all 3 tenants on XTM Hub. + saveRegistration("token-a", tenantA); + saveRegistration("token-b", tenantB); + saveRegistration("token-deleted", tenantDeleted); + + when(xtmHubClient.refreshRegistrationStatusAllTenants(any(), any(), any())) + .thenReturn(Map.of()); + + // When + collectorService.run(); + + // Then — the hub was called exactly once, carrying only the 2 active tenant IDs + ArgumentCaptor> tenantsCaptor = + ArgumentCaptor.captor(); + verify(xtmHubClient) + .refreshRegistrationStatusAllTenants(any(), any(), tenantsCaptor.capture()); + + Map sentTenants = tenantsCaptor.getValue(); + assertThat(sentTenants) + .as("Hub payload must contain exactly the 2 active tenant IDs") + .hasSize(2) + .containsKey(tenantA.getId()) + .containsKey(tenantB.getId()) + .doesNotContainKey(tenantDeleted.getId()); + } + + // -- Helpers -- + + private Tenant createTenant(String name) { + Tenant tenant = TenantFixture.getTenant(name); + tenantComposer.forTenant(tenant).persist(); + createdTenantIds.add(tenant.getId()); + return tenant; + } + + private void saveRegistration(String token, Tenant tenant) { + TenantContext.setCurrentTenant(tenant.getId()); + try { + TenantXtmHubRegistration registration = new TenantXtmHubRegistration(); + registration.setToken(token); + registration.setRegistrationStatus(XtmHubRegistrationStatus.REGISTERED); + registration.setConnectivityEmailEligible(true); + TenantXtmHubRegistration saved = tenantXtmHubRegistrationRepository.save(registration); + createdRegistrationIdToTenantId.put(saved.getId(), tenant.getId()); + } finally { + TenantContext.clearCurrentTenant(); + } + } +} + From fef4bb687b12d6ff2fa32ebd5a62d4cb8ed39817 Mon Sep 17 00:00:00 2001 From: Carine Le Bas Date: Tue, 5 May 2026 10:10:53 +0200 Subject: [PATCH 18/18] [backend] feat(multi-tenant): lint (#5560) --- .../XtmHubConnectivityCollectorServiceTest.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java b/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java index 3daefe38999..0e773036ea4 100644 --- a/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java @@ -31,9 +31,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; -/** - * Integration test for {@link XtmHubConnectivityCollectorService}. - */ +/** Integration test for {@link XtmHubConnectivityCollectorService}. */ @TestInstance(TestInstance.Lifecycle.PER_METHOD) @WithMockUser(isAdmin = true) @ExtendWith(DefaultTenantExtension.class) @@ -91,10 +89,8 @@ void whenThreeRegistrationsOneOnDeletedTenant_ShouldCallHubWithOnlyTwoActiveTena collectorService.run(); // Then — the hub was called exactly once, carrying only the 2 active tenant IDs - ArgumentCaptor> tenantsCaptor = - ArgumentCaptor.captor(); - verify(xtmHubClient) - .refreshRegistrationStatusAllTenants(any(), any(), tenantsCaptor.capture()); + ArgumentCaptor> tenantsCaptor = ArgumentCaptor.captor(); + verify(xtmHubClient).refreshRegistrationStatusAllTenants(any(), any(), tenantsCaptor.capture()); Map sentTenants = tenantsCaptor.getValue(); assertThat(sentTenants) @@ -128,4 +124,3 @@ private void saveRegistration(String token, Tenant tenant) { } } } -