From 543215cc392a74640419226c724ffef8f0dd33eb Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 17:45:29 +0200 Subject: [PATCH 1/8] feat(secrets): add vault tenant reset endpoint and fix double-vaulting - Add resetTenant() to ISecretProvider and VaultSecretProvider Deletes all secrets + DEK for a tenant, bypassing DEK decryption. Safe to call when master key has changed and old key is unavailable. - Add POST /{tenantId}/reset REST endpoint in IRestSecretStore/RestSecretStore with input validation, cache invalidation, and proper error responses. - Extract handleDekDecryptionFailure() with actionable 3-option recovery message including secret count context (0/N/unknown) for informed decision-making. - Extract generateAndPersistDek() helper from getOrCreateDek(). - Fix vaultApiKey() in AgentSetupService to detect existing vault refs and pass them through, preventing double-vaulting bug. - Fix LOGGER.errorf -> LOGGER.error to preserve stack traces in error logs. --- .../eddi/engine/setup/AgentSetupService.java | 6 ++ .../ai/labs/eddi/secrets/ISecretProvider.java | 16 ++++ .../secrets/impl/VaultSecretProvider.java | 82 ++++++++++++++++--- .../eddi/secrets/rest/IRestSecretStore.java | 22 +++++ .../eddi/secrets/rest/RestSecretStore.java | 41 ++++++++-- 5 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java index de19140ec2..51f0746ad6 100644 --- a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java +++ b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java @@ -496,6 +496,12 @@ private String vaultApiKey(String apiKey, String agentName) { return apiKey; } + // Already a vault reference — use it directly, don't re-vault + if (apiKey.startsWith("${vault:") && apiKey.endsWith("}")) { + LOGGER.infof("API key for agent '%s' is already a vault reference — using as-is.", agentName); + return apiKey; + } + if (!secretProvider.isAvailable()) { LOGGER.warn("Secrets Vault is not configured — API key will be stored in plaintext. " + "Set EDDI_VAULT_MASTER_KEY to enable encrypted storage."); diff --git a/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java b/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java index c2ace0a53a..1ead239649 100644 --- a/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java +++ b/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java @@ -110,6 +110,22 @@ public interface ISecretProvider { */ int rotateDek(String tenantId) throws SecretProviderException; + /** + * Reset the vault for a specific tenant. Deletes ALL secrets and the DEK, + * allowing the vault to start fresh with the current master key. + *

