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*");
}
}