too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -0,0 +1,232 @@
using EntKube.Secrets.Domain;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
public class SecretScopeTests
{
// ─── Factory Method Tests ────────────────────────────────────────────
[Fact]
public void ForCluster_CreatesInfrastructureScope()
{
// Arrange — Infrastructure secrets are scoped to Tenant + Environment + Cluster.
// These are things like kubeconfig credentials, DNS tokens, cloud provider secrets.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
// Act
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().Be(clusterId);
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
[Fact]
public void ForApp_CreatesApplicationScope()
{
// Arrange — Application secrets are scoped to Tenant + Customer + App + Environment.
// These are things like database connection strings, API keys for third-party services.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
// Act
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "my-api", environmentId);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().Be(customerId);
scope.AppName.Should().Be("my-api");
}
// ─── Record Construction Tests ───────────────────────────────────────
[Fact]
public void Create_WithAllFields_StoresAllValues()
{
// Arrange — A fully-scoped secret with all fields populated.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
// Act
SecretScope scope = new(tenantId, environmentId, clusterId, customerId, "my-api");
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().Be(clusterId);
scope.CustomerId.Should().Be(customerId);
scope.AppName.Should().Be("my-api");
}
[Fact]
public void Create_WithTenantOnly_AllowsNullOptionalFields()
{
// Arrange & Act — A tenant-wide secret (e.g. a shared API key)
// that isn't scoped to a specific environment, cluster, customer, or app.
Guid tenantId = Guid.NewGuid();
SecretScope scope = new(tenantId, null, null, null, null);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().BeNull();
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
[Fact]
public void Create_WithTenantAndEnvironment_ScopesToEnvironment()
{
// Arrange & Act — A secret scoped to a tenant + environment
// (e.g. a dev database password shared across all customers in dev).
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, null, null);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
// ─── BuildPath Tests ─────────────────────────────────────────────────
[Fact]
public void BuildPath_TenantOnly_ReturnsPathWithTenantPrefix()
{
// Arrange — A tenant-scoped secret should have a path like
// "tenants/{tenantId}/secrets/my-key".
Guid tenantId = Guid.NewGuid();
SecretScope scope = new(tenantId, null, null, null, null);
// Act
string path = scope.BuildPath("my-key");
// Assert
path.Should().Be($"tenants/{tenantId}/secrets/my-key");
}
[Fact]
public void BuildPath_TenantAndEnvironment_ReturnsEnvironmentScopedPath()
{
// Arrange
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, null, null);
// Act
string path = scope.BuildPath("db-password");
// Assert
path.Should().Be($"tenants/{tenantId}/environments/{environmentId}/secrets/db-password");
}
[Fact]
public void BuildPath_ClusterScope_ReturnsClusterScopedPath()
{
// Arrange — Infrastructure secret scoped to a specific cluster.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
// Act
string path = scope.BuildPath("dns-credential");
// Assert — Path includes the cluster segment.
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/clusters/{clusterId}/secrets/dns-credential");
}
[Fact]
public void BuildPath_AppScope_ReturnsFullAppScopedPath()
{
// Arrange — Application secret: tenant → environment → customer → app.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "extract01", environmentId);
// Act
string path = scope.BuildPath("api-token");
// Assert
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/apps/extract01/secrets/api-token");
}
[Fact]
public void BuildPath_CustomerWithoutApp_IncludesCustomerScope()
{
// Arrange — Customer-scoped without a specific app.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, customerId, null);
// Act
string path = scope.BuildPath("shared-token");
// Assert
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/secrets/shared-token");
}
// ─── Equality ────────────────────────────────────────────────────────
[Fact]
public void Equality_SameValues_AreEqual()
{
// Arrange — Records should have value-based equality.
Guid tenantId = Guid.NewGuid();
Guid envId = Guid.NewGuid();
SecretScope scope1 = new(tenantId, envId, null, null, null);
SecretScope scope2 = new(tenantId, envId, null, null, null);
// Assert
scope1.Should().Be(scope2);
}
}

View File

