feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||
using ZB.MOM.WW.Secrets.MasterKey;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="SecretsSessionFactory"/> against real temp-dir SQLite targets: the happy path
|
||||
/// (env KEK present → full session + seal/upsert/get/decrypt round-trip), the lockout-recovery path
|
||||
/// (KEK unresolvable → degraded session that still lists metadata), the operator-override upgrade, and
|
||||
/// the SQL-Server-with-unresolved-secret-ref fall-back to the local store.
|
||||
/// </summary>
|
||||
public sealed class SecretsSessionFactoryTests : IDisposable
|
||||
{
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), $"zb-secrets-session-{Guid.NewGuid():N}");
|
||||
private readonly List<string> _envVars = [];
|
||||
|
||||
private string DbPath => Path.Combine(_dir, "secrets.db");
|
||||
|
||||
public SecretsSessionFactoryTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string name in _envVars)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
}
|
||||
|
||||
if (Directory.Exists(_dir))
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private string SetTempKeyEnv()
|
||||
{
|
||||
byte[] key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
|
||||
_envVars.Add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
private static MasterKeyOptions UnsetEnvKey() => new()
|
||||
{
|
||||
Source = MasterKeySource.Environment,
|
||||
EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Opens_full_session_when_env_kek_present()
|
||||
{
|
||||
string envVar = SetTempKeyEnv();
|
||||
StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
|
||||
{
|
||||
Source = MasterKeySource.Environment,
|
||||
EnvVarName = envVar,
|
||||
});
|
||||
|
||||
SecretsSession session =
|
||||
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
|
||||
|
||||
Assert.True(session.KekAvailable);
|
||||
Assert.Equal("sqlite", session.StoreKind);
|
||||
Assert.NotNull(session.Cipher);
|
||||
|
||||
var name = new SecretName("test/roundtrip");
|
||||
StoredSecret row = session.Cipher!.Encrypt(name, "hunter2", SecretContentType.Text);
|
||||
await session.Store.UpsertAsync(row, CancellationToken.None);
|
||||
StoredSecret? fetched = await session.Store.GetAsync(name, CancellationToken.None);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal("hunter2", session.Cipher!.Decrypt(fetched!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opens_degraded_session_when_env_var_unset()
|
||||
{
|
||||
StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
|
||||
|
||||
SecretsSession session =
|
||||
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
|
||||
|
||||
Assert.False(session.KekAvailable);
|
||||
Assert.Null(session.Cipher);
|
||||
Assert.Null(session.MasterKey);
|
||||
Assert.NotEmpty(session.Warnings);
|
||||
|
||||
// Store is still usable for metadata ops even without a KEK.
|
||||
IReadOnlyList<SecretMetadata> list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None);
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_kek_upgrades_degraded_session()
|
||||
{
|
||||
StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
|
||||
byte[] key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
var overrideKek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
|
||||
|
||||
SecretsSession session =
|
||||
await new SecretsSessionFactory().OpenAsync(target, overrideKek, CancellationToken.None);
|
||||
|
||||
Assert.True(session.KekAvailable);
|
||||
Assert.NotNull(session.Cipher);
|
||||
Assert.Same(overrideKek, session.MasterKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning()
|
||||
{
|
||||
string envVar = SetTempKeyEnv();
|
||||
SecretsOptions secrets = new()
|
||||
{
|
||||
SqlitePath = DbPath,
|
||||
MasterKey = new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = envVar },
|
||||
};
|
||||
SqlServerSecretsOptions sqlServer = new()
|
||||
{
|
||||
ConnectionString = "Server=hub;Database=secrets;User Id=app;Password=${secret:sql/hub-pw}",
|
||||
};
|
||||
StoreTarget target = new(
|
||||
"sql-hub", AppSettingsPath: null, secrets, sqlServer, new ConfigurationBuilder().Build());
|
||||
|
||||
SecretsSession session =
|
||||
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("sqlite", session.StoreKind);
|
||||
Assert.Contains(session.Warnings, w => w.Contains("SQLite", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user