diff --git a/.gitignore b/.gitignore index 89d2950..5a55c10 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .sonarlint/ .sonarqube/ .vscode/ +.venv \ No newline at end of file diff --git a/README.md b/README.md index df520b2..d80e446 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Complete the `appsettings.json` file with the following content, replacing place "Port": 587, "Username": "", "Password": "", - "From": "", + "From": "" }, "Jwt": { "Key": "", @@ -143,6 +143,13 @@ Complete the `appsettings.json` file with the following content, replacing place "Region": "garage", "Secure": false }, + "Vault": { + "Enable": false, + "Address": "http://:8200", + "Token": "", + "Path": "electrostore", + "MountPoint": "secret" + }, "AllowedOrigins": [ "https://", "https://" diff --git a/docs/01_installation.md b/docs/01_installation.md index 68d2ecf..77c9d44 100644 --- a/docs/01_installation.md +++ b/docs/01_installation.md @@ -79,7 +79,7 @@ Complete the `appsettings.json` file with the following content, replacing place } }, "ConnectionStrings": { - "DefaultConnection": "Server=mariadb;Port=3306;Database=electrostore;Uid=electrostore;Pwd=password;" + "DefaultConnection": "Server=mariadb;Port=3306;Database=electrostore;Uid=electrostore;Pwd=electrostore;" }, "MQTT": { "Username": "electrostore", @@ -94,7 +94,7 @@ Complete the `appsettings.json` file with the following content, replacing place "Port": 587, "Username": "", "Password": "", - "From": "", + "From": "" }, "Jwt": { "Key": "", @@ -127,6 +127,13 @@ Complete the `appsettings.json` file with the following content, replacing place "Region": "garage", "Secure": false }, + "Vault": { + "Enable": false, + "Address": "http://:8200", + "Token": "", + "Path": "electrostore", + "MountPoint": "secret" + }, "AllowedOrigins": [ "https://", "https://" diff --git a/docs/generator/index.html b/docs/generator/index.html index 30d24c6..c5e5328 100644 --- a/docs/generator/index.html +++ b/docs/generator/index.html @@ -384,9 +384,15 @@

HashiCorp Vault Configurati
- + Path in Vault where secrets are stored
+ +
+ + + Mount point of the KV secrets engine +
diff --git a/docs/generator/js/config.js b/docs/generator/js/config.js index a07357f..6de0725 100644 --- a/docs/generator/js/config.js +++ b/docs/generator/js/config.js @@ -103,7 +103,8 @@ function collectConfig(formData) { config.vault = { addr: formData.get('vaultAddr') || 'http://vault:8200', token: formData.get('vaultToken'), - path: formData.get('vaultPath') || 'secret/electrostore' + path: formData.get('vaultPath') || 'electrostore', + mountPoint: formData.get('vaultMountPoint') || 'secret' }; } diff --git a/docs/generator/js/generators.js b/docs/generator/js/generators.js index 4a6da20..f32d14a 100644 --- a/docs/generator/js/generators.js +++ b/docs/generator/js/generators.js @@ -257,7 +257,7 @@ networks: } if (!config.enableS3 || config.useMQTT || config.useMariaDB || - (config.enableS3 && config.useS3) || (config.useVault && config.vault.integrated)) { + (config.enableS3 && config.useS3)) { compose += ` volumes:`; } @@ -266,7 +266,6 @@ volumes:`; if (config.useMariaDB) compose += `\n mariadb-data:`; if (config.useMQTT) compose += `\n mqtt-data:`; if (config.enableS3 && config.useS3) compose += `\n garage-data:\n garage-meta:`; - if (config.useVault && config.vault.integrated) compose += `\n vault-data:`; return compose; } @@ -385,6 +384,20 @@ function generateAppsettings(config) { }); } + if (config.useVault) { + settings.Vault = { + "Enable": true, + "Addr": config.vault.addr, + "Token": config.vault.token, + "Path": config.vault.path, + "MountPoint": config.vault.mountPoint + }; + } else { + settings.Vault = { + "Enable": false + }; + } + settings.FrontendUrl = config.frontUrl; settings.AllowedOrigins = config.allowedOrigins; @@ -444,15 +457,6 @@ function generateEnvFile(config) { env += `S3_REGION=${config.s3.region}\n\n`; } - if (config.useVault) { - env += `# HashiCorp Vault\n`; - env += `VAULT_TOKEN=${config.vault.token}\n`; - if (config.vault.integrated) { - env += `VAULT_VERSION=1.18\n`; - } - env += `\n`; - } - return env; } @@ -471,37 +475,31 @@ echo "" `; - if (config.useVault && config.vault.integrated) { + if (config.useVault) { script += `# Vault Configuration echo "Configuring HashiCorp Vault..." -echo "Starting Vault..." -docker compose up -d vault - -echo "Waiting for Vault to start (5 seconds)..." -sleep 5 - echo "Enabling KV v2 engine..." -docker exec electrostore-vault vault secrets enable -version=2 -path=secret kv || echo "KV engine already enabled" +docker exec vault vault secrets enable -version=2 -path=${config.vault.mountPoint} kv || echo "KV engine already enabled" echo "Storing secrets in Vault..." `; - script += `docker exec electrostore-vault vault kv put ${config.vault.path} mariadb_password=" + script += `docker exec vault vault kv put ${config.vault.mountPoint}/${config.vault.path} mariadb_password=" ${config.useMariaDB ? config.mariadb.password : config.mariadbExternal.password}" `; - script += `docker exec electrostore-vault vault kv patch ${config.vault.path} mqtt_password=" + script += `docker exec vault vault kv patch ${config.vault.mountPoint}/${config.vault.path} mqtt_password=" ${config.useMQTT ? config.mqtt.password : config.mqttExternal.password} `; if (config.enableSMTP && config.smtp) { - script += `docker exec electrostore-vault vault kv patch ${config.vault.path} smtp_password=" + script += `docker exec vault vault kv patch ${config.vault.mountPoint}/${config.vault.path} smtp_password=" ${config.smtp.password}" `; } - script += `docker exec electrostore-vault vault kv patch ${config.vault.path} jwt_key=" + script += `docker exec vault vault kv patch ${config.vault.mountPoint}/${config.vault.path} jwt_key=" ${config.jwt.key}" echo "Vault configuration completed" @@ -544,7 +542,7 @@ docker exec electrostore-garage /garage bucket allow --read --write ${config.s3. if (config.useVault) { script += ` echo "Storing S3 keys in Vault..." -docker exec electrostore-vault vault kv patch ${config.vault.path} s3_access_key="\$GARAGE_ACCESS_KEY" s3_secret_key="\$GARAGE_SECRET_KEY" +docker exec vault vault kv patch ${config.vault.path} s3_access_key="\$GARAGE_ACCESS_KEY" s3_secret_key="\$GARAGE_SECRET_KEY" `; } diff --git a/electrostoreAPI/Extensions/VaultConfigurationExtensions.cs b/electrostoreAPI/Extensions/VaultConfigurationExtensions.cs new file mode 100644 index 0000000..c20cf87 --- /dev/null +++ b/electrostoreAPI/Extensions/VaultConfigurationExtensions.cs @@ -0,0 +1,72 @@ +using VaultSharp; +using VaultSharp.V1.AuthMethods.Token; + + +namespace electrostore.Extensions; + +public static class VaultConfigurationExtensions +{ + public static IConfigurationBuilder AddVaultConfiguration(this IConfigurationBuilder builder) + { + var tempConfig = builder.Build(); + if (tempConfig.GetSection("Vault:Enable").Get()) + { + var vaultAddr = tempConfig.GetSection("Vault:Addr").Value ?? throw new InvalidOperationException("Vault:Addr configuration is missing."); + var vaultToken = tempConfig.GetSection("Vault:Token").Value ?? throw new InvalidOperationException("Vault:Token configuration is missing."); + var vaultPath = tempConfig.GetSection("Vault:Path").Value ?? throw new InvalidOperationException("Vault:Path configuration is missing."); + var vaultMountPoint = tempConfig.GetSection("Vault:MountPoint").Value ?? throw new InvalidOperationException("Vault:MountPoint configuration is missing."); + var authMethod = new TokenAuthMethodInfo(vaultToken); + var vaultConfig = new VaultClientSettings(vaultAddr, authMethod); + var vaultClient = new VaultClient(vaultConfig); + // in all config sections, replace values with vault secrets if they are in the format {{vault:}} + foreach (var section in tempConfig.GetChildren()) + { + foreach (var child in section.GetChildren()) + { + if (child.Value != null && child.Value.Contains("{{vault:") && child.Value.Contains("}}")) + { + Console.WriteLine($"Checking key: {child.Key} with value: {child.Value}"); + // for all occurrences of {{vault:}} in the value, replace with the corresponding vault secret + var newValue = child.Value; + int startIndex = 0; + while (true) + { + int vaultStart = newValue.IndexOf("{{vault:", startIndex); + if (vaultStart == -1) break; + int vaultEnd = newValue.IndexOf("}}", vaultStart); + if (vaultEnd == -1) break; + var vaultKey = newValue.Substring(vaultStart + 8, vaultEnd - vaultStart - 8); + var secretValue = GetVaultSecret(vaultClient, vaultPath, vaultMountPoint, vaultKey); + newValue = string.Concat(newValue.AsSpan(0, vaultStart), secretValue, newValue.AsSpan(vaultEnd + 2)); + startIndex = vaultStart + secretValue.Length; + } + // update the configuration with the new value + builder.AddInMemoryCollection(new Dictionary + { + { child.Path, newValue } + }); + } + } + } + } + return builder; + } + private static string GetVaultSecret(VaultClient vaultClient, string path, string mountPoint, string key) + { + try + { + var secret = vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: path, mountPoint: mountPoint).GetAwaiter().GetResult(); + if (secret.Data.Data.TryGetValue(key, out object? value)) + { + return value?.ToString() ?? string.Empty; + } + return string.Empty; + } + catch (Exception ex) + { + Console.WriteLine($"Error reading secret from Vault: {ex.Message}"); + Console.WriteLine($"Make sure the KV v2 engine is enabled and the secret exists at path: '{path}'"); + throw new InvalidOperationException($"Failed to retrieve secret from Vault at path '{path}' for key '{key}': {ex.Message}", ex); + } + } +} diff --git a/electrostoreAPI/Program.cs b/electrostoreAPI/Program.cs index e676d9b..49a12c9 100644 --- a/electrostoreAPI/Program.cs +++ b/electrostoreAPI/Program.cs @@ -7,6 +7,9 @@ using Microsoft.IdentityModel.JsonWebTokens; using Minio; +using VaultSharp; +using VaultSharp.V1.AuthMethods.Token; + using MQTTnet; using electrostore.Dto; using electrostore.Enums; @@ -45,6 +48,7 @@ using electrostore.Services.ValidateStoreService; using electrostore.Services.JwtService; using electrostore.Middleware; +using electrostore.Extensions; using Microsoft.OpenApi.Models; using Microsoft.AspNetCore.HttpOverrides; @@ -249,10 +253,18 @@ private static void AddAuthentication(WebApplicationBuilder builder, byte[] key) private static void AddScopes(WebApplicationBuilder builder) { - // Add services to the container. + if (builder.Configuration.GetSection("Vault:Enable").Get()) + { + var authMethod = new TokenAuthMethodInfo(builder.Configuration.GetSection("Vault:Token").Value); + var vaultConfig = new VaultClientSettings(builder.Configuration.GetSection("Vault:Addr").Value, authMethod); + builder.Services.AddSingleton(new VaultClient(vaultConfig)); + builder.Configuration.AddVaultConfiguration(); + } builder.Services.AddDbContext(options => - options.UseMySql(builder.Configuration.GetConnectionString("DefaultConnection"), new MySqlServerVersion(new Version(11, 4, 7)))); - + options.UseMySql(builder.Configuration.GetConnectionString("DefaultConnection"), + new MySqlServerVersion(new Version(11, 4, 7)) + ) + ); builder.Services.AddSingleton(sp => { var factory = new MqttClientFactory(); @@ -266,8 +278,6 @@ private static void AddScopes(WebApplicationBuilder builder) mqttClient.ConnectAsync(options); return mqttClient; }); - - // Only register MinIO client if S3 is enabled if (builder.Configuration.GetSection("S3:Enable").Get()) { builder.Services.AddSingleton(sp => @@ -283,7 +293,6 @@ private static void AddScopes(WebApplicationBuilder builder) return minioClient; }); } - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -349,29 +358,27 @@ private static void CreateRequiredDirectories() private static void InitializeDatabase(WebApplication app) { - using (var serviceScope = app.Services.CreateScope()) + using var serviceScope = app.Services.CreateScope(); + var context = serviceScope.ServiceProvider.GetRequiredService(); + // check if the database is up to date with the migrations + var pendingMigrations = context.Database.GetPendingMigrations(); + if (pendingMigrations.Any()) { - var context = serviceScope.ServiceProvider.GetRequiredService(); - // check if the database is up to date with the migrations - var pendingMigrations = context.Database.GetPendingMigrations(); - if (pendingMigrations.Any()) - { - // Appliquer les migrations si nécessaire - context.Database.Migrate(); - } - // check if the database is empty - if (!context.Users.Any()) + context.Database.Migrate(); + } + // check if the database is empty + if (!context.Users.Any()) + { + var userService = serviceScope.ServiceProvider.GetRequiredService(); + userService.CreateFirstAdminUser(new CreateUserDto { - var userService = serviceScope.ServiceProvider.GetRequiredService(); - userService.CreateFirstAdminUser(new CreateUserDto - { - nom_user = "Admin", - prenom_user = "Admin", - email_user = "admin@localhost.local", - mdp_user = "Admin@1234", - role_user = UserRole.Admin - }).Wait(); - } + nom_user = "Admin", + prenom_user = "Admin", + email_user = "admin@localhost.local", + mdp_user = "Admin@1234", + role_user = UserRole.Admin + }).Wait(); + } } } diff --git a/electrostoreAPI/config/appsettings.Development.json b/electrostoreAPI/config/appsettings.Development.json index cf86bdd..e22fcff 100644 --- a/electrostoreAPI/config/appsettings.Development.json +++ b/electrostoreAPI/config/appsettings.Development.json @@ -17,16 +17,16 @@ }, "SMTP": { "Enable": false, - "Host": "", + "Host": "", "Port": 587, - "Username": "", - "Password": "", - "From": "" + "Username": "", + "Password": "", + "From": "" }, "Jwt": { - "Key": "", - "Issuer": "", - "Audience": "", + "Key": "", + "Issuer": "", + "Audience": "", "ExpireDays": 1 }, "OAuth": { @@ -36,29 +36,36 @@ "Authority": "https:///application/o/authorize/", "RedirectUri": "https:///auth/callback", "Scope": "openid profile email", - "DisplayName": "", - "IconUrl": "", "GroupMapping": { "User": "electrostore-dev Users", "Moderator": "electrostore-dev Moderators", "Admin": "electrostore-dev Admins" - } + }, + "DisplayName": "", + "IconUrl": "" } }, "S3": { "Enable": false, + "Endpoint": ":9000", "AccessKey": "", "SecretKey": "", - "Endpoint": ":9000", "BucketName": "electrostore", "Region": "garage", "Secure": false }, - "FrontendUrl": "https://", + "Vault": { + "Enable": false, + "Address": "http://:8200", + "Token": "", + "Path": "electrostore", + "MountPoint": "secret" + }, "AllowedOrigins": [ "https://", "https://" ], + "FrontendUrl": "http://", "AllowedHosts": "*", "DemoMode": false } diff --git a/electrostoreAPI/config/appsettings.json b/electrostoreAPI/config/appsettings.json index cf86bdd..e22fcff 100644 --- a/electrostoreAPI/config/appsettings.json +++ b/electrostoreAPI/config/appsettings.json @@ -17,16 +17,16 @@ }, "SMTP": { "Enable": false, - "Host": "", + "Host": "", "Port": 587, - "Username": "", - "Password": "", - "From": "" + "Username": "", + "Password": "", + "From": "" }, "Jwt": { - "Key": "", - "Issuer": "", - "Audience": "", + "Key": "", + "Issuer": "", + "Audience": "", "ExpireDays": 1 }, "OAuth": { @@ -36,29 +36,36 @@ "Authority": "https:///application/o/authorize/", "RedirectUri": "https:///auth/callback", "Scope": "openid profile email", - "DisplayName": "", - "IconUrl": "", "GroupMapping": { "User": "electrostore-dev Users", "Moderator": "electrostore-dev Moderators", "Admin": "electrostore-dev Admins" - } + }, + "DisplayName": "", + "IconUrl": "" } }, "S3": { "Enable": false, + "Endpoint": ":9000", "AccessKey": "", "SecretKey": "", - "Endpoint": ":9000", "BucketName": "electrostore", "Region": "garage", "Secure": false }, - "FrontendUrl": "https://", + "Vault": { + "Enable": false, + "Address": "http://:8200", + "Token": "", + "Path": "electrostore", + "MountPoint": "secret" + }, "AllowedOrigins": [ "https://", "https://" ], + "FrontendUrl": "http://", "AllowedHosts": "*", "DemoMode": false } diff --git a/electrostoreAPI/electrostore.csproj b/electrostoreAPI/electrostore.csproj index 6d4d56e..6531cc0 100644 --- a/electrostoreAPI/electrostore.csproj +++ b/electrostoreAPI/electrostore.csproj @@ -27,6 +27,7 @@ + diff --git a/electrostoreIA/app_init.py b/electrostoreIA/app_init.py index fab34e3..1169708 100644 --- a/electrostoreIA/app_init.py +++ b/electrostoreIA/app_init.py @@ -2,17 +2,92 @@ import json import os +import re +import hvac import db_query from S3Manager import S3Manager +def connect_to_vault(vault_config): + """Connect to HashiCorp Vault and return client.""" + try: + client = hvac.Client( + url=vault_config.get("Addr", "http://vault:8200"), + token=vault_config.get("Token", None), + namespace=vault_config.get("Path", None) + ) + if not client.is_authenticated(): + raise ConnectionError("Failed to authenticate with Vault") + return client + except Exception as e: + raise ConnectionError(f"Failed to connect to Vault: {str(e)}") + + +def get_secret_from_vault(vault_client, secret_path, mount_point="secret"): + """Retrieve a secret from Vault.""" + try: + secret_response = vault_client.secrets.kv.v2.read_secret_version( + path=secret_path, + mount_point=mount_point + ) + return secret_response['data']['data'] + except Exception as e: + try: + secret_response = vault_client.secrets.kv.v1.read_secret( + path=secret_path, + mount_point=mount_point + ) + return secret_response['data'] + except Exception as e2: + raise ValueError(f"Failed to retrieve secret '{secret_path}': {str(e2)}") + + +def process_vault_secrets(config, vault_client, vault_config): + """Recursively process configuration and replace vault placeholders with actual secrets.""" + vault_pattern = re.compile(r'\{{vault:([^}]+)\}}') + mount_point = vault_config.get("MountPoint", "secret") + secret_path = vault_config.get("Path", "") + def replace_vault_placeholder(value): + """Replace vault placeholder with actual secret value.""" + if not isinstance(value, str): + return value + for match in vault_pattern.finditer(value): + secret_key = match.group(1) + secret_data = get_secret_from_vault(vault_client, secret_path, mount_point) + if secret_key in secret_data: + value = value.replace(f'{{{{vault:{secret_key}}}}}', str(secret_data[secret_key])) + else: + raise KeyError(f"Secret field '{secret_key}' not found in Vault path '{secret_path}'") + return value + def process_dict(d): + """Recursively process dictionary.""" + for key, value in d.items(): + if isinstance(value, dict): + process_dict(value) + elif isinstance(value, list): + d[key] = [replace_vault_placeholder(item) if isinstance(item, str) else item for item in value] + elif isinstance(value, str): + d[key] = replace_vault_placeholder(value) + process_dict(config) + return config + + def load_appsettings(config_path='/app/config/appsettings.json'): """Load application settings from JSON file.""" appsettings = {} - if os.path.exists(config_path): with open(config_path) as f: appsettings = json.load(f) + if appsettings.get("Vault", {}).get("Enable") == True or \ + str(appsettings.get("Vault", {}).get("Enable", "")).lower() == "true": + try: + vault_config = appsettings.get("Vault", {}) + vault_client = connect_to_vault(vault_config) + appsettings = process_vault_secrets(appsettings, vault_client, vault_config) + print("Successfully retrieved secrets from Vault") + except Exception as e: + print(f"Error processing Vault secrets: {str(e)}") + raise return appsettings @@ -20,20 +95,16 @@ def load_appsettings(config_path='/app/config/appsettings.json'): def initialize_database(appsettings): """Initialize database connection from settings.""" mysql_session = None - if ("ConnectionStrings" in appsettings) and ("DefaultConnection" in appsettings["ConnectionStrings"]): appsettings_string = appsettings["ConnectionStrings"]["DefaultConnection"] db_settings = {} - for setting in appsettings_string.split(';'): if setting == '': continue key, value = setting.split('=') db_settings[key] = value - mysql_session = db_query.MySQLConnection(db_settings) mysql_session.connect() - return mysql_session @@ -50,15 +121,9 @@ def initialize_application(): appsettings = load_appsettings() mysql_session = None s3_manager = None - try: - # Initialize database mysql_session = initialize_database(appsettings) - - # Initialize S3 manager s3_manager = initialize_s3_manager(appsettings) - except Exception as e: raise ConnectionError(f"Could not initialize application: {str(e)}") - - return appsettings, mysql_session, s3_manager \ No newline at end of file + return appsettings, mysql_session, s3_manager diff --git a/electrostoreIA/requirements.txt b/electrostoreIA/requirements.txt index 96c7ac0..d9bfd53 100644 --- a/electrostoreIA/requirements.txt +++ b/electrostoreIA/requirements.txt @@ -5,4 +5,5 @@ keras==3.12.0 pillow==11.1.0 mysql-connector-python==9.2.0 gunicorn==23.0.0 -minio==7.2.9 \ No newline at end of file +minio==7.2.9 +hvac==2.1.0 \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index ee0ac9b..f6fade7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,9 +33,15 @@ Python unit tests for the Flask API. These tests use pytest and pytest-flask for ```bash cd electrostoreIA -pip install -r ../tests/electrostoreIA/requirements.txt +python -m venv venv # LINUX +source venv/bin/activate +pip install -r requirements.txt +pip install -r ../tests/electrostoreIA/requirements.txt test=true python -m pytest ../tests/electrostoreIA # WINDOWS +.venv\Scripts\activate.bat +pip install -r requirements.txt +pip install -r ../tests/electrostoreIA/requirements.txt set test=true && python -m pytest ../tests/electrostoreIA ``` \ No newline at end of file diff --git a/tests/electrostoreIA/test_app_init.py b/tests/electrostoreIA/test_app_init.py index 7315e2b..71b823e 100644 --- a/tests/electrostoreIA/test_app_init.py +++ b/tests/electrostoreIA/test_app_init.py @@ -11,7 +11,8 @@ from electrostoreIA.app_init import ( load_appsettings, initialize_database, initialize_s3_manager, - initialize_application + initialize_application, connect_to_vault, get_secret_from_vault, + process_vault_secrets ) @@ -33,7 +34,7 @@ def test_load_appsettings_file_not_exists(self, mock_exists): @patch('builtins.open', new_callable=mock_open, read_data='{"test": "value"}') @patch('electrostoreIA.app_init.os.path.exists') def test_load_appsettings_success(self, mock_exists, mock_file): - """Test successful load_appsettings.""" + """Test successful load_appsettings without Vault.""" # Arrange mock_exists.return_value = True @@ -185,4 +186,304 @@ def test_initialize_application_with_error(self, mock_load_settings, mock_init_d with patch('builtins.globals', return_value={}): # Act & Assert with pytest.raises(ConnectionError, match="Could not initialize application: Database error"): - initialize_application() \ No newline at end of file + initialize_application() + + +class TestVaultIntegration: + """Test Vault integration functions.""" + + @patch('electrostoreIA.app_init.hvac.Client') + def test_connect_to_vault_success(self, mock_hvac_client): + """Test successful connection to Vault.""" + # Arrange + mock_client = MagicMock() + mock_client.is_authenticated.return_value = True + mock_hvac_client.return_value = mock_client + + vault_config = { + "Addr": "http://vault:8200", + "Token": "test-token", + "Path": "test-namespace" + } + + # Act + result = connect_to_vault(vault_config) + + # Assert + assert result == mock_client + mock_hvac_client.assert_called_once_with( + url="http://vault:8200", + token="test-token", + namespace="test-namespace" + ) + mock_client.is_authenticated.assert_called_once() + + @patch('electrostoreIA.app_init.hvac.Client') + def test_connect_to_vault_not_authenticated(self, mock_hvac_client): + """Test connection to Vault with failed authentication.""" + # Arrange + mock_client = MagicMock() + mock_client.is_authenticated.return_value = False + mock_hvac_client.return_value = mock_client + + vault_config = { + "Addr": "http://vault:8200", + "Token": "bad-token" + } + + # Act & Assert + with pytest.raises(ConnectionError, match="Failed to authenticate with Vault"): + connect_to_vault(vault_config) + + @patch('electrostoreIA.app_init.hvac.Client') + def test_connect_to_vault_connection_error(self, mock_hvac_client): + """Test connection to Vault with connection error.""" + # Arrange + mock_hvac_client.side_effect = Exception("Connection failed") + + vault_config = { + "Addr": "http://vault:8200", + "Token": "test-token" + } + + # Act & Assert + with pytest.raises(ConnectionError, match="Failed to connect to Vault: Connection failed"): + connect_to_vault(vault_config) + + def test_get_secret_from_vault_kv_v2_success(self): + """Test retrieving secret from Vault KV v2.""" + # Arrange + mock_vault_client = MagicMock() + mock_vault_client.secrets.kv.v2.read_secret_version.return_value = { + 'data': { + 'data': { + 'username': 'test-user', + 'password': 'test-pass' + } + } + } + + # Act + result = get_secret_from_vault(mock_vault_client, "database/credentials", "secret") + + # Assert + assert result == {'username': 'test-user', 'password': 'test-pass'} + mock_vault_client.secrets.kv.v2.read_secret_version.assert_called_once_with( + path="database/credentials", + mount_point="secret" + ) + + def test_get_secret_from_vault_kv_v1_fallback(self): + """Test retrieving secret from Vault KV v1 (fallback).""" + # Arrange + mock_vault_client = MagicMock() + mock_vault_client.secrets.kv.v2.read_secret_version.side_effect = Exception("KV v2 not available") + mock_vault_client.secrets.kv.v1.read_secret.return_value = { + 'data': { + 'username': 'test-user', + 'password': 'test-pass' + } + } + + # Act + result = get_secret_from_vault(mock_vault_client, "database/credentials", "secret") + + # Assert + assert result == {'username': 'test-user', 'password': 'test-pass'} + mock_vault_client.secrets.kv.v1.read_secret.assert_called_once_with( + path="database/credentials", + mount_point="secret" + ) + + def test_get_secret_from_vault_both_versions_fail(self): + """Test retrieving secret when both KV v1 and v2 fail.""" + # Arrange + mock_vault_client = MagicMock() + mock_vault_client.secrets.kv.v2.read_secret_version.side_effect = Exception("KV v2 not available") + mock_vault_client.secrets.kv.v1.read_secret.side_effect = Exception("KV v1 failed") + + # Act & Assert + with pytest.raises(ValueError, match="Failed to retrieve secret 'database/credentials'"): + get_secret_from_vault(mock_vault_client, "database/credentials", "secret") + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_simple_placeholder(self, mock_get_secret): + """Test processing simple vault placeholder.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": "key"} + + mock_get_secret.return_value = {'value': 'secret-value'} + + config = { + "ApiKey": "{{vault:value}}" + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["ApiKey"] == "secret-value" + mock_get_secret.assert_called_once_with(mock_vault_client, "key", "secret") + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_with_field(self, mock_get_secret): + """Test processing vault placeholder with specific field.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": ""} + + mock_get_secret.return_value = { + 'username': 'test-user', + 'password': 'test-pass' + } + + config = { + "Database": { + "Username": "{{vault:username}}", + "Password": "{{vault:password}}" + } + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["Database"]["Username"] == "test-user" + assert result["Database"]["Password"] == "test-pass" + assert mock_get_secret.call_count == 2 + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_in_connection_string(self, mock_get_secret): + """Test processing vault placeholders in connection string.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": "database/credentials"} + + mock_get_secret.return_value = { + 'username': 'dbuser', + 'password': 'dbpass' + } + + config = { + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Uid={{vault:username}};Pwd={{vault:password}};" + }, + "Vault": { + "Enabled": True, + "Addr": "http://vault:8200", + "Token": "test-token", + "MountPoint": "secret", + "Path": "electrostore" + } + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["ConnectionStrings"]["DefaultConnection"] == "Server=localhost;Uid=dbuser;Pwd=dbpass;" + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_nested_dict(self, mock_get_secret): + """Test processing vault placeholders in nested dictionaries.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": ""} + + mock_get_secret.return_value = {'value': 'secret-key'} + + config = { + "Level1": { + "Level2": { + "Level3": { + "ApiKey": "{{vault:value}}" + } + } + } + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["Level1"]["Level2"]["Level3"]["ApiKey"] == "secret-key" + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_list(self, mock_get_secret): + """Test processing vault placeholders in lists.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": ""} + + mock_get_secret.return_value = {'token1': 'secret-token', 'token2': 'secret-token'} + + config = { + "Tokens": ["{{vault:token1}}", "{{vault:token2}}", "plain-token"] + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["Tokens"] == ["secret-token", "secret-token", "plain-token"] + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_error_handling(self, mock_get_secret): + """Test error handling when secret retrieval fails.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": ""} + + mock_get_secret.side_effect = ValueError("Failed to retrieve secret 'key'") + config = { + "ApiKey": "{{vault:key}}" + } + # Act & Assert + with pytest.raises(ValueError, match="Failed to retrieve secret 'key'"): + process_vault_secrets(config, mock_vault_client, vault_config) + + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_non_string_values(self, mock_get_secret): + """Test processing config with non-string values.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "secret", "Path": ""} + + config = { + "Port": 8080, + "Enabled": True, + "Ratio": 3.14, + "Items": None + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert - non-string values should remain unchanged + assert result["Port"] == 8080 + assert result["Enabled"] is True + assert result["Ratio"] == 3.14 + assert result["Items"] is None + mock_get_secret.assert_not_called() + + @patch('electrostoreIA.app_init.get_secret_from_vault') + def test_process_vault_secrets_custom_mount_point(self, mock_get_secret): + """Test processing vault placeholders with custom mount point.""" + # Arrange + mock_vault_client = MagicMock() + vault_config = {"MountPoint": "custom-kv", "Path": "key"} + + mock_get_secret.return_value = {'value': 'secret-value'} + + config = { + "ApiKey": "{{vault:value}}" + } + + # Act + result = process_vault_secrets(config, mock_vault_client, vault_config) + + # Assert + assert result["ApiKey"] == "secret-value" + mock_get_secret.assert_called_once_with(mock_vault_client, "key", "custom-kv")