Files
Entkube/tests/EntKube.Web.Tests/VaultServiceTests.cs
2026-05-18 08:37:09 +02:00

540 lines
17 KiB
C#

using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for the VaultService which manages per-tenant vaults, secrets for apps
/// and components, and Kubernetes sync configuration. Uses SQLite in-memory
/// for fast, isolated database tests.
/// </summary>
public class VaultServiceTests : IDisposable
{
private static readonly byte[] TestRootKey = Convert.FromBase64String(
"dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly VaultService sut;
public VaultServiceTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
db = new ApplicationDbContext(options);
dbFactory = new TestDbContextFactory(connection);
db.Database.EnsureCreated();
VaultEncryptionService encryption = new(TestRootKey);
sut = new VaultService(dbFactory, encryption);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
private Tenant CreateTenant(string name = "TestCo", string slug = "testco")
{
Tenant tenant = new() { Id = Guid.NewGuid(), Name = name, Slug = slug };
db.Tenants.Add(tenant);
db.SaveChanges();
return tenant;
}
private (Tenant tenant, KubernetesCluster cluster) CreateTenantWithCluster()
{
Tenant tenant = CreateTenant();
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "production" };
db.Environments.Add(env);
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "prod-cluster",
ApiServerUrl = "https://k8s.example.com"
};
db.KubernetesClusters.Add(cluster);
db.SaveChanges();
return (tenant, cluster);
}
private (Tenant tenant, App app) CreateTenantWithApp()
{
Tenant tenant = CreateTenant();
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "CustomerA" };
db.Customers.Add(customer);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "MyApp" };
db.Apps.Add(app);
db.SaveChanges();
return (tenant, app);
}
// --- Vault Initialization ---
[Fact]
public async Task InitializeVaultAsync_CreatesVaultForTenant()
{
// When a tenant is first set up, we create a vault with a sealed DEK.
Tenant tenant = CreateTenant();
SecretVault vault = await sut.InitializeVaultAsync(tenant.Id);
vault.Should().NotBeNull();
vault.TenantId.Should().Be(tenant.Id);
vault.EncryptedDataKey.Should().NotBeEmpty();
vault.Nonce.Should().NotBeEmpty();
}
[Fact]
public async Task InitializeVaultAsync_CalledTwice_ReturnsSameVault()
{
// Idempotent — if the vault already exists, return it.
Tenant tenant = CreateTenant();
SecretVault first = await sut.InitializeVaultAsync(tenant.Id);
SecretVault second = await sut.InitializeVaultAsync(tenant.Id);
second.Id.Should().Be(first.Id);
}
[Fact]
public async Task GetVaultAsync_ReturnsNullWhenNoVaultExists()
{
SecretVault? vault = await sut.GetVaultAsync(Guid.NewGuid());
vault.Should().BeNull();
}
// --- App Secrets ---
[Fact]
public async Task SetAppSecretAsync_CreatesNewSecret()
{
// Store a secret for a customer app.
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_PASSWORD", "s3cret!");
secret.Name.Should().Be("DB_PASSWORD");
secret.AppId.Should().Be(app.Id);
secret.EncryptedValue.Should().NotBeEmpty();
}
[Fact]
public async Task SetAppSecretAsync_SameNameUpdatesExisting()
{
// Setting a secret with the same name should update rather than create a duplicate.
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "old-value");
VaultSecret updated = await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "new-value");
// Should still be just one secret with that name.
List<VaultSecret> secrets = await sut.GetAppSecretsAsync(tenant.Id, app.Id);
secrets.Should().HaveCount(1);
secrets[0].Id.Should().Be(updated.Id);
}
[Fact]
public async Task GetAppSecretValueAsync_DecryptsCorrectly()
{
// The decrypted value should match what was originally stored.
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
await sut.SetAppSecretAsync(tenant.Id, app.Id, "TOKEN", "my-secret-token-123");
string? value = await sut.GetAppSecretValueAsync(tenant.Id, app.Id, "TOKEN");
value.Should().Be("my-secret-token-123");
}
[Fact]
public async Task GetAppSecretsAsync_ReturnsAllSecretsForApp()
{
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
await sut.SetAppSecretAsync(tenant.Id, app.Id, "SECRET_A", "val-a");
await sut.SetAppSecretAsync(tenant.Id, app.Id, "SECRET_B", "val-b");
List<VaultSecret> secrets = await sut.GetAppSecretsAsync(tenant.Id, app.Id);
secrets.Should().HaveCount(2);
secrets.Select(s => s.Name).Should().Contain(["SECRET_A", "SECRET_B"]);
}
[Fact]
public async Task DeleteAppSecretAsync_RemovesSecret()
{
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "TEMP", "delete-me");
bool deleted = await sut.DeleteSecretAsync(secret.Id);
deleted.Should().BeTrue();
List<VaultSecret> remaining = await sut.GetAppSecretsAsync(tenant.Id, app.Id);
remaining.Should().BeEmpty();
}
// --- Component Secrets ---
[Fact]
public async Task SetComponentSecretAsync_CreatesNewSecret()
{
(Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.InitializeVaultAsync(tenant.Id);
ClusterComponent component = await sut.CreateComponentAsync(
cluster.Id, "minio", "HelmChart");
VaultSecret secret = await sut.SetComponentSecretAsync(
tenant.Id, component.Id, "MINIO_ROOT_PASSWORD", "admin123");
secret.Name.Should().Be("MINIO_ROOT_PASSWORD");
secret.ComponentId.Should().Be(component.Id);
}
[Fact]
public async Task GetComponentSecretValueAsync_DecryptsCorrectly()
{
(Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.InitializeVaultAsync(tenant.Id);
ClusterComponent component = await sut.CreateComponentAsync(
cluster.Id, "postgres", "HelmChart");
await sut.SetComponentSecretAsync(
tenant.Id, component.Id, "PG_PASSWORD", "postgres-secret-pw");
string? value = await sut.GetComponentSecretValueAsync(
tenant.Id, component.Id, "PG_PASSWORD");
value.Should().Be("postgres-secret-pw");
}
[Fact]
public async Task GetComponentSecretsAsync_ReturnsOnlyComponentSecrets()
{
(Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.InitializeVaultAsync(tenant.Id);
ClusterComponent comp = await sut.CreateComponentAsync(cluster.Id, "redis", "HelmChart");
await sut.SetComponentSecretAsync(tenant.Id, comp.Id, "REDIS_PASSWORD", "r3dis");
await sut.SetComponentSecretAsync(tenant.Id, comp.Id, "REDIS_TLS_CERT", "cert-data");
List<VaultSecret> secrets = await sut.GetComponentSecretsAsync(tenant.Id, comp.Id);
secrets.Should().HaveCount(2);
}
// --- Kubernetes Sync Configuration ---
[Fact]
public async Task ConfigureKubernetesSyncAsync_SetsFields()
{
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_URL", "postgres://...");
await sut.ConfigureKubernetesSyncAsync(
secret.Id, syncEnabled: true, secretName: "myapp-db", ns: "production");
VaultSecret? reloaded = await db.VaultSecrets.FindAsync(secret.Id);
reloaded!.SyncToKubernetes.Should().BeTrue();
reloaded.KubernetesSecretName.Should().Be("myapp-db");
reloaded.KubernetesNamespace.Should().Be("production");
}
[Fact]
public async Task ConfigureKubernetesSyncAsync_DisablesSync()
{
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "KEY", "val");
await sut.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: true, secretName: "s", ns: "ns");
await sut.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null);
VaultSecret? reloaded = await db.VaultSecrets.FindAsync(secret.Id);
reloaded!.SyncToKubernetes.Should().BeFalse();
}
// --- Cluster Components ---
[Fact]
public async Task CreateComponentAsync_CreatesComponent()
{
(_, KubernetesCluster cluster) = CreateTenantWithCluster();
ClusterComponent component = await sut.CreateComponentAsync(
cluster.Id, "keycloak", "HelmChart");
component.Name.Should().Be("keycloak");
component.ComponentType.Should().Be("HelmChart");
component.ClusterId.Should().Be(cluster.Id);
}
[Fact]
public async Task GetComponentsAsync_ReturnsComponentsForCluster()
{
(_, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.CreateComponentAsync(cluster.Id, "minio", "HelmChart");
await sut.CreateComponentAsync(cluster.Id, "cnpg", "Operator");
List<ClusterComponent> components = await sut.GetComponentsAsync(cluster.Id);
components.Should().HaveCount(2);
}
[Fact]
public async Task DeleteComponentAsync_RemovesComponent()
{
(_, KubernetesCluster cluster) = CreateTenantWithCluster();
ClusterComponent component = await sut.CreateComponentAsync(cluster.Id, "temp", "Deployment");
bool deleted = await sut.DeleteComponentAsync(component.Id);
deleted.Should().BeTrue();
List<ClusterComponent> remaining = await sut.GetComponentsAsync(cluster.Id);
remaining.Should().BeEmpty();
}
// ──────── GetSecretValueByIdAsync ────────
[Fact]
public async Task GetSecretValueByIdAsync_ReturnsDecryptedValue()
{
// Arrange
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "my-secret-value");
// Act
string? value = await sut.GetSecretValueByIdAsync(secret.Id);
// Assert
value.Should().Be("my-secret-value");
}
[Fact]
public async Task GetSecretValueByIdAsync_NonExistent_ReturnsNull()
{
// Act
string? value = await sut.GetSecretValueByIdAsync(Guid.NewGuid());
// Assert
value.Should().BeNull();
}
// ──────── UpdateSecretValueAsync ────────
[Fact]
public async Task UpdateSecretValueAsync_UpdatesValue()
{
// Arrange
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_PASS", "old-password");
// Act
bool updated = await sut.UpdateSecretValueAsync(secret.Id, "new-password");
// Assert
updated.Should().BeTrue();
string? value = await sut.GetSecretValueByIdAsync(secret.Id);
value.Should().Be("new-password");
}
[Fact]
public async Task UpdateSecretValueAsync_NonExistent_ReturnsFalse()
{
// Act
bool updated = await sut.UpdateSecretValueAsync(Guid.NewGuid(), "anything");
// Assert
updated.Should().BeFalse();
}
// ──────── CanDeleteSecretAsync ────────
[Fact]
public async Task CanDeleteSecretAsync_NoBindings_ReturnsTrue()
{
// Arrange
(Tenant tenant, App app) = CreateTenantWithApp();
await sut.InitializeVaultAsync(tenant.Id);
VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "TEMP", "value");
// Act
(bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id);
// Assert
canDelete.Should().BeTrue();
reason.Should().BeNull();
}
[Fact]
public async Task CanDeleteSecretAsync_WithActiveStorageBinding_ReturnsFalse()
{
// Arrange — a storage link secret that has an active binding.
(Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.InitializeVaultAsync(tenant.Id);
Data.Environment env = await db.Environments.FirstAsync(e => e.TenantId == tenant.Id);
StorageLink link = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Provider = StorageProvider.AwsS3,
Name = "Backups"
};
db.StorageLinks.Add(link);
await db.SaveChangesAsync();
VaultSecret secret = await sut.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIA...");
// Create an app deployment that binds to this storage.
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Acme" };
db.Customers.Add(customer);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "api" };
db.Apps.Add(app);
AppDeployment deployment = new()
{
Id = Guid.NewGuid(),
AppId = app.Id,
Name = "api-prod",
Type = DeploymentType.HelmChart,
EnvironmentId = env.Id,
ClusterId = cluster.Id,
Namespace = "default"
};
db.AppDeployments.Add(deployment);
StorageBinding binding = new()
{
Id = Guid.NewGuid(),
StorageLinkId = link.Id,
AppDeploymentId = deployment.Id,
KubernetesSecretName = "backup-creds",
SyncEnabled = true
};
db.Set<StorageBinding>().Add(binding);
await db.SaveChangesAsync();
// Act
(bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id);
// Assert
canDelete.Should().BeFalse();
reason.Should().Contain("storage binding");
}
[Fact]
public async Task CanDeleteSecretAsync_WithDisabledBinding_ReturnsTrue()
{
// Arrange — same setup but binding has SyncEnabled = false.
(Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster();
await sut.InitializeVaultAsync(tenant.Id);
Data.Environment env = await db.Environments.FirstAsync(e => e.TenantId == tenant.Id);
StorageLink link = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Provider = StorageProvider.AwsS3,
Name = "Archives"
};
db.StorageLinks.Add(link);
await db.SaveChangesAsync();
VaultSecret secret = await sut.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t");
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Acme2" };
db.Customers.Add(customer);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "worker" };
db.Apps.Add(app);
AppDeployment deployment = new()
{
Id = Guid.NewGuid(),
AppId = app.Id,
Name = "worker-prod",
Type = DeploymentType.Yaml,
EnvironmentId = env.Id,
ClusterId = cluster.Id,
Namespace = "workers"
};
db.AppDeployments.Add(deployment);
StorageBinding binding = new()
{
Id = Guid.NewGuid(),
StorageLinkId = link.Id,
AppDeploymentId = deployment.Id,
KubernetesSecretName = "archive-creds",
SyncEnabled = false
};
db.Set<StorageBinding>().Add(binding);
await db.SaveChangesAsync();
// Act
(bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id);
// Assert — disabled binding should not block deletion.
canDelete.Should().BeTrue();
reason.Should().BeNull();
}
}