From 5fb6cf7c089bf10c8fb7c07298e820aa1e097aef Mon Sep 17 00:00:00 2001 From: Kiryl_Kurnosenka Date: Thu, 10 Sep 2026 19:50:24 +0300 Subject: [PATCH 1/3] fix: return login statuses on platform toolset/application GET #1928 Co-Authored-By: Claude Code --- .../controller/ApplicationController.java | 28 +-- .../controller/ConfigResourceController.java | 21 +- .../server/controller/ResourceController.java | 26 +-- .../ExternalServiceStatusEnricher.java | 27 +++ .../util/CredentialsLocatorFactory.java | 13 ++ .../server/PlatformAppToolsetApiTest.java | 204 +++++++++++++++++- 6 files changed, 264 insertions(+), 55 deletions(-) diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java index de15ce432..6005432f8 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java @@ -7,7 +7,6 @@ import com.epam.aidial.core.config.ResourceAccessType; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.Route; -import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; import com.epam.aidial.core.metaschemas.MetaSchemaHolder; import com.epam.aidial.core.openapi.annotations.ApiOperation; @@ -40,6 +39,7 @@ import com.epam.aidial.core.storage.http.HttpStatus; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.util.UrlUtil; import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; @@ -114,7 +114,8 @@ public Future getApplication(String applicationId) { // sign-in status (one credential lookup per service). The listing skips both to avoid // N×M lookups — it returns the inline definitions only. overlayUserAuthoredServices(data, application); - enrichExternalServiceStatuses(data); + new ExternalServiceStatusEnricher(context, resourceAuthSettingsService) + .enrichApplication(UrlUtil.tryDecodePath(data.getId()), data.getExternalServices()); return data; }) .onSuccess(data -> context.respond(HttpStatus.OK, data)) @@ -465,27 +466,4 @@ private static String appPart(String id) { ? id.substring(CredentialsLocatorFactory.APPLICATIONS_PREFIX.length()) : id; } - private void enrichExternalServiceStatuses(ApplicationData data) { - Map services = data.getExternalServices(); - if (services == null || services.isEmpty()) { - return; - } - String appId = appPart(data.getId()); - ExternalServiceStatusEnricher enricher = new ExternalServiceStatusEnricher(context, resourceAuthSettingsService); - for (Map.Entry entry : services.entrySet()) { - ResourceAuthSettings authSettings = entry.getValue().getAuthSettings(); - if (authSettings == null) { - continue; - } - try { - String scopeId = CredentialsLocatorFactory.APPLICATIONS_PREFIX + appId - + CredentialsLocatorFactory.EXTERNAL_SERVICES_SEPARATOR + entry.getKey(); - CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId, context); - enricher.enrich(locator, authSettings); - } catch (RuntimeException e) { - log.warn("Failed to compute external-service status for '{}' on '{}'", entry.getKey(), data.getId(), e); - } - } - } - } diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java index bed814318..2087c249a 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java @@ -34,6 +34,7 @@ import com.epam.aidial.core.server.security.Operation; import com.epam.aidial.core.server.service.AdminManagedFieldsWriteMode; import com.epam.aidial.core.server.service.ApplicationService; +import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.ToolSetService; import com.epam.aidial.core.server.service.config.ConfigEntityCodec; import com.epam.aidial.core.server.util.ProxyUtil; @@ -49,6 +50,7 @@ import com.epam.aidial.core.storage.service.LockService; import com.epam.aidial.core.storage.service.ResourceService; import com.epam.aidial.core.storage.util.EtagHeader; +import com.epam.aidial.core.storage.util.UrlUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -1119,19 +1121,30 @@ private Future handleGet() { // actually be shown the hint. case APPLICATION -> handleSingleGetFromBlob(ResourceTypes.APPLICATION, (key, application) -> { + Application entity = (Application) application; + new ExternalServiceStatusEnricher(context, context.getProxy().getResourceAuthSettingsService()) + .enrichApplication(path, entity.getExternalServices()); if (admin) { applicationService.decryptExternalServiceSecretsForResponse( - descriptorFor(ResourceTypes.APPLICATION), (Application) application); + descriptorFor(ResourceTypes.APPLICATION), entity); } - return redactExternalServiceSecrets(projectItem(application, key), admin); + return redactExternalServiceSecrets(projectItem(entity, key), admin); }); case TOOL_SET -> handleSingleGetFromBlob(ResourceTypes.TOOL_SET, (key, toolSet) -> { + ToolSet entity = (ToolSet) toolSet; + if (entity.getAuthSettings() != null) { + try { + toolSetService.setResourceAuthStatuses(context, entity, UrlUtil.encodePath(path)); + } catch (RuntimeException e) { + log.warn("Failed to compute auth statuses for platform toolset '{}'", path, e); + } + } if (admin) { toolSetService.decryptAuthSettingsForResponse( - descriptorFor(ResourceTypes.TOOL_SET), (ToolSet) toolSet); + descriptorFor(ResourceTypes.TOOL_SET), entity); } - return redactAuthSettingsSecrets(projectItem(toolSet, key), admin); + return redactAuthSettingsSecrets(projectItem(entity, key), admin); }); case GLOBAL_SETTINGS -> handleSettingsGet(config); default -> respondMethodNotAllowed(); diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java index 76c5f5171..46eb53fee 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java @@ -6,7 +6,6 @@ import com.epam.aidial.core.config.Features; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.ToolSet; -import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.openapi.annotations.ApiHeader; import com.epam.aidial.core.openapi.annotations.ApiOperation; import com.epam.aidial.core.openapi.annotations.ApiOperations; @@ -564,7 +563,8 @@ private Future> getApplicationData(ResourceDe proxy.getExternalServiceService().decryptSecretsForResponse(descriptor, application); } overlayUserAuthoredServices(descriptor, application); - enrichExternalServiceStatuses(descriptor, application); + new ExternalServiceStatusEnricher(context, proxy.getResourceAuthSettingsService()) + .enrichApplication(descriptor.getDecodedUrl(), application.getExternalServices()); clearExternalServiceSecrets(application, hasWriteAccess); if (!accessService.hasAdminAccess(context)) { @@ -591,28 +591,6 @@ private void overlayUserAuthoredServices(ResourceDescriptor descriptor, Applicat application.setExternalServices(merged); } - private void enrichExternalServiceStatuses(ResourceDescriptor descriptor, Application application) { - Map services = application.getExternalServices(); - if (services == null || services.isEmpty()) { - return; - } - ExternalServiceStatusEnricher enricher = new ExternalServiceStatusEnricher( - context, proxy.getResourceAuthSettingsService()); - for (Map.Entry entry : services.entrySet()) { - ResourceAuthSettings authSettings = entry.getValue() == null ? null : entry.getValue().getAuthSettings(); - if (authSettings == null) { - continue; - } - try { - String scopeId = descriptor.getUrl() + CredentialsLocatorFactory.EXTERNAL_SERVICES_SEPARATOR + entry.getKey(); - CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId, context); - enricher.enrich(locator, authSettings); - } catch (RuntimeException e) { - log.warn("Failed to compute external-service status for '{}' on '{}'", entry.getKey(), descriptor.getUrl(), e); - } - } - } - private static void clearExternalServiceSecrets(Application application, boolean hasWriteAccess) { Map services = application.getExternalServices(); if (services == null) { diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java index c824b07f0..ac187e562 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java @@ -1,12 +1,17 @@ package com.epam.aidial.core.server.service; import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.ExternalService; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.ResourceAuthStatus; import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.util.CredentialsDescriptorFactory; +import com.epam.aidial.core.server.util.CredentialsLocatorFactory; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; /** * Fills in the auth statuses of an application's external services for one response. @@ -16,6 +21,7 @@ * *