@@ -0,0 +1,178 @@
using EntKube.Secrets.Crypto;
using EntKube.Secrets.Domain;
using EntKube.Secrets.Infrastructure;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
/// <summary>
/// Tests for service token authentication. Other EntKube services authenticate
/// to the secrets service using service tokens scoped to specific path prefixes
/// with specific permissions. Tokens are now tenant-scoped.
/// </summary>
public class ServiceTokenTests
{
private readonly InMemoryVaultRepository repository = new();
private readonly byte[] kek = AesGcmEncryptor.GenerateKey();
private readonly Guid tenantId = Guid.NewGuid();
private Vault CreateVault() => new(repository);
[Fact]
public async Task CreateToken_ReturnsTokenWithCorrectPolicies()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "clusters-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read | AccessOperation.Write | AccessOperation.List),
new("shared/certificates/", AccessOperation.Read)
});
token.Token.Should().NotBeNullOrEmpty();
token.Name.Should().Be("clusters-service");
token.Policies.Should().HaveCount(2);
}
[Fact]
public async Task ValidateToken_WithValidToken_ReturnsTrue()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "test-service",
policies: new List<AccessPolicy>
{
new("test/", AccessOperation.Read)
});
TokenValidationResult result = await vault.ValidateTokenAsync(token.Token);
result.IsValid.Should().BeTrue();
result.Name.Should().Be("test-service");
}
[Fact]
public async Task ValidateToken_WithInvalidToken_ReturnsFalse()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
TokenValidationResult result = await vault.ValidateTokenAsync("invalid-token-that-was-never-issued");
result.IsValid.Should().BeFalse();
}
[Fact]
public async Task CheckAccess_WithMatchingPolicy_AllowsOperation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "clusters-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read | AccessOperation.Write)
});
bool canRead = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Read);
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Write);
canRead.Should().BeTrue();
canWrite.Should().BeTrue();
}
[Fact]
public async Task CheckAccess_WithNoMatchingPolicy_DeniesOperation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "readonly-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read)
});
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/secret", AccessOperation.Write);
bool canReadOther = await vault.CheckAccessAsync(token.Token, "identity/users/", AccessOperation.Read);
canWrite.Should().BeFalse();
canReadOther.Should().BeFalse();
}
[Fact]
public async Task RevokeToken_PreventsSubsequentValidation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "temporary-service",
policies: new List<AccessPolicy>
{
new("temp/", AccessOperation.Read)
});
TokenValidationResult validBefore = await vault.ValidateTokenAsync(token.Token);
validBefore.IsValid.Should().BeTrue();
await vault.RevokeTokenAsync(token.Token);
TokenValidationResult validAfter = await vault.ValidateTokenAsync(token.Token);
validAfter.IsValid.Should().BeFalse();
}
[Fact]
public async Task ListTokens_ReturnsActiveTokenMetadata()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.CreateServiceTokenAsync(tenantId, "service-a", new List<AccessPolicy>
{
new("a/", AccessOperation.Read)
});
await vault.CreateServiceTokenAsync(tenantId, "service-b", new List<AccessPolicy>
{
new("b/", AccessOperation.Read | AccessOperation.Write)
});
List<ServiceTokenInfo> tokens = await vault.ListTokensAsync(tenantId);
tokens.Should().HaveCount(2);
tokens.Should().Contain(t => t.Name == "service-a");
tokens.Should().Contain(t => t.Name == "service-b");
}
[Fact]
public async Task MultiInstance_TokenCreatedOnOnePodValidatesOnAnother()
{
Vault pod1 = CreateVault();
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
Vault pod2 = CreateVault();
await pod2.AutoUnsealAsync(kek);
ServiceTokenResult token = await pod1.CreateServiceTokenAsync(
tenantId,
name: "cross-pod-service",
policies: new List<AccessPolicy> { new("shared/", AccessOperation.Read) });
TokenValidationResult result = await pod2.ValidateTokenAsync(token.Token);
result.IsValid.Should().BeTrue();
result.Name.Should().Be("cross-pod-service");
}
}

View File

@@ -0,0 +1,518 @@
using EntKube.Secrets.Crypto;
using EntKube.Secrets.Domain;
using EntKube.Secrets.Infrastructure;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
/// <summary>
/// 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.
/// </summary>
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<Task> act = () => vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => vault2.AutoUnsealAsync(wrongKek);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*KEK*");
}
[Fact]
public async Task AutoUnseal_WhenNotInitialized_Fails()
{
Vault vault = CreateVault();
Func<Task> act = () => vault.AutoUnsealAsync(kek);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => vault2.AutoUnsealAsync(kek);
await act.Should().ThrowAsync<InvalidOperationException>();
}
// ─── 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<Task> act = () => vault.PutSecretAsync(tenantId, "path/to/secret", "value");
await act.Should().ThrowAsync<VaultSealedException>();
}
[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<Task> act = () => vault.GetSecretAsync(tenantId, "my/secret");
await act.Should().ThrowAsync<VaultSealedException>();
}
[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<string> 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<Task> act = () => vault.ListSecretsAsync(tenantId, "any/");
await act.Should().ThrowAsync<VaultSealedException>();
}
// ─── 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<AuditEntry> 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");
}
}