+ * This is a destructive operation — all encrypted secrets for the tenant + * will be permanently deleted. This bypasses DEK decryption entirely, making it + * safe to call even when the master key has changed. + * + * @param tenantId + * the tenant to reset + * @return the number of secrets that were deleted + * @throws SecretProviderException + * if the reset fails + */ + int resetTenant(String tenantId) throws SecretProviderException; + /** * Check if the secret provider is properly configured and operational. * diff --git a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java index d2e09e647a..d5b339005e 100644 --- a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java +++ b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java @@ -391,6 +391,27 @@ public int rotateKek(String oldMasterKey, String newMasterKey) throws SecretProv } } + @Override + public int resetTenant(String tenantId) throws SecretProviderException { + ensureAvailable(); + + try { + // Delete all secrets first, then the DEK + var secrets = persistence.listSecretsByTenant(tenantId); + int secretCount = secrets.size(); + + for (var secret : secrets) { + persistence.deleteSecret(secret.getTenantId(), secret.getKeyName()); + } + persistence.deleteDek(tenantId); + + LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", tenantId, secretCount); + return secretCount; + } catch (PersistenceException e) { + throw new SecretProviderException("Failed to reset vault for tenant " + tenantId, e); + } + } + // === Private helpers === private byte[] getOrCreateDek(String tenantId) throws SecretProviderException { @@ -398,23 +419,64 @@ private byte[] getOrCreateDek(String tenantId) throws SecretProviderException { var dekOpt = persistence.findDek(tenantId); if (dekOpt.isPresent()) { EncryptedDek encryptedDek = dekOpt.get(); - return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek); + try { + return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek); + } catch (EnvelopeCrypto.CryptoException e) { + return handleDekDecryptionFailure(tenantId, e); + } } - // Generate a new DEK for this tenant - byte[] newDek = EnvelopeCrypto.generateDek(); - EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek); - - EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now()); - - persistence.upsertDek(dek); - LOGGER.infof("Generated new DEK for tenant: %s", tenantId); - return newDek; + return generateAndPersistDek(tenantId); } catch (PersistenceException e) { throw new SecretProviderException("Persistence failure while managing DEK for tenant " + tenantId, e); } } + /** + * Handles the case where an existing DEK cannot be decrypted — typically + * because EDDI_VAULT_MASTER_KEY changed since the DEK was created. + *

+ * Never auto-recovers. Always fails with a clear, actionable error so the user + * can choose the appropriate recovery path. + */ + private byte[] handleDekDecryptionFailure(String tenantId, EnvelopeCrypto.CryptoException cause) throws SecretProviderException { + int secretCount; + try { + secretCount = persistence.listSecretsByTenant(tenantId).size(); + } catch (PersistenceException e) { + secretCount = -1; // unknown + } + + String secretInfo = secretCount == 0 + ? "No secrets are stored for this tenant, so no data would be lost by resetting." + : secretCount > 0 + ? secretCount + " secret(s) are stored for this tenant and would be permanently lost if you reset." + : "Unable to determine how many secrets are stored for this tenant."; + + throw new SecretProviderException( + "Cannot decrypt the Data Encryption Key (DEK) for tenant '" + tenantId + "'. " + + "This means the EDDI_VAULT_MASTER_KEY has changed since the DEK was created. " + + secretInfo + " " + + "Recovery options: " + + "(1) Set EDDI_VAULT_MASTER_KEY back to the original value and restart. " + + "(2) If you have both old and new keys, use POST /secretstore/secrets/admin/rotate-kek " + + "to migrate all encrypted data to the new key. " + + "(3) To start fresh (deletes all secrets for this tenant), use " + + "POST /secretstore/secrets/" + tenantId + "/reset to clear the vault for this tenant.", + cause); + } + + private byte[] generateAndPersistDek(String tenantId) { + byte[] newDek = EnvelopeCrypto.generateDek(); + EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek); + + EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now()); + + persistence.upsertDek(dek); + LOGGER.infof("Generated new DEK for tenant: %s", tenantId); + return newDek; + } + private void ensureAvailable() throws SecretProviderException { if (!available) { throw new SecretProviderException("Secrets Vault is not available. Set EDDI_VAULT_MASTER_KEY environment variable."); diff --git a/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java b/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java index 5d70cc3409..ffee3c7753 100644 --- a/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java +++ b/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java @@ -135,6 +135,28 @@ public interface IRestSecretStore { + "EDDI_VAULT_MASTER_KEY environment variable and restart the application.") Response rotateKek(KekRotationRequest body); + /** + * Reset the vault for a specific tenant. Deletes ALL secrets and the DEK for + * the tenant, allowing the vault to start fresh with the current master key. + *

+ * This is a destructive operation — all encrypted secrets for the tenant will + * be permanently deleted. Use this when the master key has changed and the old + * key is not available. + * + * @param tenantId + * the tenant to reset + * @return 200 with details of what was deleted + */ + @POST + @Path("/{tenantId}/reset") + @Produces(MediaType.APPLICATION_JSON) + @RolesAllowed("eddi-admin") + @Operation(summary = "Reset vault for a tenant", + description = "Deletes ALL secrets and the Data Encryption Key for the tenant. " + + "Use this when the master key has changed and recovery is not possible. " + + "WARNING: This permanently destroys all encrypted secrets for the tenant.") + Response resetTenant(@PathParam("tenantId") String tenantId); + /** * Request body for storing a secret. Includes the plaintext value, an optional * description, and an optional allowed-agents list. diff --git a/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java b/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java index 8e92669442..138464571f 100644 --- a/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java +++ b/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java @@ -117,7 +117,7 @@ public Response storeSecret(String tenantId, String keyName, SecretRequest body) return Response.status(Response.Status.CREATED).entity(responseRef).build(); } } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to store secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage()); + LOGGER.error("Failed to store secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to store secret")).build(); } } @@ -142,7 +142,7 @@ public Response deleteSecret(String tenantId, String keyName) { } catch (ISecretProvider.SecretNotFoundException e) { return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build(); } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to delete secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage()); + LOGGER.error("Failed to delete secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to delete secret")).build(); } } @@ -165,7 +165,7 @@ public Response getSecretMetadata(String tenantId, String keyName) { } catch (ISecretProvider.SecretNotFoundException e) { return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build(); } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to get secret metadata: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage()); + LOGGER.error("Failed to get secret metadata: " + sanitize(tenantId) + "/" + sanitize(keyName), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to get metadata")).build(); } } @@ -184,7 +184,7 @@ public Response listSecrets(String tenantId) { try { return Response.ok(secretProvider.listKeys(tenantId)).build(); } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to list secrets: %s — %s", sanitize(tenantId), e.getMessage()); + LOGGER.error("Failed to list secrets for tenant: " + sanitize(tenantId), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to list secrets")).build(); } } @@ -218,7 +218,7 @@ public Response rotateDek(String tenantId) { return Response.ok(Map.of("tenantId", tenantId, "secretsReEncrypted", count, "message", "DEK rotated successfully. " + count + " secrets re-encrypted.")).build(); } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to rotate DEK for tenant %s: %s", sanitize(tenantId), e.getMessage()); + LOGGER.error("Failed to rotate DEK for tenant: " + sanitize(tenantId), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "DEK rotation failed: " + e.getMessage())).build(); } } @@ -249,8 +249,37 @@ public Response rotateKek(KekRotationRequest body) { return Response.ok(Map.of("deksReEncrypted", count, "message", "KEK rotated successfully. " + count + " DEKs re-encrypted. " + "IMPORTANT: Update the EDDI_VAULT_MASTER_KEY environment variable to the new key and restart.")).build(); } catch (ISecretProvider.SecretProviderException e) { - LOGGER.errorf("Failed to rotate KEK: %s", e.getMessage()); + LOGGER.error("Failed to rotate KEK", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "KEK rotation failed: " + e.getMessage())).build(); } } + + @Override + public Response resetTenant(String tenantId) { + var unavailable = vaultUnavailableResponse(); + if (unavailable.isPresent()) + return unavailable.get(); + + try { + validateId(tenantId, "tenantId"); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build(); + } + + try { + int deletedSecrets = secretProvider.resetTenant(tenantId); + secretResolver.invalidateAll(); + return Response.ok(Map.of( + "tenantId", tenantId, + "secretsDeleted", deletedSecrets, + "message", "Vault reset for tenant '" + tenantId + "'. " + + deletedSecrets + " secret(s) deleted, DEK removed. " + + "The next secret store operation will generate a fresh DEK with the current master key.")) + .build(); + } catch (ISecretProvider.SecretProviderException e) { + LOGGER.error("Failed to reset vault for tenant: " + sanitize(tenantId), e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Vault reset failed: " + e.getMessage())).build(); + } + } } From 06ef33aafb04c721429fef1998eb4727308bae7b Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 17:45:48 +0200 Subject: [PATCH 2/8] test(secrets): add comprehensive tests for resetTenant, handleDekDecryptionFailure, vaultApiKey - VaultSecretProviderBranchTest: 8 new tests covering resetTenant (happy path, empty tenant, persistence failure, vault unavailable), handleDekDecryptionFailure (with N/0/unknown secrets), and generateAndPersistDek (new DEK generation). - RestSecretStoreTest: 6 new tests for resetTenant endpoint (200 success, 503 unavailable, 400 invalid/blank tenantId, 500 provider exception, cache invalidation). - AgentSetupServiceBranchCoverageTest: 3 new tests for vaultApiKey passthrough (vault reference, null, blank). - AgentSigningServiceTest: add resetTenant() stub to InMemorySecretProvider. All 141 tests pass. Coverage: RestSecretStore 100%/95.7%, VaultSecretProvider 78.1%/80.4% (new methods fully covered, remaining gaps in pre-existing code). --- .../agents/AgentSigningServiceTest.java | 6 + .../AgentSetupServiceBranchCoverageTest.java | 740 ++++++++++++++++++ .../impl/VaultSecretProviderBranchTest.java | 548 +++++++++++++ .../secrets/rest/RestSecretStoreTest.java | 272 +++++++ 4 files changed, 1566 insertions(+) create mode 100644 src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java create mode 100644 src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java diff --git a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java index 74de6cc2a6..2b003bfdea 100644 --- a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java +++ b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java @@ -178,6 +178,12 @@ public int rotateDek(String tenantId) { return 0; } + @Override + public int resetTenant(String tenantId) { + store.entrySet().removeIf(e -> e.getKey().startsWith(tenantId + ":")); + return 0; + } + @Override public boolean isAvailable() { return true; diff --git a/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java new file mode 100644 index 0000000000..51ae15a2fc --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java @@ -0,0 +1,740 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.setup; + +import ai.labs.eddi.engine.api.IRestAgentAdministration; +import ai.labs.eddi.engine.model.Deployment; +import ai.labs.eddi.engine.runtime.client.factory.IRestInterfaceFactory; +import ai.labs.eddi.engine.runtime.client.factory.RestInterfaceFactory; +import ai.labs.eddi.secrets.ISecretProvider; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.openMocks; + +@DisplayName("AgentSetupService — Branch Coverage") +class AgentSetupServiceBranchCoverageTest { + + @Mock + private IRestInterfaceFactory restInterfaceFactory; + @Mock + private IRestAgentAdministration agentAdmin; + @Mock + private ISecretProvider secretProvider; + + private AgentSetupService service; + + @BeforeEach + void setUp() { + openMocks(this); + service = new AgentSetupService(restInterfaceFactory, agentAdmin, secretProvider, "http://localhost:11434"); + } + + // ─── parseEnvironment ──────────────────────────────────────────────── + + @Nested + @DisplayName("parseEnvironment") + class ParseEnvironment { + + @Test + @DisplayName("null returns production") + void nullEnv() { + assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment(null)); + } + + @Test + @DisplayName("blank returns production") + void blankEnv() { + assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment(" ")); + } + + @Test + @DisplayName("'test' returns test") + void testEnv() { + assertEquals(Deployment.Environment.test, AgentSetupService.parseEnvironment("test")); + } + + @Test + @DisplayName("'PRODUCTION' returns production") + void productionUpperCase() { + assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment("PRODUCTION")); + } + + @Test + @DisplayName("invalid value returns production") + void invalidEnv() { + assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment("staging")); + } + } + + // ─── extractIdFromLocation ─────────────────────────────────────────── + + @Nested + @DisplayName("extractIdFromLocation") + class ExtractId { + + @Test + @DisplayName("null returns null") + void nullLocation() { + assertNull(AgentSetupService.extractIdFromLocation(null)); + } + + @Test + @DisplayName("blank returns null") + void blankLocation() { + assertNull(AgentSetupService.extractIdFromLocation(" ")); + } + + @Test + @DisplayName("normal location extracts ID") + void normalLocation() { + assertEquals("abc123", AgentSetupService.extractIdFromLocation("/store/resources/abc123?version=1")); + } + + @Test + @DisplayName("location without query") + void noQuery() { + assertEquals("myId", AgentSetupService.extractIdFromLocation("/store/resources/myId")); + } + + @Test + @DisplayName("trailing slash returns null") + void trailingSlash() { + assertNull(AgentSetupService.extractIdFromLocation("/store/resources/")); + } + } + + // ─── extractVersionFromLocation ────────────────────────────────────── + + @Nested + @DisplayName("extractVersionFromLocation") + class ExtractVersion { + + @Test + @DisplayName("null returns 1") + void nullLocation() { + assertEquals(1, AgentSetupService.extractVersionFromLocation(null)); + } + + @Test + @DisplayName("no version param returns 1") + void noVersion() { + assertEquals(1, AgentSetupService.extractVersionFromLocation("/store/resources/abc")); + } + + @Test + @DisplayName("version=3 returns 3") + void normalVersion() { + assertEquals(3, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=3")); + } + + @Test + @DisplayName("version with trailing ¶m returns correctly") + void versionWithAmpersand() { + assertEquals(5, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=5&other=foo")); + } + + @Test + @DisplayName("invalid version number returns 1") + void invalidVersion() { + assertEquals(1, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=notanumber")); + } + } + + // ─── isLocalLlmProvider ────────────────────────────────────────────── + + @Nested + @DisplayName("isLocalLlmProvider") + class IsLocalLlm { + + @Test + @DisplayName("null returns false") + void nullProvider() { + assertFalse(AgentSetupService.isLocalLlmProvider(null)); + } + + @Test + @DisplayName("blank returns false") + void blankProvider() { + assertFalse(AgentSetupService.isLocalLlmProvider(" ")); + } + + @Test + @DisplayName("ollama returns true") + void ollama() { + assertTrue(AgentSetupService.isLocalLlmProvider("ollama")); + } + + @Test + @DisplayName("jlama returns true") + void jlama() { + assertTrue(AgentSetupService.isLocalLlmProvider("jlama")); + } + + @Test + @DisplayName("bedrock returns true") + void bedrock() { + assertTrue(AgentSetupService.isLocalLlmProvider("bedrock")); + } + + @Test + @DisplayName("oracle-genai returns true") + void oracleGenai() { + assertTrue(AgentSetupService.isLocalLlmProvider("oracle-genai")); + } + + @Test + @DisplayName("OLLAMA (uppercase) returns true") + void ollamaUpperCase() { + assertTrue(AgentSetupService.isLocalLlmProvider("OLLAMA")); + } + + @Test + @DisplayName("openai returns false") + void openai() { + assertFalse(AgentSetupService.isLocalLlmProvider("openai")); + } + + @Test + @DisplayName("anthropic returns false") + void anthropic() { + assertFalse(AgentSetupService.isLocalLlmProvider("anthropic")); + } + } + + // ─── supportsResponseFormat ────────────────────────────────────────── + + @Nested + @DisplayName("supportsResponseFormat") + class SupportsResponseFormat { + + @Test + @DisplayName("openai supports response format") + void openai() { + assertTrue(AgentSetupService.supportsResponseFormat("openai")); + } + + @Test + @DisplayName("mistral supports response format") + void mistral() { + assertTrue(AgentSetupService.supportsResponseFormat("mistral")); + } + + @Test + @DisplayName("azure-openai supports response format") + void azureOpenai() { + assertTrue(AgentSetupService.supportsResponseFormat("azure-openai")); + } + + @Test + @DisplayName("anthropic does not support response format") + void anthropic() { + assertFalse(AgentSetupService.supportsResponseFormat("anthropic")); + } + + @Test + @DisplayName("gemini does not support response format") + void gemini() { + assertFalse(AgentSetupService.supportsResponseFormat("gemini")); + } + + @Test + @DisplayName("ollama does not support response format") + void ollama() { + assertFalse(AgentSetupService.supportsResponseFormat("ollama")); + } + } + + // ─── buildPromptResponseJson ───────────────────────────────────────── + + @Nested + @DisplayName("buildPromptResponseJson") + class BuildPromptResponseJson { + + @Test + @DisplayName("neither quick replies nor sentiment returns null") + void neitherEnabled() { + assertNull(AgentSetupService.buildPromptResponseJson(false, false)); + } + + @Test + @DisplayName("quick replies only returns JSON with quickReplies") + void quickRepliesOnly() { + String result = AgentSetupService.buildPromptResponseJson(true, false); + assertNotNull(result); + assertTrue(result.contains("quickReplies")); + assertTrue(result.contains("htmlResponseText")); + assertFalse(result.contains("sentiment")); + } + + @Test + @DisplayName("sentiment only returns JSON with sentiment") + void sentimentOnly() { + String result = AgentSetupService.buildPromptResponseJson(false, true); + assertNotNull(result); + assertTrue(result.contains("sentiment")); + assertTrue(result.contains("htmlResponseText")); + assertFalse(result.contains("quickReplies")); + } + + @Test + @DisplayName("both enabled returns JSON with both") + void bothEnabled() { + String result = AgentSetupService.buildPromptResponseJson(true, true); + assertNotNull(result); + assertTrue(result.contains("quickReplies")); + assertTrue(result.contains("sentiment")); + assertTrue(result.contains("htmlResponseText")); + } + } + + // ─── resolveParams ────────────────────────────────────────────────── + + @Nested + @DisplayName("resolveParams") + class ResolveParams { + + @Test + @DisplayName("null provider defaults to anthropic") + void nullProvider() { + var params = service.resolveParams(null, null, null, null); + assertEquals("anthropic", params.providerType()); + assertEquals("claude-sonnet-4-6", params.modelId()); + assertTrue(params.shouldDeploy()); + assertEquals(Deployment.Environment.production, params.env()); + } + + @Test + @DisplayName("blank provider defaults to anthropic") + void blankProvider() { + var params = service.resolveParams(" ", " ", null, null); + assertEquals("anthropic", params.providerType()); + assertEquals("claude-sonnet-4-6", params.modelId()); + } + + @Test + @DisplayName("explicit provider and model used") + void explicitProviderAndModel() { + var params = service.resolveParams("openai", "gpt-4", true, "test"); + assertEquals("openai", params.providerType()); + assertEquals("gpt-4", params.modelId()); + assertTrue(params.shouldDeploy()); + assertEquals(Deployment.Environment.test, params.env()); + } + + @Test + @DisplayName("deploy=false disables deploy") + void deployFalse() { + var params = service.resolveParams("openai", "gpt-4", false, null); + assertFalse(params.shouldDeploy()); + } + } + + // ─── setupAgent validation ────────────────────────────────────────── + + @Nested + @DisplayName("setupAgent — validation") + class SetupAgentValidation { + + @Test + @DisplayName("null agent name throws") + void nullAgentName() { + var req = new SetupAgentRequest(null, "prompt", "anthropic", "model", + "key", null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("blank agent name throws") + void blankAgentName() { + var req = new SetupAgentRequest(" ", "prompt", "anthropic", "model", + "key", null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("null system prompt throws") + void nullSystemPrompt() { + var req = new SetupAgentRequest("Agent", null, "anthropic", "model", + "key", null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("blank system prompt throws") + void blankSystemPrompt() { + var req = new SetupAgentRequest("Agent", " ", "anthropic", "model", + "key", null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("cloud provider without API key throws") + void cloudProviderNoApiKey() { + var req = new SetupAgentRequest("Agent", "prompt", "openai", "gpt-4", + null, null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("cloud provider with blank API key throws") + void cloudProviderBlankApiKey() { + var req = new SetupAgentRequest("Agent", "prompt", "anthropic", "model", + " ", null, null, null, null, null, null, null, null, null); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + + @Test + @DisplayName("local provider (ollama) without API key does NOT throw for validation") + void localProviderNoApiKeyOk() throws Exception { + var req = new SetupAgentRequest("Agent", "prompt", "ollama", "llama3", + null, null, null, null, null, null, null, null, false, null); + // Will fail at REST call, but validation should pass + when(restInterfaceFactory.get(any())).thenThrow(new RestInterfaceFactory.RestInterfaceFactoryException("mock", new RuntimeException())); + assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req)); + } + } + + // ─── deployAndWait branches ────────────────────────────────────────── + + @Nested + @DisplayName("deployAndWait") + class DeployAndWait { + + @Test + @DisplayName("HTTP 200 with READY status") + void http200Ready() { + @SuppressWarnings("unchecked") + Map body = Map.of("status", "READY"); + Response response = mock(Response.class); + when(response.getStatus()).thenReturn(200); + when(response.getEntity()).thenReturn(body); + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertEquals(true, result.get("deployed")); + assertEquals("READY", result.get("deploymentStatus")); + } + + @Test + @DisplayName("HTTP 200 with ERROR status includes error") + void http200ErrorStatus() { + @SuppressWarnings("unchecked") + Map body = Map.of("status", "ERROR", "error", "LLM unreachable"); + Response response = mock(Response.class); + when(response.getStatus()).thenReturn(200); + when(response.getEntity()).thenReturn(body); + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertFalse((Boolean) result.get("deployed")); + assertEquals("ERROR", result.get("deploymentStatus")); + assertNotNull(result.get("deployWarning")); + } + + @Test + @DisplayName("HTTP 200 with null body → parse error branch") + void http200NullBody() { + Response response = mock(Response.class); + when(response.getStatus()).thenReturn(200); + when(response.getEntity()).thenThrow(new ClassCastException("not a map")); + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertEquals(false, result.get("deployed")); + assertEquals("UNKNOWN", result.get("deploymentStatus")); + } + + @Test + @DisplayName("HTTP 202 → in progress") + void http202() { + Response response = mock(Response.class); + when(response.getStatus()).thenReturn(202); + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertEquals(false, result.get("deployed")); + assertEquals("IN_PROGRESS", result.get("deploymentStatus")); + } + + @Test + @DisplayName("HTTP 500 → unexpected status") + void http500() { + Response response = mock(Response.class); + when(response.getStatus()).thenReturn(500); + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertEquals(false, result.get("deployed")); + assertNotNull(result.get("deployError")); + } + + @Test + @DisplayName("deploy throws exception") + void deployException() { + when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())) + .thenThrow(new RuntimeException("network error")); + + var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1); + assertEquals(false, result.get("deployed")); + assertNotNull(result.get("deployError")); + } + } + + // ─── createLlmConfig branches ──────────────────────────────────────── + + @Nested + @DisplayName("createLlmConfig — provider branches") + class CreateLlmConfig { + + @Test + @DisplayName("ollama provider sets baseUrl") + void ollamaProvider() { + var config = service.createLlmConfig("ollama", "llama3", null, "prompt", + false, null, null, null, false, false, null); + assertNotNull(config); + assertEquals(1, config.tasks().size()); + var params = config.tasks().get(0).getParameters(); + assertEquals("llama3", params.get("model")); + assertEquals("http://localhost:11434", params.get("baseUrl")); + } + + @Test + @DisplayName("ollama with custom baseUrl") + void ollamaCustomBaseUrl() { + var config = service.createLlmConfig("ollama", "llama3", null, "prompt", + false, null, "http://custom:1234", null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("http://custom:1234", params.get("baseUrl")); + } + + @Test + @DisplayName("jlama provider sets modelName and authToken") + void jlamaProvider() { + var config = service.createLlmConfig("jlama", "model-x", "mytoken", "prompt", + false, null, null, null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("model-x", params.get("modelName")); + assertEquals("mytoken", params.get("authToken")); + } + + @Test + @DisplayName("jlama without API key does not set authToken") + void jlamaNoApiKey() { + var config = service.createLlmConfig("jlama", "model-x", null, "prompt", + false, null, null, null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertNull(params.get("authToken")); + } + + @Test + @DisplayName("bedrock provider sets modelId") + void bedrockProvider() { + var config = service.createLlmConfig("bedrock", "anthropic.claude-3", null, "prompt", + false, null, null, null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("anthropic.claude-3", params.get("modelId")); + } + + @Test + @DisplayName("azure-openai sets deploymentName, apiKey, endpoint, responseFormat") + void azureOpenai() { + String promptJson = "some json format"; + var config = service.createLlmConfig("azure-openai", "gpt-4", "mykey", "prompt", + false, null, "https://myaoi.openai.azure.com", promptJson, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("gpt-4", params.get("deploymentName")); + assertEquals("mykey", params.get("apiKey")); + assertEquals("https://myaoi.openai.azure.com", params.get("endpoint")); + assertEquals("json", params.get("responseFormat")); + } + + @Test + @DisplayName("oracle-genai sets modelName") + void oracleGenai() { + var config = service.createLlmConfig("oracle-genai", "cohere.command", null, "prompt", + false, null, null, null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("cohere.command", params.get("modelName")); + } + + @Test + @DisplayName("default provider (anthropic) sets modelName, apiKey, responseFormat if json") + void defaultProviderWithJson() { + // openai is in 'supportsResponseFormat' so it takes the default branch but with + // responseFormat + var config = service.createLlmConfig("openai", "gpt-4", "sk-key", "prompt", + false, null, "https://custom.api.com", "json schema", false, false, null); + var params = config.tasks().get(0).getParameters(); + assertEquals("gpt-4", params.get("modelName")); + assertEquals("sk-key", params.get("apiKey")); + assertEquals("https://custom.api.com", params.get("baseUrl")); + assertEquals("json", params.get("responseFormat")); + } + + @Test + @DisplayName("default provider without baseUrl does not set baseUrl") + void defaultProviderNoBaseUrl() { + var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt", + false, null, null, null, false, false, null); + var params = config.tasks().get(0).getParameters(); + assertNull(params.get("baseUrl")); + } + + @Test + @DisplayName("tooling enabled with whitelist") + void toolingWithWhitelist() { + var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt", + true, "tool1, tool2, tool3", null, null, false, false, null); + var task = config.tasks().get(0); + assertTrue(task.getEnableBuiltInTools()); + assertNotNull(task.getBuiltInToolsWhitelist()); + assertEquals(3, task.getBuiltInToolsWhitelist().size()); + } + + @Test + @DisplayName("tooling enabled without whitelist") + void toolingNoWhitelist() { + var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt", + true, null, null, null, false, false, null); + var task = config.tasks().get(0); + assertTrue(task.getEnableBuiltInTools()); + } + + @Test + @DisplayName("tool URIs set tools list") + void toolUris() { + var uris = java.util.List.of("/httpcalls/loc1", "/httpcalls/loc2"); + var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt", + false, null, null, null, false, false, uris); + var task = config.tasks().get(0); + assertEquals(uris, task.getTools()); + } + + @Test + @DisplayName("promptResponseJson sets postResponse and addToOutput=false") + void promptResponseJsonSetsPostResponse() { + var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt", + false, null, null, "json format", true, false, null); + var task = config.tasks().get(0); + assertNotNull(task.getPostResponse()); + assertEquals("false", task.getParameters().get("addToOutput")); + assertEquals("true", task.getParameters().get("convertToObject")); + } + } + + // ─── buildPostResponse ─────────────────────────────────────────────── + + @Nested + @DisplayName("buildPostResponse") + class BuildPostResponse { + + @Test + @DisplayName("without quick replies — no QR instructions") + void noQuickReplies() { + var postResponse = service.buildPostResponse(false, false); + assertNotNull(postResponse.getOutputBuildInstructions()); + assertNull(postResponse.getQrBuildInstructions()); + } + + @Test + @DisplayName("with quick replies — has QR instructions") + void withQuickReplies() { + var postResponse = service.buildPostResponse(true, false); + assertNotNull(postResponse.getQrBuildInstructions()); + assertEquals(1, postResponse.getQrBuildInstructions().size()); + } + } + + // ─── createWorkflowConfig branches ─────────────────────────────────── + + @Nested + @DisplayName("createWorkflowConfig") + class CreateWorkflowConfig { + + @Test + @DisplayName("all locations provided — full pipeline") + void fullPipeline() { + var config = service.createWorkflowConfig( + "/parser/loc", "/behavior/loc", + java.util.List.of("/http1", "/http2"), + java.util.List.of("/mcp1"), + "/langchain/loc", "/output/loc"); + // parser + behavior + 2 httpcalls + 1 mcpcalls + langchain + output = 7 + assertEquals(7, config.getWorkflowSteps().size()); + } + + @Test + @DisplayName("null parser location — skipped") + void nullParser() { + var config = service.createWorkflowConfig( + null, "/behavior/loc", + null, null, "/langchain/loc", null); + // behavior + langchain = 2 + assertEquals(2, config.getWorkflowSteps().size()); + } + + @Test + @DisplayName("null output location — skipped") + void nullOutput() { + var config = service.createWorkflowConfig( + "/parser/loc", "/behavior/loc", + null, null, "/langchain/loc", null); + // parser + behavior + langchain = 3 + assertEquals(3, config.getWorkflowSteps().size()); + } + } + + // ─── vaultApiKey (private, tested via reflection) ──────────────────── + + @Nested + @DisplayName("vaultApiKey") + class VaultApiKey { + + private String invokeVaultApiKey(String apiKey, String agentName) throws Exception { + var method = AgentSetupService.class.getDeclaredMethod("vaultApiKey", String.class, String.class); + method.setAccessible(true); + return (String) method.invoke(service, apiKey, agentName); + } + + @Test + @DisplayName("vault reference is passed through as-is (not re-vaulted)") + void passthroughVaultReference() throws Exception { + String vaultRef = "${vault:anthropic-api-key}"; + + String result = invokeVaultApiKey(vaultRef, "MyAgent"); + + assertEquals(vaultRef, result, "Already-vaulted reference should be returned unchanged"); + // Should NOT attempt to store anything + verifyNoInteractions(secretProvider); + } + + @Test + @DisplayName("null apiKey returns null") + void nullApiKey() throws Exception { + String result = invokeVaultApiKey(null, "MyAgent"); + + assertNull(result); + verifyNoInteractions(secretProvider); + } + + @Test + @DisplayName("blank apiKey returns blank") + void blankApiKey() throws Exception { + String result = invokeVaultApiKey(" ", "MyAgent"); + + assertEquals(" ", result); + verifyNoInteractions(secretProvider); + } + } +} diff --git a/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java new file mode 100644 index 0000000000..a02166a68a --- /dev/null +++ b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java @@ -0,0 +1,548 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.secrets.impl; + +import ai.labs.eddi.secrets.ISecretProvider.SecretProviderException; +import ai.labs.eddi.secrets.crypto.EnvelopeCrypto; +import ai.labs.eddi.secrets.crypto.VaultSaltManager; +import ai.labs.eddi.secrets.model.*; +import ai.labs.eddi.secrets.persistence.ISecretPersistence; +import ai.labs.eddi.secrets.persistence.PersistenceException; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.quarkus.runtime.StartupEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Extended branch coverage tests for {@link VaultSecretProvider}. Focuses on: - + * getOrCreateDek (new DEK generation path) - getOrCreateDek persistence failure + * - rotateDek success - rotateKek success (both legacy and non-legacy salt) - + * getMetadata persistence error - listKeys persistence error - + * updateLastAccessed persistence failure - store with encryption failure - + * resolve with crypto failure (DEK decryption) + */ +@DisplayName("VaultSecretProvider Extended Branch Coverage Tests") +class VaultSecretProviderBranchTest { + + private static final String MASTER_KEY = "test-master-key-12345678901234"; + private static final byte[] FIXED_SALT = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + private static final String TENANT_ID = "test-tenant"; + private static final String KEY_NAME = "api-key"; + + @Mock + private ISecretPersistence persistence; + + @Mock + private VaultSaltManager saltManager; + + private SimpleMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + meterRegistry = new SimpleMeterRegistry(); + } + + private VaultSecretProvider createAvailableProvider() { + when(saltManager.getSalt()).thenReturn(FIXED_SALT); + when(saltManager.isUsingLegacySalt()).thenReturn(false); + + VaultSecretProvider provider = new VaultSecretProvider( + Optional.of(MASTER_KEY), persistence, saltManager, meterRegistry); + provider.initMetrics(); + provider.onStartup(mock(StartupEvent.class)); + return provider; + } + + private EncryptedDek createEncryptedDek(byte[] rawDek) { + byte[] kek = EnvelopeCrypto.deriveKeyFromString(MASTER_KEY, FIXED_SALT); + EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(rawDek, kek); + return new EncryptedDek("dek-id-1", TENANT_ID, + encResult.ciphertext(), encResult.iv(), Instant.now()); + } + + private EncryptedSecret createEncryptedSecret(String plaintext, byte[] dek) { + EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encrypt(plaintext, dek); + String checksum = EnvelopeCrypto.sha256Hex(plaintext); + return new EncryptedSecret( + "secret-id-1", TENANT_ID, KEY_NAME, + encResult.ciphertext(), encResult.iv(), + TENANT_ID, checksum, "test description", + List.of("*"), Instant.now(), null, null); + } + + // ─── getOrCreateDek — new DEK generation ─── + + @Nested + @DisplayName("getOrCreateDek") + class GetOrCreateDekTests { + + @Test + @DisplayName("generates new DEK when none exists for tenant") + void generatesNewDekWhenNoneExists() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + // No existing DEK + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.empty()); + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.empty()); + + // Store should succeed, creating a new DEK along the way + provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "test-value", "desc", null); + + // Verify DEK was upserted + verify(persistence).upsertDek(any(EncryptedDek.class)); + verify(persistence).upsertSecret(any(EncryptedSecret.class)); + } + + @Test + @DisplayName("DEK persistence failure throws SecretProviderException") + void dekPersistenceFailure() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.empty()); + doThrow(new PersistenceException("DEK write failed")) + .when(persistence).upsertDek(any(EncryptedDek.class)); + + assertThrows(SecretProviderException.class, + () -> provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, null)); + } + } + + // ─── rotateDek success ─── + + @Nested + @DisplayName("rotateDek") + class RotateDekTests { + + @Test + @DisplayName("successful DEK rotation re-encrypts all secrets") + void successfulRotation() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + EncryptedSecret encSecret = createEncryptedSecret("my-secret", dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(encDek)); + when(persistence.listSecretsByTenant(TENANT_ID)).thenReturn(List.of(encSecret)); + + int count = provider.rotateDek(TENANT_ID); + + assertEquals(1, count); + // Old secret was re-encrypted + new DEK stored + verify(persistence).upsertSecret(any(EncryptedSecret.class)); + verify(persistence).upsertDek(any(EncryptedDek.class)); + } + + @Test + @DisplayName("persistence error during DEK rotation throws SecretProviderException") + void persistenceErrorDuringRotation() { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + EncryptedSecret encSecret = createEncryptedSecret("my-secret", dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(encDek)); + when(persistence.listSecretsByTenant(TENANT_ID)).thenReturn(List.of(encSecret)); + doThrow(new PersistenceException("write failed")) + .when(persistence).upsertSecret(any(EncryptedSecret.class)); + + assertThrows(SecretProviderException.class, + () -> provider.rotateDek(TENANT_ID)); + } + } + + // ─── rotateKek success ─── + + @Nested + @DisplayName("rotateKek") + class RotateKekTests { + + @Test + @DisplayName("successful KEK rotation re-encrypts all DEKs") + void successfulKekRotation() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + + when(persistence.listAllDeks()).thenReturn(List.of(encDek)); + + int count = provider.rotateKek(MASTER_KEY, "new-master-key-abc12345678901"); + + assertEquals(1, count); + verify(persistence).upsertDek(any(EncryptedDek.class)); + } + + @Test + @DisplayName("KEK rotation with legacy salt migration") + void kekRotationWithLegacySalt() throws Exception { + when(saltManager.getSalt()).thenReturn(FIXED_SALT); + when(saltManager.isUsingLegacySalt()).thenReturn(true); + + VaultSecretProvider provider = new VaultSecretProvider( + Optional.of(MASTER_KEY), persistence, saltManager, meterRegistry); + provider.initMetrics(); + provider.onStartup(mock(StartupEvent.class)); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + + when(persistence.listAllDeks()).thenReturn(List.of(encDek)); + + int count = provider.rotateKek(MASTER_KEY, "new-master-key-abc12345678901"); + + assertEquals(1, count); + verify(saltManager).migrateSalt(any(byte[].class)); + } + + @Test + @DisplayName("persistence error during KEK rotation throws SecretProviderException") + void persistenceErrorDuringKekRotation() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.listAllDeks()) + .thenThrow(new PersistenceException("DB down")); + + assertThrows(SecretProviderException.class, + () -> provider.rotateKek(MASTER_KEY, "new-key-12345678901234567890")); + } + } + + // ─── getMetadata persistence error ─── + + @Nested + @DisplayName("getMetadata errors") + class GetMetadataErrorTests { + + @Test + @DisplayName("persistence error throws SecretProviderException") + void persistenceError() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.findSecret(TENANT_ID, KEY_NAME)) + .thenThrow(new PersistenceException("DB down")); + + assertThrows(SecretProviderException.class, + () -> provider.getMetadata(new SecretReference(TENANT_ID, KEY_NAME))); + } + } + + // ─── listKeys persistence error ─── + + @Nested + @DisplayName("listKeys errors") + class ListKeysErrorTests { + + @Test + @DisplayName("persistence error throws SecretProviderException") + void persistenceError() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenThrow(new PersistenceException("DB down")); + + assertThrows(SecretProviderException.class, + () -> provider.listKeys(TENANT_ID)); + } + } + + // ─── updateLastAccessed failure path ─── + + @Nested + @DisplayName("updateLastAccessed") + class UpdateLastAccessedTests { + + @Test + @DisplayName("successful resolve calls updateLastAccessed via upsertSecret") + void successfulResolveUpdatesLastAccessed() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + String plaintext = "my-secret"; + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + EncryptedSecret encSecret = createEncryptedSecret(plaintext, dek); + + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.of(encSecret)); + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(encDek)); + + String result = provider.resolve(new SecretReference(TENANT_ID, KEY_NAME)); + assertEquals(plaintext, result); + + // updateLastAccessed should have called upsertSecret + verify(persistence).upsertSecret(any(EncryptedSecret.class)); + } + } + + // ─── resolve with new DEK creation (no existing DEK) ─── + + @Nested + @DisplayName("resolve with no existing DEK") + class ResolveWithNewDek { + + @Test + @DisplayName("resolve fails when no DEK and no secret") + void resolveFailsNoSecret() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.empty()); + + assertThrows(ai.labs.eddi.secrets.ISecretProvider.SecretNotFoundException.class, + () -> provider.resolve(new SecretReference(TENANT_ID, KEY_NAME))); + } + } + + // ─── store with null allowedAgents defaults to ["*"] ─── + + @Nested + @DisplayName("store defaults") + class StoreDefaults { + + @Test + @DisplayName("null allowedAgents defaults to wildcard") + void nullAllowedAgentsDefaults() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(encDek)); + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.empty()); + + provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, null); + + var captor = org.mockito.ArgumentCaptor.forClass(EncryptedSecret.class); + verify(persistence).upsertSecret(captor.capture()); + assertEquals(List.of("*"), captor.getValue().getAllowedAgents()); + } + + @Test + @DisplayName("null description is stored as null") + void nullDescription() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek encDek = createEncryptedDek(dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(encDek)); + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.empty()); + + provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, List.of("agent-1")); + + var captor = org.mockito.ArgumentCaptor.forClass(EncryptedSecret.class); + verify(persistence).upsertSecret(captor.capture()); + assertNull(captor.getValue().getDescription()); + assertEquals(List.of("agent-1"), captor.getValue().getAllowedAgents()); + } + } + + // ─── createUnavailableProvider helper ─── + + private VaultSecretProvider createUnavailableProvider() { + VaultSecretProvider provider = new VaultSecretProvider( + Optional.empty(), persistence, saltManager, meterRegistry); + provider.initMetrics(); + return provider; + } + + // ─── resetTenant ─── + + @Nested + @DisplayName("resetTenant") + class ResetTenantTests { + + @Test + @DisplayName("happy path deletes all secrets then DEK and returns count") + void happyPath() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedSecret secret1 = createEncryptedSecret("val1", dek); + EncryptedSecret secret2 = createEncryptedSecret("val2", dek); + // Give them distinct key names for verification + secret1.setKeyName("key-1"); + secret2.setKeyName("key-2"); + + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenReturn(List.of(secret1, secret2)); + + int result = provider.resetTenant(TENANT_ID); + + assertEquals(2, result); + verify(persistence).deleteSecret(TENANT_ID, "key-1"); + verify(persistence).deleteSecret(TENANT_ID, "key-2"); + verify(persistence).deleteDek(TENANT_ID); + } + + @Test + @DisplayName("empty tenant still deletes DEK and returns 0") + void emptyTenant() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenReturn(List.of()); + + int result = provider.resetTenant(TENANT_ID); + + assertEquals(0, result); + verify(persistence, never()).deleteSecret(anyString(), anyString()); + verify(persistence).deleteDek(TENANT_ID); + } + + @Test + @DisplayName("persistence failure wraps as SecretProviderException") + void persistenceFailure() { + VaultSecretProvider provider = createAvailableProvider(); + + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenThrow(new PersistenceException("DB down")); + + var ex = assertThrows(SecretProviderException.class, + () -> provider.resetTenant(TENANT_ID)); + assertTrue(ex.getMessage().contains("Failed to reset vault")); + } + + @Test + @DisplayName("vault unavailable throws SecretProviderException") + void vaultUnavailable() { + VaultSecretProvider provider = createUnavailableProvider(); + + assertThrows(SecretProviderException.class, + () -> provider.resetTenant(TENANT_ID)); + } + } + + // ─── handleDekDecryptionFailure (triggered via getOrCreateDek) ─── + + @Nested + @DisplayName("handleDekDecryptionFailure") + class HandleDekDecryptionFailureTests { + + /** + * Creates a DEK encrypted with a DIFFERENT master key so that decryption with + * the current KEK fails with AEADBadTagException (wrapped in CryptoException). + */ + private EncryptedDek createDekEncryptedWithDifferentKey(byte[] rawDek) { + String differentMasterKey = "DIFFERENT-master-key-9876543210"; + byte[] wrongKek = EnvelopeCrypto.deriveKeyFromString(differentMasterKey, FIXED_SALT); + EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(rawDek, wrongKek); + return new EncryptedDek("dek-wrong", TENANT_ID, + encResult.ciphertext(), encResult.iv(), Instant.now()); + } + + @Test + @DisplayName("with secrets stored — error message lists count and recovery options") + void withSecretsStored() { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek wrongDek = createDekEncryptedWithDifferentKey(dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(wrongDek)); + when(persistence.listSecretsByTenant(TENANT_ID)).thenReturn( + List.of(createEncryptedSecret("s1", dek), + createEncryptedSecret("s2", dek), + createEncryptedSecret("s3", dek))); + + var ex = assertThrows(SecretProviderException.class, + () -> provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, null)); + + assertTrue(ex.getMessage().contains("3 secret(s) are stored"), + "Expected '3 secret(s) are stored' but got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("Set EDDI_VAULT_MASTER_KEY back")); + assertTrue(ex.getMessage().contains("rotate-kek")); + assertTrue(ex.getMessage().contains("/reset")); + } + + @Test + @DisplayName("with 0 secrets — message says no data would be lost") + void withZeroSecrets() { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek wrongDek = createDekEncryptedWithDifferentKey(dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(wrongDek)); + when(persistence.listSecretsByTenant(TENANT_ID)).thenReturn(List.of()); + + var ex = assertThrows(SecretProviderException.class, + () -> provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, null)); + + assertTrue(ex.getMessage().contains("No secrets are stored"), + "Expected 'No secrets are stored' but got: " + ex.getMessage()); + } + + @Test + @DisplayName("persistence error counting secrets — message says unable to determine") + void persistenceErrorCounting() { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedDek wrongDek = createDekEncryptedWithDifferentKey(dek); + + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.of(wrongDek)); + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenThrow(new PersistenceException("DB error")); + + var ex = assertThrows(SecretProviderException.class, + () -> provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "value", null, null)); + + assertTrue(ex.getMessage().contains("Unable to determine"), + "Expected 'Unable to determine' but got: " + ex.getMessage()); + } + } + + // ─── generateAndPersistDek (triggered when no DEK exists) ─── + + @Nested + @DisplayName("generateAndPersistDek") + class GenerateAndPersistDekTests { + + @Test + @DisplayName("new DEK generated and stored end-to-end when none exists") + void newDekGenerated() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + // No existing DEK → triggers generateAndPersistDek + when(persistence.findDek(TENANT_ID)).thenReturn(Optional.empty()); + when(persistence.findSecret(TENANT_ID, KEY_NAME)).thenReturn(Optional.empty()); + + // Store a secret — this forces DEK creation + provider.store(new SecretReference(TENANT_ID, KEY_NAME), + "test-secret-value", "desc", null); + + // Verify a new DEK was upserted + var dekCaptor = org.mockito.ArgumentCaptor.forClass(EncryptedDek.class); + verify(persistence).upsertDek(dekCaptor.capture()); + EncryptedDek generatedDek = dekCaptor.getValue(); + assertEquals(TENANT_ID, generatedDek.getTenantId()); + assertNotNull(generatedDek.getEncryptedDek()); + assertNotNull(generatedDek.getIv()); + + // Verify the secret was also stored + verify(persistence).upsertSecret(any(EncryptedSecret.class)); + } + } +} diff --git a/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java b/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java index a46c2a8773..637766fb0a 100644 --- a/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java +++ b/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java @@ -9,7 +9,10 @@ import ai.labs.eddi.secrets.model.SecretMetadata; import ai.labs.eddi.secrets.model.SecretReference; import jakarta.ws.rs.core.Response; +import ai.labs.eddi.secrets.impl.VaultSecretProvider; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.time.Instant; @@ -260,4 +263,273 @@ void storeSecret_returnsFullFormRefForCustomTenant() throws Exception { Map body = (Map) resp.getEntity(); assertEquals("${vault:acme-corp/dbPassword}", body.get("reference")); } + + // ─── storeSecret — error paths ─── + + @Nested + @DisplayName("storeSecret error paths") + class StoreSecretErrors { + + @Test + @DisplayName("should return 500 when store throws SecretProviderException") + void returns500OnProviderException() throws Exception { + when(secretProvider.getMetadata(any())) + .thenThrow(new ISecretProvider.SecretNotFoundException("not found")); + doThrow(new ISecretProvider.SecretProviderException("IO error")) + .when(secretProvider).store(any(), any(), any(), any()); + + Response resp = rest.storeSecret("default", "myKey", + new IRestSecretStore.SecretRequest("secret123", "desc", null)); + + assertEquals(500, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 when tenantId is null") + void returns400ForNullTenantId() { + Response resp = rest.storeSecret(null, "key", + new IRestSecretStore.SecretRequest("val", null, null)); + assertEquals(400, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 when value is null in body") + void returns400ForNullValue() { + Response resp = rest.storeSecret("default", "key", + new IRestSecretStore.SecretRequest(null, null, null)); + assertEquals(400, resp.getStatus()); + } + } + + // ─── deleteSecret — additional error paths ─── + + @Nested + @DisplayName("deleteSecret additional paths") + class DeleteSecretAdditional { + + @Test + @DisplayName("should return 503 when vault is unavailable") + void returns503WhenUnavailable() { + when(secretProvider.isAvailable()).thenReturn(false); + Response resp = rest.deleteSecret("default", "myKey"); + assertEquals(503, resp.getStatus()); + } + + @Test + @DisplayName("should return 500 when delete throws SecretProviderException") + void returns500OnProviderException() throws Exception { + doThrow(new ISecretProvider.SecretProviderException("IO error")) + .when(secretProvider).delete(any()); + + Response resp = rest.deleteSecret("default", "myKey"); + assertEquals(500, resp.getStatus()); + } + } + + // ─── getSecretMetadata — additional paths ─── + + @Nested + @DisplayName("getSecretMetadata additional paths") + class GetMetadataAdditional { + + @Test + @DisplayName("should return 503 when vault is unavailable") + void returns503WhenUnavailable() { + when(secretProvider.isAvailable()).thenReturn(false); + Response resp = rest.getSecretMetadata("default", "myKey"); + assertEquals(503, resp.getStatus()); + } + + @Test + @DisplayName("should return 500 when getMetadata throws SecretProviderException") + void returns500OnProviderException() throws Exception { + when(secretProvider.getMetadata(any())) + .thenThrow(new ISecretProvider.SecretProviderException("corrupt data")); + + Response resp = rest.getSecretMetadata("default", "myKey"); + assertEquals(500, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 for invalid tenantId") + void returns400ForInvalidTenantId() { + Response resp = rest.getSecretMetadata("../evil", "myKey"); + assertEquals(400, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 for invalid keyName") + void returns400ForInvalidKeyName() { + Response resp = rest.getSecretMetadata("default", "key with spaces"); + assertEquals(400, resp.getStatus()); + } + } + + // ─── listSecrets — additional paths ─── + + @Nested + @DisplayName("listSecrets additional paths") + class ListSecretsAdditional { + + @Test + @DisplayName("should return 503 when vault is unavailable") + void returns503WhenUnavailable() { + when(secretProvider.isAvailable()).thenReturn(false); + Response resp = rest.listSecrets("default"); + assertEquals(503, resp.getStatus()); + } + + @Test + @DisplayName("should return 500 when listKeys throws SecretProviderException") + void returns500OnProviderException() throws Exception { + when(secretProvider.listKeys("default")) + .thenThrow(new ISecretProvider.SecretProviderException("DB error")); + + Response resp = rest.listSecrets("default"); + assertEquals(500, resp.getStatus()); + } + } + + // ─── rotateKek — additional paths ─── + + @Nested + @DisplayName("rotateKek additional paths") + class RotateKekAdditional { + + @Test + @DisplayName("should return 503 when vault is unavailable") + void returns503WhenUnavailable() { + when(secretProvider.isAvailable()).thenReturn(false); + Response resp = rest.rotateKek( + new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + assertEquals(503, resp.getStatus()); + } + + @Test + @DisplayName("should return 500 when provider is not VaultSecretProvider") + void returns500WhenNotVaultProvider() { + // Default mock is ISecretProvider (not VaultSecretProvider) + Response resp = rest.rotateKek( + new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + assertEquals(500, resp.getStatus()); + } + + @Test + @DisplayName("should return 200 on successful KEK rotation with VaultSecretProvider") + @SuppressWarnings("unchecked") + void returns200OnSuccess() throws Exception { + var vaultProvider = mock(VaultSecretProvider.class); + when(vaultProvider.isAvailable()).thenReturn(true); + when(vaultProvider.rotateKek("oldkey123", "newkey12345678")).thenReturn(5); + var vaultRest = new RestSecretStore(vaultProvider, secretResolver); + + Response resp = vaultRest.rotateKek( + new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + + assertEquals(200, resp.getStatus()); + Map body = (Map) resp.getEntity(); + assertEquals(5, body.get("deksReEncrypted")); + verify(secretResolver).invalidateAll(); + } + + @Test + @DisplayName("should return 500 when VaultSecretProvider.rotateKek throws") + void returns500OnRotateKekFailure() throws Exception { + var vaultProvider = mock(VaultSecretProvider.class); + when(vaultProvider.isAvailable()).thenReturn(true); + when(vaultProvider.rotateKek(any(), any())) + .thenThrow(new ISecretProvider.SecretProviderException("Key derivation failed")); + var vaultRest = new RestSecretStore(vaultProvider, secretResolver); + + Response resp = vaultRest.rotateKek( + new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + + assertEquals(500, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 when newMasterKey is null") + void returns400WhenNewKeyNull() { + Response resp = rest.rotateKek( + new IRestSecretStore.KekRotationRequest("oldkey123", null)); + assertEquals(400, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 when oldMasterKey is blank") + void returns400WhenOldKeyBlank() { + Response resp = rest.rotateKek( + new IRestSecretStore.KekRotationRequest(" ", "newkey12345678")); + assertEquals(400, resp.getStatus()); + } + } + + // ─── resetTenant ─── + + @Nested + @DisplayName("resetTenant") + class ResetTenantTests { + + @Test + @DisplayName("should return 200 with secretsDeleted count on success") + @SuppressWarnings("unchecked") + void returns200OnSuccess() throws Exception { + when(secretProvider.resetTenant("default")).thenReturn(3); + + Response resp = rest.resetTenant("default"); + + assertEquals(200, resp.getStatus()); + Map body = (Map) resp.getEntity(); + assertEquals(3, body.get("secretsDeleted")); + assertNotNull(body.get("message")); + assertEquals("default", body.get("tenantId")); + } + + @Test + @DisplayName("should return 503 when vault is unavailable") + void returns503WhenUnavailable() { + when(secretProvider.isAvailable()).thenReturn(false); + + Response resp = rest.resetTenant("default"); + + assertEquals(503, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 for path-traversal tenantId") + void returns400ForPathTraversalTenantId() { + Response resp = rest.resetTenant("../etc/passwd"); + + assertEquals(400, resp.getStatus()); + } + + @Test + @DisplayName("should return 400 for blank tenantId") + void returns400ForBlankTenantId() { + Response resp = rest.resetTenant(" "); + + assertEquals(400, resp.getStatus()); + } + + @Test + @DisplayName("should return 500 when provider throws SecretProviderException") + void returns500OnProviderException() throws Exception { + when(secretProvider.resetTenant("default")) + .thenThrow(new ISecretProvider.SecretProviderException("DEK corrupted")); + + Response resp = rest.resetTenant("default"); + + assertEquals(500, resp.getStatus()); + } + + @Test + @DisplayName("should invalidate all cached secrets on success") + void invalidatesCacheOnSuccess() throws Exception { + when(secretProvider.resetTenant("default")).thenReturn(2); + + rest.resetTenant("default"); + + verify(secretResolver).invalidateAll(); + } + } } From b80bbdda502eab4ff9ba9adb1b8661f084e698f7 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 18:33:01 +0200 Subject: [PATCH 3/8] fix(secrets): address PR review feedback - vaultApiKey: use SecretReference.isVaultReference() + compiledPattern() to also handle legacy references (CodeRabbit + Copilot). - resetTenant: count actual successful deletions via deleteSecret() return value instead of assuming secrets.size() (Copilot). - InMemorySecretProvider.resetTenant: return actual deleted count (Copilot). - Add tests for legacy eddivault and full-form vault reference passthrough. - Fix deleteSecret mock to return true for accurate count assertions. --- .../eddi/engine/setup/AgentSetupService.java | 6 +++-- .../secrets/impl/VaultSecretProvider.java | 10 +++++---- .../agents/AgentSigningServiceTest.java | 3 ++- .../AgentSetupServiceBranchCoverageTest.java | 22 +++++++++++++++++++ .../impl/VaultSecretProviderBranchTest.java | 2 ++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java index 51f0746ad6..24e4c027d1 100644 --- a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java +++ b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java @@ -496,8 +496,10 @@ private String vaultApiKey(String apiKey, String agentName) { return apiKey; } - // Already a vault reference — use it directly, don't re-vault - if (apiKey.startsWith("${vault:") && apiKey.endsWith("}")) { + // Already a vault reference — use it directly, don't re-vault (supports legacy + // ${eddivault:...}) + if (SecretReference.isVaultReference(apiKey) + && SecretReference.compiledPattern().matcher(apiKey).matches()) { LOGGER.infof("API key for agent '%s' is already a vault reference — using as-is.", agentName); return apiKey; } diff --git a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java index d5b339005e..f27f52d7eb 100644 --- a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java +++ b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java @@ -398,15 +398,17 @@ public int resetTenant(String tenantId) throws SecretProviderException { try { // Delete all secrets first, then the DEK var secrets = persistence.listSecretsByTenant(tenantId); - int secretCount = secrets.size(); + int deletedCount = 0; for (var secret : secrets) { - persistence.deleteSecret(secret.getTenantId(), secret.getKeyName()); + if (persistence.deleteSecret(tenantId, secret.getKeyName())) { + deletedCount++; + } } persistence.deleteDek(tenantId); - LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", tenantId, secretCount); - return secretCount; + LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", tenantId, deletedCount); + return deletedCount; } catch (PersistenceException e) { throw new SecretProviderException("Failed to reset vault for tenant " + tenantId, e); } diff --git a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java index 2b003bfdea..8595269c47 100644 --- a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java +++ b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java @@ -180,8 +180,9 @@ public int rotateDek(String tenantId) { @Override public int resetTenant(String tenantId) { + int before = store.size(); store.entrySet().removeIf(e -> e.getKey().startsWith(tenantId + ":")); - return 0; + return before - store.size(); } @Override diff --git a/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java index 51ae15a2fc..65242aca2c 100644 --- a/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java +++ b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java @@ -736,5 +736,27 @@ void blankApiKey() throws Exception { assertEquals(" ", result); verifyNoInteractions(secretProvider); } + + @Test + @DisplayName("legacy ${eddivault:...} reference is passed through as-is") + void passthroughLegacyVaultReference() throws Exception { + String legacyRef = "${eddivault:old-api-key}"; + + String result = invokeVaultApiKey(legacyRef, "LegacyAgent"); + + assertEquals(legacyRef, result, "Legacy eddivault reference should be returned unchanged"); + verifyNoInteractions(secretProvider); + } + + @Test + @DisplayName("full-form ${vault:tenant/key} reference is passed through as-is") + void passthroughFullFormVaultReference() throws Exception { + String fullRef = "${vault:my-tenant/my-api-key}"; + + String result = invokeVaultApiKey(fullRef, "MultiTenantAgent"); + + assertEquals(fullRef, result, "Full-form vault reference should be returned unchanged"); + verifyNoInteractions(secretProvider); + } } } diff --git a/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java index a02166a68a..0d87f0984a 100644 --- a/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java +++ b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java @@ -384,6 +384,8 @@ void happyPath() throws Exception { when(persistence.listSecretsByTenant(TENANT_ID)) .thenReturn(List.of(secret1, secret2)); + when(persistence.deleteSecret(eq(TENANT_ID), anyString())) + .thenReturn(true); int result = provider.resetTenant(TENANT_ID); From 1ac6e0009b0cdb96d9f3c70078ad12fc3dd40ae7 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 18:54:25 +0200 Subject: [PATCH 4/8] fix(test): resolve gitleaks false positives in rotateKek tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace high-entropy dummy value 'newkey12345678' with clearly-fake 'test-new-master-key' in RestSecretStoreTest rotateKek fixtures. - Add historical fingerprints to .gitleaksignore for the already-pushed commit (06ef33aa) — these are unit test fixture values, not real secrets. --- .gitleaksignore | 7 +++++++ .../labs/eddi/secrets/rest/RestSecretStoreTest.java | 12 ++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index f6b00a4644..cda01103ff 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -11,3 +11,10 @@ # # Review this file periodically to ensure suppressions are still valid. # ───────────────────────────────────────────────────────────────── + +# RestSecretStoreTest rotateKek tests — dummy KEK rotation fixture values (not real secrets) +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445 diff --git a/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java b/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java index 637766fb0a..30d9773f95 100644 --- a/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java +++ b/src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java @@ -401,7 +401,7 @@ class RotateKekAdditional { void returns503WhenUnavailable() { when(secretProvider.isAvailable()).thenReturn(false); Response resp = rest.rotateKek( - new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + new IRestSecretStore.KekRotationRequest("oldkey123", "test-new-master-key")); assertEquals(503, resp.getStatus()); } @@ -410,7 +410,7 @@ void returns503WhenUnavailable() { void returns500WhenNotVaultProvider() { // Default mock is ISecretProvider (not VaultSecretProvider) Response resp = rest.rotateKek( - new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + new IRestSecretStore.KekRotationRequest("oldkey123", "test-new-master-key")); assertEquals(500, resp.getStatus()); } @@ -420,11 +420,11 @@ void returns500WhenNotVaultProvider() { void returns200OnSuccess() throws Exception { var vaultProvider = mock(VaultSecretProvider.class); when(vaultProvider.isAvailable()).thenReturn(true); - when(vaultProvider.rotateKek("oldkey123", "newkey12345678")).thenReturn(5); + when(vaultProvider.rotateKek("oldkey123", "test-new-master-key")).thenReturn(5); var vaultRest = new RestSecretStore(vaultProvider, secretResolver); Response resp = vaultRest.rotateKek( - new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + new IRestSecretStore.KekRotationRequest("oldkey123", "test-new-master-key")); assertEquals(200, resp.getStatus()); Map body = (Map) resp.getEntity(); @@ -442,7 +442,7 @@ void returns500OnRotateKekFailure() throws Exception { var vaultRest = new RestSecretStore(vaultProvider, secretResolver); Response resp = vaultRest.rotateKek( - new IRestSecretStore.KekRotationRequest("oldkey123", "newkey12345678")); + new IRestSecretStore.KekRotationRequest("oldkey123", "test-new-master-key")); assertEquals(500, resp.getStatus()); } @@ -459,7 +459,7 @@ void returns400WhenNewKeyNull() { @DisplayName("should return 400 when oldMasterKey is blank") void returns400WhenOldKeyBlank() { Response resp = rest.rotateKek( - new IRestSecretStore.KekRotationRequest(" ", "newkey12345678")); + new IRestSecretStore.KekRotationRequest(" ", "test-new-master-key")); assertEquals(400, resp.getStatus()); } } From 93116429c34f11c917cb55df09ac5f8c72622ae5 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 18:54:41 +0200 Subject: [PATCH 5/8] test(properties): expand PropertySetterTaskTest with 17 new branch coverage tests - fromObjectPath: String, Map, List, Integer, Float, Boolean value types - fromObjectPath + toObjectPath: PathNavigator.setValue integration - scope=secret: auto-vault store + graceful degradation on vault failure - valueFloat, valueBoolean, valueList: typed property instruction coverage - TemplateEngineException: wraps in LifecycleException - CATCH_ANY_INPUT_AS_PROPERTY: empty input skips property - Previous step null actions: null-safe actions data handling - configure: URI-based config loading + ServiceException wrapping Total: 45 tests (28 existing + 17 new), all passing. --- .../impl/PropertySetterTaskTest.java | 767 ++++++++++++++++++ 1 file changed, 767 insertions(+) diff --git a/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java b/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java index 4281a51e09..1d8a18a7d7 100644 --- a/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java +++ b/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java @@ -6,6 +6,9 @@ import ai.labs.eddi.configs.properties.model.Property; import ai.labs.eddi.configs.properties.model.PropertyInstruction; +import ai.labs.eddi.configs.propertysetter.model.PropertySetterConfiguration; +import ai.labs.eddi.engine.lifecycle.exceptions.LifecycleException; +import ai.labs.eddi.engine.lifecycle.exceptions.WorkflowConfigurationException; import ai.labs.eddi.engine.memory.IConversationMemory; import ai.labs.eddi.engine.memory.IConversationMemory.*; import ai.labs.eddi.engine.memory.IData; @@ -13,6 +16,7 @@ import ai.labs.eddi.engine.memory.IMemoryItemConverter; import ai.labs.eddi.engine.model.Context; import ai.labs.eddi.engine.runtime.client.configuration.IResourceClientLibrary; +import ai.labs.eddi.engine.runtime.service.ServiceException; import ai.labs.eddi.modules.nlp.expressions.Expressions; import ai.labs.eddi.modules.nlp.expressions.utilities.IExpressionProvider; import ai.labs.eddi.modules.properties.IPropertySetter; @@ -24,7 +28,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.net.URI; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -406,6 +412,730 @@ void catchAnyInput() throws Exception { assertDoesNotThrow(() -> task.execute(memory, propertySetter)); verify(conversationProperties).put(eq("user_input"), any(Property.class)); } + + @Test + @DisplayName("fromObjectPath with String value — templates and stores as Property") + void fromObjectPathStringValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("result", "theValue")); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("extracted"); + instruction.setFromObjectPath("context.result"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("extracted"), captor.capture()); + assertEquals("theValue", captor.getValue().getValueString()); + } + + @Test + @DisplayName("fromObjectPath with Map value — stores as Property with Map value") + void fromObjectPathMapValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var innerMap = new LinkedHashMap(); + innerMap.put("a", "1"); + innerMap.put("b", "2"); + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("data", innerMap)); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("mapProp"); + instruction.setFromObjectPath("context.data"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("mapProp"), captor.capture()); + assertEquals(innerMap, captor.getValue().getValueObject()); + } + + @Test + @DisplayName("fromObjectPath with List value — stores as Property with List value") + void fromObjectPathListValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var innerList = new ArrayList<>(List.of("x", "y", "z")); + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("items", innerList)); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("listProp"); + instruction.setFromObjectPath("context.items"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("listProp"), captor.capture()); + assertEquals(innerList, captor.getValue().getValueList()); + } + + @Test + @DisplayName("fromObjectPath with Integer value — stores as Property with Integer value") + void fromObjectPathIntegerValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("count", 99)); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("intProp"); + instruction.setFromObjectPath("context.count"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("intProp"), captor.capture()); + assertEquals(99, captor.getValue().getValueInt()); + } + + @Test + @DisplayName("fromObjectPath with Float value — stores as Property with Float value") + void fromObjectPathFloatValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("rate", 3.14f)); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("floatProp"); + instruction.setFromObjectPath("context.rate"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("floatProp"), captor.capture()); + assertEquals(3.14f, captor.getValue().getValueFloat()); + } + + @Test + @DisplayName("fromObjectPath with Boolean value — stores as Property with Boolean value") + void fromObjectPathBooleanValue() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("extract")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("active", Boolean.TRUE)); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("boolProp"); + instruction.setFromObjectPath("context.active"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("extract")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("boolProp"), captor.capture()); + assertEquals(Boolean.TRUE, captor.getValue().getValueBoolean()); + } + + @Test + @DisplayName("fromObjectPath with toObjectPath set — calls PathNavigator.setValue") + void fromObjectPathWithToObjectPath() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("copy")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + // Build a mutable nested map for PathNavigator.setValue to write into + var targetMap = new HashMap(); + var templateDataObjects = new HashMap(); + templateDataObjects.put("context", Map.of("source", "sourceValue")); + templateDataObjects.put("target", targetMap); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("copied"); + instruction.setFromObjectPath("context.source"); + instruction.setToObjectPath("target.dest"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("copy")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + // When toObjectPath is set, should NOT put into conversationProperties directly + verify(conversationProperties, never()).put(eq("copied"), any(Property.class)); + // Instead, PathNavigator.setValue should have written to the target map + assertEquals("sourceValue", targetMap.get("dest")); + } + + @Test + @DisplayName("scope=secret — auto-vaults plaintext and stores vault reference") + void scopeSecretAutoVaults() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("store_secret")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + when(memory.getAgentId()).thenReturn("agent123"); + + // No input data matching the secret, so scrubbing is skipped + when(currentStep.getLatestData("input:initial")).thenReturn(null); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("apiKey"); + instruction.setValueString("my-secret-key-123"); + instruction.setScope(Property.Scope.secret); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("store_secret")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + // Verify secretProvider.store was called + verify(secretProvider).store(any(), eq("my-secret-key-123"), anyString(), anyList()); + + // Verify the property is stored with conversation scope (not secret) and vault + // ref + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("apiKey"), captor.capture()); + var storedProperty = captor.getValue(); + assertEquals(Property.Scope.conversation, storedProperty.getScope()); + assertTrue(storedProperty.getValueString().startsWith("${vault:")); + } + + @Test + @DisplayName("scope=secret vault fails — logs error and returns plaintext") + void scopeSecretVaultFails() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("store_secret")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + when(memory.getAgentId()).thenReturn("agent456"); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + // Make vault storage fail + doThrow(new ISecretProvider.SecretProviderException("Vault unavailable")) + .when(secretProvider).store(any(), anyString(), anyString(), anyList()); + + var instruction = new PropertyInstruction(); + instruction.setName("apiKey"); + instruction.setValueString("plaintext-secret"); + instruction.setScope(Property.Scope.secret); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("store_secret")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + // Should not throw — graceful degradation + assertDoesNotThrow(() -> task.execute(memory, propertySetter)); + + // Verify property is stored with plaintext (degraded mode) + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("apiKey"), captor.capture()); + assertEquals("plaintext-secret", captor.getValue().getValueString()); + } + + @Test + @DisplayName("valueFloat — stores Float property via instruction") + void valueFloat() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("set_rate")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("rate"); + instruction.setValueFloat(9.99f); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("set_rate")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("rate"), captor.capture()); + assertEquals(9.99f, captor.getValue().getValueFloat()); + } + + @Test + @DisplayName("valueBoolean — stores Boolean property via instruction") + void valueBoolean() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("set_flag")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var instruction = new PropertyInstruction(); + instruction.setName("enabled"); + instruction.setValueBoolean(true); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("set_flag")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("enabled"), captor.capture()); + assertEquals(true, captor.getValue().getValueBoolean()); + } + + @Test + @DisplayName("valueList — stores List property via instruction") + void valueList() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("set_tags")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenAnswer(inv -> inv.getArgument(0)); + + var tagList = List.of("tag1", "tag2", "tag3"); + var instruction = new PropertyInstruction(); + instruction.setName("tags"); + instruction.setValueList(tagList); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("set_tags")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(0); + + task.execute(memory, propertySetter); + + var captor = ArgumentCaptor.forClass(Property.class); + verify(conversationProperties).put(eq("tags"), captor.capture()); + assertEquals(tagList, captor.getValue().getValueList()); + } + + @Test + @DisplayName("template processing failure — wraps in LifecycleException") + void templateProcessingFailureThrowsLifecycleException() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + when(currentStep.getLatestData("expressions:parsed")).thenReturn(null); + when(currentStep.getAllData("context")).thenReturn(null); + + var actionsData = mock(IData.class); + when(currentStep.getLatestData("actions")).thenReturn(actionsData); + when(actionsData.getResult()).thenReturn(List.of("fail")); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + // First call to processTemplate (for name) succeeds, second (for value) fails + when(templatingEngine.processTemplate(anyString(), anyMap())) + .thenReturn("resolvedName") + .thenThrow(new ITemplatingEngine.TemplateEngineException("Bad template", null)); + + var instruction = new PropertyInstruction(); + instruction.setName("broken"); + instruction.setValueString("{{invalid}}"); + instruction.setScope(Property.Scope.conversation); + instruction.setOverride(true); + + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("fail")); + setOnActions.setSetProperties(List.of(instruction)); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions)); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + assertThrows(LifecycleException.class, () -> task.execute(memory, propertySetter)); + } + + @Test + @DisplayName("CATCH_ANY_INPUT_AS_PROPERTY with empty input — should NOT add property") + void catchAnyInputEmptyInput() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + var expressionsData = mock(IData.class); + when(expressionsData.getResult()).thenReturn(""); + when(currentStep.getLatestData("expressions:parsed")).thenReturn(expressionsData); + when(currentStep.getAllData("context")).thenReturn(null); + when(currentStep.getLatestData("actions")).thenReturn(null); + + when(expressionProvider.parseExpressions("")).thenReturn(new Expressions()); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(1); + + var previousStep = mock(IWritableConversationStep.class); + when(previousSteps.get(0)).thenReturn(previousStep); + + var prevActionsData = mock(IData.class); + when(previousStep.getLatestData("actions")).thenReturn(prevActionsData); + when(prevActionsData.getResult()).thenReturn(List.of("CATCH_ANY_INPUT_AS_PROPERTY")); + + var inputData = mock(IData.class); + when(currentStep.getLatestData("input:initial")).thenReturn(inputData); + when(inputData.getResult()).thenReturn(""); // empty input + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of()); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + task.execute(memory, propertySetter); + + // Should NOT store user_input property for empty input + verify(conversationProperties, never()).put(eq("user_input"), any(Property.class)); + } + + @Test + @DisplayName("previous step has no actions data — should not throw") + void previousStepNullActionsData() throws Exception { + var memory = mock(IConversationMemory.class); + var currentStep = mock(IWritableConversationStep.class); + when(memory.getCurrentStep()).thenReturn(currentStep); + + var expressionsData = mock(IData.class); + when(expressionsData.getResult()).thenReturn(""); + when(currentStep.getLatestData("expressions:parsed")).thenReturn(expressionsData); + when(currentStep.getAllData("context")).thenReturn(null); + when(currentStep.getLatestData("actions")).thenReturn(null); + + when(expressionProvider.parseExpressions("")).thenReturn(new Expressions()); + + var conversationProperties = mock(IConversationProperties.class); + when(memory.getConversationProperties()).thenReturn(conversationProperties); + + var templateDataObjects = new HashMap(); + when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects); + + var previousSteps = mock(IConversationStepStack.class); + when(memory.getPreviousSteps()).thenReturn(previousSteps); + when(previousSteps.size()).thenReturn(1); + + var previousStep = mock(IWritableConversationStep.class); + when(previousSteps.get(0)).thenReturn(previousStep); + // Previous step has null actions data + when(previousStep.getLatestData("actions")).thenReturn(null); + + var propertySetter = mock(IPropertySetter.class); + when(propertySetter.getSetOnActionsList()).thenReturn(List.of()); + when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>()); + + assertDoesNotThrow(() -> task.execute(memory, propertySetter)); + verify(conversationProperties, never()).put(eq("user_input"), any(Property.class)); + } } @Nested @@ -437,5 +1167,42 @@ void noConfig() throws Exception { assertNotNull(result); assertInstanceOf(IPropertySetter.class, result); } + + @Test + @DisplayName("URI based config loading — calls resourceClientLibrary.getResource") + void uriBasedConfigLoading() throws Exception { + var propertySetterConfig = new PropertySetterConfiguration(); + var setOnActions = new SetOnActions(); + setOnActions.setActions(List.of("remote_action")); + setOnActions.setSetProperties(List.of()); + propertySetterConfig.setSetOnActions(List.of(setOnActions)); + + when(resourceClientLibrary.getResource(any(URI.class), eq(PropertySetterConfiguration.class))) + .thenReturn(propertySetterConfig); + + var config = new HashMap(); + config.put("uri", "eddi://ai.labs.propertysetter/propertysetter/abc123?version=1"); + + var result = task.configure(config, Map.of()); + + assertNotNull(result); + assertInstanceOf(IPropertySetter.class, result); + verify(resourceClientLibrary).getResource(any(URI.class), eq(PropertySetterConfiguration.class)); + } + + @Test + @DisplayName("ServiceException wraps in WorkflowConfigurationException") + void serviceExceptionWrapsInWorkflowConfigurationException() throws Exception { + when(resourceClientLibrary.getResource(any(URI.class), eq(PropertySetterConfiguration.class))) + .thenThrow(new ServiceException("Connection refused")); + + var config = new HashMap(); + config.put("uri", "eddi://ai.labs.propertysetter/propertysetter/abc123?version=1"); + + var ex = assertThrows(WorkflowConfigurationException.class, + () -> task.configure(config, Map.of())); + assertTrue(ex.getMessage().contains("Error while fetching PropertySetterConfiguration")); + assertTrue(ex.getMessage().contains("Connection refused")); + } } } From cba3444893e6d90760b3b21829fea3814c9e549a Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 18:59:02 +0200 Subject: [PATCH 6/8] fix(secrets): sanitize tenantId in logs and add partial-delete test - Fix CodeQL log injection finding: sanitize(tenantId) in resetTenant log message and exception message using LogSanitizer. - Add partial-delete test proving returned count excludes failed deletes (mixed true/false outcome from deleteSecret). PR nitpick feedback. --- .../secrets/impl/VaultSecretProvider.java | 6 +++-- .../impl/VaultSecretProviderBranchTest.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java index f27f52d7eb..3db0629c2e 100644 --- a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java +++ b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java @@ -26,6 +26,8 @@ import java.util.*; import java.util.AbstractMap.SimpleEntry; +import static ai.labs.eddi.utils.LogSanitizer.sanitize; + /** * Production-grade {@link ISecretProvider} using envelope encryption with * persistent storage. @@ -407,10 +409,10 @@ public int resetTenant(String tenantId) throws SecretProviderException { } persistence.deleteDek(tenantId); - LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", tenantId, deletedCount); + LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", sanitize(tenantId), deletedCount); return deletedCount; } catch (PersistenceException e) { - throw new SecretProviderException("Failed to reset vault for tenant " + tenantId, e); + throw new SecretProviderException("Failed to reset vault for tenant " + sanitize(tenantId), e); } } diff --git a/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java index 0d87f0984a..a4f69fae38 100644 --- a/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java +++ b/src/test/java/ai/labs/eddi/secrets/impl/VaultSecretProviderBranchTest.java @@ -410,6 +410,28 @@ void emptyTenant() throws Exception { verify(persistence).deleteDek(TENANT_ID); } + @Test + @DisplayName("partial delete — count excludes failed deletes") + void partialDelete() throws Exception { + VaultSecretProvider provider = createAvailableProvider(); + + byte[] dek = EnvelopeCrypto.generateDek(); + EncryptedSecret secret1 = createEncryptedSecret("val1", dek); + EncryptedSecret secret2 = createEncryptedSecret("val2", dek); + secret1.setKeyName("key-ok"); + secret2.setKeyName("key-gone"); + + when(persistence.listSecretsByTenant(TENANT_ID)) + .thenReturn(List.of(secret1, secret2)); + when(persistence.deleteSecret(TENANT_ID, "key-ok")).thenReturn(true); + when(persistence.deleteSecret(TENANT_ID, "key-gone")).thenReturn(false); + + int result = provider.resetTenant(TENANT_ID); + + assertEquals(1, result, "Should count only successful deletes"); + verify(persistence).deleteDek(TENANT_ID); + } + @Test @DisplayName("persistence failure wraps as SecretProviderException") void persistenceFailure() { From fff18833f84fd7371bfed401bc0355f40ccdef5a Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 19:02:45 +0200 Subject: [PATCH 7/8] chore: add per-line comments to gitleaksignore entries --- .gitleaksignore | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index cda01103ff..8978f546d7 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -13,8 +13,8 @@ # ───────────────────────────────────────────────────────────────── # RestSecretStoreTest rotateKek tests — dummy KEK rotation fixture values (not real secrets) -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404 -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413 -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423 -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427 -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404 # rotateKek test fixture value +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413 # rotateKek test fixture value +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423 # rotateKek test fixture value +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427 # rotateKek test fixture value +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445 # rotateKek test fixture value From fc1c83e508cd8adf495cde55c4afc1b5d7de9e8e Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 17 Jun 2026 19:58:47 +0200 Subject: [PATCH 8/8] fix: remove inline comments from gitleaksignore fingerprints Gitleaks only supports full-line # comments. Inline comments after fingerprints were being treated as part of the fingerprint string, preventing the suppression from matching. --- .gitleaksignore | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 8978f546d7..cda01103ff 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -13,8 +13,8 @@ # ───────────────────────────────────────────────────────────────── # RestSecretStoreTest rotateKek tests — dummy KEK rotation fixture values (not real secrets) -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404 # rotateKek test fixture value -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413 # rotateKek test fixture value -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423 # rotateKek test fixture value -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427 # rotateKek test fixture value -06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445 # rotateKek test fixture value +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427 +06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445