Memoizes that lookup, so create one per response rather than per service. */ +@Slf4j public class ExternalServiceStatusEnricher { private final ProxyContext context; @@ -35,6 +41,27 @@ public void enrich(CredentialsLocator credentialsLocator, ResourceAuthSettings a } } + /** + * Enriches every external service of one application. One failing service is logged and skipped + * so it cannot fail the whole response. + */ + public void enrichApplication(String appId, Map services) { + if (services == null || services.isEmpty() || appId == null) { + return; + } + for (Map.Entry entry : services.entrySet()) { + ResourceAuthSettings authSettings = entry.getValue() == null ? null : entry.getValue().getAuthSettings(); + if (authSettings == null) { + continue; + } + try { + enrich(CredentialsLocatorFactory.fromExternalService(appId, entry.getKey(), context), authSettings); + } catch (RuntimeException e) { + log.warn("Failed to compute external-service status for '{}' on '{}'", entry.getKey(), appId, e); + } + } + } + private boolean hasOfflineCredentials() { if (offlineCredentials == null) { // A userless caller (an API key) is not a person, so it holds no offline credentials by definition. diff --git a/server/src/main/java/com/epam/aidial/core/server/util/CredentialsLocatorFactory.java b/server/src/main/java/com/epam/aidial/core/server/util/CredentialsLocatorFactory.java index d31689002..5d4e4017b 100644 --- a/server/src/main/java/com/epam/aidial/core/server/util/CredentialsLocatorFactory.java +++ b/server/src/main/java/com/epam/aidial/core/server/util/CredentialsLocatorFactory.java @@ -62,6 +62,19 @@ public static CredentialsLocator fromExternalServiceScope(String scopeId, ProxyC return new CredentialsLocator(resourceId, bucketInfo); } + /** + * Builds a {@link CredentialsLocator} for one external service from its parts. {@code appId} is the + * decoded app segment of the credential scope — a bare name for config-sourced/platform apps, or + * the full decoded {@code applications/{bucket}/{path}} url for dynamic ones. + */ + public static CredentialsLocator fromExternalService(String appId, String externalServiceId, ProxyContext proxyContext) { + String appSegment = appId.startsWith(APPLICATIONS_PREFIX) + ? appId.substring(APPLICATIONS_PREFIX.length()) : appId; + String scopeId = APPLICATIONS_PREFIX + UrlUtil.encodePath(appSegment) + + EXTERNAL_SERVICES_SEPARATOR + UrlUtil.encodePath(externalServiceId); + return fromExternalServiceScope(scopeId, proxyContext); + } + // Storage path a credential is read from / written to. Static-config apps normalize to // applications/config/{appName}/...; dynamic apps preserve the app path. private static String normalizeResourceId(boolean configApp, String appPart, String externalServiceId) { diff --git a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java index 16a7078a2..f20b352d5 100644 --- a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java @@ -12,8 +12,9 @@ /** * HTTP integration tests for applications/toolsets materialized into the {@code platform} bucket. * Covers PUT/GET/DELETE round-trip via {@code ConfigResourceController}, userRoles survival, - * function-type-app rejection, and that decrypted auth_settings/external-service secrets - * held in the merged {@code Config} never leak on GET. + * function-type-app rejection, that decrypted auth_settings/external-service secrets + * held in the merged {@code Config} never leak on GET, and that GET computes the per-user + * sign-in statuses for toolset auth settings and application external services. */ public class PlatformAppToolsetApiTest extends ResourceBaseTest { @@ -404,6 +405,205 @@ void testPlatformToolsetHintOnReadAndRoundTrip() { } } + /** + * The config API read must surface the same per-user sign-in statuses as {@code /openai/toolsets} + * and the generic resource GET. API_KEY auth keeps the test hermetic — no token endpoint to mock. + */ + @Test + void testPlatformToolSetAuthStatusesOnGet() { + String body = """ + { + "endpoint": "http://localhost:9876", + "transport": "HTTP", + "display_name": "Status Toolset", + "auth_settings": { + "authentication_type": "API_KEY", + "api_key_header": "Authorization" + } + } + """; + verify(send(HttpMethod.PUT, "/v1/toolsets/platform/status-toolset", null, body, + "authorization", "admin", "If-None-Match", "*"), 200); + + Response before = send(HttpMethod.GET, "/v1/toolsets/platform/status-toolset", null, "", + "authorization", "admin"); + verify(before, 200); + assertTrue(before.body().contains("\"global_auth_status\":\"SIGNED_OUT\""), + () -> "expected global_auth_status on platform toolset GET: " + before.body()); + assertTrue(before.body().contains("\"user_level_auth_status\":\"SIGNED_OUT\""), + () -> "expected user_level_auth_status on platform toolset GET: " + before.body()); + assertFalse(before.body().contains("\"client_secret\""), + () -> "client_secret must stay absent: " + before.body()); + + // Short-name url — the form sign-in normalizes for platform deployments; it pins the scope + // the GET-side enrichment has to read from. + verify(send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "status-toolset", + "credentialsLevel": "GLOBAL", + "authenticationType": "API_KEY", + "api_key": "Bearer api_key" + } + """, "authorization", "admin"), 200, "true"); + verify(send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "status-toolset", + "credentialsLevel": "USER", + "authenticationType": "API_KEY", + "api_key": "Bearer api_key" + } + """, "authorization", "admin"), 200, "true"); + + Response after = send(HttpMethod.GET, "/v1/toolsets/platform/status-toolset", null, "", + "authorization", "admin"); + verify(after, 200); + assertTrue(after.body().contains("\"global_auth_status\":\"SIGNED_IN\""), + () -> "global_auth_status must flip after GLOBAL sign-in: " + after.body()); + assertTrue(after.body().contains("\"user_level_auth_status\":\"SIGNED_IN\""), + () -> "user_level_auth_status must flip after USER sign-in: " + after.body()); + } + + @Test + void testPlatformApplicationExternalServiceAuthStatusesOnGet() { + String body = """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "Status App", + "external_services": { + "apikey-svc": { + "display_name": "Billing", + "auth_settings": { + "authentication_type": "API_KEY", + "api_key_header": "Authorization" + } + } + } + } + """; + verify(send(HttpMethod.PUT, "/v1/applications/platform/status-app", null, body, + "authorization", "admin", "If-None-Match", "*"), 200); + + Response before = send(HttpMethod.GET, "/v1/applications/platform/status-app", null, "", + "authorization", "admin"); + verify(before, 200); + assertTrue(before.body().contains("\"user_level_auth_status\":\"SIGNED_OUT\""), + () -> "expected user_level_auth_status on platform app GET: " + before.body()); + assertTrue(before.body().contains("\"app_level_auth_status\":\"SIGNED_OUT\""), + () -> "expected app_level_auth_status on platform app GET: " + before.body()); + assertFalse(before.body().contains("\"client_secret\""), + () -> "client_secret must stay absent: " + before.body()); + + // Short-name scope — the same form /v1/ops/external-service/signin normalizes for platform apps. + verify(send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "applications/status-app/external_services/apikey-svc", + "credentials_level": "USER", + "authentication_type": "API_KEY", + "api_key": "k" + } + """, "authorization", "admin"), 200, "true"); + verify(send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "applications/status-app/external_services/apikey-svc", + "credentials_level": "APPLICATION", + "authentication_type": "API_KEY", + "api_key": "k" + } + """, "authorization", "admin"), 200, "true"); + + Response after = send(HttpMethod.GET, "/v1/applications/platform/status-app", null, "", + "authorization", "admin"); + verify(after, 200); + assertTrue(after.body().contains("\"user_level_auth_status\":\"SIGNED_IN\""), + () -> "user_level_auth_status must flip after USER sign-in: " + after.body()); + assertTrue(after.body().contains("\"app_level_auth_status\":\"SIGNED_IN\""), + () -> "app_level_auth_status must flip after APPLICATION sign-in: " + after.body()); + } + + /** + * Statuses and the admin hint are not mutually exclusive: the same GET response must carry both, + * and the sign-in flow behind the status must not disturb the stored secret the hint derives from. + */ + @Test + void testPlatformToolSetStatusesAndHintTogether() { + String secret = "platform-toolset-secret-9c4f"; + String body = """ + { + "endpoint": "http://localhost:9876/mcp", + "transport": "HTTP", + "display_name": "Hint Status Toolset", + "auth_settings": { + "authentication_type": "OAUTH", + "client_id": "cid", + "client_secret": "%s", + "redirect_uri": "http://localhost:3000/auth/signin", + "authorization_endpoint": "http://localhost:9876/authorize", + "token_endpoint": "http://localhost:9876/token" + } + } + """.formatted(secret); + + // The OAUTH sign-in drives protected-resource and authorization-server discovery against the + // toolset endpoint before redeeming the code, so each well-known route is mapped explicitly. + String protectedResourceMetadata = """ + { + "resource": "http://localhost:9876/mcp", + "authorization_servers": ["http://localhost:9876"] + } + """; + String authServerMetadata = """ + { + "issuer": "http://localhost:9876", + "authorization_endpoint": "http://localhost:9876/authorize", + "token_endpoint": "http://localhost:9876/token", + "code_challenge_methods_supported": ["S256"] + } + """; + String tokenResponse = """ + { + "access_token": "t", + "refresh_token": "r", + "expires_in": 3600 + } + """; + try (TestWebServer server = new TestWebServer(9876)) { + server.map(HttpMethod.GET, "/.well-known/oauth-protected-resource/mcp", + 200, protectedResourceMetadata, "Content-Type", "application/json"); + server.map(HttpMethod.GET, "/.well-known/oauth-authorization-server", + 200, authServerMetadata, "Content-Type", "application/json"); + server.map(HttpMethod.POST, "/token", 200, tokenResponse, "Content-Type", "application/json"); + verify(send(HttpMethod.PUT, "/v1/toolsets/platform/hint-status-toolset", null, body, + "authorization", "admin", "If-None-Match", "*"), 200); + + Response get = send(HttpMethod.GET, "/v1/toolsets/platform/hint-status-toolset", null, "", + "authorization", "admin"); + verify(get, 200); + assertTrue(get.body().contains("\"client_secret_hint\":\"9c4f\""), + () -> "admin must see the hint: " + get.body()); + assertTrue(get.body().contains("\"user_level_auth_status\":\"SIGNED_OUT\""), + () -> "expected user_level_auth_status alongside the hint: " + get.body()); + assertFalse(get.body().contains(secret), () -> "secret leaked: " + get.body()); + + verify(send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "hint-status-toolset", + "credentialsLevel": "USER", + "authenticationType": "OAUTH", + "code": "auth-code" + } + """, "authorization", "admin"), 200, "true"); + + Response after = send(HttpMethod.GET, "/v1/toolsets/platform/hint-status-toolset", null, "", + "authorization", "admin"); + verify(after, 200); + assertTrue(after.body().contains("\"user_level_auth_status\":\"SIGNED_IN\""), + () -> "user_level_auth_status must flip after USER sign-in: " + after.body()); + assertTrue(after.body().contains("\"client_secret_hint\":\"9c4f\""), + () -> "hint must survive the sign-in round trip: " + after.body()); + assertFalse(after.body().contains(secret), () -> "secret leaked: " + after.body()); + } + } + @Test void testFunctionTypeApplicationRejectedOnPlatform() { String body = """ From 6f146d80edb70c919edb54f06377fecb316ddf51 Mon Sep 17 00:00:00 2001 From: Kiryl_Kurnosenka Date: Mon, 14 Sep 2026 21:24:31 +0300 Subject: [PATCH 2/3] refactor: move toolset auth-status enrichment into ResourceAuthStatusEnricher #1928 Renames ExternalServiceStatusEnricher to ResourceAuthStatusEnricher and moves toolset status enrichment there (enrichToolSet), absorbing the id encode/decode handling from the controllers. ToolSetService.setResourceAuthStatuses is removed; ConfigResourceController, ResourceController and ToolSetController now go through the enricher. ConfigResourceController also injects ResourceAuthSettingsService via the constructor like its other services. Unit tests follow the moved logic (ResourceAuthStatusEnricherTest). Co-Authored-By: Claude Code --- .../controller/ApplicationController.java | 4 +- .../controller/ConfigResourceController.java | 50 +++----- .../server/controller/ControllerSelector.java | 12 +- .../ExternalServiceManagementController.java | 6 +- .../server/controller/ResourceController.java | 8 +- .../server/controller/ToolSetController.java | 13 ++- ...r.java => ResourceAuthStatusEnricher.java} | 30 ++++- .../core/server/service/ToolSetService.java | 5 - .../controller/ToolSetControllerTest.java | 25 +++- .../ResourceAuthStatusEnricherTest.java | 109 ++++++++++++++++++ .../server/service/ToolSetServiceTest.java | 86 -------------- 11 files changed, 197 insertions(+), 151 deletions(-) rename server/src/main/java/com/epam/aidial/core/server/service/{ExternalServiceStatusEnricher.java => ResourceAuthStatusEnricher.java} (69%) create mode 100644 server/src/test/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricherTest.java diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java index 6005432f8..caa536e33 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java @@ -27,8 +27,8 @@ import com.epam.aidial.core.server.service.ApplicationSchemaService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.DeploymentService; -import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.PermissionDeniedException; +import com.epam.aidial.core.server.service.ResourceAuthStatusEnricher; import com.epam.aidial.core.server.service.UserExternalServiceService; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; import com.epam.aidial.core.server.util.ProxyUtil; @@ -114,7 +114,7 @@ public Future getApplication(String applicationId) { // sign-in status (one credential lookup per service). The listing skips both to avoid // N×M lookups — it returns the inline definitions only. overlayUserAuthoredServices(data, application); - new ExternalServiceStatusEnricher(context, resourceAuthSettingsService) + new ResourceAuthStatusEnricher(context, resourceAuthSettingsService) .enrichApplication(UrlUtil.tryDecodePath(data.getId()), data.getExternalServices()); return data; }) diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java index 2087c249a..924639054 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java @@ -11,6 +11,7 @@ import com.epam.aidial.core.config.Route; import com.epam.aidial.core.config.ToolSet; import com.epam.aidial.core.config.Translator; +import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; import com.epam.aidial.core.openapi.annotations.ApiExtension; import com.epam.aidial.core.openapi.annotations.ApiHeader; import com.epam.aidial.core.openapi.annotations.ApiOperation; @@ -20,6 +21,7 @@ import com.epam.aidial.core.openapi.annotations.ApiSchema; import com.epam.aidial.core.openapi.annotations.OpenApiDescriptions; import com.epam.aidial.core.openapi.annotations.ParameterIn; +import com.epam.aidial.core.server.Proxy; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.config.ConfigPostProcessor; import com.epam.aidial.core.server.config.InvalidEntityRecord; @@ -34,7 +36,7 @@ import com.epam.aidial.core.server.security.Operation; import com.epam.aidial.core.server.service.AdminManagedFieldsWriteMode; import com.epam.aidial.core.server.service.ApplicationService; -import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; +import com.epam.aidial.core.server.service.ResourceAuthStatusEnricher; import com.epam.aidial.core.server.service.ToolSetService; import com.epam.aidial.core.server.service.config.ConfigEntityCodec; import com.epam.aidial.core.server.util.ProxyUtil; @@ -50,7 +52,6 @@ import com.epam.aidial.core.storage.service.LockService; import com.epam.aidial.core.storage.service.ResourceService; import com.epam.aidial.core.storage.util.EtagHeader; -import com.epam.aidial.core.storage.util.UrlUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -103,35 +104,28 @@ public class ConfigResourceController implements Controller { private final LockService lockService; private final ApplicationService applicationService; private final ToolSetService toolSetService; + private final ResourceAuthSettingsService resourceAuthSettingsService; private final String entityType; private final String bucket; private final String path; - public ConfigResourceController(ProxyContext context, - ConfigAuthorizationService authorizationService, - MergedConfigStore mergedConfigStore, - ResourceService resourceService, - AsyncTaskExecutor taskExecutor, - SecretFieldProcessor secretFieldProcessor, - boolean softValidation, - ApiKeyStore apiKeyStore, - LockService lockService, - ApplicationService applicationService, - ToolSetService toolSetService, + public ConfigResourceController(Proxy proxy, + ProxyContext context, String entityType, String bucket, String path) { this.context = context; - this.authorizationService = authorizationService; - this.mergedConfigStore = mergedConfigStore; - this.resourceService = resourceService; - this.taskExecutor = taskExecutor; - this.secretFieldProcessor = secretFieldProcessor; - this.softValidation = softValidation; - this.apiKeyStore = apiKeyStore; - this.lockService = lockService; - this.applicationService = applicationService; - this.toolSetService = toolSetService; + this.authorizationService = proxy.getConfigAuthService(); + this.mergedConfigStore = (MergedConfigStore) proxy.getConfigStore(); + this.resourceService = proxy.getResourceService(); + this.taskExecutor = proxy.getTaskExecutor(); + this.secretFieldProcessor = mergedConfigStore.getSecretFieldProcessor(); + this.softValidation = mergedConfigStore.isSoftValidation(); + this.apiKeyStore = proxy.getApiKeyStore(); + this.lockService = proxy.getLockService(); + this.applicationService = proxy.getApplicationService(); + this.toolSetService = proxy.getToolSetService(); + this.resourceAuthSettingsService = proxy.getResourceAuthSettingsService(); this.entityType = entityType; this.bucket = bucket; this.path = path; @@ -1122,7 +1116,7 @@ private Future handleGet() { case APPLICATION -> handleSingleGetFromBlob(ResourceTypes.APPLICATION, (key, application) -> { Application entity = (Application) application; - new ExternalServiceStatusEnricher(context, context.getProxy().getResourceAuthSettingsService()) + new ResourceAuthStatusEnricher(context, resourceAuthSettingsService) .enrichApplication(path, entity.getExternalServices()); if (admin) { applicationService.decryptExternalServiceSecretsForResponse( @@ -1133,13 +1127,7 @@ private Future handleGet() { case TOOL_SET -> handleSingleGetFromBlob(ResourceTypes.TOOL_SET, (key, toolSet) -> { ToolSet entity = (ToolSet) toolSet; - if (entity.getAuthSettings() != null) { - try { - toolSetService.setResourceAuthStatuses(context, entity, UrlUtil.encodePath(path)); - } catch (RuntimeException e) { - log.warn("Failed to compute auth statuses for platform toolset '{}'", path, e); - } - } + new ResourceAuthStatusEnricher(context, resourceAuthSettingsService).enrichToolSet(path, entity); if (admin) { toolSetService.decryptAuthSettingsForResponse( descriptorFor(ResourceTypes.TOOL_SET), entity); diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java index 67ebbaea0..3255c7aac 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java @@ -665,17 +665,7 @@ private static Controller configResourceController(Proxy proxy, ProxyContext con // FILES/RESOURCE routes (see ResourceDescriptorFactory.fromAnyUrl) and the {@code path} // contract on ResourceDescriptorFactory.fromDecoded ("url decoded relative path"). String path = UrlUtil.decodePath(pathMatcher.group("path")); - ConfigAuthorizationService authService = new AdminRoleAuthorizationService(proxy.getAccessService()); - MergedConfigStore mergedConfigStore = (MergedConfigStore) proxy.getConfigStore(); - return new ConfigResourceController(context, authService, mergedConfigStore, - proxy.getResourceService(), proxy.getTaskExecutor(), - mergedConfigStore.getSecretFieldProcessor(), - mergedConfigStore.isSoftValidation(), - proxy.getApiKeyStore(), - proxy.getLockService(), - proxy.getApplicationService(), - proxy.getToolSetService(), - entityType, bucket, path); + return new ConfigResourceController(proxy, context, entityType, bucket, path); } private static Controller configResourceMetadataController(Proxy proxy, ProxyContext context, Matcher pathMatcher) { diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java index 83e548fd1..a99d0c34d 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java @@ -25,8 +25,8 @@ import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.ExternalServiceService; -import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.PermissionDeniedException; +import com.epam.aidial.core.server.service.ResourceAuthStatusEnricher; import com.epam.aidial.core.server.service.UserExternalServiceService; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; import com.epam.aidial.core.server.util.ProxyUtil; @@ -61,7 +61,7 @@ public class ExternalServiceManagementController { private final EncryptionService encryptionService; private final ResourceCredentialsService resourceCredentialsService; private final ResourceAuthSettingsService resourceAuthSettingsService; - private final ExternalServiceStatusEnricher statusEnricher; + private final ResourceAuthStatusEnricher statusEnricher; public ExternalServiceManagementController(Proxy proxy, ProxyContext context) { this.context = context; @@ -73,7 +73,7 @@ public ExternalServiceManagementController(Proxy proxy, ProxyContext context) { this.encryptionService = proxy.getEncryptionService(); this.resourceCredentialsService = proxy.getResourceCredentialsService(); this.resourceAuthSettingsService = proxy.getResourceAuthSettingsService(); - this.statusEnricher = new ExternalServiceStatusEnricher(context, resourceAuthSettingsService); + this.statusEnricher = new ResourceAuthStatusEnricher(context, resourceAuthSettingsService); } @ApiOperation( diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java index 46eb53fee..30df072bc 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java @@ -23,9 +23,9 @@ import com.epam.aidial.core.server.service.ApplicationSchemaService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.DeploymentService; -import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.ExternalServicesWriteMode; import com.epam.aidial.core.server.service.PermissionDeniedException; +import com.epam.aidial.core.server.service.ResourceAuthStatusEnricher; import com.epam.aidial.core.server.service.ToolSetService; import com.epam.aidial.core.server.util.ApplicationTypeSchemaProcessingException; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; @@ -54,7 +54,6 @@ import java.net.ConnectException; import java.net.http.HttpConnectTimeoutException; import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -563,7 +562,7 @@ private Future> getApplicationData(ResourceDe proxy.getExternalServiceService().decryptSecretsForResponse(descriptor, application); } overlayUserAuthoredServices(descriptor, application); - new ExternalServiceStatusEnricher(context, proxy.getResourceAuthSettingsService()) + new ResourceAuthStatusEnricher(context, proxy.getResourceAuthSettingsService()) .enrichApplication(descriptor.getDecodedUrl(), application.getExternalServices()); clearExternalServiceSecrets(application, hasWriteAccess); @@ -647,7 +646,8 @@ private Future> getToolsetData(ResourceDescri Pair result = toolSetService.getToolSet(descriptor, etagHeader); ResourceItemMetadata meta = result.getKey(); ToolSet toolSet = result.getValue(); - toolSetService.setResourceAuthStatuses(context, toolSet, descriptor.getUrl()); + new ResourceAuthStatusEnricher(context, proxy.getResourceAuthSettingsService()) + .enrichToolSet(descriptor.getDecodedUrl(), toolSet); toolSetService.redactAuthSettings(descriptor, toolSet, hasWriteAccess); if (!hasWriteAccess) { toolSet.setEndpoint(null); diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ToolSetController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ToolSetController.java index 80a6f4ae2..62934bb87 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ToolSetController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ToolSetController.java @@ -14,6 +14,7 @@ import com.epam.aidial.core.server.data.ToolSetData; import com.epam.aidial.core.server.service.DeploymentService; import com.epam.aidial.core.server.service.PermissionDeniedException; +import com.epam.aidial.core.server.service.ResourceAuthStatusEnricher; import com.epam.aidial.core.server.service.ToolSetService; import com.epam.aidial.core.server.vertx.AsyncTaskExecutor; import com.epam.aidial.core.storage.data.ResourceItemMetadata; @@ -66,8 +67,8 @@ public Future getToolSet(String toolSetId) { taskExecutor.submit(() -> { Deployment deployment = deploymentService.findDeployment(context, toolSetId); if (deployment instanceof ToolSet toolSet) { - String encodedToolSetId = UrlUtil.encodePath(toolSetId); - toolSetService.setResourceAuthStatuses(context, toolSet, encodedToolSetId); + new ResourceAuthStatusEnricher(context, context.getProxy().getResourceAuthSettingsService()) + .enrichToolSet(toolSetId, toolSet); return toolSet; } throw new ResourceNotFoundException("Toolset is not found: " + toolSetId); @@ -101,10 +102,12 @@ public Future getToolSets() { } private List mergeToolsets(List resourceToolsets, Config config) { + ResourceAuthStatusEnricher enricher = + new ResourceAuthStatusEnricher(context, context.getProxy().getResourceAuthSettingsService()); List list = new ArrayList<>(); for (ToolSet toolSet : config.getToolsets().values()) { if (toolSet.hasAccess(context.getUserRoles())) { - toolSetService.setResourceAuthStatuses(context, toolSet, toolSet.getName()); + enricher.enrichToolSet(UrlUtil.tryDecodePath(toolSet.getName()), toolSet); list.add(toolSet); } } @@ -139,9 +142,11 @@ private Void updateAuthStatus(int start, int end, List toolSets) { if (end - start <= 0) { return null; } + ResourceAuthStatusEnricher enricher = + new ResourceAuthStatusEnricher(context, context.getProxy().getResourceAuthSettingsService()); for (int i = start; i < end; i++) { ToolSet toolSet = toolSets.get(i); - toolSetService.setResourceAuthStatuses(context, toolSet, toolSet.getName()); + enricher.enrichToolSet(UrlUtil.tryDecodePath(toolSet.getName()), toolSet); } return null; } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java b/server/src/main/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricher.java similarity index 69% rename from server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java rename to server/src/main/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricher.java index ac187e562..a82a3ac4d 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricher.java @@ -4,31 +4,35 @@ import com.epam.aidial.core.config.ExternalService; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.ResourceAuthStatus; +import com.epam.aidial.core.config.ToolSet; import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.util.CredentialsDescriptorFactory; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.util.UrlUtil; import lombok.extern.slf4j.Slf4j; import java.util.Map; /** - * Fills in the auth statuses of an application's external services for one response. + * Fills in the per-caller auth statuses of an application's external services or a toolset for one + * response. * *

A DIAL-native service has no per-service credential, so its user level is answered from the caller's * platform-wide offline credentials instead of its (always empty) USER-level records. * - *

Memoizes that lookup, so create one per response rather than per service. + *

Memoizes that lookup, so create one per response rather than per entity. */ @Slf4j -public class ExternalServiceStatusEnricher { +public class ResourceAuthStatusEnricher { private final ProxyContext context; private final ResourceAuthSettingsService resourceAuthSettingsService; private Boolean offlineCredentials; - public ExternalServiceStatusEnricher(ProxyContext context, ResourceAuthSettingsService resourceAuthSettingsService) { + public ResourceAuthStatusEnricher(ProxyContext context, ResourceAuthSettingsService resourceAuthSettingsService) { this.context = context; this.resourceAuthSettingsService = resourceAuthSettingsService; } @@ -62,6 +66,24 @@ public void enrichApplication(String appId, Map service } } + /** + * Sets the auth statuses of one toolset. {@code toolSetId} is the decoded toolset id — a bare name + * for config-sourced/platform toolsets, or the full decoded {@code toolsets/{bucket}/{path}} url + * for dynamic ones. + */ + public void enrichToolSet(String toolSetId, ToolSet toolSet) { + if (toolSet == null || toolSetId == null || toolSet.getAuthSettings() == null) { + return; + } + try { + CredentialsLocator locator = CredentialsLocatorFactory.fromAnyUrl( + UrlUtil.encodePath(toolSetId), context, ResourceTypes.TOOL_SET); + resourceAuthSettingsService.setResourceAuthStatuses(locator, toolSet.getAuthSettings(), context.getInitiatorId()); + } catch (RuntimeException e) { + log.warn("Failed to compute auth statuses for toolset '{}'", toolSetId, e); + } + } + private boolean hasOfflineCredentials() { if (offlineCredentials == null) { // A userless caller (an API key) is not a person, so it holds no offline credentials by definition. diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ToolSetService.java b/server/src/main/java/com/epam/aidial/core/server/service/ToolSetService.java index 2c4944ab3..6c2b712e8 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ToolSetService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ToolSetService.java @@ -274,11 +274,6 @@ public void copyToolSet(ProxyContext context, } } - public void setResourceAuthStatuses(ProxyContext context, ToolSet toolSet, String encodedToolSetId) { - CredentialsLocator credentialsLocator = CredentialsLocatorFactory.fromAnyUrl(encodedToolSetId, context, ResourceTypes.TOOL_SET); - resourceAuthSettingsService.setResourceAuthStatuses(credentialsLocator, toolSet.getAuthSettings(), context.getInitiatorId()); - } - private boolean copyCredentials(ProxyContext context, ResourceDescriptor source, ResourceDescriptor destination, CredentialsLevel credentialsLevel, boolean overwrite) { CredentialsDescriptor sourceCredentialDescriptor = diff --git a/server/src/test/java/com/epam/aidial/core/server/controller/ToolSetControllerTest.java b/server/src/test/java/com/epam/aidial/core/server/controller/ToolSetControllerTest.java index c4e737539..f5e4acc22 100644 --- a/server/src/test/java/com/epam/aidial/core/server/controller/ToolSetControllerTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/controller/ToolSetControllerTest.java @@ -1,9 +1,14 @@ package com.epam.aidial.core.server.controller; +import com.epam.aidial.core.config.Config; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.ToolSet; +import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; +import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; import com.epam.aidial.core.server.Proxy; import com.epam.aidial.core.server.ProxyContext; +import com.epam.aidial.core.server.data.ApiKeyData; +import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.service.DeploymentService; import com.epam.aidial.core.server.service.ToolSetService; import com.epam.aidial.core.server.vertx.AsyncTaskExecutor; @@ -11,13 +16,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.concurrent.Callable; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -33,6 +42,10 @@ class ToolSetControllerTest { @Mock private ToolSetService toolSetService; @Mock + private ResourceAuthSettingsService resourceAuthSettingsService; + @Mock + private EncryptionService encryptionService; + @Mock private Proxy proxy; private ToolSetController controller; @@ -65,10 +78,20 @@ void getToolSet_setsResourceAuthStatuses() { toolSet.setAuthSettings(new ResourceAuthSettings()); when(deploymentService.findDeployment(context, toolSetId)).thenReturn(toolSet); + when(proxy.getResourceAuthSettingsService()).thenReturn(resourceAuthSettingsService); + when(proxy.getEncryptionService()).thenReturn(encryptionService); + when(context.getConfig()).thenReturn(mock(Config.class)); + when(context.getApiKeyData()).thenReturn(mock(ApiKeyData.class)); + when(context.getUserId()).thenReturn("user-123"); + when(context.getInitiatorId()).thenReturn("initiator-id"); + when(encryptionService.encrypt("Users/user-123/")).thenReturn("encrypted-user-123"); controller.getToolSet(toolSetId); - verify(toolSetService).setResourceAuthStatuses(context, toolSet, toolSetId); + ArgumentCaptor credentialsLocatorCaptor = ArgumentCaptor.forClass(CredentialsLocator.class); + verify(resourceAuthSettingsService).setResourceAuthStatuses( + credentialsLocatorCaptor.capture(), eq(toolSet.getAuthSettings()), eq("initiator-id")); + assertEquals(toolSetId, credentialsLocatorCaptor.getValue().getResourceId()); } //TODO: add more tests diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricherTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricherTest.java new file mode 100644 index 000000000..fe1d5ff4b --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/service/ResourceAuthStatusEnricherTest.java @@ -0,0 +1,109 @@ +package com.epam.aidial.core.server.service; + +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.Config; +import com.epam.aidial.core.config.ResourceAuthSettings; +import com.epam.aidial.core.config.ToolSet; +import com.epam.aidial.core.credentials.data.credentials.BucketInfo; +import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; +import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; +import com.epam.aidial.core.server.Proxy; +import com.epam.aidial.core.server.ProxyContext; +import com.epam.aidial.core.server.data.ApiKeyData; +import com.epam.aidial.core.server.security.EncryptionService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ResourceAuthStatusEnricherTest { + + @Mock + private ProxyContext context; + @Mock + private Proxy proxy; + @Mock + private EncryptionService encryptionService; + @Mock + private ResourceAuthSettingsService resourceAuthSettingsService; + + @Test + void testEnrichToolSetResolvesLocatorFromDecodedId() { + // Given + String toolSetId = "toolsets/test-toolset"; + ToolSet toolSet = new ToolSet(); + toolSet.setName(toolSetId); + toolSet.setAuthSettings(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId("clientId") + .clientSecret("clientSecret") + .build()); + + when(context.getProxy()).thenReturn(proxy); + when(proxy.getEncryptionService()).thenReturn(encryptionService); + when(context.getConfig()).thenReturn(mock(Config.class)); + when(context.getApiKeyData()).thenReturn(mock(ApiKeyData.class)); + when(context.getUserId()).thenReturn("user-123"); + when(encryptionService.encrypt("Users/user-123/")).thenReturn("encrypted-user-123"); + + // When + new ResourceAuthStatusEnricher(context, resourceAuthSettingsService).enrichToolSet(toolSetId, toolSet); + + // Then + assertNotNull(toolSet.getAuthSettings().getClientId()); + assertEquals("clientSecret", toolSet.getAuthSettings().getClientSecret()); + + ArgumentCaptor credentialsLocatorCaptor = ArgumentCaptor.forClass(CredentialsLocator.class); + verify(resourceAuthSettingsService).setResourceAuthStatuses(credentialsLocatorCaptor.capture(), any(), any()); + CredentialsLocator credentialsLocator = credentialsLocatorCaptor.getValue(); + assertEquals(toolSetId, credentialsLocator.getResourceId()); + assertEquals(2, credentialsLocator.getBuckets().size()); + Set bucketNames = credentialsLocator.getBuckets().values().stream() + .map(BucketInfo::name) + .collect(Collectors.toSet()); + assertEquals(Set.of("public", "encrypted-user-123"), bucketNames); + } + + @Test + void testEnrichToolSetDoesNotMutateClientSecret() { + // Given + String toolSetId = "toolsets/test-toolset"; + ToolSet toolSet = new ToolSet(); + toolSet.setName(toolSetId); + toolSet.setAuthSettings(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId("clientId") + .clientSecret("clientSecret") + .codeVerifier("codeVerifier") + .build()); + + when(context.getProxy()).thenReturn(proxy); + when(proxy.getEncryptionService()).thenReturn(encryptionService); + when(context.getConfig()).thenReturn(mock(Config.class)); + when(context.getApiKeyData()).thenReturn(mock(ApiKeyData.class)); + when(context.getUserId()).thenReturn("user-123"); + when(encryptionService.encrypt("Users/user-123/")).thenReturn("encrypted-user-123"); + + // When - enrich multiple times (simulating multiple API requests) + ResourceAuthStatusEnricher enricher = new ResourceAuthStatusEnricher(context, resourceAuthSettingsService); + enricher.enrichToolSet(toolSetId, toolSet); + enricher.enrichToolSet(toolSetId, toolSet); + + // Then - clientSecret and codeVerifier must be preserved after multiple calls + assertEquals("clientSecret", toolSet.getAuthSettings().getClientSecret()); + assertEquals("codeVerifier", toolSet.getAuthSettings().getCodeVerifier()); + } + +} diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ToolSetServiceTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ToolSetServiceTest.java index 7ebd24854..b1f9aa30a 100644 --- a/server/src/test/java/com/epam/aidial/core/server/service/ToolSetServiceTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/service/ToolSetServiceTest.java @@ -1,17 +1,11 @@ package com.epam.aidial.core.server.service; import com.epam.aidial.core.config.AuthenticationType; -import com.epam.aidial.core.config.Config; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.config.ToolSet; import com.epam.aidial.core.credentials.data.credentials.BucketInfo; -import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.credentials.service.ResourceAuthSettingsEncryptionService; import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; -import com.epam.aidial.core.server.Proxy; -import com.epam.aidial.core.server.ProxyContext; -import com.epam.aidial.core.server.data.ApiKeyData; -import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.util.ProxyUtil; import com.epam.aidial.core.storage.data.ResourceItemMetadata; import com.epam.aidial.core.storage.resource.ResourceDescriptor; @@ -28,9 +22,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Set; import java.util.function.Function; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -54,12 +46,6 @@ class ToolSetServiceTest { @Mock private ResourceAuthSettingsEncryptionService resourceAuthSettingsEncryptionService; @Mock - private EncryptionService encryptionService; - @Mock - private ProxyContext context; - @Mock - private Proxy proxy; - @Mock private CatalogSchemaService catalogSchemaService; @InjectMocks @@ -216,78 +202,6 @@ void testGetToolSet_ShouldEncryptAuthSettings() { proxyUtil.close(); } - @Test - void testSetResourceAuthStatuses() { - // Given - String toolSetId = "toolsets/test-toolset"; - ToolSet toolSet = createToolSet(); - toolSet.setName(toolSetId); - - ResourceAuthSettings resourceAuthSettings = ResourceAuthSettings.builder() - .authenticationType(AuthenticationType.OAUTH) - .clientId("clientId") - .clientSecret("clientSecret") - .build(); - - toolSet.setAuthSettings(resourceAuthSettings); - - when(context.getProxy()).thenReturn(proxy); - when(proxy.getEncryptionService()).thenReturn(encryptionService); - when(context.getConfig()).thenReturn(mock(Config.class)); - when(context.getApiKeyData()).thenReturn(mock(ApiKeyData.class)); - when(context.getUserId()).thenReturn("user-123"); - when(encryptionService.encrypt("Users/user-123/")).thenReturn("encrypted-user-123"); - - // When - toolSetService.setResourceAuthStatuses(context, toolSet, toolSetId); - - // Then - assertNotNull(toolSet.getAuthSettings().getClientId()); - assertEquals("clientSecret", toolSet.getAuthSettings().getClientSecret()); - - ArgumentCaptor credentialsLocatorCaptor = ArgumentCaptor.forClass(CredentialsLocator.class); - verify(resourceAuthSettingsService).setResourceAuthStatuses(credentialsLocatorCaptor.capture(), any(), any()); - CredentialsLocator credentialsLocator = credentialsLocatorCaptor.getValue(); - assertEquals(toolSetId, credentialsLocator.getResourceId()); - assertEquals(2, credentialsLocator.getBuckets().size()); - Set bucketNames = credentialsLocator.getBuckets().values().stream() - .map(BucketInfo::name) - .collect(Collectors.toSet()); - assertEquals(Set.of("public", "encrypted-user-123"), bucketNames); - } - - @Test - void testSetResourceAuthStatuses_doesNotMutateClientSecret() { - // Given - String toolSetId = "toolsets/test-toolset"; - ToolSet toolSet = createToolSet(); - toolSet.setName(toolSetId); - - ResourceAuthSettings resourceAuthSettings = ResourceAuthSettings.builder() - .authenticationType(AuthenticationType.OAUTH) - .clientId("clientId") - .clientSecret("clientSecret") - .codeVerifier("codeVerifier") - .build(); - - toolSet.setAuthSettings(resourceAuthSettings); - - when(context.getProxy()).thenReturn(proxy); - when(proxy.getEncryptionService()).thenReturn(encryptionService); - when(context.getConfig()).thenReturn(mock(Config.class)); - when(context.getApiKeyData()).thenReturn(mock(ApiKeyData.class)); - when(context.getUserId()).thenReturn("user-123"); - when(encryptionService.encrypt("Users/user-123/")).thenReturn("encrypted-user-123"); - - // When - call setResourceAuthStatuses multiple times (simulating multiple API requests) - toolSetService.setResourceAuthStatuses(context, toolSet, toolSetId); - toolSetService.setResourceAuthStatuses(context, toolSet, toolSetId); - - // Then - clientSecret and codeVerifier must be preserved after multiple calls - assertEquals("clientSecret", toolSet.getAuthSettings().getClientSecret()); - assertEquals("codeVerifier", toolSet.getAuthSettings().getCodeVerifier()); - } - private static ToolSet createToolSet() { ToolSet toolSet = new ToolSet(); toolSet.setEndpoint("endpoint"); From 6916778620f9eed310bb79069f1b0c0e26c4caf4 Mon Sep 17 00:00:00 2001 From: Kiryl_Kurnosenka Date: Mon, 14 Sep 2026 21:46:33 +0300 Subject: [PATCH 3/3] test: drop stale stubs for the refactored ConfigResourceController constructor #1928 The platform-route tests stubbed proxy.getAccessService() (and app/toolset services) that the constructor no longer reads, tripping Mockito strict stubs; only the config store is dereferenced at construction time. Co-Authored-By: Claude Code --- .../core/server/controller/ControllerSelectorTest.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/src/test/java/com/epam/aidial/core/server/controller/ControllerSelectorTest.java b/server/src/test/java/com/epam/aidial/core/server/controller/ControllerSelectorTest.java index 620cea4d2..9432e8b53 100644 --- a/server/src/test/java/com/epam/aidial/core/server/controller/ControllerSelectorTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/controller/ControllerSelectorTest.java @@ -6,7 +6,6 @@ import com.epam.aidial.core.server.controller.route.GlobalRouteController; import com.epam.aidial.core.server.security.AccessService; import com.epam.aidial.core.server.service.ApplicationService; -import com.epam.aidial.core.server.service.ToolSetService; import io.vertx.core.Context; import io.vertx.core.Vertx; import io.vertx.core.http.HttpMethod; @@ -480,11 +479,7 @@ public void testSelectGetPublicToolSetStillRoutesToResourceController() { } private void stubConfigResourceControllerDependencies() { - when(proxy.getAccessService()).thenReturn(mock(AccessService.class)); - MergedConfigStore mergedConfigStore = mock(MergedConfigStore.class); - when(proxy.getConfigStore()).thenReturn(mergedConfigStore); - when(proxy.getApplicationService()).thenReturn(mock(ApplicationService.class)); - when(proxy.getToolSetService()).thenReturn(mock(ToolSetService.class)); + when(proxy.getConfigStore()).thenReturn(mock(MergedConfigStore.class)); } @Test