using EntKube.Secrets.Crypto; using EntKube.Secrets.Domain; using EntKube.Secrets.Infrastructure; using FluentAssertions; namespace EntKube.Secrets.Tests.Domain; /// /// Tests for the multi-tenant Vault aggregate. Each tenant gets their own /// master encryption key (MEK) for cryptographic isolation. /// /// The vault manages per-tenant MEKs — initialization, auto-unseal, and /// secret operations are all scoped to a specific tenant. /// public class VaultTests { private readonly InMemoryVaultRepository repository = new(); private readonly byte[] kek = AesGcmEncryptor.GenerateKey(); private readonly Guid tenantId = Guid.NewGuid(); private Vault CreateVault() => new(repository); // ─── Initialization ────────────────────────────────────────────────── [Fact] public async Task Initialize_GeneratesMasterKeyAndAutoUnseals() { // Arrange — A fresh vault backed by an empty repository. Vault vault = CreateVault(); // Act — Initialize a tenant with KEK, 5 Shamir shares, threshold of 3. InitializationResult result = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); // Assert — Shares returned for DR, tenant's vault is immediately unsealed. result.Shares.Should().HaveCount(5); result.Threshold.Should().Be(3); vault.IsTenantInitialized(tenantId).Should().BeTrue(); vault.IsTenantSealed(tenantId).Should().BeFalse(); } [Fact] public async Task Initialize_WhenAlreadyInitialized_Fails() { // A tenant's vault can only be initialized once. Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); Func act = () => vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); await act.Should().ThrowAsync() .WithMessage("*already initialized*"); } // ─── Auto-Unseal (Normal K8s Startup) ──────────────────────────────── [Fact] public async Task AutoUnseal_WithCorrectKek_UnsealsAllTenants() { // Arrange — Initialize two tenants, then seal both. Guid tenant1 = Guid.NewGuid(); Guid tenant2 = Guid.NewGuid(); Vault vault = CreateVault(); await vault.InitializeAsync(tenant1, kek, totalShares: 1, threshold: 1); await vault.InitializeAsync(tenant2, kek, totalShares: 1, threshold: 1); await vault.SealAsync(); // Act — A new vault instance auto-unseals all tenants. Vault vault2 = CreateVault(); await vault2.AutoUnsealAsync(kek); // Assert — Both tenants unsealed. vault2.IsTenantSealed(tenant1).Should().BeFalse(); vault2.IsTenantSealed(tenant2).Should().BeFalse(); } [Fact] public async Task AutoUnseal_WithWrongKek_Fails() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.SealAsync(); byte[] wrongKek = AesGcmEncryptor.GenerateKey(); Vault vault2 = CreateVault(); Func act = () => vault2.AutoUnsealAsync(wrongKek); await act.Should().ThrowAsync() .WithMessage("*KEK*"); } [Fact] public async Task AutoUnseal_WhenNotInitialized_Fails() { Vault vault = CreateVault(); Func act = () => vault.AutoUnsealAsync(kek); await act.Should().ThrowAsync() .WithMessage("*not initialized*"); } // ─── Manual Unseal (Disaster Recovery) ─────────────────────────────── [Fact] public async Task ManualUnseal_WithEnoughShares_UnsealsVault() { Vault vault = CreateVault(); InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); await vault.SealAsync(tenantId); vault.IsTenantSealed(tenantId).Should().BeTrue(); // Act — Provide 3 of 5 shares. await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[2]); UnsealResult unsealResult = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[4]); unsealResult.IsUnsealed.Should().BeTrue(); vault.IsTenantSealed(tenantId).Should().BeFalse(); } [Fact] public async Task ManualUnseal_WithInsufficientShares_RemainsSealed() { Vault vault = CreateVault(); InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); await vault.SealAsync(tenantId); UnsealResult result1 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); UnsealResult result2 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); result1.IsUnsealed.Should().BeFalse(); result1.SharesProvided.Should().Be(1); result1.SharesRequired.Should().Be(3); result2.IsUnsealed.Should().BeFalse(); result2.SharesProvided.Should().Be(2); vault.IsTenantSealed(tenantId).Should().BeTrue(); } // ─── Multi-Instance (Simulating K8s Pods) ──────────────────────────── [Fact] public async Task MultiInstance_BothPodsCanAutoUnseal() { Vault pod1 = CreateVault(); await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); Vault pod2 = CreateVault(); await pod2.AutoUnsealAsync(kek); pod1.IsTenantSealed(tenantId).Should().BeFalse(); pod2.IsTenantSealed(tenantId).Should().BeFalse(); } [Fact] public async Task MultiInstance_SecretWrittenByOnePodReadableByAnother() { Vault pod1 = CreateVault(); await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); Vault pod2 = CreateVault(); await pod2.AutoUnsealAsync(kek); await pod1.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "cloudflare-api-token"); string? value = await pod2.GetSecretAsync(tenantId, "clusters/prod/dns-cred"); value.Should().Be("cloudflare-api-token"); } [Fact] public async Task MultiInstance_SealingOnePodDoesNotAffectOther() { Vault pod1 = CreateVault(); await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await pod1.PutSecretAsync(tenantId, "shared/secret", "hello"); Vault pod2 = CreateVault(); await pod2.AutoUnsealAsync(kek); await pod1.SealAsync(tenantId); pod1.IsTenantSealed(tenantId).Should().BeTrue(); pod2.IsTenantSealed(tenantId).Should().BeFalse(); string? value = await pod2.GetSecretAsync(tenantId, "shared/secret"); value.Should().Be("hello"); } // ─── Multi-Tenant Isolation ────────────────────────────────────────── [Fact] public async Task MultiTenant_SecretsAreCryptographicallyIsolated() { // Each tenant has their own MEK. A secret written for tenant A // cannot be decrypted by tenant B's MEK. Guid tenantA = Guid.NewGuid(); Guid tenantB = Guid.NewGuid(); Vault vault = CreateVault(); await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1); await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1); // Each tenant writes a secret at the same logical key but different paths. await vault.PutSecretAsync(tenantA, "tenants/a/config/api-key", "tenant-a-secret"); await vault.PutSecretAsync(tenantB, "tenants/b/config/api-key", "tenant-b-secret"); // Each tenant can only read their own secrets. string? valueA = await vault.GetSecretAsync(tenantA, "tenants/a/config/api-key"); string? valueB = await vault.GetSecretAsync(tenantB, "tenants/b/config/api-key"); valueA.Should().Be("tenant-a-secret"); valueB.Should().Be("tenant-b-secret"); } [Fact] public async Task MultiTenant_SealingOneTenantDoesNotAffectAnother() { Guid tenantA = Guid.NewGuid(); Guid tenantB = Guid.NewGuid(); Vault vault = CreateVault(); await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1); await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1); // Seal only tenant A. await vault.SealAsync(tenantA); vault.IsTenantSealed(tenantA).Should().BeTrue(); vault.IsTenantSealed(tenantB).Should().BeFalse(); } // ─── Seal / Unseal Lifecycle ───────────────────────────────────────── [Fact] public async Task Seal_ClearsMasterKeyFromMemory() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); vault.IsTenantSealed(tenantId).Should().BeFalse(); await vault.SealAsync(tenantId); vault.IsTenantSealed(tenantId).Should().BeTrue(); } [Fact] public async Task SealThenAutoUnseal_Works() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); await vault.SealAsync(); vault.IsSealed.Should().BeTrue(); await vault.AutoUnsealAsync(kek); vault.IsTenantSealed(tenantId).Should().BeFalse(); } [Fact] public async Task RepeatedSealUnsealCycles_Work() { Vault vault = CreateVault(); InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); await vault.SealAsync(); await vault.AutoUnsealAsync(kek); vault.IsTenantSealed(tenantId).Should().BeFalse(); await vault.SealAsync(tenantId); await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); vault.IsTenantSealed(tenantId).Should().BeFalse(); } // ─── KEK Rotation ──────────────────────────────────────────────────── [Fact] public async Task ReWrapMasterKey_AllowsAutoUnsealWithNewKek() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "test/secret", "original-value"); byte[] newKek = AesGcmEncryptor.GenerateKey(); await vault.ReWrapMasterKeyAsync(tenantId, newKek); await vault.SealAsync(); await vault.AutoUnsealAsync(newKek); string? value = await vault.GetSecretAsync(tenantId, "test/secret"); value.Should().Be("original-value"); } [Fact] public async Task ReWrapMasterKey_OldKekNoLongerWorks() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); byte[] newKek = AesGcmEncryptor.GenerateKey(); await vault.ReWrapMasterKeyAsync(tenantId, newKek); await vault.SealAsync(); Vault vault2 = CreateVault(); Func act = () => vault2.AutoUnsealAsync(kek); await act.Should().ThrowAsync(); } // ─── Secret Operations ─────────────────────────────────────────────── [Fact] public async Task PutSecret_WhenUnsealed_StoresEncryptedSecret() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential", "cloudflare-api-token-value"); string? retrieved = await vault.GetSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential"); retrieved.Should().Be("cloudflare-api-token-value"); } [Fact] public async Task PutSecret_WhenSealed_ThrowsVaultSealedException() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.SealAsync(tenantId); Func act = () => vault.PutSecretAsync(tenantId, "path/to/secret", "value"); await act.Should().ThrowAsync(); } [Fact] public async Task GetSecret_WhenSealed_ThrowsVaultSealedException() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "my/secret", "value"); await vault.SealAsync(tenantId); Func act = () => vault.GetSecretAsync(tenantId, "my/secret"); await act.Should().ThrowAsync(); } [Fact] public async Task GetSecret_NonExistentPath_ReturnsNull() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); string? result = await vault.GetSecretAsync(tenantId, "does/not/exist"); result.Should().BeNull(); } [Fact] public async Task PutSecret_OverwritesExistingSecret() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "config/api-key", "old-value"); await vault.PutSecretAsync(tenantId, "config/api-key", "new-value"); string? result = await vault.GetSecretAsync(tenantId, "config/api-key"); result.Should().Be("new-value"); } [Fact] public async Task PutSecret_CreatesVersionHistory() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "config/api-key", "v1"); await vault.PutSecretAsync(tenantId, "config/api-key", "v2"); await vault.PutSecretAsync(tenantId, "config/api-key", "v3"); string? current = await vault.GetSecretAsync(tenantId, "config/api-key"); int versionCount = await vault.GetSecretVersionCountAsync(tenantId, "config/api-key"); current.Should().Be("v3"); versionCount.Should().Be(3); } [Fact] public async Task GetSecretVersion_RetrievesSpecificVersion() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "config/api-key", "version-one"); await vault.PutSecretAsync(tenantId, "config/api-key", "version-two"); await vault.PutSecretAsync(tenantId, "config/api-key", "version-three"); string? v1 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 1); string? v2 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 2); string? v3 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 3); v1.Should().Be("version-one"); v2.Should().Be("version-two"); v3.Should().Be("version-three"); } [Fact] public async Task DeleteSecret_SoftDeletesAndPreventsRetrieval() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "temp/secret", "temporary-value"); await vault.DeleteSecretAsync(tenantId, "temp/secret"); string? result = await vault.GetSecretAsync(tenantId, "temp/secret"); result.Should().BeNull(); } [Fact] public async Task ListSecrets_ReturnsPathsWithoutValues() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "value1"); await vault.PutSecretAsync(tenantId, "clusters/prod/tls-key", "value2"); await vault.PutSecretAsync(tenantId, "clusters/staging/dns-cred", "value3"); List paths = await vault.ListSecretsAsync(tenantId, "clusters/prod/"); paths.Should().HaveCount(2); paths.Should().Contain("clusters/prod/dns-cred"); paths.Should().Contain("clusters/prod/tls-key"); } [Fact] public async Task ListSecrets_WhenSealed_ThrowsVaultSealedException() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.SealAsync(tenantId); Func act = () => vault.ListSecretsAsync(tenantId, "any/"); await act.Should().ThrowAsync(); } // ─── Secrets Persist Across Seal/Unseal ────────────────────────────── [Fact] public async Task Secrets_SurviveSealAutoUnsealCycle() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); await vault.PutSecretAsync(tenantId, "persistent/secret", "survive-seal-unseal"); await vault.SealAsync(); await vault.AutoUnsealAsync(kek); string? recovered = await vault.GetSecretAsync(tenantId, "persistent/secret"); recovered.Should().Be("survive-seal-unseal"); } [Fact] public async Task Secrets_SurviveManualShamirUnseal() { Vault vault = CreateVault(); InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); await vault.PutSecretAsync(tenantId, "dr/secret", "disaster-recovery-value"); await vault.SealAsync(tenantId); await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); string? recovered = await vault.GetSecretAsync(tenantId, "dr/secret"); recovered.Should().Be("disaster-recovery-value"); } // ─── Audit ─────────────────────────────────────────────────────────── [Fact] public async Task Operations_AreAuditLogged() { Vault vault = CreateVault(); await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); await vault.PutSecretAsync(tenantId, "audit/test", "value"); await vault.GetSecretAsync(tenantId, "audit/test"); await vault.DeleteSecretAsync(tenantId, "audit/test"); List entries = await vault.GetAuditLogAsync(); entries.Should().Contain(e => e.Operation == "initialize"); entries.Should().Contain(e => e.Operation == "put" && e.Path == "audit/test"); entries.Should().Contain(e => e.Operation == "get" && e.Path == "audit/test"); entries.Should().Contain(e => e.Operation == "delete" && e.Path == "audit/test"); } }