Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3bd63ee
[backend/frontend] feat(xtmhub): connectivity check from api with tenant
carinelebas Apr 20, 2026
3aeadfc
[backend] feat(xtmhub): connectivity check for xtmhub collector
carinelebas Apr 20, 2026
4aece9a
[backend] feat(xtmhub): Also send connectivity lost mail to users man…
carinelebas Apr 22, 2026
18f899e
[backend/frontend] fix: copilot feedback
carinelebas Apr 22, 2026
5ab0f70
[backend] feat(xtmhub): send connectivity loss email if all tenants h…
carinelebas Apr 22, 2026
7ab65c4
[backend] feat(xtmhub): collector refresh connectivity only for non d…
carinelebas Apr 22, 2026
6bdb473
[backend] fix(multi-tenant): we should not register platform with / t…
carinelebas Apr 23, 2026
4eacadd
[backend] feat(multi-tenant): autoregister default tenant
carinelebas Apr 23, 2026
65ef43e
[backend] feat(multi-tenant): use registration entity for contact us
carinelebas Apr 23, 2026
4b72b1a
[backend] feat(multi-tenant): clean up xtmhub registration from platf…
carinelebas Apr 23, 2026
98b056e
[backend] fix(multi-tenant): fix dot color issue when platform is reg…
carinelebas Apr 23, 2026
2c2c994
[backend] doc(xtmhub): fix auto-register endpoint description
carinelebas Apr 23, 2026
7a6e22f
Merge branch 'release/current' into issue/priv/94
carinelebas Apr 24, 2026
50ebac5
[backend/frontend] feat(xtmhub): send tenant name in register and ref…
carinelebas Apr 27, 2026
b5586de
[frontend] chore: lint (#5615)
carinelebas Apr 28, 2026
9c437ca
[frontend] chore: update tests (#5615)
carinelebas Apr 28, 2026
130915d
Merge branch 'release/current' into issue/5615
carinelebas Apr 29, 2026
885588b
[frontend] fix: merge issue (#5615)
carinelebas Apr 29, 2026
97b1555
Merge branch 'release/current' into issue/5615
carinelebas Apr 29, 2026
58dc29f
Merge branch 'release/current' into issue/5615
carinelebas May 5, 2026
447c6e4
[backend] feat(multi-tenant): use bypassRls and add test (#5560)
carinelebas May 5, 2026
fef4bb6
[backend] feat(multi-tenant): lint (#5560)
carinelebas May 5, 2026
711b321
Merge branch 'release/current' into issue/5615
carinelebas May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package io.openaev.xtmhub;

public record TenantRegistrationDetails(String token, String url) {}
public record TenantRegistrationDetails(String token, String url, String tenantName) {}
28 changes: 22 additions & 6 deletions openaev-api/src/main/java/io/openaev/xtmhub/XtmHubClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,25 @@ 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");
httpPost.addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE);
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;
}
}
Expand Down Expand Up @@ -107,6 +112,7 @@ public boolean autoRegister(
String platformUrl,
String platformVersion,
String tenantId,
String tenantName,
Long usersCount) {
PlatformSettings settings = platformSettingsService.findSettings();

Expand All @@ -125,6 +131,7 @@ public boolean autoRegister(
platformUrl,
platformVersion,
tenantId,
tenantName,
usersCount);
httpPost.setEntity(httpBody);
return httpClient.execute(httpPost, this::parseResponseAsSuccess);
Expand Down Expand Up @@ -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(
"""
Expand All @@ -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());
Expand All @@ -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);
Expand Down Expand Up @@ -233,6 +247,7 @@ private StringEntity buildAutoRegisterBody(
String platformUrl,
String platformVersion,
String tenantId,
String tenantName,
Long usersCount) {

JsonObject platform = new JsonObject();
Expand All @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions openaev-api/src/main/java/io/openaev/xtmhub/XtmHubService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<TenantXtmHubRegistration> getRegistration() {
return tenantXtmHubRegistrationRepository.findByTenantId(TenantContext.getCurrentTenant());
Expand All @@ -59,14 +62,17 @@ 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()),
settings.getPlatformId(),
settings.getPlatformName(),
settings.getPlatformBaseUrl(),
settings.getPlatformVersion(),
TenantContext.getCurrentTenant(),
tenantId,
tenantName,
usersCount)) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "Failed to register the platform on XtmHub");
Expand Down Expand Up @@ -102,6 +108,7 @@ public TenantXtmHubRegistration refreshConnectivity() {
return updateRegistrationStatus(registration.get(), checkResult);
}

@BypassRls
public void refreshConnectivityAllTenants() {
PlatformSettings settings = platformSettingsService.findSettings();

Expand All @@ -115,10 +122,11 @@ public void refreshConnectivityAllTenants() {
Map<String, TenantRegistrationDetails> 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<String, XtmHubConnectivityStatus> statuses =
Expand Down Expand Up @@ -166,14 +174,16 @@ 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(
settings.getPlatformId(),
settings.getPlatformVersion(),
registration.getToken(),
url,
TenantContext.getCurrentTenant());
TenantContext.getCurrentTenant(),
tenantName);

LocalDateTime lastCheck = parseLastConnectivityCheck(registration);

Expand Down
16 changes: 14 additions & 2 deletions openaev-api/src/test/java/io/openaev/xtmhub/XtmHubServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()))
Expand All @@ -107,7 +111,8 @@ void setUp() {
xtmHubConfig,
xtmHubClient,
xtmHubEmailService,
tenantXtmHubRegistrationRepository);
tenantXtmHubRegistrationRepository,
tenantRepository);
}

@AfterEach
Expand Down Expand Up @@ -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. */
Expand 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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> createdRegistrationIdToTenantId = new LinkedHashMap<>();
private final List<String> 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<Map<String, TenantRegistrationDetails>> tenantsCaptor = ArgumentCaptor.captor();
verify(xtmHubClient).refreshRegistrationStatusAllTenants(any(), any(), tenantsCaptor.capture());

Map<String, TenantRegistrationDetails> 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();
}
}
}
Loading
Loading