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 96fdd0dab0b..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,9 +1,11 @@ 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; 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; @@ -37,6 +39,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()); @@ -59,6 +62,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()), @@ -66,7 +71,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"); @@ -102,6 +108,7 @@ public TenantXtmHubRegistration refreshConnectivity() { return updateRegistrationStatus(registration.get(), checkResult); } + @BypassRls public void refreshConnectivityAllTenants() { PlatformSettings settings = platformSettingsService.findSettings(); @@ -115,10 +122,11 @@ public void refreshConnectivityAllTenants() { Map tenants = new HashMap<>(); for (TenantXtmHubRegistration registration : registrations) { String tenantId = registration.getTenant().getId(); + String tenantName = registration.getTenant().getName(); tenants.put( tenantId, new TenantRegistrationDetails( - registration.getToken(), tenantSettingsService.buildTenantUrl(tenantId))); + registration.getToken(), tenantSettingsService.buildTenantUrl(tenantId), tenantName)); } Map statuses = @@ -166,6 +174,7 @@ private TenantXtmHubRegistration findOrCreateRegistration() { private ConnectivityCheckResult checkConnectivityStatus( PlatformSettings settings, TenantXtmHubRegistration registration) { String url = tenantSettingsService.buildTenantUrl(TenantContext.getCurrentTenant()); + String tenantName = registration.getTenant().getName(); XtmHubConnectivityStatus status = xtmHubClient.refreshRegistrationStatusSingleTenant( @@ -173,7 +182,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 b5b55bc8d05..a15956dfe2c 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; @@ -56,6 +57,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; @@ -90,6 +92,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())); // Default: build tenant URL from a fixed base URL lenient() .when(tenantSettingsService.buildTenantUrl(any())) @@ -107,7 +111,8 @@ void setUp() { xtmHubConfig, xtmHubClient, xtmHubEmailService, - tenantXtmHubRegistrationRepository); + tenantXtmHubRegistrationRepository, + tenantRepository); } @AfterEach @@ -197,6 +202,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. */ @@ -212,7 +218,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; } @@ -965,6 +973,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 @@ -979,6 +990,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-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..0e773036ea4 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/xtmhub/collector/XtmHubConnectivityCollectorServiceTest.java @@ -0,0 +1,126 @@ +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(); + } + } +} 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 391f7084fb4..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,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 UrlHelperModule from '../../../../../../utils/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/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, }); @@ -197,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); }); @@ -384,6 +397,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, 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 7a124f5b2d3..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 { DEFAULT_TENANT_UUID } from '../../../../../utils/url-helper'; +import { getCurrentTenantId } from '../../../../../utils/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