using EntKube.Web.Data; using EntKube.Web.Services; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; namespace EntKube.Web.Tests; /// /// Tests for the StorageService which manages external storage links (AWS S3, /// Azure Storage, Cleura S3) and MinIO discovery. Uses SQLite in-memory for /// fast, isolated database tests. /// /// Note: MinIO discovery tests are not included here because they require /// a live Kubernetes cluster. These tests focus on the CRUD operations /// for external storage links and vault credential management. /// public class StorageServiceTests : IDisposable { private static readonly byte[] TestRootKey = Convert.FromBase64String( "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); private readonly SqliteConnection connection; private readonly ApplicationDbContext db; private readonly TestDbContextFactory dbFactory; private readonly VaultService vaultService; private readonly StorageService sut; public StorageServiceTests() { 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); vaultService = new VaultService(dbFactory, encryption); Mock httpFactory = new(); OpenStackS3Service openStackS3 = new(vaultService, httpFactory.Object); sut = new StorageService(dbFactory, vaultService, openStackS3); } public void Dispose() { db.Dispose(); connection.Dispose(); } private (Tenant tenant, Data.Environment env) CreateTenantWithEnvironment() { Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; db.Tenants.Add(tenant); Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; db.Set().Add(env); db.SaveChanges(); return (tenant, env); } // ──────── IsMinioAvailableAsync ──────── [Fact] public async Task IsMinioAvailableAsync_NoComponents_ReturnsFalse() { // Arrange — tenant with no clusters/components. (Tenant tenant, _) = CreateTenantWithEnvironment(); // Act bool result = await sut.IsMinioAvailableAsync(tenant.Id); // Assert result.Should().BeFalse(); } [Fact] public async Task IsMinioAvailableAsync_MinioInstalled_ReturnsTrue() { // Arrange — tenant with a cluster that has minio installed. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "prod-cluster", ApiServerUrl = "https://k8s.example.com" }; db.KubernetesClusters.Add(cluster); ClusterComponent component = new() { Id = Guid.NewGuid(), ClusterId = cluster.Id, Name = "minio", ComponentType = "helm", Status = ComponentStatus.Installed }; db.ClusterComponents.Add(component); await db.SaveChangesAsync(); // Act bool result = await sut.IsMinioAvailableAsync(tenant.Id); // Assert result.Should().BeTrue(); } // ──────── GetStorageLinksAsync ──────── [Fact] public async Task GetStorageLinksAsync_NoLinks_ReturnsEmpty() { // Arrange (Tenant tenant, _) = CreateTenantWithEnvironment(); // Act List result = await sut.GetStorageLinksAsync(tenant.Id); // Assert result.Should().BeEmpty(); } [Fact] public async Task GetStorageLinksAsync_WithLinks_ReturnsAll() { // Arrange — add two links for the tenant. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); db.StorageLinks.AddRange( new StorageLink { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.AwsS3, Name = "Backups" }, new StorageLink { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Media" }); await db.SaveChangesAsync(); // Act List result = await sut.GetStorageLinksAsync(tenant.Id); // Assert result.Should().HaveCount(2); result.Select(s => s.Name).Should().Contain("Backups").And.Contain("Media"); } [Fact] public async Task GetStorageLinksAsync_FilterByEnvironment_ReturnsFiltered() { // Arrange — two environments, one link each. (Tenant tenant, Data.Environment env1) = CreateTenantWithEnvironment(); Data.Environment env2 = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Staging" }; db.Set().Add(env2); db.StorageLinks.AddRange( new StorageLink { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env1.Id, Provider = StorageProvider.AwsS3, Name = "Prod Backups" }, new StorageLink { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env2.Id, Provider = StorageProvider.AwsS3, Name = "Staging Backups" }); await db.SaveChangesAsync(); // Act List result = await sut.GetStorageLinksAsync(tenant.Id, env1.Id); // Assert result.Should().HaveCount(1); result[0].Name.Should().Be("Prod Backups"); } // ──────── CreateStorageLinkAsync ──────── [Fact] public async Task CreateStorageLinkAsync_CreatesLinkAndStoresCredentials() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); // Act — create a link with credentials. StorageLink link = await sut.CreateStorageLinkAsync( tenant.Id, env.Id, StorageProvider.AwsS3, "My Bucket", "https://s3.eu-west-1.amazonaws.com", "my-app-backups", "eu-west-1", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Production backup bucket"); // Assert — the link is persisted. StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); saved.Should().NotBeNull(); saved!.Name.Should().Be("My Bucket"); saved.Provider.Should().Be(StorageProvider.AwsS3); saved.BucketName.Should().Be("my-app-backups"); saved.Region.Should().Be("eu-west-1"); saved.Endpoint.Should().Be("https://s3.eu-west-1.amazonaws.com"); saved.Notes.Should().Be("Production backup bucket"); // Assert — credentials are stored in the vault. List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secrets.Should().HaveCount(2); secrets.Select(s => s.Name).Should().Contain("ACCESS_KEY").And.Contain("SECRET_KEY"); } [Fact] public async Task CreateStorageLinkAsync_WithoutCredentials_CreatesLinkOnly() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); // Act — create a link without credentials (just a reference). StorageLink link = await sut.CreateStorageLinkAsync( tenant.Id, env.Id, StorageProvider.AzureStorage, "Azure Media", "https://myaccount.blob.core.windows.net", "media-container", "swedencentral", null, null, null); // Assert — link exists, no vault secrets. StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); saved.Should().NotBeNull(); saved!.Provider.Should().Be(StorageProvider.AzureStorage); List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secrets.Should().BeEmpty(); } // ──────── UpdateStorageLinkAsync ──────── [Fact] public async Task UpdateStorageLinkAsync_UpdatesMetadata() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Old Name", BucketName = "old-bucket" }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Act await sut.UpdateStorageLinkAsync( link.Id, "New Name", "https://new-endpoint.com", "new-bucket", "eu-north-1", "Updated notes"); // Assert — clear tracking so we get fresh data from DB db.ChangeTracker.Clear(); StorageLink? updated = await db.StorageLinks.FindAsync(link.Id); updated!.Name.Should().Be("New Name"); updated.Endpoint.Should().Be("https://new-endpoint.com"); updated.BucketName.Should().Be("new-bucket"); updated.Region.Should().Be("eu-north-1"); updated.Notes.Should().Be("Updated notes"); } // ──────── DeleteStorageLinkAsync ──────── [Fact] public async Task DeleteStorageLinkAsync_RemovesLinkAndVaultSecrets() { // Arrange — create a link with credentials. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); StorageLink link = await sut.CreateStorageLinkAsync( tenant.Id, env.Id, StorageProvider.AwsS3, "To Delete", "https://s3.amazonaws.com", "bucket", "us-east-1", "key123", "secret456", null); // Verify credentials exist before deletion. List secretsBefore = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secretsBefore.Should().HaveCount(2); // Act await sut.DeleteStorageLinkAsync(tenant.Id, link.Id); // Assert — link and secrets are gone. StorageLink? deleted = await db.StorageLinks.FindAsync(link.Id); deleted.Should().BeNull(); List secretsAfter = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secretsAfter.Should().BeEmpty(); } // ──────── GetEnvironmentsAsync ──────── [Fact] public async Task GetEnvironmentsAsync_ReturnsTenantEnvironments() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); // Act List result = await sut.GetEnvironmentsAsync(tenant.Id); // Assert result.Should().HaveCount(1); result[0].Name.Should().Be("Production"); } // ──────── OpenStack Connections ──────── [Fact] public async Task GetOpenStackConnectionsAsync_NoConnections_ReturnsEmpty() { // Arrange (Tenant tenant, _) = CreateTenantWithEnvironment(); // Act List result = await sut.GetOpenStackConnectionsAsync(tenant.Id); // Assert result.Should().BeEmpty(); } [Fact] public async Task CreateOpenStackConnectionAsync_CreatesConnectionAndStoresPassword() { // Arrange (Tenant tenant, _) = CreateTenantWithEnvironment(); // Act OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( tenant.Id, "Cleura Prod", "https://identity.c2.citycloud.com:5000/v3", "Kna1", "my-project", "abc123", "Default", "Default", "admin@example.com", "supersecret"); // Assert — connection is persisted with metadata. OpenStackConnection? saved = await db.OpenStackConnections.FindAsync(connection.Id); saved.Should().NotBeNull(); saved!.Name.Should().Be("Cleura Prod"); saved.AuthUrl.Should().Be("https://identity.c2.citycloud.com:5000/v3"); saved.Region.Should().Be("Kna1"); saved.ProjectName.Should().Be("my-project"); saved.ProjectId.Should().Be("abc123"); saved.UserDomainName.Should().Be("Default"); saved.ProjectDomainName.Should().Be("Default"); saved.Username.Should().Be("admin@example.com"); // Assert — password is in the vault. List secrets = await vaultService.GetOpenStackSecretsAsync(tenant.Id, connection.Id); secrets.Should().HaveCount(1); secrets[0].Name.Should().Be("OS_PASSWORD"); } [Fact] public async Task DeleteOpenStackConnectionAsync_RemovesConnectionAndVaultSecrets() { // Arrange (Tenant tenant, _) = CreateTenantWithEnvironment(); OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( tenant.Id, "ToDelete", "https://auth.example.com/v3", "Sto2", null, null, null, null, "user", "pass123"); // Act bool deleted = await sut.DeleteOpenStackConnectionAsync(tenant.Id, connection.Id); // Assert deleted.Should().BeTrue(); OpenStackConnection? found = await db.OpenStackConnections.FindAsync(connection.Id); found.Should().BeNull(); List secrets = await vaultService.GetOpenStackSecretsAsync(tenant.Id, connection.Id); secrets.Should().BeEmpty(); } [Fact] public async Task DeleteOpenStackConnectionAsync_WithLinkedStorage_ReturnsFalse() { // Arrange — create a connection with a storage link referencing it. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( tenant.Id, "InUse", "https://auth.example.com/v3", "Kna1", null, null, null, null, null, null); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Cleura Bucket", OpenStackConnectionId = connection.Id }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Act — should not delete because a link depends on it. bool deleted = await sut.DeleteOpenStackConnectionAsync(tenant.Id, connection.Id); // Assert deleted.Should().BeFalse(); OpenStackConnection? stillExists = await db.OpenStackConnections.FindAsync(connection.Id); stillExists.Should().NotBeNull(); } [Fact] public async Task CreateStorageLinkAsync_CleuraWithOpenStack_SetsConnectionId() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( tenant.Id, "Cleura", "https://auth.example.com/v3", "Kna1", null, null, null, null, null, null); // Act StorageLink link = await sut.CreateStorageLinkAsync( tenant.Id, env.Id, StorageProvider.CleuraS3, "Cleura Bucket", "https://s3-kna1.cloudferro.com", "my-bucket", "Kna1", null, null, null, connection.Id); // Assert StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); saved.Should().NotBeNull(); saved!.OpenStackConnectionId.Should().Be(connection.Id); saved.Provider.Should().Be(StorageProvider.CleuraS3); } // ══════════════════════════════════════════════════════════════ // Storage Bindings // ══════════════════════════════════════════════════════════════ private (KubernetesCluster cluster, AppDeployment deployment, StorageLink link) CreateDeploymentWithStorageLink( Tenant tenant, Data.Environment env) { 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 = "billing-api" }; db.Apps.Add(app); KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "prod-cluster", ApiServerUrl = "https://k8s.example.com" }; db.KubernetesClusters.Add(cluster); AppDeployment deployment = new() { Id = Guid.NewGuid(), AppId = app.Id, Name = "billing-api-prod", Type = DeploymentType.HelmChart, EnvironmentId = env.Id, ClusterId = cluster.Id, Namespace = "billing" }; db.AppDeployments.Add(deployment); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.AwsS3, Name = "Invoice PDFs", Endpoint = "https://s3.eu-west-1.amazonaws.com", BucketName = "acme-invoices", Region = "eu-west-1" }; db.StorageLinks.Add(link); db.SaveChanges(); return (cluster, deployment, link); } [Fact] public async Task BindStorageToDeploymentAsync_CreatesBinding() { // Arrange — a deployment and a storage link ready to be connected. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); // Act — bind the storage to the deployment so credentials will be synced // to a K8s Secret named "invoice-storage" in the deployment's namespace. StorageBinding binding = await sut.BindStorageToDeploymentAsync( link.Id, deployment.Id, "invoice-storage"); // Assert — the binding exists and references both sides correctly. binding.Should().NotBeNull(); binding.StorageLinkId.Should().Be(link.Id); binding.AppDeploymentId.Should().Be(deployment.Id); binding.ComponentId.Should().BeNull(); binding.KubernetesSecretName.Should().Be("invoice-storage"); binding.SyncEnabled.Should().BeTrue(); StorageBinding? persisted = await db.Set().FindAsync(binding.Id); persisted.Should().NotBeNull(); } [Fact] public async Task BindStorageToComponentAsync_CreatesBinding() { // Arrange — a cluster component that needs access to storage. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "infra-cluster", ApiServerUrl = "https://k8s.infra.example.com" }; db.KubernetesClusters.Add(cluster); ClusterComponent component = new() { Id = Guid.NewGuid(), ClusterId = cluster.Id, Name = "loki", ComponentType = "helm", Namespace = "monitoring" }; db.ClusterComponents.Add(component); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Loki Chunks", Endpoint = "https://s3-kna1.citycloud.com", BucketName = "loki-chunks", Region = "Kna1" }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Act — bind storage to the component. StorageBinding binding = await sut.BindStorageToComponentAsync( link.Id, component.Id, "loki-s3-credentials"); // Assert binding.Should().NotBeNull(); binding.StorageLinkId.Should().Be(link.Id); binding.AppDeploymentId.Should().BeNull(); binding.ComponentId.Should().Be(component.Id); binding.KubernetesSecretName.Should().Be("loki-s3-credentials"); } [Fact] public async Task GetBindingsForDeploymentAsync_ReturnsOnlyDeploymentBindings() { // Arrange — one deployment with two storage bindings. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); StorageLink link2 = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.AzureStorage, Name = "Logs", Endpoint = "https://acmelogs.blob.core.windows.net", BucketName = "logs" }; db.StorageLinks.Add(link2); await db.SaveChangesAsync(); await sut.BindStorageToDeploymentAsync(link.Id, deployment.Id, "invoices"); await sut.BindStorageToDeploymentAsync(link2.Id, deployment.Id, "logs"); // Act List bindings = await sut.GetBindingsForDeploymentAsync(deployment.Id); // Assert bindings.Should().HaveCount(2); bindings.Select(b => b.KubernetesSecretName) .Should().Contain("invoices").And.Contain("logs"); bindings.Should().AllSatisfy(b => b.StorageLink.Should().NotBeNull()); } [Fact] public async Task UnbindStorageAsync_RemovesBinding() { // Arrange (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); StorageBinding binding = await sut.BindStorageToDeploymentAsync( link.Id, deployment.Id, "temp-storage"); // Act — unbind the storage. bool removed = await sut.UnbindStorageAsync(binding.Id); // Assert removed.Should().BeTrue(); StorageBinding? found = await db.Set().FindAsync(binding.Id); found.Should().BeNull(); } [Fact] public async Task UnbindStorageAsync_NonExistentBinding_ReturnsFalse() { // Act bool removed = await sut.UnbindStorageAsync(Guid.NewGuid()); // Assert removed.Should().BeFalse(); } [Fact] public async Task BindStorageToDeploymentAsync_ConfiguresVaultSecretsForSync() { // Arrange — a storage link with credentials already in the vault. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); // Store S3 credentials in the vault for this storage link. await vaultService.InitializeVaultAsync(tenant.Id); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIAEXAMPLE"); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t"); // Act — bind the storage, which should mark vault secrets for K8s sync. StorageBinding binding = await sut.BindStorageToDeploymentAsync( link.Id, deployment.Id, "invoice-storage"); // Assert — the storage link's secrets should now have K8s sync configured // targeting the deployment's namespace and the binding's secret name. List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secrets.Should().HaveCount(2); secrets.Should().AllSatisfy(s => { s.SyncToKubernetes.Should().BeTrue(); s.KubernetesSecretName.Should().Be("invoice-storage"); s.KubernetesNamespace.Should().Be("billing"); }); } [Fact] public async Task UnbindStorageAsync_DisablesVaultSecretSync() { // Arrange — bind a storage with credentials, then unbind. (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); await vaultService.InitializeVaultAsync(tenant.Id); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIAEXAMPLE"); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t"); StorageBinding binding = await sut.BindStorageToDeploymentAsync( link.Id, deployment.Id, "invoice-storage"); // Act — unbind. await sut.UnbindStorageAsync(binding.Id); // Assert — secrets should no longer be marked for K8s sync. List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); secrets.Should().AllSatisfy(s => { s.SyncToKubernetes.Should().BeFalse(); s.KubernetesSecretName.Should().BeNull(); s.KubernetesNamespace.Should().BeNull(); }); } }