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 Cleura S3 bucket management operations exposed via StorageService. /// These verify that the StorageService correctly validates inputs, loads /// credentials from the vault, and delegates to OpenStackS3Service. /// /// Since actual S3/Keystone calls require live infrastructure, we mock /// OpenStackS3Service and focus on the orchestration logic. /// public class CleuraS3ManagementTests : 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 Mock httpFactory; private readonly StorageService sut; // We'll use a real OpenStackS3Service since the methods we're testing // on StorageService validate state before calling it. For actual S3 calls // that would fail, we test error paths. public CleuraS3ManagementTests() { 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); httpFactory = new Mock(); OpenStackS3Service openStackS3 = new(vaultService, httpFactory.Object); sut = new StorageService(dbFactory, vaultService, openStackS3); } public void Dispose() { db.Dispose(); connection.Dispose(); } // ──────── Helpers ──────── private async Task<(Tenant tenant, Data.Environment env, StorageLink link)> CreateCleuraLinkWithCredentialsAsync() { // Create tenant, environment, OpenStack connection, and a Cleura S3 storage link // with access/secret keys stored in the vault. 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); OpenStackConnection osConn = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Cleura Kna1", AuthUrl = "https://identity.c2.citycloud.com:5000/v3", Region = "Kna1", Username = "testuser", ProjectId = "project-123" }; db.OpenStackConnections.Add(osConn); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Test Bucket", Endpoint = "https://s3-kna1.citycloud.com:8080", BucketName = "my-test-bucket", Region = "Kna1", OpenStackConnectionId = osConn.Id }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Store credentials in vault. await vaultService.InitializeVaultAsync(tenant.Id); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKTEST123", default); await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "secrettest456", default); return (tenant, env, link); } // ──────── ListCleuraS3BucketsAsync ──────── [Fact] public async Task ListCleuraS3BucketsAsync_LinkNotFound_Throws() { // Arrange — no storage link exists. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, Guid.NewGuid()); await act.Should().ThrowAsync() .WithMessage("*not found*"); } [Fact] public async Task ListCleuraS3BucketsAsync_MissingCredentials_Throws() { // Arrange — link exists but no vault secrets. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; db.Set().Add(env); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "No Creds", Endpoint = "https://s3-kna1.citycloud.com:8080", BucketName = "bucket", Region = "Kna1" }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); await vaultService.InitializeVaultAsync(tenant.Id); // Act & Assert — should throw because credentials don't exist in vault. Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id); await act.Should().ThrowAsync() .WithMessage("*credentials*"); } [Fact] public async Task ListCleuraS3BucketsAsync_WrongProvider_Throws() { // Arrange — link exists but is AWS S3, not Cleura. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; db.Set().Add(env); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.AwsS3, Name = "AWS Bucket", Endpoint = "https://s3.eu-west-1.amazonaws.com", BucketName = "bucket", Region = "eu-west-1" }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Act & Assert — wrong provider should not be found. Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id); await act.Should().ThrowAsync() .WithMessage("*not found*"); } // ──────── DeleteCleuraS3BucketAsync ──────── [Fact] public async Task DeleteCleuraS3BucketAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.DeleteCleuraS3BucketAsync(tenant.Id, Guid.NewGuid()); await act.Should().ThrowAsync() .WithMessage("*not found*"); } // ──────── RotateCleuraS3CredentialsAsync ──────── [Fact] public async Task RotateCleuraS3CredentialsAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, Guid.NewGuid()); await act.Should().ThrowAsync() .WithMessage("*not found*"); } [Fact] public async Task RotateCleuraS3CredentialsAsync_MissingOpenStackConnection_Throws() { // Arrange — link exists but its OpenStack connection has been deleted. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; db.Set().Add(env); // Create a real OpenStack connection so the FK is valid. OpenStackConnection osConn = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "ToBeDeleted", AuthUrl = "https://identity.example.com:5000/v3", Region = "Kna1", Username = "user" }; db.OpenStackConnections.Add(osConn); StorageLink link = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Provider = StorageProvider.CleuraS3, Name = "Orphaned", Endpoint = "https://s3-kna1.citycloud.com:8080", BucketName = "bucket", Region = "Kna1", OpenStackConnectionId = osConn.Id }; db.StorageLinks.Add(link); await db.SaveChangesAsync(); // Now delete the OpenStack connection to simulate orphaned state. db.OpenStackConnections.Remove(osConn); link.OpenStackConnectionId = null; await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, link.Id); await act.Should().ThrowAsync() .WithMessage("*OpenStack connection not found*"); } // ──────── GetStorageLinkSecretValueAsync (VaultService) ──────── [Fact] public async Task GetStorageLinkSecretValueAsync_ReturnsDecryptedValue() { // Arrange — store a secret and retrieve it. (Tenant tenant, _, StorageLink link) = await CreateCleuraLinkWithCredentialsAsync(); // Act string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenant.Id, link.Id, "ACCESS_KEY"); // Assert accessKey.Should().Be("AKTEST123"); } [Fact] public async Task GetStorageLinkSecretValueAsync_NotFound_ReturnsNull() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); await vaultService.InitializeVaultAsync(tenant.Id); // Act string? result = await vaultService.GetStorageLinkSecretValueAsync(tenant.Id, Guid.NewGuid(), "MISSING"); // Assert result.Should().BeNull(); } // ──────── GetBucketCorsAsync / SetBucketCorsAsync ──────── [Fact] public async Task GetBucketCorsAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.GetBucketCorsAsync(tenant.Id, Guid.NewGuid()); await act.Should().ThrowAsync() .WithMessage("*not found*"); } [Fact] public async Task SetBucketCorsAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); List rules = [new() { AllowedOrigins = ["*"], AllowedMethods = ["GET"] }]; // Act & Assert Func act = () => sut.SetBucketCorsAsync(tenant.Id, Guid.NewGuid(), rules); await act.Should().ThrowAsync() .WithMessage("*not found*"); } // ──────── GetBucketPolicyAsync / SetBucketPolicyAsync ──────── [Fact] public async Task GetBucketPolicyAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.GetBucketPolicyAsync(tenant.Id, Guid.NewGuid()); await act.Should().ThrowAsync() .WithMessage("*not found*"); } [Fact] public async Task SetBucketPolicyAsync_LinkNotFound_Throws() { // Arrange Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); // Act & Assert Func act = () => sut.SetBucketPolicyAsync(tenant.Id, Guid.NewGuid(), "{}"); await act.Should().ThrowAsync() .WithMessage("*not found*"); } }