using EntKube.Web.Data; using EntKube.Web.Services; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace EntKube.Web.Tests; /// /// 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. /// 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 options = new DbContextOptionsBuilder() .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 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 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 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 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 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 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().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().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(); } }