using MeterVault.Infrastructure.Ingestion; using MeterVault.Infrastructure.Security; using Microsoft.AspNetCore.DataProtection; namespace MeterVault.Integration.Tests.Ingestion; /// /// Connector secrets may be stored two ways (SDD ยง6.4): encrypted at rest after being typed into /// the admin UI, or as the name of an environment variable resolved at runtime. These pin which /// form wins and, more importantly, that neither form ever leaves plaintext in the config JSON. /// public sealed class EndpointSecretTests { private const string UnsetVar = "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR"; private static SecretProtector NewProtector() => new(new EphemeralDataProtectionProvider()); [Fact] public void Encrypted_token_round_trips_and_is_not_plaintext_in_the_config() { var protector = NewProtector(); var config = new HaEndpointConfig { BaseUrl = "http://ha.local:8123", TokenEnc = protector.Protect("super-secret-token"), }; Assert.Equal("super-secret-token", config.ResolveToken(protector)); // This JSON is what lands in ingestion_endpoint.config, in pg_dump, and in a JSON export. Assert.DoesNotContain("super-secret-token", config.ToJson(), StringComparison.Ordinal); } [Fact] public void Env_var_is_used_when_no_encrypted_token_is_present() { var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable(variable, "from-the-environment"); try { var config = new HaEndpointConfig { TokenEnv = variable }; Assert.Equal("from-the-environment", config.ResolveToken(NewProtector())); } finally { Environment.SetEnvironmentVariable(variable, null); } } [Fact] public void Encrypted_token_wins_when_both_forms_are_set() { var protector = NewProtector(); var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable(variable, "from-the-environment"); try { var config = new HaEndpointConfig { TokenEnv = variable, TokenEnc = protector.Protect("typed-in-the-ui"), }; Assert.Equal("typed-in-the-ui", config.ResolveToken(protector)); } finally { Environment.SetEnvironmentVariable(variable, null); } } [Fact] public void Undecryptable_ciphertext_falls_back_instead_of_throwing() { // A key ring restored without its keys: the worker must degrade, not crash on a timer. var config = new HaEndpointConfig { TokenEnc = "not-valid-ciphertext", TokenEnv = UnsetVar }; var exception = Record.Exception(() => config.ResolveToken(NewProtector())); Assert.Null(exception); Assert.Null(config.ResolveToken(NewProtector())); } [Fact] public void Resolving_without_a_protector_still_reads_the_env_var() { // ResolveToken() is called with no protector in unit contexts; the env-var path must work. var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable(variable, "plain-env"); try { Assert.Equal("plain-env", new HaEndpointConfig { TokenEnv = variable }.ResolveToken()); } finally { Environment.SetEnvironmentVariable(variable, null); } } [Fact] public void Mqtt_password_is_encrypted_while_username_stays_readable() { var protector = NewProtector(); var config = new EndpointConfig { Host = "broker.local", Username = "metervault", PasswordEnc = protector.Protect("broker-password"), }; Assert.Equal("metervault", config.ResolveUsername(protector)); Assert.Equal("broker-password", config.ResolvePassword(protector)); var json = config.ToJson(); Assert.DoesNotContain("broker-password", json, StringComparison.Ordinal); Assert.Contains("metervault", json, StringComparison.Ordinal); } }