Mark subproject as dirty in CMKS - Infra

This commit is contained in:
Nils Blomgren
2026-05-18 08:37:09 +02:00
parent 328d494530
commit 8b94fb8826
635 changed files with 81145 additions and 100551 deletions

View File

@@ -0,0 +1,216 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// An app belongs to a customer and can be deployed to one or many environments.
/// The many-to-many between App and Environment is tracked via AppEnvironment.
/// </summary>
public class AppEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public AppEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task App_CanBeCreated_WithinCustomer()
{
// An app represents a software application owned by a customer.
// It belongs to exactly one customer.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
App app = new()
{
Id = Guid.NewGuid(),
CustomerId = customer.Id,
Name = "Payment Service"
};
context.Apps.Add(app);
await context.SaveChangesAsync();
App? retrieved = await context.Apps
.Include(a => a.Customer)
.FirstOrDefaultAsync(a => a.Name == "Payment Service");
retrieved.Should().NotBeNull();
retrieved!.Customer.Name.Should().Be("Big Corp");
}
[Fact]
public async Task App_NameMustBeUniqueWithinCustomer()
{
// Two apps under the same customer cannot share the same name.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
context.Apps.Add(new App { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" });
await context.SaveChangesAsync();
context.Apps.Add(new App { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" });
Func<Task> act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task App_CanBelongToOneEnvironment()
{
// An app is deployed to environments via the AppEnvironment join.
// Here we test the simplest case: one app in one environment.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(env);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" };
context.Apps.Add(app);
context.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = env.Id });
await context.SaveChangesAsync();
List<AppEnvironment> links = await context.AppEnvironments
.Where(ae => ae.AppId == app.Id)
.Include(ae => ae.Environment)
.ToListAsync();
links.Should().HaveCount(1);
links[0].Environment.Name.Should().Be("Production");
}
[Fact]
public async Task App_CanBelongToMultipleEnvironments()
{
// A typical app might be deployed to dev, staging, and production
// simultaneously. Each link is a separate AppEnvironment record.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
Data.Environment dev = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" };
Data.Environment staging = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Staging" };
Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.AddRange(dev, staging, prod);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" };
context.Apps.Add(app);
context.AppEnvironments.AddRange(
new AppEnvironment { AppId = app.Id, EnvironmentId = dev.Id },
new AppEnvironment { AppId = app.Id, EnvironmentId = staging.Id },
new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id }
);
await context.SaveChangesAsync();
List<AppEnvironment> links = await context.AppEnvironments
.Where(ae => ae.AppId == app.Id)
.ToListAsync();
links.Should().HaveCount(3);
}
[Fact]
public async Task Environment_CanHaveMultipleApps()
{
// Multiple apps can be deployed to the same environment.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(prod);
App appA = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" };
App appB = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "Worker" };
context.Apps.AddRange(appA, appB);
context.AppEnvironments.AddRange(
new AppEnvironment { AppId = appA.Id, EnvironmentId = prod.Id },
new AppEnvironment { AppId = appB.Id, EnvironmentId = prod.Id }
);
await context.SaveChangesAsync();
List<AppEnvironment> links = await context.AppEnvironments
.Where(ae => ae.EnvironmentId == prod.Id)
.ToListAsync();
links.Should().HaveCount(2);
}
[Fact]
public async Task AppEnvironment_CannotDuplicate()
{
// The same app cannot be linked to the same environment twice.
// We use a second context instance to bypass the change tracker
// and verify the database-level unique constraint.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" };
context.Customers.Add(customer);
Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(prod);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" };
context.Apps.Add(app);
context.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id });
await context.SaveChangesAsync();
// Use a fresh context to avoid the change tracker catching the duplicate.
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
ApplicationDbContext secondContext = new(options);
secondContext.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id });
Func<Task> act = async () => await secondContext.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,83 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Verifies that the ApplicationDbContext can be created and used with SQLite,
/// and that Identity tables are correctly defined in the model. This gives us
/// confidence that the EF Core model is valid and migrations will apply cleanly.
/// </summary>
public class ApplicationDbContextTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public ApplicationDbContextTests()
{
// We use an in-memory SQLite database that stays open for the test's
// lifetime. This is the fastest way to test EF Core behavior with a
// real relational provider (not the InMemory provider which lacks
// constraint checking).
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public void Constructor_WithSqliteOptions_CreatesContextSuccessfully()
{
// The context should be usable — if EnsureCreated worked, the model is valid.
context.Should().NotBeNull();
context.Database.CanConnect().Should().BeTrue();
}
[Fact]
public void Model_ContainsIdentityTables()
{
// The Identity schema should define the Users table via the ApplicationUser entity.
IEnumerable<string> entityTypes = context.Model.GetEntityTypes().Select(e => e.ClrType.Name);
entityTypes.Should().Contain("ApplicationUser");
}
[Fact]
public async Task Users_CanInsertAndRetrieve()
{
// Arrange — create a user entity to persist.
ApplicationUser user = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "testuser@example.com",
NormalizedUserName = "TESTUSER@EXAMPLE.COM",
Email = "testuser@example.com",
NormalizedEmail = "TESTUSER@EXAMPLE.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
// Act — insert and read back.
context.Users.Add(user);
await context.SaveChangesAsync();
ApplicationUser? retrieved = await context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser@example.com");
// Assert — the user should round-trip through the database.
retrieved.Should().NotBeNull();
retrieved!.Email.Should().Be("testuser@example.com");
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,399 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace EntKube.Web.Tests;
/// <summary>
/// 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.
/// </summary>
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<IHttpClientFactory> 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<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<IHttpClientFactory>();
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<Data.Environment>().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<Task> act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, Guid.NewGuid());
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Data.Environment>().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<Task> act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Data.Environment>().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<Task> act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => sut.DeleteCleuraS3BucketAsync(tenant.Id, Guid.NewGuid());
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, Guid.NewGuid());
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Data.Environment>().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<Task> act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, link.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => sut.GetBucketCorsAsync(tenant.Id, Guid.NewGuid());
await act.Should().ThrowAsync<InvalidOperationException>()
.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<S3CorsRule> rules = [new() { AllowedOrigins = ["*"], AllowedMethods = ["GET"] }];
// Act & Assert
Func<Task> act = () => sut.SetBucketCorsAsync(tenant.Id, Guid.NewGuid(), rules);
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => sut.GetBucketPolicyAsync(tenant.Id, Guid.NewGuid());
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Task> act = () => sut.SetBucketPolicyAsync(tenant.Id, Guid.NewGuid(), "{}");
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not found*");
}
}

View File

@@ -0,0 +1,168 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// A Kubernetes cluster belongs to a tenant and is associated with an environment.
/// One environment can have many clusters (e.g. a production environment might
/// span multiple clusters for redundancy or regional distribution).
/// </summary>
public class ClusterEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public ClusterEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task Cluster_CanBeCreated_WithTenantAndEnvironment()
{
// A cluster is registered under a tenant and placed into an environment.
// It has a name and an API server endpoint for connectivity.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(env);
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "prod-eu-west-1",
ApiServerUrl = "https://k8s.prod-eu.example.com:6443"
};
context.KubernetesClusters.Add(cluster);
await context.SaveChangesAsync();
KubernetesCluster? retrieved = await context.KubernetesClusters
.Include(c => c.Tenant)
.Include(c => c.Environment)
.FirstOrDefaultAsync(c => c.Name == "prod-eu-west-1");
retrieved.Should().NotBeNull();
retrieved!.Tenant.Name.Should().Be("Acme");
retrieved.Environment.Name.Should().Be("Production");
retrieved.ApiServerUrl.Should().Be("https://k8s.prod-eu.example.com:6443");
}
[Fact]
public async Task Cluster_NameMustBeUniqueWithinTenant()
{
// Two clusters in the same tenant cannot share the same name.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(env);
context.KubernetesClusters.Add(new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id,
Name = "prod-01", ApiServerUrl = "https://a.example.com:6443"
});
await context.SaveChangesAsync();
context.KubernetesClusters.Add(new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id,
Name = "prod-01", ApiServerUrl = "https://b.example.com:6443"
});
Func<Task> act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task Environment_CanHaveMultipleClusters()
{
// A production environment might span multiple clusters for
// redundancy or regional distribution.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.Add(prod);
context.KubernetesClusters.AddRange(
new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id,
Name = "prod-eu-west-1", ApiServerUrl = "https://eu-west.example.com:6443"
},
new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id,
Name = "prod-us-east-1", ApiServerUrl = "https://us-east.example.com:6443"
}
);
await context.SaveChangesAsync();
List<KubernetesCluster> clusters = await context.KubernetesClusters
.Where(c => c.EnvironmentId == prod.Id)
.ToListAsync();
clusters.Should().HaveCount(2);
}
[Fact]
public async Task Tenant_CanHaveClustersAcrossEnvironments()
{
// A tenant has clusters spread across different environments.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Data.Environment dev = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" };
Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" };
context.Environments.AddRange(dev, prod);
context.KubernetesClusters.AddRange(
new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = dev.Id,
Name = "dev-01", ApiServerUrl = "https://dev.example.com:6443"
},
new KubernetesCluster
{
Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id,
Name = "prod-01", ApiServerUrl = "https://prod.example.com:6443"
}
);
await context.SaveChangesAsync();
List<KubernetesCluster> clusters = await context.KubernetesClusters
.Where(c => c.TenantId == tenant.Id)
.ToListAsync();
clusters.Should().HaveCount(2);
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,846 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for CnpgService — the orchestration layer for managed CloudNativePG clusters.
/// Covers cluster creation, deletion, upgrade, backup, restore, and database management.
///
/// The Kubernetes API interactions are mocked via IKubernetesClientFactory.
/// Database state management and vault secret creation are tested against
/// a real SQLite in-memory database to verify the full orchestration flow.
/// </summary>
public class CnpgServiceTests : 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<IKubernetesClientFactory> k8sFactory;
private readonly CnpgService sut;
public CnpgServiceTests()
{
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);
vaultService = new VaultService(dbFactory, encryption);
k8sFactory = new Mock<IKubernetesClientFactory>();
sut = new CnpgService(dbFactory, vaultService, k8sFactory.Object);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── Helpers ────────
private async Task<(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink)> SeedTenantWithClusterAsync()
{
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<Data.Environment>().Add(env);
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "prod-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config\nclusters: []"
};
db.KubernetesClusters.Add(cluster);
// Install the CNPG operator component on this cluster.
ClusterComponent cnpgOperator = new()
{
Id = Guid.NewGuid(),
ClusterId = cluster.Id,
Name = "cloudnative-pg",
ComponentType = "helm",
Status = ComponentStatus.Installed,
Namespace = "cnpg-system"
};
db.ClusterComponents.Add(cnpgOperator);
// Create a storage link for Barman backups.
StorageLink storageLink = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Provider = StorageProvider.CleuraS3,
Name = "Backup Bucket",
Endpoint = "https://s3-kna1.citycloud.com",
BucketName = "cnpg-backups",
Region = "Kna1"
};
db.StorageLinks.Add(storageLink);
await db.SaveChangesAsync();
// Initialize vault and store S3 credentials.
await vaultService.InitializeVaultAsync(tenant.Id);
await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "ACCESS_KEY", "test-access-key");
await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "SECRET_KEY", "test-secret-key");
return (tenant, cluster, storageLink);
}
// ──────── CreateClusterAsync ────────
[Fact]
public async Task CreateClusterAsync_ValidInput_CreatesDbRecordAndReturnsCluster()
{
// Arrange — a tenant with a cluster and CNPG operator installed.
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act — create a new managed CNPG cluster.
CnpgCluster result = await sut.CreateClusterAsync(
tenant.Id, cluster.Id, "my-pg", "databases", 3, "10Gi",
storageLink.Id, "0 0 2 * * *");
// Assert — the cluster record should exist in the database.
result.Should().NotBeNull();
result.Name.Should().Be("my-pg");
result.Namespace.Should().Be("databases");
result.PostgresVersion.Should().Be("18");
result.Instances.Should().Be(3);
result.StorageSize.Should().Be("10Gi");
result.StorageLinkId.Should().Be(storageLink.Id);
result.BackupSchedule.Should().Be("0 0 2 * * *");
result.Status.Should().Be(CnpgClusterStatus.Creating);
CnpgCluster? persisted = await db.CnpgClusters.FindAsync(result.Id);
persisted.Should().NotBeNull();
}
[Fact]
public async Task CreateClusterAsync_WithoutBackup_CreatesClusterWithNoStorageLink()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act — create without backup configuration.
CnpgCluster result = await sut.CreateClusterAsync(
tenant.Id, cluster.Id, "standalone-pg", "default", 1, "5Gi",
storageLinkId: null, backupSchedule: null);
// Assert
result.StorageLinkId.Should().BeNull();
result.BackupSchedule.Should().BeNull();
}
[Fact]
public async Task CreateClusterAsync_NoCnpgOperator_ThrowsInvalidOperation()
{
// Arrange — tenant with a cluster but NO CNPG operator.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "NoCnpg", Slug = "no-cnpg" };
db.Tenants.Add(tenant);
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" };
db.Set<Data.Environment>().Add(env);
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "dev-cluster",
ApiServerUrl = "https://k8s.dev.example.com",
Kubeconfig = "fake"
};
db.KubernetesClusters.Add(cluster);
await db.SaveChangesAsync();
// Act & Assert
Func<Task> act = () => sut.CreateClusterAsync(
tenant.Id, cluster.Id, "pg", "default", 1, "5Gi", null, null);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*CloudNativePG operator*not installed*");
}
[Fact]
public async Task CreateClusterAsync_AppliesManifestToKubernetes()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
List<string> appliedManifests = [];
string? appliedKubeconfig = null;
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((manifest, kubeconfig, _) =>
{
appliedManifests.Add(manifest);
appliedKubeconfig = kubeconfig;
})
.Returns(Task.CompletedTask);
// Act
await sut.CreateClusterAsync(
tenant.Id, cluster.Id, "backup-pg", "databases", 3, "20Gi",
storageLink.Id, "0 0 2 * * *");
// Assert — verify the manifests contain expected CNPG configuration.
string allManifests = string.Join("\n", appliedManifests);
allManifests.Should().Contain("kind: Cluster");
allManifests.Should().Contain("postgresql.cnpg.io/v1");
allManifests.Should().Contain("name: backup-pg");
allManifests.Should().Contain("namespace: databases");
allManifests.Should().Contain("instances: 3");
allManifests.Should().Contain("size: 20Gi");
allManifests.Should().Contain("postgresql:18");
allManifests.Should().Contain("barman-cloud.cloudnative-pg.io");
allManifests.Should().Contain("kind: ObjectStore");
allManifests.Should().Contain("barmanObjectName: backup-pg-object-store");
allManifests.Should().Contain("cnpg-backups");
appliedKubeconfig.Should().NotBeNullOrEmpty();
}
// ──────── DeleteClusterAsync ────────
[Fact]
public async Task DeleteClusterAsync_RemovesFromK8sAndDatabase()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "to-delete",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.DeleteManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteClusterAsync(tenant.Id, cnpg.Id);
// Assert — use a fresh context to avoid EF tracking cache.
using ApplicationDbContext verifyDb = dbFactory.CreateDbContext();
CnpgCluster? deleted = await verifyDb.CnpgClusters
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == cnpg.Id);
deleted.Should().BeNull();
}
// ──────── UpgradeClusterAsync ────────
[Fact]
public async Task UpgradeClusterAsync_MinorVersion_UpdatesVersionAndAppliesManifest()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "upgrade-me",
Namespace = "databases",
PostgresVersion = "18.1",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act — minor version bump within the same major
await sut.UpgradeClusterAsync(tenant.Id, cnpg.Id, "18.2");
// Assert
using ApplicationDbContext verifyDb = dbFactory.CreateDbContext();
CnpgCluster? upgraded = await verifyDb.CnpgClusters.FindAsync(cnpg.Id);
upgraded!.PostgresVersion.Should().Be("18.2");
upgraded.Status.Should().Be(CnpgClusterStatus.Upgrading);
}
[Fact]
public async Task UpgradeClusterAsync_MajorVersion_ThrowsWithRestoreGuidance()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "upgrade-major",
Namespace = "databases",
PostgresVersion = "17",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
// Act & Assert — major version jump should be rejected
Func<Task> act = () => sut.UpgradeClusterAsync(tenant.Id, cnpg.Id, "18");
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*Major version upgrades*restore*");
}
// ──────── MajorUpgradeAsync ────────
[Fact]
public async Task MajorUpgradeAsync_RestoresNewClusterAndRemovesOld()
{
// Arrange — a v17 cluster with a database and backup storage
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "prod-db",
Namespace = "databases",
PostgresVersion = "17",
Instances = 3,
StorageSize = "20Gi",
StorageLinkId = storageLink.Id,
Status = CnpgClusterStatus.Running
};
CnpgDatabase database = new()
{
Id = Guid.NewGuid(),
CnpgClusterId = cnpg.Id,
Name = "app_data",
Owner = "app_data_owner",
Status = CnpgDatabaseStatus.Ready
};
db.CnpgClusters.Add(cnpg);
db.CnpgDatabases.Add(database);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
k8sFactory.Setup(f => f.DeleteManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
CnpgCluster result = await sut.MajorUpgradeAsync(tenant.Id, cnpg.Id, "18");
// Assert — new cluster takes the original name and version
result.Name.Should().Be("prod-db");
result.PostgresVersion.Should().Be("18");
result.Status.Should().Be(CnpgClusterStatus.Running);
// Assert — old cluster record is removed
using ApplicationDbContext verifyDb = dbFactory.CreateDbContext();
bool oldExists = await verifyDb.CnpgClusters.AnyAsync(c => c.Id == cnpg.Id);
oldExists.Should().BeFalse();
// Assert — databases transferred to the new cluster
CnpgDatabase? movedDb = await verifyDb.CnpgDatabases.FindAsync(database.Id);
movedDb!.CnpgClusterId.Should().Be(result.Id);
// Assert — K8s operations: backup applied, old deleted, temp deleted, final applied
k8sFactory.Verify(f => f.ApplyManifestAsync(
It.Is<string>(m => m.Contains("kind: Backup")),
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
k8sFactory.Verify(f => f.DeleteManifestAsync(
"Cluster", "prod-db", "databases",
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
k8sFactory.Verify(f => f.DeleteManifestAsync(
"Cluster", "prod-db-v18", "databases",
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MajorUpgradeAsync_WithoutStorage_Throws()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "no-backup",
Namespace = "databases",
PostgresVersion = "17",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
// Act & Assert
Func<Task> act = () => sut.MajorUpgradeAsync(tenant.Id, cnpg.Id, "18");
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*backup storage*");
}
// ──────── BackupAsync ────────
[Fact]
public async Task BackupAsync_CreatesBackupRecordAndAppliesCR()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "backup-target",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
StorageLinkId = storageLink.Id,
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
CnpgBackup backup = await sut.BackupAsync(tenant.Id, cnpg.Id);
// Assert
backup.Should().NotBeNull();
backup.CnpgClusterId.Should().Be(cnpg.Id);
backup.Status.Should().Be(CnpgBackupStatus.Running);
backup.Type.Should().Be(CnpgBackupType.OnDemand);
backup.Name.Should().StartWith("backup-target-");
}
[Fact]
public async Task BackupAsync_NoStorageLink_ThrowsInvalidOperation()
{
// Arrange — cluster without backup storage configured.
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "no-backup-storage",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
StorageLinkId = null,
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
// Act & Assert
Func<Task> act = () => sut.BackupAsync(tenant.Id, cnpg.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*backup storage*not configured*");
}
// ──────── RestoreAsync ────────
[Fact]
public async Task RestoreAsync_CreatesNewClusterWithRecoveryBootstrap()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
CnpgCluster source = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "source-cluster",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
StorageLinkId = storageLink.Id,
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(source);
await db.SaveChangesAsync();
string? appliedManifest = null;
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((manifest, _, _) => appliedManifest = manifest)
.Returns(Task.CompletedTask);
DateTime targetTime = new(2026, 5, 17, 10, 30, 0, DateTimeKind.Utc);
// Act
CnpgCluster restored = await sut.RestoreAsync(
tenant.Id, source.Id, "restored-cluster", targetTime);
// Assert — new cluster created with Restoring status.
restored.Name.Should().Be("restored-cluster");
restored.Status.Should().Be(CnpgClusterStatus.Restoring);
restored.StorageLinkId.Should().Be(storageLink.Id);
appliedManifest.Should().Contain("recovery");
appliedManifest.Should().Contain("2026-05-17");
appliedManifest.Should().Contain("restored-cluster");
}
// ──────── CreateDatabaseAsync ────────
[Fact]
public async Task CreateDatabaseAsync_CreatesRecordAndStoresSecrets()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "app-cluster",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ExecuteSqlAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
CnpgDatabase database = await sut.CreateDatabaseAsync(
tenant.Id, cnpg.Id, "myapp");
// Assert — database record created.
database.Should().NotBeNull();
database.Name.Should().Be("myapp");
database.Owner.Should().Be("myapp_owner");
database.Status.Should().Be(CnpgDatabaseStatus.Ready);
CnpgDatabase? persisted = await db.CnpgDatabases.FindAsync(database.Id);
persisted.Should().NotBeNull();
}
[Fact]
public async Task CreateDatabaseAsync_StoresVaultSecretsTaggedForK8sSync()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "secret-cluster",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ExecuteSqlAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
CnpgDatabase database = await sut.CreateDatabaseAsync(
tenant.Id, cnpg.Id, "analytics");
// Assert — vault secrets should be created with K8s sync tags.
List<VaultSecret> secrets = await db.VaultSecrets
.Where(s => s.CnpgDatabaseId == database.Id)
.ToListAsync();
secrets.Should().HaveCountGreaterThanOrEqualTo(5);
secrets.Should().Contain(s => s.Name == "HOST");
secrets.Should().Contain(s => s.Name == "PORT");
secrets.Should().Contain(s => s.Name == "DATABASE");
secrets.Should().Contain(s => s.Name == "USERNAME");
secrets.Should().Contain(s => s.Name == "PASSWORD");
// All secrets should be tagged for K8s sync.
secrets.Should().OnlyContain(s => s.SyncToKubernetes == true);
secrets.Should().OnlyContain(s => s.KubernetesSecretName == "secret-cluster-analytics-credentials");
secrets.Should().OnlyContain(s => s.KubernetesNamespace == "databases");
}
// ──────── DeleteDatabaseAsync ────────
[Fact]
public async Task DeleteDatabaseAsync_RemovesDatabaseAndSecrets()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "db-cluster",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.Add(cnpg);
await db.SaveChangesAsync();
k8sFactory.Setup(f => f.ExecuteSqlAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
CnpgDatabase database = await sut.CreateDatabaseAsync(
tenant.Id, cnpg.Id, "to-remove");
// Act
await sut.DeleteDatabaseAsync(tenant.Id, cnpg.Id, database.Id);
// Assert
CnpgDatabase? deleted = await db.CnpgDatabases.FindAsync(database.Id);
deleted.Should().BeNull();
List<VaultSecret> remainingSecrets = await db.VaultSecrets
.Where(s => s.CnpgDatabaseId == database.Id)
.ToListAsync();
remainingSecrets.Should().BeEmpty();
}
// ──────── GetClustersAsync ────────
[Fact]
public async Task GetClustersAsync_ReturnsOnlyTenantClusters()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
CnpgCluster cnpg1 = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "cluster-1",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "10Gi",
Status = CnpgClusterStatus.Running
};
CnpgCluster cnpg2 = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
KubernetesClusterId = cluster.Id,
Name = "cluster-2",
Namespace = "databases",
PostgresVersion = "18",
StorageSize = "20Gi",
Status = CnpgClusterStatus.Running
};
db.CnpgClusters.AddRange(cnpg1, cnpg2);
await db.SaveChangesAsync();
// Act
List<CnpgCluster> results = await sut.GetClustersAsync(tenant.Id);
// Assert
results.Should().HaveCount(2);
results.Should().Contain(c => c.Name == "cluster-1");
results.Should().Contain(c => c.Name == "cluster-2");
}
// ──────── Manifest Generation ────────
[Fact]
public async Task CreateClusterAsync_ManifestIncludesBackupStorageCredentials()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
List<string> appliedManifests = [];
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((manifest, _, _) => appliedManifests.Add(manifest))
.Returns(Task.CompletedTask);
// Act
await sut.CreateClusterAsync(
tenant.Id, cluster.Id, "cred-test", "databases", 3, "10Gi",
storageLink.Id, "0 0 2 * * *");
// Assert — manifests should reference the K8s secret for S3 credentials.
string allManifests = string.Join("\n", appliedManifests);
allManifests.Should().Contain("s3Credentials");
allManifests.Should().Contain("endpointURL");
allManifests.Should().Contain("s3-kna1.citycloud.com");
allManifests.Should().Contain("cnpg-backups");
}
[Fact]
public async Task CreateClusterAsync_ManifestIncludesScheduledBackup()
{
// Arrange
(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync();
string? appliedManifest = null;
k8sFactory.Setup(f => f.ApplyManifestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((manifest, _, _) => appliedManifest = manifest)
.Returns(Task.CompletedTask);
// Act
await sut.CreateClusterAsync(
tenant.Id, cluster.Id, "sched-test", "databases", 3, "10Gi",
storageLink.Id, "0 0 2 * * *");
// Assert — a ScheduledBackup resource should be in the manifest.
appliedManifest.Should().Contain("ScheduledBackup");
appliedManifest.Should().Contain("0 0 2 * * *");
}
}

View File

@@ -0,0 +1,368 @@
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 ComponentCatalog — the static registry of well-known
/// infrastructure components. Verifies that catalog entries are valid,
/// the lookup methods work, and that catalog-based registration flows
/// correctly through the lifecycle service.
/// </summary>
public class ComponentCatalogTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly ComponentLifecycleService lifecycleService;
private readonly Guid clusterId = Guid.NewGuid();
public ComponentCatalogTests()
{
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();
// Seed a tenant, environment, and cluster for integration tests.
Guid tenantId = Guid.NewGuid();
Guid envId = Guid.NewGuid();
Tenant tenant = new() { Id = tenantId, Name = "CatalogTenant", Slug = "catalog" };
Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" };
KubernetesCluster cluster = new()
{
Id = clusterId,
TenantId = tenantId,
EnvironmentId = envId,
Name = "catalog-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config"
};
db.Set<Tenant>().Add(tenant);
db.Set<Data.Environment>().Add(env);
db.KubernetesClusters.Add(cluster);
db.SaveChanges();
byte[] testRootKey = Convert.FromBase64String("dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
VaultEncryptionService encryption = new(testRootKey);
VaultService vaultService = new(dbFactory, encryption);
lifecycleService = new ComponentLifecycleService(dbFactory, vaultService);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── Catalog integrity ────────
[Fact]
public void Catalog_HasEntries()
{
// The catalog should never be empty — we ship with known components.
ComponentCatalog.Entries.Should().NotBeEmpty();
}
[Fact]
public void Catalog_AllEntriesHaveRequiredFields()
{
// Every catalog entry must have all the fields needed for a valid
// Helm install. Missing fields would cause install failures.
foreach (CatalogEntry entry in ComponentCatalog.Entries)
{
entry.Key.Should().NotBeNullOrWhiteSpace($"entry must have a key");
entry.DisplayName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a display name");
entry.Description.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a description");
entry.Icon.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have an icon");
entry.Category.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a category");
entry.HelmRepoUrl.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm repo URL");
entry.HelmChartName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm chart name");
entry.DefaultNamespace.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a default namespace");
}
}
[Fact]
public void Catalog_KeysAreUnique()
{
// Duplicate keys would cause lookup ambiguity.
List<string> keys = ComponentCatalog.Entries.Select(e => e.Key).ToList();
keys.Should().OnlyHaveUniqueItems();
}
// ──────── Lookup ────────
[Fact]
public void GetByKey_ExistingEntry_ReturnsEntry()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("kube-prometheus-stack");
entry.Should().NotBeNull();
entry!.DisplayName.Should().Be("Kube Prometheus Stack");
entry.Category.Should().Be("Monitoring");
}
[Fact]
public void GetByKey_CaseInsensitive_ReturnsEntry()
{
// Operators shouldn't have to remember exact casing.
CatalogEntry? entry = ComponentCatalog.GetByKey("MINIO");
entry.Should().NotBeNull();
entry!.Key.Should().Be("minio");
}
[Fact]
public void GetByKey_Unknown_ReturnsNull()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("nonexistent-component");
entry.Should().BeNull();
}
[Fact]
public void GetByCategory_GroupsCorrectly()
{
IReadOnlyList<IGrouping<string, CatalogEntry>> groups = ComponentCatalog.GetByCategory();
groups.Should().NotBeEmpty();
groups.SelectMany(g => g).Should().HaveCount(ComponentCatalog.Entries.Count);
}
// ──────── Registration from catalog ────────
[Fact]
public void ToRegistration_FillsAllHelmDetails()
{
// When we create a registration from a catalog entry, it should
// carry over all the Helm details so the operator doesn't need
// to enter them manually.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
registration.Name.Should().Be("kube-prometheus-stack");
registration.ComponentType.Should().Be("HelmChart");
registration.Namespace.Should().Be("monitoring");
registration.HelmRepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts");
registration.HelmChartName.Should().Be("kube-prometheus-stack");
registration.ReleaseName.Should().Be("kube-prometheus-stack");
registration.HelmValues.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task RegisterFromCatalog_CreatesComponentWithDefaults()
{
// The full flow: pick from catalog → register → component is ready
// with all Helm details pre-filled.
CatalogEntry entry = ComponentCatalog.GetByKey("minio")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration);
component.Name.Should().Be("minio");
component.HelmRepoUrl.Should().Be("https://charts.min.io/");
component.HelmChartName.Should().Be("minio");
component.Namespace.Should().Be("minio");
component.Status.Should().Be(ComponentStatus.NotInstalled);
component.HelmValues.Should().Contain("rootUser");
}
[Fact]
public async Task RegisterFromCatalog_CanOverrideValues()
{
// Operators should be able to modify the default values before
// registering — for example, changing resource limits or passwords.
CatalogEntry entry = ComponentCatalog.GetByKey("cert-manager")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
// Override the values with custom config.
registration.HelmValues = """
crds:
enabled: true
resources:
requests:
memory: 256Mi
cpu: 100m
""";
ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration);
component.HelmValues.Should().Contain("256Mi");
component.HelmValues.Should().NotContain("128Mi");
}
[Fact]
public async Task RegisterFromCatalog_DuplicateRejected()
{
// Can't install the same catalog component twice on one cluster.
CatalogEntry entry = ComponentCatalog.GetByKey("traefik")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
await lifecycleService.RegisterComponentAsync(clusterId, registration);
// Second attempt should be rejected.
Func<Task> secondAttempt = () => lifecycleService.RegisterComponentAsync(
clusterId, ComponentCatalog.ToRegistration(entry));
await secondAttempt.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already exists*");
}
// ──────── Dependency resolution ────────
[Fact]
public void CheckDependencies_NoDependencies_Satisfied()
{
// Components with no dependencies should always pass the check.
CatalogEntry entry = ComponentCatalog.GetByKey("minio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeTrue();
result.MissingDependencies.Should().BeEmpty();
result.MissingOneOfRequirements.Should().BeEmpty();
}
[Fact]
public void CheckDependencies_DirectDependencyMissing_NotSatisfied()
{
// kube-prometheus-stack requires cert-manager and letsencrypt-issuer.
// If they're not installed, the check should report them missing.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("cert-manager");
result.MissingDependencies.Should().Contain("letsencrypt-issuer");
}
[Fact]
public void CheckDependencies_OneOfMissing_NotSatisfied()
{
// kube-prometheus-stack needs either traefik or istio.
// If neither is present, the one-of requirement should be missing.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer"]);
result.IsSatisfied.Should().BeFalse();
result.MissingOneOfRequirements.Should().HaveCount(1);
result.MissingOneOfRequirements[0].Label.Should().Be("Ingress Controller");
}
[Fact]
public void CheckDependencies_TraefikSatisfiesIngress()
{
// Installing traefik should satisfy the "one of ingress" requirement.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer", "traefik"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_IstioSatisfiesIngress()
{
// Installing istio should also satisfy the "one of ingress" requirement.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer", "istio"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_IstioRequiresBase()
{
// The istio gateway depends on istio-base (istiod).
CatalogEntry entry = ComponentCatalog.GetByKey("istio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("istio-base");
}
[Fact]
public void CheckDependencies_IstioWithBase_Satisfied()
{
CatalogEntry entry = ComponentCatalog.GetByKey("istio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["istio-base"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_LetsEncryptRequiresCertManager()
{
CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("cert-manager");
}
[Fact]
public void CheckDependencies_CaseInsensitive()
{
// Installed component names should match case-insensitively.
CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["Cert-Manager"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void Catalog_IstioBaseExists()
{
// Ensure the istio-base entry exists since istio depends on it.
CatalogEntry? entry = ComponentCatalog.GetByKey("istio-base");
entry.Should().NotBeNull();
entry!.HelmChartName.Should().Be("istiod");
}
[Fact]
public void Catalog_LetsEncryptIssuerExists()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("letsencrypt-issuer");
entry.Should().NotBeNull();
entry!.Dependencies.Should().Contain("cert-manager");
}
}

View File

@@ -0,0 +1,495 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for ComponentLifecycleService — the service that manages the full
/// lifecycle of cluster components (install, configure, upgrade, uninstall).
/// Tests cover the data-layer operations and validation; actual Helm CLI
/// execution requires a live cluster + helm binary.
/// </summary>
public class ComponentLifecycleServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly ComponentLifecycleService sut;
private readonly VaultService vaultService;
private readonly Guid tenantId = Guid.NewGuid();
private readonly Guid envId = Guid.NewGuid();
private readonly Guid clusterId = Guid.NewGuid();
public ComponentLifecycleServiceTests()
{
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();
// Seed a tenant, environment, and cluster for component lifecycle tests.
Tenant tenant = new() { Id = tenantId, Name = "LifecycleTenant", Slug = "lifecycle" };
Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" };
KubernetesCluster cluster = new()
{
Id = clusterId,
TenantId = tenantId,
EnvironmentId = envId,
Name = "lifecycle-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://k8s.example.com\n name: test\ncontexts:\n- context:\n cluster: test\n user: test\n name: test\ncurrent-context: test\nusers:\n- name: test\n user:\n token: fake-token"
};
db.Set<Tenant>().Add(tenant);
db.Set<Data.Environment>().Add(env);
db.KubernetesClusters.Add(cluster);
db.SaveChanges();
byte[] testRootKey = Convert.FromBase64String("dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
VaultEncryptionService encryption = new(testRootKey);
vaultService = new VaultService(dbFactory, encryption);
sut = new ComponentLifecycleService(dbFactory, vaultService);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── RegisterComponentAsync ────────
[Fact]
public async Task RegisterComponentAsync_ValidInput_CreatesWithNotInstalledStatus()
{
// Arrange — a new Helm component registration for kube-prometheus-stack.
ComponentRegistration registration = new()
{
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmRepoUrl = "https://prometheus-community.github.io/helm-charts",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
// Act
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration);
// Assert — component exists in DB with NotInstalled status.
component.Should().NotBeNull();
component.Status.Should().Be(ComponentStatus.NotInstalled);
component.Namespace.Should().Be("monitoring");
component.HelmRepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts");
component.HelmChartName.Should().Be("kube-prometheus-stack");
component.HelmChartVersion.Should().Be("65.1.0");
component.ReleaseName.Should().Be("kube-prometheus-stack");
}
[Fact]
public async Task RegisterComponentAsync_DuplicateName_Throws()
{
// Arrange — register a component, then try to register another with the same name.
ComponentRegistration registration = new()
{
Name = "duplicate-comp",
ComponentType = "HelmChart",
Namespace = "default"
};
await sut.RegisterComponentAsync(clusterId, registration);
// Act & Assert
Func<Task> act = () => sut.RegisterComponentAsync(clusterId, registration);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already exists*");
}
// ──────── UpdateConfigurationAsync ────────
[Fact]
public async Task UpdateConfigurationAsync_ExistingComponent_UpdatesHelmValues()
{
// Arrange — register a component, then update its values.
ComponentRegistration reg = new()
{
Name = "config-test",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
string newValues = "alertmanager:\n enabled: true\ngrafana:\n enabled: true";
// Act
ClusterComponent updated = await sut.UpdateConfigurationAsync(
component.Id, newValues, chartVersion: "65.2.0");
// Assert
updated.HelmValues.Should().Be(newValues);
updated.HelmChartVersion.Should().Be("65.2.0");
}
[Fact]
public async Task UpdateConfigurationAsync_NonExistentComponent_Throws()
{
// Act & Assert
Func<Task> act = () => sut.UpdateConfigurationAsync(Guid.NewGuid(), "values: true");
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not found*");
}
// ──────── PrepareInstallAsync — validates before install ────────
[Fact]
public async Task PrepareInstallAsync_ValidComponent_SetsInstallingStatus()
{
// Arrange — a properly configured component ready to install.
ComponentRegistration reg = new()
{
Name = "ready-to-install",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmRepoUrl = "https://prometheus-community.github.io/helm-charts",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Act
ClusterComponent prepared = await sut.PrepareInstallAsync(component.Id);
// Assert — status should transition to Installing.
prepared.Status.Should().Be(ComponentStatus.Installing);
}
[Fact]
public async Task PrepareInstallAsync_MissingHelmChart_ReturnsFailure()
{
// Arrange — component without chart info can't be installed.
ComponentRegistration reg = new()
{
Name = "no-chart",
ComponentType = "HelmChart",
Namespace = "default"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Act & Assert
Func<Task> act = () => sut.PrepareInstallAsync(component.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*chart name*");
}
[Fact]
public async Task PrepareInstallAsync_AlreadyInstalled_Throws()
{
// Arrange — component that's already in Installed state.
ComponentRegistration reg = new()
{
Name = "already-installed",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Manually set status to Installed to simulate.
// Re-fetch from test db since the returned entity is detached.
ClusterComponent tracked = (await db.Set<ClusterComponent>().FindAsync(component.Id))!;
tracked.Status = ComponentStatus.Installed;
await db.SaveChangesAsync();
// Act & Assert — can't install something that's already installed (use upgrade instead).
Func<Task> act = () => sut.PrepareInstallAsync(component.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already installed*");
}
// ──────── MarkInstallResultAsync ────────
[Fact]
public async Task MarkInstallResultAsync_Success_SetsInstalledStatus()
{
// Arrange
ComponentRegistration reg = new()
{
Name = "mark-success",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
await sut.PrepareInstallAsync(component.Id);
// Act
ClusterComponent result = await sut.MarkInstallResultAsync(component.Id, success: true);
// Assert
result.Status.Should().Be(ComponentStatus.Installed);
result.InstalledAt.Should().NotBeNull();
result.LastError.Should().BeNull();
}
[Fact]
public async Task MarkInstallResultAsync_Failure_SetsFailedStatusWithError()
{
// Arrange
ComponentRegistration reg = new()
{
Name = "mark-failure",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
await sut.PrepareInstallAsync(component.Id);
// Act
ClusterComponent result = await sut.MarkInstallResultAsync(
component.Id, success: false, error: "timeout waiting for resources");
// Assert
result.Status.Should().Be(ComponentStatus.Failed);
result.LastError.Should().Contain("timeout");
result.InstalledAt.Should().BeNull();
}
// ──────── PrepareUninstallAsync ────────
[Fact]
public async Task PrepareUninstallAsync_InstalledComponent_SetsUninstallingStatus()
{
// Arrange — an installed component ready to be removed.
ComponentRegistration reg = new()
{
Name = "to-uninstall",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Re-fetch from test db since the returned entity is detached.
ClusterComponent tracked = (await db.Set<ClusterComponent>().FindAsync(component.Id))!;
tracked.Status = ComponentStatus.Installed;
tracked.InstalledAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Act
ClusterComponent prepared = await sut.PrepareUninstallAsync(component.Id);
// Assert
prepared.Status.Should().Be(ComponentStatus.Uninstalling);
}
[Fact]
public async Task PrepareUninstallAsync_NotInstalled_Throws()
{
// Arrange — can't uninstall something that's not installed.
ComponentRegistration reg = new()
{
Name = "not-installed-yet",
ComponentType = "HelmChart",
Namespace = "default",
HelmChartName = "some-chart"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Act & Assert
Func<Task> act = () => sut.PrepareUninstallAsync(component.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not installed*");
}
// ──────── GetInstallCommandAsync — builds helm CLI command ────────
[Fact]
public async Task GetInstallCommandAsync_WithAllFields_ReturnsCorrectCommand()
{
// Arrange — a fully configured component.
ComponentRegistration reg = new()
{
Name = "full-cmd",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmRepoUrl = "https://prometheus-community.github.io/helm-charts",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0",
ReleaseName = "prom-stack"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
await sut.UpdateConfigurationAsync(component.Id, "grafana:\n enabled: true");
// Act
HelmCommand command = await sut.GetInstallCommandAsync(component.Id);
// Assert — verify the command structure is correct.
command.Operation.Should().Be("upgrade --install");
command.ReleaseName.Should().Be("prom-stack");
command.ChartReference.Should().Contain("kube-prometheus-stack");
command.Namespace.Should().Be("monitoring");
command.RepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts");
command.Version.Should().Be("65.1.0");
command.HasValues.Should().BeTrue();
}
[Fact]
public async Task GetUninstallCommandAsync_InstalledComponent_ReturnsCorrectCommand()
{
// Arrange
ComponentRegistration reg = new()
{
Name = "uninstall-cmd",
ComponentType = "HelmChart",
Namespace = "monitoring",
HelmChartName = "kube-prometheus-stack",
ReleaseName = "prom-stack"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg);
// Re-fetch from test db since the returned entity is detached.
ClusterComponent tracked = (await db.Set<ClusterComponent>().FindAsync(component.Id))!;
tracked.Status = ComponentStatus.Installed;
await db.SaveChangesAsync();
// Act
HelmCommand command = await sut.GetUninstallCommandAsync(component.Id);
// Assert
command.Operation.Should().Be("uninstall");
command.ReleaseName.Should().Be("prom-stack");
command.Namespace.Should().Be("monitoring");
}
// ──────── Secret Injection ────────
[Fact]
public async Task GetInstallCommandAsync_WithVaultSecret_InjectsSecretIntoValues()
{
// Arrange — register kube-prometheus-stack and store a Grafana admin
// password in the vault. The password should NOT be in the plain YAML
// but SHOULD appear in the final install command values.
ComponentRegistration registration = new()
{
Name = "kube-prometheus-stack",
ComponentType = "Helm",
Namespace = "monitoring",
HelmRepoUrl = "https://prometheus-community.github.io/helm-charts",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0",
ReleaseName = "kube-prometheus-stack",
HelmValues = "grafana:\n enabled: true\n"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration);
// Store the admin password in the vault under the secret name
// matching the catalog's SecretName for grafana-password.
await vaultService.InitializeVaultAsync(tenantId);
await vaultService.SetComponentSecretAsync(tenantId, component.Id, "GRAFANA_ADMIN_PASSWORD", "SuperSecret123!");
await sut.PrepareInstallAsync(component.Id);
// Act
HelmCommand command = await sut.GetInstallCommandAsync(component.Id);
// Assert — the values YAML should contain the injected secret.
command.ValuesYaml.Should().Contain("SuperSecret123!");
command.ValuesYaml.Should().Contain("adminPassword");
command.HasValues.Should().BeTrue();
}
[Fact]
public async Task GetInstallCommandAsync_WithoutVaultSecret_DoesNotInjectAnything()
{
// Arrange — register kube-prometheus-stack but DON'T store any secret.
// The values YAML should remain unchanged.
ComponentRegistration registration = new()
{
Name = "kube-prometheus-stack",
ComponentType = "Helm",
Namespace = "monitoring",
HelmRepoUrl = "https://prometheus-community.github.io/helm-charts",
HelmChartName = "kube-prometheus-stack",
HelmChartVersion = "65.1.0",
ReleaseName = "prom-no-secret",
HelmValues = "grafana:\n enabled: true\n"
};
ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration);
await sut.PrepareInstallAsync(component.Id);
// Act
HelmCommand command = await sut.GetInstallCommandAsync(component.Id);
// Assert — no secret injection means original YAML is preserved.
command.ValuesYaml.Should().Be("grafana:\n enabled: true\n");
command.HasValues.Should().BeTrue();
}
}

View File

@@ -0,0 +1,395 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for ComponentScanService — verifies Helm release decoding, discovery
/// logic, and import behavior. Uses SQLite in-memory for isolated database tests.
/// </summary>
public class ComponentScanServiceTests : IDisposable
{
private static readonly byte[] TestRootKey = Convert.FromBase64String(
"dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly ComponentScanService sut;
private readonly Guid tenantId = Guid.NewGuid();
private readonly Guid environmentId = Guid.NewGuid();
private readonly Guid clusterId = Guid.NewGuid();
private readonly KubernetesCluster testCluster;
public ComponentScanServiceTests()
{
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);
VaultService vaultService = new(dbFactory, encryption);
sut = new ComponentScanService(dbFactory, vaultService);
// Seed tenant, environment, and cluster.
db.Tenants.Add(new Tenant { Id = tenantId, Name = "TestTenant", Slug = "test" });
db.Environments.Add(new Data.Environment { Id = environmentId, TenantId = tenantId, Name = "dev" });
testCluster = new KubernetesCluster
{
Id = clusterId,
TenantId = tenantId,
EnvironmentId = environmentId,
Name = "test-cluster",
ApiServerUrl = "https://k8s.example.com"
};
db.KubernetesClusters.Add(testCluster);
db.SaveChanges();
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── DecodeHelmRelease Tests ────────
[Fact]
public void DecodeHelmRelease_ValidPayload_ReturnsRelease()
{
// Arrange — create a realistic Helm release JSON, gzip it, base64 it.
HelmReleaseJson releaseJson = new()
{
Name = "my-app",
Namespace = "production",
Chart = new HelmChartJson
{
Metadata = new HelmChartMetadataJson
{
Name = "nginx",
Version = "1.2.3",
AppVersion = "1.25.0"
}
},
Info = new HelmInfoJson
{
Status = "deployed",
LastDeployed = "2025-01-15T10:30:00Z"
},
Config = JsonDocument.Parse("{\"replicaCount\":3,\"image\":{\"tag\":\"latest\"}}").RootElement
};
byte[] secretData = EncodeHelmRelease(releaseJson);
// Act — invoke the private method via the public ScanHelmReleasesAsync,
// but since we can't easily mock K8s client, test the decode logic directly.
DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 5);
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("my-app");
result.Namespace.Should().Be("production");
result.ChartName.Should().Be("nginx");
result.ChartVersion.Should().Be("1.2.3");
result.AppVersion.Should().Be("1.25.0");
result.Status.Should().Be("deployed");
result.Revision.Should().Be(5);
result.Values.Should().Contain("replicaCount");
result.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void DecodeHelmRelease_EmptyConfig_ReturnsNullValues()
{
// Arrange — release with empty config {}.
HelmReleaseJson releaseJson = new()
{
Name = "minimal",
Namespace = "default",
Chart = new HelmChartJson
{
Metadata = new HelmChartMetadataJson { Name = "redis", Version = "0.1.0" }
},
Info = new HelmInfoJson { Status = "deployed" },
Config = JsonDocument.Parse("{}").RootElement
};
byte[] secretData = EncodeHelmRelease(releaseJson);
// Act
DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 1);
// Assert — empty config means no custom values.
result.Should().NotBeNull();
result!.Values.Should().BeNull();
}
[Fact]
public void DecodeHelmRelease_InvalidData_ReturnsFallbackFromLabels()
{
// Arrange — garbage data that isn't valid base64 of gzipped content.
byte[] secretData = Encoding.UTF8.GetBytes("not-valid-base64!!!");
// Act — the service gracefully falls back to label-based metadata.
DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 1);
// Assert — returns a minimal release from secret labels.
result.Should().NotBeNull();
result!.Name.Should().Be("test");
result.Namespace.Should().Be("default");
result.Revision.Should().Be(1);
}
// ──────── ImportReleaseAsync Tests ────────
[Fact]
public async Task ImportReleaseAsync_NewRelease_CreatesComponent()
{
// Arrange
DiscoveredHelmRelease release = new()
{
Name = "grafana",
Namespace = "monitoring",
ChartName = "grafana",
ChartVersion = "7.0.0",
Status = "deployed",
Revision = 3,
Values = "{\"persistence\":{\"enabled\":true}}",
UpdatedAt = DateTime.UtcNow
};
// Act
ClusterComponent result = await sut.ImportReleaseAsync(testCluster, release);
// Assert
result.Name.Should().Be("grafana");
result.Namespace.Should().Be("monitoring");
result.HelmChartName.Should().Be("grafana");
result.HelmChartVersion.Should().Be("7.0.0");
result.Status.Should().Be(ComponentStatus.Installed);
result.HelmValues.Should().Contain("persistence");
result.ClusterId.Should().Be(clusterId);
// Verify persisted in DB.
ClusterComponent? persisted = await db.ClusterComponents.FindAsync(result.Id);
persisted.Should().NotBeNull();
}
[Fact]
public async Task ImportReleaseAsync_ExistingComponent_UpdatesIt()
{
// Arrange — pre-create a component with outdated info.
ClusterComponent existing = new()
{
Id = Guid.NewGuid(),
ClusterId = clusterId,
Name = "prometheus",
ComponentType = "HelmChart",
Namespace = "old-namespace",
HelmChartVersion = "1.0.0",
Status = ComponentStatus.NotInstalled
};
db.ClusterComponents.Add(existing);
await db.SaveChangesAsync();
DiscoveredHelmRelease release = new()
{
Name = "prometheus",
Namespace = "monitoring",
ChartName = "kube-prometheus-stack",
ChartVersion = "55.0.0",
Status = "deployed",
Revision = 12,
Values = "{\"alertmanager\":{\"enabled\":false}}"
};
// Act
ClusterComponent result = await sut.ImportReleaseAsync(testCluster, release);
// Assert — should update existing, not create new.
result.Id.Should().Be(existing.Id);
result.Namespace.Should().Be("monitoring");
result.HelmChartVersion.Should().Be("55.0.0");
result.HelmChartName.Should().Be("kube-prometheus-stack");
result.Status.Should().Be(ComponentStatus.Installed);
int count = await db.ClusterComponents.CountAsync(c => c.ClusterId == clusterId && c.Name == "prometheus");
count.Should().Be(1);
}
[Fact]
public async Task ImportAllNewReleasesAsync_SkipsAlreadyTracked()
{
// Arrange — mix of tracked and new releases.
List<DiscoveredHelmRelease> releases =
[
new() { Name = "existing-one", Namespace = "ns1", Status = "deployed", Revision = 1, AlreadyTracked = true },
new() { Name = "new-one", Namespace = "ns2", ChartName = "new-chart", ChartVersion = "1.0.0", Status = "deployed", Revision = 1, AlreadyTracked = false },
new() { Name = "new-two", Namespace = "ns3", ChartName = "other-chart", ChartVersion = "2.0.0", Status = "deployed", Revision = 1, AlreadyTracked = false }
];
// Act
int imported = await sut.ImportAllNewReleasesAsync(testCluster, releases);
// Assert — only non-tracked releases should be imported.
imported.Should().Be(2);
int total = await db.ClusterComponents.CountAsync(c => c.ClusterId == clusterId);
total.Should().Be(2);
}
// ──────── MapStatus Tests ────────
[Theory]
[InlineData("deployed", ComponentStatus.Installed)]
[InlineData("failed", ComponentStatus.Failed)]
[InlineData("pending-install", ComponentStatus.Installing)]
[InlineData("pending-upgrade", ComponentStatus.Installing)]
[InlineData("pending-rollback", ComponentStatus.Installing)]
[InlineData("uninstalling", ComponentStatus.Uninstalling)]
[InlineData("unknown-status", ComponentStatus.NotInstalled)]
[InlineData(null, ComponentStatus.NotInstalled)]
public void MapStatus_MapsHelmStatusToComponentStatus(string? helmStatus, ComponentStatus expected)
{
// Use reflection to test the private static method.
System.Reflection.MethodInfo? method = typeof(ComponentScanService)
.GetMethod("MapStatus", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
ComponentStatus result = (ComponentStatus)method!.Invoke(null, [helmStatus])!;
result.Should().Be(expected);
}
// ──────── Helpers ────────
/// <summary>
/// Encodes a Helm release JSON object into the format stored in K8s secrets:
/// JSON → gzip → base64 (Helm). The K8s layer handles the outer base64.
/// </summary>
private static byte[] EncodeHelmRelease(HelmReleaseJson release)
{
string json = JsonSerializer.Serialize(release, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
using MemoryStream compressedStream = new();
using (GZipStream gzipStream = new(compressedStream, CompressionMode.Compress))
{
gzipStream.Write(jsonBytes);
}
// Helm base64 encoding.
string helmBase64 = Convert.ToBase64String(compressedStream.ToArray());
return Encoding.UTF8.GetBytes(helmBase64);
}
/// <summary>
/// Invokes the private DecodeHelmRelease method via reflection for unit testing.
/// </summary>
private static DiscoveredHelmRelease? InvokeDecodeHelmRelease(byte[] secretData, int revision)
{
System.Reflection.MethodInfo? method = typeof(ComponentScanService)
.GetMethod("DecodeHelmRelease", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (method is null)
{
throw new InvalidOperationException("DecodeHelmRelease method not found");
}
// Build a minimal V1Secret with the release data.
k8s.Models.V1Secret secret = new()
{
Metadata = new k8s.Models.V1ObjectMeta
{
Name = "sh.helm.release.v1.test.v1",
NamespaceProperty = "default",
Labels = new Dictionary<string, string>
{
["name"] = "test",
["owner"] = "helm",
["version"] = revision.ToString()
}
},
Data = new Dictionary<string, byte[]>
{
["release"] = secretData
}
};
return method.Invoke(null, [secret, revision]) as DiscoveredHelmRelease;
}
// ──────── Test DTOs for creating Helm release JSON ────────
private class HelmReleaseJson
{
public string Name { get; set; } = "";
public string Namespace { get; set; } = "";
public HelmChartJson? Chart { get; set; }
public HelmInfoJson? Info { get; set; }
public JsonElement? Config { get; set; }
}
private class HelmChartJson
{
public HelmChartMetadataJson? Metadata { get; set; }
}
private class HelmChartMetadataJson
{
public string? Name { get; set; }
public string? Version { get; set; }
public string? AppVersion { get; set; }
}
private class HelmInfoJson
{
public string? Status { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("last_deployed")]
public string? LastDeployed { get; set; }
}
}

View File

@@ -0,0 +1,236 @@
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 CustomerAccessService which manages user access to specific
/// customers. A tenant admin grants access, and the portal uses this to filter
/// which customers a user can see.
/// </summary>
public class CustomerAccessServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly CustomerAccessService sut;
public CustomerAccessServiceTests()
{
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();
sut = new CustomerAccessService(dbFactory);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ── Helpers ──
/// <summary>
/// Creates a tenant with a customer and an application user — the minimum
/// scaffolding needed to grant customer access.
/// </summary>
private (ApplicationUser user, Customer customer, Tenant tenant) CreateUserAndCustomer(
string userName = "alice@example.com",
string customerName = "Contoso")
{
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" };
db.Tenants.Add(tenant);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = customerName };
db.Customers.Add(customer);
ApplicationUser user = new() { Id = Guid.NewGuid().ToString(), UserName = userName, Email = userName };
db.Users.Add(user);
db.SaveChanges();
return (user, customer, tenant);
}
// ════════════════════════════════════════════════════════════════
// GrantAccessAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GrantAccessAsync_CreatesAccessWithDefaultViewerRole()
{
// Arrange — we have a user and a customer.
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
// Act — grant the user access to the customer.
CustomerAccess access = await sut.GrantAccessAsync(user.Id, customer.Id);
// Assert — access is created with Viewer role by default.
access.UserId.Should().Be(user.Id);
access.CustomerId.Should().Be(customer.Id);
access.Role.Should().Be(CustomerAccessRole.Viewer);
}
[Fact]
public async Task GrantAccessAsync_WithOperatorRole_StoresRole()
{
// Arrange
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
// Act
CustomerAccess access = await sut.GrantAccessAsync(
user.Id, customer.Id, CustomerAccessRole.Operator);
// Assert
access.Role.Should().Be(CustomerAccessRole.Operator);
}
[Fact]
public async Task GrantAccessAsync_DuplicateGrant_Throws()
{
// Arrange — grant access once.
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
await sut.GrantAccessAsync(user.Id, customer.Id);
// Act & Assert — granting again should fail (composite key violation).
Func<Task> act = () => sut.GrantAccessAsync(user.Id, customer.Id);
await act.Should().ThrowAsync<InvalidOperationException>();
}
// ════════════════════════════════════════════════════════════════
// RevokeAccessAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task RevokeAccessAsync_RemovesAccess()
{
// Arrange
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
await sut.GrantAccessAsync(user.Id, customer.Id);
// Act
await sut.RevokeAccessAsync(user.Id, customer.Id);
// Assert
List<Customer> customers = await sut.GetAccessibleCustomersAsync(user.Id);
customers.Should().BeEmpty();
}
// ════════════════════════════════════════════════════════════════
// UpdateRoleAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateRoleAsync_ChangesRole()
{
// Arrange — user starts as Viewer.
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
await sut.GrantAccessAsync(user.Id, customer.Id, CustomerAccessRole.Viewer);
// Act — promote to Admin.
await sut.UpdateRoleAsync(user.Id, customer.Id, CustomerAccessRole.Admin);
// Assert
CustomerAccess? access = await sut.GetAccessAsync(user.Id, customer.Id);
access.Should().NotBeNull();
access!.Role.Should().Be(CustomerAccessRole.Admin);
}
// ════════════════════════════════════════════════════════════════
// GetAccessibleCustomersAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetAccessibleCustomersAsync_ReturnsOnlyGrantedCustomers()
{
// Arrange — user has access to Contoso but not Fabrikam.
(ApplicationUser user, Customer contoso, Tenant tenant) = CreateUserAndCustomer();
Customer fabrikam = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Fabrikam" };
db.Customers.Add(fabrikam);
db.SaveChanges();
await sut.GrantAccessAsync(user.Id, contoso.Id);
// Act
List<Customer> customers = await sut.GetAccessibleCustomersAsync(user.Id);
// Assert — only Contoso, not Fabrikam.
customers.Should().HaveCount(1);
customers[0].Name.Should().Be("Contoso");
}
[Fact]
public async Task GetAccessibleCustomersAsync_IncludesApps()
{
// Arrange — customer with an app.
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" };
db.Apps.Add(app);
db.SaveChanges();
await sut.GrantAccessAsync(user.Id, customer.Id);
// Act
List<Customer> customers = await sut.GetAccessibleCustomersAsync(user.Id);
// Assert — the customer includes its apps.
customers[0].Apps.Should().HaveCount(1);
customers[0].Apps.First().Name.Should().Be("billing-api");
}
// ════════════════════════════════════════════════════════════════
// GetAccessAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetAccessAsync_ReturnsNullWhenNoAccess()
{
// Arrange
(ApplicationUser user, Customer customer, _) = CreateUserAndCustomer();
// Act — no access has been granted.
CustomerAccess? access = await sut.GetAccessAsync(user.Id, customer.Id);
// Assert
access.Should().BeNull();
}
// ════════════════════════════════════════════════════════════════
// GetCustomerUsersAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetCustomerUsersAsync_ReturnsAllUsersWithAccess()
{
// Arrange — two users with access to the same customer.
(ApplicationUser alice, Customer customer, _) = CreateUserAndCustomer("alice@example.com");
ApplicationUser bob = new() { Id = Guid.NewGuid().ToString(), UserName = "bob@example.com", Email = "bob@example.com" };
db.Users.Add(bob);
db.SaveChanges();
await sut.GrantAccessAsync(alice.Id, customer.Id, CustomerAccessRole.Admin);
await sut.GrantAccessAsync(bob.Id, customer.Id, CustomerAccessRole.Viewer);
// Act
List<CustomerAccess> accesses = await sut.GetCustomerUsersAsync(customer.Id);
// Assert
accesses.Should().HaveCount(2);
accesses.Should().Contain(a => a.User.UserName == "alice@example.com" && a.Role == CustomerAccessRole.Admin);
accesses.Should().Contain(a => a.User.UserName == "bob@example.com" && a.Role == CustomerAccessRole.Viewer);
}
}

View File

@@ -0,0 +1,119 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// A customer represents an end-client or account within a tenant. Tenants
/// may serve multiple customers, and this entity tracks that relationship.
/// </summary>
public class CustomerEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public CustomerEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task Customer_CanBeCreated_WithinTenant()
{
// A customer belongs to a tenant. It represents an end-client
// or account that the tenant organization serves.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Customer customer = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Name = "Big Corp"
};
context.Customers.Add(customer);
await context.SaveChangesAsync();
Customer? retrieved = await context.Customers
.Include(c => c.Tenant)
.FirstOrDefaultAsync(c => c.Name == "Big Corp");
retrieved.Should().NotBeNull();
retrieved!.Tenant.Name.Should().Be("Acme");
}
[Fact]
public async Task Customer_NameMustBeUniqueWithinTenant()
{
// Two customers in the same tenant cannot share the same name.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
context.Customers.Add(new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" });
await context.SaveChangesAsync();
context.Customers.Add(new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" });
Func<Task> act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task Customer_SameNameAllowedInDifferentTenants()
{
// Different tenants can each have a customer named "Big Corp".
Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Beta", Slug = "beta" };
context.Tenants.AddRange(tenantA, tenantB);
context.Customers.AddRange(
new Customer { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Big Corp" },
new Customer { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Big Corp" }
);
await context.SaveChangesAsync();
List<Customer> customers = await context.Customers.Where(c => c.Name == "Big Corp").ToListAsync();
customers.Should().HaveCount(2);
}
[Fact]
public async Task Tenant_CanHaveMultipleCustomers()
{
// A tenant typically serves several customers.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
context.Customers.AddRange(
new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer A" },
new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer B" },
new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer C" }
);
await context.SaveChangesAsync();
List<Customer> customers = await context.Customers.Where(c => c.TenantId == tenant.Id).ToListAsync();
customers.Should().HaveCount(3);
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,417 @@
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 DeploymentService which manages app deployments — creating
/// deployments (Manual, Yaml, Helm), managing manifests, and tracking status.
/// Uses SQLite in-memory for fast, isolated database tests.
/// </summary>
public class DeploymentServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly DeploymentService sut;
public DeploymentServiceTests()
{
// Stand up a fresh in-memory SQLite database for each test.
// This gives us real EF Core behavior without touching disk.
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();
sut = new DeploymentService(dbFactory);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ── Helpers ──
/// <summary>
/// Sets up a tenant with an environment, cluster, customer, and app — the
/// minimum scaffolding needed to create a deployment.
/// </summary>
private (App app, Data.Environment env, KubernetesCluster cluster) CreateTestApp()
{
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.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);
Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Contoso" };
db.Customers.Add(customer);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" };
db.Apps.Add(app);
db.SaveChanges();
return (app, env, cluster);
}
// ════════════════════════════════════════════════════════════════
// CreateDeploymentAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task CreateDeploymentAsync_ManualType_CreatesWithUnknownStatus()
{
// Arrange — we need an app, environment, and cluster to target.
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
// Act — create a manual deployment targeting the prod cluster.
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "billing-deploy", DeploymentType.Manual,
env.Id, cluster.Id, "billing-ns");
// Assert — the deployment exists with the right defaults.
deployment.Id.Should().NotBeEmpty();
deployment.Name.Should().Be("billing-deploy");
deployment.Type.Should().Be(DeploymentType.Manual);
deployment.Namespace.Should().Be("billing-ns");
deployment.SyncStatus.Should().Be(SyncStatus.Unknown);
deployment.HealthStatus.Should().Be(HealthStatus.Unknown);
}
[Fact]
public async Task CreateDeploymentAsync_HelmType_StoresChartInfo()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
// Act — create a Helm deployment with chart details.
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "minio-deploy", DeploymentType.HelmChart,
env.Id, cluster.Id, "minio-ns",
helmRepoUrl: "https://charts.min.io",
helmChartName: "minio",
helmChartVersion: "5.0.0");
// Assert
deployment.Type.Should().Be(DeploymentType.HelmChart);
deployment.HelmRepoUrl.Should().Be("https://charts.min.io");
deployment.HelmChartName.Should().Be("minio");
deployment.HelmChartVersion.Should().Be("5.0.0");
}
[Fact]
public async Task CreateDeploymentAsync_DuplicateName_Throws()
{
// Arrange — create a deployment, then try to create another with the same name.
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
await sut.CreateDeploymentAsync(
app.Id, "billing-deploy", DeploymentType.Manual,
env.Id, cluster.Id, "billing-ns");
// Act & Assert
Func<Task> act = () => sut.CreateDeploymentAsync(
app.Id, "billing-deploy", DeploymentType.Manual,
env.Id, cluster.Id, "other-ns");
await act.Should().ThrowAsync<DbUpdateException>();
}
// ════════════════════════════════════════════════════════════════
// GetDeploymentsAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetDeploymentsAsync_ReturnsDeploymentsForApp()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
await sut.CreateDeploymentAsync(app.Id, "deploy-2", DeploymentType.HelmChart, env.Id, cluster.Id, "ns2");
// Act
List<AppDeployment> deployments = await sut.GetDeploymentsAsync(app.Id);
// Assert — both deployments returned with related data loaded.
deployments.Should().HaveCount(2);
deployments.Should().Contain(d => d.Name == "deploy-1");
deployments.Should().Contain(d => d.Name == "deploy-2");
}
[Fact]
public async Task GetDeploymentsAsync_IncludesEnvironmentAndCluster()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
// Act
List<AppDeployment> deployments = await sut.GetDeploymentsAsync(app.Id);
// Assert — navigation properties should be loaded.
deployments[0].Environment.Name.Should().Be("production");
deployments[0].Cluster.Name.Should().Be("prod-cluster");
}
// ════════════════════════════════════════════════════════════════
// DeleteDeploymentAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task DeleteDeploymentAsync_RemovesDeployment()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "to-delete", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
// Act
await sut.DeleteDeploymentAsync(deployment.Id);
// Assert
List<AppDeployment> remaining = await sut.GetDeploymentsAsync(app.Id);
remaining.Should().BeEmpty();
}
// ════════════════════════════════════════════════════════════════
// Manifest CRUD
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task AddManifestAsync_CreatesManifest()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "yaml-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1");
string yaml = "apiVersion: v1\nkind: Service\nmetadata:\n name: billing-svc";
// Act — add a manifest to the deployment.
DeploymentManifest manifest = await sut.AddManifestAsync(
deployment.Id, "Service", "billing-svc", yaml, sortOrder: 1);
// Assert
manifest.Id.Should().NotBeEmpty();
manifest.Kind.Should().Be("Service");
manifest.Name.Should().Be("billing-svc");
manifest.YamlContent.Should().Be(yaml);
}
[Fact]
public async Task GetManifestsAsync_ReturnsOrderedManifests()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "ordered-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1");
await sut.AddManifestAsync(deployment.Id, "Service", "svc", "svc-yaml", sortOrder: 2);
await sut.AddManifestAsync(deployment.Id, "Deployment", "dep", "dep-yaml", sortOrder: 1);
await sut.AddManifestAsync(deployment.Id, "PersistentVolumeClaim", "pvc", "pvc-yaml", sortOrder: 0);
// Act
List<DeploymentManifest> manifests = await sut.GetManifestsAsync(deployment.Id);
// Assert — returned in SortOrder (PVC → Deployment → Service).
manifests.Should().HaveCount(3);
manifests[0].Kind.Should().Be("PersistentVolumeClaim");
manifests[1].Kind.Should().Be("Deployment");
manifests[2].Kind.Should().Be("Service");
}
[Fact]
public async Task UpdateManifestAsync_UpdatesYamlContent()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "update-test", DeploymentType.Yaml, env.Id, cluster.Id, "ns1");
DeploymentManifest manifest = await sut.AddManifestAsync(
deployment.Id, "Deployment", "billing", "old-yaml", sortOrder: 0);
// Act
await sut.UpdateManifestAsync(manifest.Id, "new-yaml-content");
// Assert
List<DeploymentManifest> manifests = await sut.GetManifestsAsync(deployment.Id);
manifests[0].YamlContent.Should().Be("new-yaml-content");
}
[Fact]
public async Task DeleteManifestAsync_RemovesManifest()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "del-manifest", DeploymentType.Yaml, env.Id, cluster.Id, "ns1");
DeploymentManifest manifest = await sut.AddManifestAsync(
deployment.Id, "Service", "svc", "yaml", sortOrder: 0);
// Act
await sut.DeleteManifestAsync(manifest.Id);
// Assert
List<DeploymentManifest> remaining = await sut.GetManifestsAsync(deployment.Id);
remaining.Should().BeEmpty();
}
// ════════════════════════════════════════════════════════════════
// Helm Values
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateHelmValuesAsync_StoresYaml()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "helm-deploy", DeploymentType.HelmChart, env.Id, cluster.Id, "minio-ns",
helmRepoUrl: "https://charts.min.io", helmChartName: "minio", helmChartVersion: "5.0.0");
string values = "replicas: 3\npersistence:\n size: 10Gi";
// Act
await sut.UpdateHelmValuesAsync(deployment.Id, values);
// Assert — reload from DB to verify persistence.
List<AppDeployment> deployments = await sut.GetDeploymentsAsync(app.Id);
deployments[0].HelmValues.Should().Be(values);
}
// ════════════════════════════════════════════════════════════════
// Deployment Resources (ArgoCD-style tree)
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task UpsertResourceAsync_CreatesNewResource()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "res-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
// Act — the cluster watcher reports a Deployment resource.
DeploymentResource resource = await sut.UpsertResourceAsync(
deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1",
SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3 replicas");
// Assert
resource.Id.Should().NotBeEmpty();
resource.Kind.Should().Be("Deployment");
resource.SyncStatus.Should().Be(SyncStatus.Synced);
resource.HealthStatus.Should().Be(HealthStatus.Healthy);
}
[Fact]
public async Task UpsertResourceAsync_UpdatesExistingResource()
{
// Arrange — create a resource, then update its health.
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "upsert-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
await sut.UpsertResourceAsync(
deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1",
SyncStatus.Synced, HealthStatus.Progressing, "Rolling update");
// Act — same resource, updated status.
await sut.UpsertResourceAsync(
deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1",
SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3");
// Assert — only one resource, with the updated health.
List<DeploymentResource> resources = await sut.GetResourceTreeAsync(deployment.Id);
resources.Should().HaveCount(1);
resources[0].HealthStatus.Should().Be(HealthStatus.Healthy);
resources[0].StatusMessage.Should().Be("Running 3/3");
}
[Fact]
public async Task GetResourceTreeAsync_ReturnsOnlyRootResources()
{
// Arrange — build a two-level tree: Deployment → ReplicaSet.
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "tree-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
DeploymentResource parent = await sut.UpsertResourceAsync(
deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1",
SyncStatus.Synced, HealthStatus.Healthy, null);
await sut.UpsertResourceAsync(
deployment.Id, "apps", "v1", "ReplicaSet", "billing-api-abc123", "ns1",
SyncStatus.Synced, HealthStatus.Healthy, null,
parentResourceId: parent.Id);
// Act — get the tree roots.
List<DeploymentResource> roots = await sut.GetResourceTreeAsync(deployment.Id);
// Assert — only the Deployment root, with the ReplicaSet as a child.
roots.Should().HaveCount(1);
roots[0].Kind.Should().Be("Deployment");
roots[0].ChildResources.Should().HaveCount(1);
roots[0].ChildResources.First().Kind.Should().Be("ReplicaSet");
}
// ════════════════════════════════════════════════════════════════
// UpdateDeploymentStatusAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateDeploymentStatusAsync_UpdatesStatusFields()
{
// Arrange
(App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp();
AppDeployment deployment = await sut.CreateDeploymentAsync(
app.Id, "status-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1");
// Act — simulate a sync completion.
await sut.UpdateDeploymentStatusAsync(
deployment.Id, SyncStatus.Synced, HealthStatus.Healthy, "All resources healthy");
// Assert
List<AppDeployment> deployments = await sut.GetDeploymentsAsync(app.Id);
deployments[0].SyncStatus.Should().Be(SyncStatus.Synced);
deployments[0].HealthStatus.Should().Be(HealthStatus.Healthy);
deployments[0].StatusMessage.Should().Be("All resources healthy");
deployments[0].LastSyncedAt.Should().NotBeNull();
}
}

View File

@@ -9,7 +9,9 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />

View File

@@ -0,0 +1,123 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// An environment represents a deployment stage within a tenant (e.g. "dev",
/// "staging", "production"). Resources like clusters and services are scoped
/// to an environment within a tenant.
/// </summary>
public class EnvironmentEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public EnvironmentEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task Environment_CanBeCreated_WithinTenant()
{
// Each tenant has its own set of environments that represent
// deployment stages. An environment has a name and belongs
// to exactly one tenant.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Data.Environment environment = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Name = "Production"
};
context.Environments.Add(environment);
await context.SaveChangesAsync();
Data.Environment? retrieved = await context.Environments
.Include(e => e.Tenant)
.FirstOrDefaultAsync(e => e.Name == "Production");
retrieved.Should().NotBeNull();
retrieved!.Tenant.Name.Should().Be("Acme");
}
[Fact]
public async Task Environment_NameMustBeUniqueWithinTenant()
{
// Two environments in the same tenant cannot share the same name —
// you can't have two "Production" environments in one organization.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
context.Environments.Add(new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" });
await context.SaveChangesAsync();
context.Environments.Add(new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" });
Func<Task> act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task Environment_SameNameAllowedInDifferentTenants()
{
// Different tenants can each have a "Production" environment —
// uniqueness is scoped to the tenant, not globally.
Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Beta", Slug = "beta" };
context.Tenants.AddRange(tenantA, tenantB);
context.Environments.AddRange(
new Data.Environment { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Production" },
new Data.Environment { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Production" }
);
await context.SaveChangesAsync();
List<Data.Environment> envs = await context.Environments.Where(e => e.Name == "Production").ToListAsync();
envs.Should().HaveCount(2);
}
[Fact]
public async Task Tenant_CanHaveMultipleEnvironments()
{
// A typical tenant might have dev, staging, and production environments.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
context.Environments.AddRange(
new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" },
new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Staging" },
new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }
);
await context.SaveChangesAsync();
List<Data.Environment> envs = await context.Environments.Where(e => e.TenantId == tenant.Id).ToListAsync();
envs.Should().HaveCount(3);
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,385 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for ExternalRouteService — manages exposing components externally
/// via Gateway API HTTPRoutes. Covers route creation, validation, duplicate
/// detection, YAML generation, and gateway resolution.
/// </summary>
public class ExternalRouteServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly ExternalRouteService sut;
private readonly Guid clusterId = Guid.NewGuid();
private readonly Guid componentId = Guid.NewGuid();
public ExternalRouteServiceTests()
{
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();
// Seed a cluster with traefik installed and a monitoring component.
Guid tenantId = Guid.NewGuid();
Guid envId = Guid.NewGuid();
Tenant tenant = new() { Id = tenantId, Name = "RouteTenant", Slug = "route" };
Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" };
KubernetesCluster cluster = new()
{
Id = clusterId,
TenantId = tenantId,
EnvironmentId = envId,
Name = "route-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config"
};
ClusterComponent traefik = new()
{
Id = Guid.NewGuid(),
ClusterId = clusterId,
Name = "traefik",
ComponentType = "HelmChart",
Namespace = "traefik",
Status = ComponentStatus.Installed
};
ClusterComponent monitoring = new()
{
Id = componentId,
ClusterId = clusterId,
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Namespace = "monitoring",
ReleaseName = "kube-prometheus-stack",
Status = ComponentStatus.Installed
};
db.Set<Tenant>().Add(tenant);
db.Set<Data.Environment>().Add(env);
db.KubernetesClusters.Add(cluster);
db.ClusterComponents.AddRange(traefik, monitoring);
db.SaveChanges();
sut = new ExternalRouteService(dbFactory);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── Route creation ────────
[Fact]
public async Task AddRoute_WithClusterIssuer_CreatesRoute()
{
// The simplest happy path — expose Grafana with Let's Encrypt.
ExternalRouteRequest request = new()
{
Hostname = "grafana.example.com",
ServiceName = "kube-prometheus-stack-grafana",
ServicePort = 80,
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
route.Hostname.Should().Be("grafana.example.com");
route.ServiceName.Should().Be("kube-prometheus-stack-grafana");
route.ServicePort.Should().Be(80);
route.TlsMode.Should().Be(TlsMode.ClusterIssuer);
route.ClusterIssuerName.Should().Be("letsencrypt-prod");
route.GatewayName.Should().Be("traefik-gateway");
route.GatewayNamespace.Should().Be("traefik");
}
[Fact]
public async Task AddRoute_WithManualTls_CreatesRoute()
{
// Manual TLS — operator provides their own certificate.
ExternalRouteRequest request = new()
{
Hostname = "prometheus.example.com",
ServicePort = 9090,
TlsMode = TlsMode.Manual,
TlsCertificate = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
TlsPrivateKey = "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
route.TlsMode.Should().Be(TlsMode.Manual);
route.TlsCertificate.Should().StartWith("-----BEGIN CERTIFICATE-----");
route.TlsPrivateKey.Should().StartWith("-----BEGIN PRIVATE KEY-----");
}
[Fact]
public async Task AddRoute_DefaultsServiceNameFromComponent()
{
// When no service name is specified, use the component's release name.
ExternalRouteRequest request = new()
{
Hostname = "alerts.example.com",
ServicePort = 9093,
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
route.ServiceName.Should().Be("kube-prometheus-stack");
}
// ──────── Validation ────────
[Fact]
public async Task AddRoute_EmptyHostname_Throws()
{
ExternalRouteRequest request = new()
{
Hostname = " ",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
Func<Task> act = () => sut.AddRouteAsync(componentId, request);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*Hostname is required*");
}
[Fact]
public async Task AddRoute_ClusterIssuer_MissingIssuerName_Throws()
{
ExternalRouteRequest request = new()
{
Hostname = "app.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = null
};
Func<Task> act = () => sut.AddRouteAsync(componentId, request);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*ClusterIssuer name is required*");
}
[Fact]
public async Task AddRoute_ManualTls_MissingCert_Throws()
{
ExternalRouteRequest request = new()
{
Hostname = "app.example.com",
TlsMode = TlsMode.Manual,
TlsCertificate = null
};
Func<Task> act = () => sut.AddRouteAsync(componentId, request);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*TLS certificate is required*");
}
[Fact]
public async Task AddRoute_DuplicateHostname_Throws()
{
// Can't use the same hostname twice on the same cluster.
ExternalRouteRequest request = new()
{
Hostname = "unique.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
await sut.AddRouteAsync(componentId, request);
Func<Task> duplicate = () => sut.AddRouteAsync(componentId, request);
await duplicate.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already in use*");
}
// ──────── Route retrieval and deletion ────────
[Fact]
public async Task GetRoutes_ReturnsComponentRoutes()
{
ExternalRouteRequest request1 = new()
{
Hostname = "a.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRouteRequest request2 = new()
{
Hostname = "b.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
await sut.AddRouteAsync(componentId, request1);
await sut.AddRouteAsync(componentId, request2);
List<ExternalRoute> routes = await sut.GetRoutesAsync(componentId);
routes.Should().HaveCount(2);
routes.Select(r => r.Hostname).Should().BeEquivalentTo(["a.example.com", "b.example.com"]);
}
[Fact]
public async Task DeleteRoute_RemovesRoute()
{
ExternalRouteRequest request = new()
{
Hostname = "deleteme.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
await sut.DeleteRouteAsync(route.Id);
List<ExternalRoute> routes = await sut.GetRoutesAsync(componentId);
routes.Should().BeEmpty();
}
// ──────── YAML generation ────────
[Fact]
public async Task GenerateHttpRouteYaml_ClusterIssuer_IncludesAnnotation()
{
ExternalRouteRequest request = new()
{
Hostname = "grafana.example.com",
ServiceName = "kube-prometheus-stack-grafana",
ServicePort = 80,
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id);
yaml.Should().Contain("kind: HTTPRoute");
yaml.Should().Contain("grafana.example.com");
yaml.Should().Contain("cert-manager.io/cluster-issuer: \"letsencrypt-prod\"");
yaml.Should().Contain("name: traefik-gateway");
yaml.Should().Contain("kube-prometheus-stack-grafana");
}
[Fact]
public async Task GenerateHttpRouteYaml_ManualTls_ReferencesSecret()
{
ExternalRouteRequest request = new()
{
Hostname = "manual.example.com",
ServiceName = "my-service",
ServicePort = 443,
TlsMode = TlsMode.Manual,
TlsCertificate = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id);
yaml.Should().Contain("kind: HTTPRoute");
yaml.Should().Contain("manual.example.com");
yaml.Should().Contain("my-service-tls");
yaml.Should().Contain("kind: Secret");
}
[Fact]
public void GenerateTlsSecretYaml_ManualMode_GeneratesSecret()
{
ExternalRoute route = new()
{
Id = Guid.NewGuid(),
ComponentId = componentId,
Hostname = "secure.example.com",
ServiceName = "my-svc",
ServicePort = 443,
TlsMode = TlsMode.Manual,
TlsCertificate = "CERT_DATA",
TlsPrivateKey = "KEY_DATA",
Component = new ClusterComponent
{
Id = componentId,
ClusterId = clusterId,
Name = "test",
ComponentType = "HelmChart",
Namespace = "apps"
}
};
string yaml = ExternalRouteService.GenerateTlsSecretYaml(route);
yaml.Should().Contain("kind: Secret");
yaml.Should().Contain("type: kubernetes.io/tls");
yaml.Should().Contain("namespace: apps");
yaml.Should().Contain("my-svc-tls");
}
[Fact]
public void GenerateTlsSecretYaml_ClusterIssuerMode_ReturnsEmpty()
{
// No Secret needed for automatic TLS — cert-manager handles it.
ExternalRoute route = new()
{
Id = Guid.NewGuid(),
ComponentId = componentId,
Hostname = "auto.example.com",
ServiceName = "svc",
ServicePort = 80,
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
string yaml = ExternalRouteService.GenerateTlsSecretYaml(route);
yaml.Should().BeEmpty();
}
// ──────── Gateway resolution ────────
[Fact]
public async Task AddRoute_ResolvesTraefikGateway()
{
// Cluster has Traefik installed, so gateway should resolve to traefik-gateway.
ExternalRouteRequest request = new()
{
Hostname = "gw-test.example.com",
TlsMode = TlsMode.ClusterIssuer,
ClusterIssuerName = "letsencrypt-prod"
};
ExternalRoute route = await sut.AddRouteAsync(componentId, request);
route.GatewayName.Should().Be("traefik-gateway");
route.GatewayNamespace.Should().Be("traefik");
}
}

View File

@@ -0,0 +1,152 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Groups provide a way to organize users within a tenant. A group belongs
/// to exactly one tenant and can contain multiple users. This enables bulk
/// permission assignment and logical grouping of team members.
/// </summary>
public class GroupEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public GroupEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task Group_CanBeCreated_WithinTenant()
{
// A group lives within a tenant's boundary. It has a name and belongs
// to exactly one tenant — you can't share groups across tenants.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Group group = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Name = "Engineering"
};
context.Groups.Add(group);
await context.SaveChangesAsync();
Group? retrieved = await context.Groups
.Include(g => g.Tenant)
.FirstOrDefaultAsync(g => g.Name == "Engineering");
retrieved.Should().NotBeNull();
retrieved!.Tenant.Name.Should().Be("Acme");
}
[Fact]
public async Task Group_CanHaveMultipleMembers()
{
// Users are added to groups through a membership join entity.
// Multiple users can belong to the same group.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Group group = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Engineering" };
context.Groups.Add(group);
ApplicationUser userA = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "alice@acme.com",
NormalizedUserName = "ALICE@ACME.COM",
Email = "alice@acme.com",
NormalizedEmail = "ALICE@ACME.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
ApplicationUser userB = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "bob@acme.com",
NormalizedUserName = "BOB@ACME.COM",
Email = "bob@acme.com",
NormalizedEmail = "BOB@ACME.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
context.Users.AddRange(userA, userB);
context.GroupMemberships.AddRange(
new GroupMembership { UserId = userA.Id, GroupId = group.Id },
new GroupMembership { UserId = userB.Id, GroupId = group.Id }
);
await context.SaveChangesAsync();
List<GroupMembership> members = await context.GroupMemberships
.Where(gm => gm.GroupId == group.Id)
.ToListAsync();
members.Should().HaveCount(2);
}
[Fact]
public async Task User_CanBelongToMultipleGroups()
{
// A user might be in "Engineering" and "On-Call" simultaneously.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
Group groupA = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Engineering" };
Group groupB = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "On-Call" };
context.Groups.AddRange(groupA, groupB);
ApplicationUser user = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "alice@acme.com",
NormalizedUserName = "ALICE@ACME.COM",
Email = "alice@acme.com",
NormalizedEmail = "ALICE@ACME.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
context.Users.Add(user);
context.GroupMemberships.AddRange(
new GroupMembership { UserId = user.Id, GroupId = groupA.Id },
new GroupMembership { UserId = user.Id, GroupId = groupB.Id }
);
await context.SaveChangesAsync();
List<GroupMembership> memberships = await context.GroupMemberships
.Where(gm => gm.UserId == user.Id)
.ToListAsync();
memberships.Should().HaveCount(2);
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,155 @@
using EntKube.Web.Services;
using FluentAssertions;
namespace EntKube.Web.Tests;
/// <summary>
/// A kubeconfig file contains clusters, users, and contexts. When a user pastes
/// or uploads a kubeconfig, we need to parse it and present the available contexts
/// so they can choose which cluster to register.
/// </summary>
public class KubeconfigParserTests
{
private const string SingleContextKubeconfig = """
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.prod.example.com:6443
certificate-authority-data: LS0tLS1C...
name: prod-cluster
contexts:
- context:
cluster: prod-cluster
user: admin-user
namespace: default
name: prod-context
current-context: prod-context
users:
- name: admin-user
user:
token: secret-token-123
""";
private const string MultiContextKubeconfig = """
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.dev.example.com:6443
name: dev-cluster
- cluster:
server: https://k8s.staging.example.com:6443
name: staging-cluster
- cluster:
server: https://k8s.prod.example.com:6443
name: prod-cluster
contexts:
- context:
cluster: dev-cluster
user: dev-user
name: dev
- context:
cluster: staging-cluster
user: staging-user
name: staging
- context:
cluster: prod-cluster
user: prod-user
name: production
current-context: dev
users:
- name: dev-user
user:
token: dev-token
- name: staging-user
user:
token: staging-token
- name: prod-user
user:
token: prod-token
""";
[Fact]
public void ParseContexts_SingleContext_ReturnsOneEntry()
{
// A kubeconfig with one context should return that context
// with its name and the referenced cluster's server URL.
List<KubeconfigContext> contexts = KubeconfigParser.ParseContexts(SingleContextKubeconfig);
contexts.Should().HaveCount(1);
contexts[0].Name.Should().Be("prod-context");
contexts[0].ClusterServer.Should().Be("https://k8s.prod.example.com:6443");
}
[Fact]
public void ParseContexts_MultipleContexts_ReturnsAll()
{
// When multiple contexts exist, the user must choose.
List<KubeconfigContext> contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig);
contexts.Should().HaveCount(3);
contexts.Select(c => c.Name).Should().Contain(["dev", "staging", "production"]);
}
[Fact]
public void ParseContexts_ResolvesServerUrlFromClusterReference()
{
// Each context references a cluster by name — we resolve that
// to the actual server URL so the user sees something meaningful.
List<KubeconfigContext> contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig);
KubeconfigContext dev = contexts.First(c => c.Name == "dev");
dev.ClusterServer.Should().Be("https://k8s.dev.example.com:6443");
KubeconfigContext prod = contexts.First(c => c.Name == "production");
prod.ClusterServer.Should().Be("https://k8s.prod.example.com:6443");
}
[Fact]
public void ParseContexts_IdentifiesCurrentContext()
{
// The current-context field tells us which context is active by default.
List<KubeconfigContext> contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig);
contexts.First(c => c.Name == "dev").IsCurrent.Should().BeTrue();
contexts.First(c => c.Name == "staging").IsCurrent.Should().BeFalse();
}
[Fact]
public void ParseContexts_EmptyOrInvalidYaml_ReturnsEmptyList()
{
// Invalid input should not throw — just return empty.
KubeconfigParser.ParseContexts("").Should().BeEmpty();
KubeconfigParser.ParseContexts("not: valid: kubeconfig").Should().BeEmpty();
KubeconfigParser.ParseContexts("random text").Should().BeEmpty();
}
[Fact]
public void ParseContexts_MissingClusterReference_SkipsContext()
{
// If a context references a cluster that doesn't exist in the file, skip it.
string brokenConfig = """
apiVersion: v1
kind: Config
clusters: []
contexts:
- context:
cluster: nonexistent
user: someone
name: orphan-context
current-context: orphan-context
users: []
""";
List<KubeconfigContext> contexts = KubeconfigParser.ParseContexts(brokenConfig);
contexts.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,220 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for KubernetesOperationsService — the service that performs live
/// cluster operations (pod logs, restart, redeploy) using stored kubeconfig.
///
/// These tests verify the service's data-layer behavior (looking up clusters,
/// building client configs). Actual K8s API calls are integration-level and
/// require a live cluster. We test the plumbing, not the wire.
/// </summary>
public class KubernetesOperationsServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly KubernetesOperationsService sut;
public KubernetesOperationsServiceTests()
{
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();
sut = new KubernetesOperationsService(dbFactory);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ── Helpers ──
private KubernetesCluster CreateCluster(string? kubeconfig = null)
{
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.Environments.Add(env);
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "prod-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = kubeconfig
};
db.KubernetesClusters.Add(cluster);
db.SaveChanges();
return cluster;
}
private (AppDeployment deployment, KubernetesCluster cluster) CreateDeploymentWithCluster()
{
KubernetesCluster cluster = CreateCluster();
Customer customer = new() { Id = Guid.NewGuid(), TenantId = cluster.TenantId, Name = "Contoso" };
db.Customers.Add(customer);
App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" };
db.Apps.Add(app);
AppDeployment deployment = new()
{
Id = Guid.NewGuid(),
AppId = app.Id,
Name = "billing-deploy",
Type = DeploymentType.Manual,
EnvironmentId = cluster.EnvironmentId,
ClusterId = cluster.Id,
Namespace = "billing-ns"
};
db.AppDeployments.Add(deployment);
db.SaveChanges();
return (deployment, cluster);
}
// ════════════════════════════════════════════════════════════════
// GetDeploymentWithClusterAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetDeploymentWithClusterAsync_ReturnsDeploymentAndCluster()
{
// Arrange
(AppDeployment deployment, KubernetesCluster cluster) = CreateDeploymentWithCluster();
// Act — the service looks up the deployment with its cluster attached.
AppDeployment? result = await sut.GetDeploymentWithClusterAsync(deployment.Id);
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("billing-deploy");
result.Cluster.Should().NotBeNull();
result.Cluster.Name.Should().Be("prod-cluster");
}
[Fact]
public async Task GetDeploymentWithClusterAsync_ReturnsNullForMissing()
{
// Act
AppDeployment? result = await sut.GetDeploymentWithClusterAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
// ════════════════════════════════════════════════════════════════
// GetPodsAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetPodsAsync_WithoutKubeconfig_ReturnsClusterNotConfiguredError()
{
// Arrange — cluster has no kubeconfig stored.
(AppDeployment deployment, _) = CreateDeploymentWithCluster();
// Act — trying to get pods without a kubeconfig should return an error.
KubernetesOperationResult<List<PodInfo>> result = await sut.GetPodsAsync(deployment.Id);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
// ════════════════════════════════════════════════════════════════
// RestartDeploymentAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task RestartDeploymentAsync_WithoutKubeconfig_ReturnsError()
{
// Arrange
(AppDeployment deployment, _) = CreateDeploymentWithCluster();
// Act
KubernetesOperationResult result = await sut.RestartDeploymentAsync(
deployment.Id, "billing-api");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
// ════════════════════════════════════════════════════════════════
// DeletePodAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task DeletePodAsync_WithoutKubeconfig_ReturnsError()
{
// Arrange
(AppDeployment deployment, _) = CreateDeploymentWithCluster();
// Act
KubernetesOperationResult result = await sut.DeletePodAsync(
deployment.Id, "billing-api-abc123");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
// ════════════════════════════════════════════════════════════════
// GetPodLogsAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task GetPodLogsAsync_WithoutKubeconfig_ReturnsError()
{
// Arrange
(AppDeployment deployment, _) = CreateDeploymentWithCluster();
// Act
KubernetesOperationResult<string> result = await sut.GetPodLogsAsync(
deployment.Id, "billing-api-abc123");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
// ════════════════════════════════════════════════════════════════
// ScaleDeploymentAsync
// ════════════════════════════════════════════════════════════════
[Fact]
public async Task ScaleDeploymentAsync_WithoutKubeconfig_ReturnsError()
{
// Arrange
(AppDeployment deployment, _) = CreateDeploymentWithCluster();
// Act
KubernetesOperationResult result = await sut.ScaleDeploymentAsync(
deployment.Id, "billing-api", 3);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
}

View File

@@ -0,0 +1,551 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for PrometheusService — the service that connects to a kube-prometheus-stack
/// running in a cluster and retrieves health/status metrics. These tests verify the
/// data-layer plumbing (finding clusters, validating configuration) and response
/// parsing. Actual Prometheus queries require a live cluster.
/// </summary>
public class PrometheusServiceTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly PrometheusService sut;
public PrometheusServiceTests()
{
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();
sut = new PrometheusService(dbFactory);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── GetClusterHealthAsync — failure paths ────────
[Fact]
public async Task GetClusterHealthAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — use a random cluster ID that doesn't exist in the database.
Guid nonExistentId = Guid.NewGuid();
// Act
KubernetesOperationResult<ClusterHealthSummary> result =
await sut.GetClusterHealthAsync(nonExistentId);
// Assert — should fail gracefully with a clear message.
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task GetClusterHealthAsync_NoPrometheusComponent_ReturnsFailure()
{
// Arrange — cluster exists but has no prometheus component configured.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant", Slug = "test" };
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" };
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "prod-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config"
};
db.Set<Tenant>().Add(tenant);
db.Set<Data.Environment>().Add(env);
db.KubernetesClusters.Add(cluster);
await db.SaveChangesAsync();
// Act
KubernetesOperationResult<ClusterHealthSummary> result =
await sut.GetClusterHealthAsync(cluster.Id);
// Assert — no prometheus component means we can't query metrics.
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("prometheus");
}
[Fact]
public async Task GetClusterHealthAsync_NoKubeconfig_ReturnsFailure()
{
// Arrange — cluster has prometheus component but no kubeconfig stored.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant2", Slug = "test2" };
Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" };
KubernetesCluster cluster = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
EnvironmentId = env.Id,
Name = "no-kubeconfig-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = null
};
ClusterComponent prometheusComponent = new()
{
Id = Guid.NewGuid(),
ClusterId = cluster.Id,
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}"""
};
db.Set<Tenant>().Add(tenant);
db.Set<Data.Environment>().Add(env);
db.KubernetesClusters.Add(cluster);
db.ClusterComponents.Add(prometheusComponent);
await db.SaveChangesAsync();
// Act
KubernetesOperationResult<ClusterHealthSummary> result =
await sut.GetClusterHealthAsync(cluster.Id);
// Assert — without kubeconfig we can't connect to the cluster.
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("kubeconfig");
}
// ──────── ParsePrometheusResponse — parsing metric results ────────
[Fact]
public void ParseInstantQueryResult_ValidVectorResponse_ReturnsValues()
{
// Arrange — a typical Prometheus instant query response for `up` metric.
string json = """
{
"status": "success",
"data": {
"resultType": "vector",
"result": [
{"metric": {"job": "node-exporter", "instance": "node1:9100"}, "value": [1700000000, "1"]},
{"metric": {"job": "node-exporter", "instance": "node2:9100"}, "value": [1700000000, "1"]},
{"metric": {"job": "node-exporter", "instance": "node3:9100"}, "value": [1700000000, "0"]}
]
}
}
""";
// Act
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
// Assert — three results parsed, with correct values.
results.Should().HaveCount(3);
results[0].Value.Should().Be(1.0);
results[1].Value.Should().Be(1.0);
results[2].Value.Should().Be(0.0);
results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100");
}
[Fact]
public void ParseInstantQueryResult_EmptyResult_ReturnsEmptyList()
{
// Arrange — Prometheus returns success but no matching series.
string json = """
{
"status": "success",
"data": {
"resultType": "vector",
"result": []
}
}
""";
// Act
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
// Assert
results.Should().BeEmpty();
}
[Fact]
public void ParseInstantQueryResult_ErrorResponse_ReturnsEmptyList()
{
// Arrange — Prometheus returns an error status.
string json = """
{
"status": "error",
"errorType": "bad_data",
"error": "invalid expression"
}
""";
// Act
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
// Assert — graceful degradation, no exception thrown.
results.Should().BeEmpty();
}
[Fact]
public void ParseInstantQueryResult_ScalarResponse_ReturnsSingleValue()
{
// Arrange — a scalar query result (e.g. `count(up)`).
string json = """
{
"status": "success",
"data": {
"resultType": "scalar",
"result": [1700000000, "42"]
}
}
""";
// Act
List<PrometheusMetricResult> results = PrometheusService.ParseInstantQueryResult(json);
// Assert
results.Should().HaveCount(1);
results[0].Value.Should().Be(42.0);
}
// ──────── GetPrometheusConfig — extracting config from component ────────
[Fact]
public void GetPrometheusConfig_ValidJson_ReturnsConfig()
{
// Arrange — a component with properly formatted configuration JSON.
ClusterComponent component = new()
{
Id = Guid.NewGuid(),
ClusterId = Guid.NewGuid(),
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}"""
};
// Act
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
// Assert
config.Should().NotBeNull();
config!.Namespace.Should().Be("monitoring");
config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus");
config.ServicePort.Should().Be(9090);
}
[Fact]
public void GetPrometheusConfig_NullConfiguration_ReturnsDefaultConfig()
{
// Arrange — component exists but has no configuration set yet.
// We should return sensible defaults for kube-prometheus-stack.
ClusterComponent component = new()
{
Id = Guid.NewGuid(),
ClusterId = Guid.NewGuid(),
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Configuration = null
};
// Act
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
// Assert — defaults for standard kube-prometheus-stack helm install.
config.Should().NotBeNull();
config!.Namespace.Should().Be("monitoring");
config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus");
config.ServicePort.Should().Be(9090);
}
[Fact]
public void GetPrometheusConfig_InvalidJson_ReturnsDefaultConfig()
{
// Arrange — configuration is corrupt JSON.
ClusterComponent component = new()
{
Id = Guid.NewGuid(),
ClusterId = Guid.NewGuid(),
Name = "kube-prometheus-stack",
ComponentType = "HelmChart",
Configuration = "not valid json {"
};
// Act
PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component);
// Assert — graceful fallback to defaults.
config.Should().NotBeNull();
config!.Namespace.Should().Be("monitoring");
}
// ──────── ParseRangeQueryResult — time-series parsing ────────
[Fact]
public void ParseRangeQueryResult_ValidMatrixResponse_ReturnsTimeSeries()
{
// Arrange — a typical Prometheus range query response with matrix data.
// Each result is a series with multiple [timestamp, value] pairs over time.
string json = """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [
{
"metric": {"instance": "node1:9100"},
"values": [
[1700000000, "45.2"],
[1700000060, "47.1"],
[1700000120, "43.8"],
[1700000180, "50.3"]
]
}
]
}
}
""";
// Act
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
// Assert — one series with four data points.
results.Should().HaveCount(1);
results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100");
results[0].DataPoints.Should().HaveCount(4);
results[0].DataPoints[0].Value.Should().BeApproximately(45.2, 0.01);
results[0].DataPoints[3].Value.Should().BeApproximately(50.3, 0.01);
results[0].DataPoints[0].Timestamp.Should().BeAfter(DateTime.UnixEpoch);
}
[Fact]
public void ParseRangeQueryResult_MultipleSeriesResponse_ReturnsAll()
{
// Arrange — two series (e.g. CPU per node) in a range query.
string json = """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [
{
"metric": {"node": "node1"},
"values": [[1700000000, "30.0"], [1700000060, "35.0"]]
},
{
"metric": {"node": "node2"},
"values": [[1700000000, "60.0"], [1700000060, "62.5"]]
}
]
}
}
""";
// Act
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
// Assert
results.Should().HaveCount(2);
results[0].Labels["node"].Should().Be("node1");
results[1].Labels["node"].Should().Be("node2");
results[1].DataPoints[1].Value.Should().BeApproximately(62.5, 0.01);
}
[Fact]
public void ParseRangeQueryResult_EmptyResult_ReturnsEmptyList()
{
// Arrange
string json = """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": []
}
}
""";
// Act
List<PrometheusTimeSeries> results = PrometheusService.ParseRangeQueryResult(json);
// Assert
results.Should().BeEmpty();
}
// ──────── ParseAlertmanagerAlerts — alertmanager response parsing ────────
[Fact]
public void ParseAlertmanagerAlerts_ValidResponse_ReturnsAlerts()
{
// Arrange — Alertmanager /api/v2/alerts returns an array of alert objects.
string json = """
[
{
"labels": {"alertname": "HighCPU", "severity": "warning", "instance": "node1:9100"},
"annotations": {"summary": "CPU usage is above 90%", "description": "Node node1 has high CPU load"},
"startsAt": "2026-05-16T10:30:00Z",
"endsAt": "0001-01-01T00:00:00Z",
"status": {"state": "active"},
"fingerprint": "abc123"
},
{
"labels": {"alertname": "PodCrashLooping", "severity": "critical", "namespace": "default", "pod": "api-7f8b9-xyz"},
"annotations": {"summary": "Pod is crash looping"},
"startsAt": "2026-05-16T09:15:00Z",
"endsAt": "0001-01-01T00:00:00Z",
"status": {"state": "active"},
"fingerprint": "def456"
}
]
""";
// Act
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
// Assert — two alerts parsed with correct severity and labels.
alerts.Should().HaveCount(2);
alerts[0].Name.Should().Be("HighCPU");
alerts[0].Severity.Should().Be("warning");
alerts[0].Summary.Should().Contain("CPU usage");
alerts[0].State.Should().Be("active");
alerts[0].Labels.Should().ContainKey("instance");
alerts[1].Name.Should().Be("PodCrashLooping");
alerts[1].Severity.Should().Be("critical");
}
[Fact]
public void ParseAlertmanagerAlerts_EmptyArray_ReturnsEmptyList()
{
// Arrange
string json = "[]";
// Act
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
// Assert
alerts.Should().BeEmpty();
}
[Fact]
public void ParseAlertmanagerAlerts_InvalidJson_ReturnsEmptyList()
{
// Arrange — corrupt JSON should degrade gracefully.
string json = "not valid json {";
// Act
List<AlertInfo> alerts = PrometheusService.ParseAlertmanagerAlerts(json);
// Assert
alerts.Should().BeEmpty();
}
// ──────── ParseAlertmanagerSilences — silence parsing ────────
[Fact]
public void ParseAlertmanagerSilences_ValidResponse_ReturnsSilences()
{
// Arrange — Alertmanager /api/v2/silences response.
string json = """
[
{
"id": "silence-001",
"status": {"state": "active"},
"comment": "Maintenance window for node upgrade",
"createdBy": "ops-team",
"startsAt": "2026-05-16T08:00:00Z",
"endsAt": "2026-05-16T12:00:00Z",
"matchers": [
{"name": "instance", "value": "node1:9100", "isRegex": false, "isEqual": true}
]
},
{
"id": "silence-002",
"status": {"state": "expired"},
"comment": "Past maintenance",
"createdBy": "admin",
"startsAt": "2026-05-15T00:00:00Z",
"endsAt": "2026-05-15T04:00:00Z",
"matchers": [
{"name": "alertname", "value": "Watchdog", "isRegex": false, "isEqual": true}
]
}
]
""";
// Act
List<SilenceInfo> silences = PrometheusService.ParseAlertmanagerSilences(json);
// Assert — two silences, one active and one expired.
silences.Should().HaveCount(2);
silences[0].Id.Should().Be("silence-001");
silences[0].State.Should().Be("active");
silences[0].Comment.Should().Contain("Maintenance");
silences[0].CreatedBy.Should().Be("ops-team");
silences[0].Matchers.Should().HaveCount(1);
silences[0].Matchers[0].Name.Should().Be("instance");
silences[1].State.Should().Be("expired");
}
}

View File

@@ -0,0 +1,29 @@
using EntKube.Web.Services;
using FluentAssertions;
namespace EntKube.Web.Tests;
/// <summary>
/// The slug is auto-generated from the tenant name — users shouldn't have to
/// think about URL encoding. These tests verify the conversion logic.
/// </summary>
public class SlugGenerationTests
{
[Theory]
[InlineData("Acme Corp", "acme-corp")]
[InlineData("My Cool Tenant", "my-cool-tenant")]
[InlineData("hello", "hello")]
[InlineData("UPPERCASE", "uppercase")]
[InlineData("special!@#chars", "special-chars")]
[InlineData(" spaces ", "spaces")]
[InlineData("multi---hyphens", "multi-hyphens")]
[InlineData("trailing-", "trailing")]
[InlineData("-leading", "leading")]
[InlineData("dots.and.periods", "dots-and-periods")]
public void GenerateSlug_ProducesExpectedResult(string name, string expected)
{
string slug = TenantService.GenerateSlug(name);
slug.Should().Be(expected);
}
}

View File

@@ -0,0 +1,797 @@
using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace EntKube.Web.Tests;
/// <summary>
/// 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.
/// </summary>
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<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
db = new ApplicationDbContext(options);
dbFactory = new TestDbContextFactory(connection);
db.Database.EnsureCreated();
VaultEncryptionService encryption = new(TestRootKey);
vaultService = new VaultService(dbFactory, encryption);
Mock<IHttpClientFactory> 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<Data.Environment>().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<StorageLink> 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<StorageLink> 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<Data.Environment>().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<StorageLink> 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<VaultSecret> 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<VaultSecret> 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<VaultSecret> 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<VaultSecret> 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<Data.Environment> 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<OpenStackConnection> 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<VaultSecret> 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<VaultSecret> 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<StorageBinding>().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<StorageBinding> 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<StorageBinding>().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<VaultSecret> 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<VaultSecret> secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id);
secrets.Should().AllSatisfy(s =>
{
s.SyncToKubernetes.Should().BeFalse();
s.KubernetesSecretName.Should().BeNull();
s.KubernetesNamespace.Should().BeNull();
});
}
}

View File

@@ -0,0 +1,191 @@
using EntKube.Web.Data;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// A tenant represents an organization or workspace in EntKube. Users can
/// belong to multiple tenants, each membership carrying a role that determines
/// what that user can do within that tenant's scope.
/// </summary>
public class TenantEntityTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext context;
public TenantEntityTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
context = new ApplicationDbContext(options);
context.Database.EnsureCreated();
}
[Fact]
public async Task Tenant_CanBeCreated_WithNameAndSlug()
{
// A tenant is the fundamental organizational unit. Every resource
// in EntKube is scoped to a tenant. We need at minimum a name
// (human-friendly) and a slug (URL-safe identifier).
Tenant tenant = new()
{
Id = Guid.NewGuid(),
Name = "Acme Corp",
Slug = "acme-corp"
};
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
Tenant? retrieved = await context.Tenants.FirstOrDefaultAsync(t => t.Slug == "acme-corp");
retrieved.Should().NotBeNull();
retrieved!.Name.Should().Be("Acme Corp");
}
[Fact]
public async Task Tenant_SlugMustBeUnique()
{
// Two tenants cannot share the same slug — it's the unique identifier
// used in URLs and API calls to target a specific tenant.
Tenant first = new() { Id = Guid.NewGuid(), Name = "First", Slug = "shared-slug" };
Tenant second = new() { Id = Guid.NewGuid(), Name = "Second", Slug = "shared-slug" };
context.Tenants.Add(first);
await context.SaveChangesAsync();
context.Tenants.Add(second);
Func<Task> act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task TenantRole_CanBeCreated_WithinTenant()
{
// Each tenant defines its own set of roles. The "Administrator" role
// is the primary role that grants full control within a tenant.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme Corp", Slug = "acme" };
context.Tenants.Add(tenant);
TenantRole adminRole = new()
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Name = "Administrator"
};
context.TenantRoles.Add(adminRole);
await context.SaveChangesAsync();
TenantRole? retrieved = await context.TenantRoles
.FirstOrDefaultAsync(r => r.TenantId == tenant.Id && r.Name == "Administrator");
retrieved.Should().NotBeNull();
}
[Fact]
public async Task TenantMembership_AssociatesUserWithTenantAndRole()
{
// A user joins a tenant through a membership, which also assigns them
// a role within that tenant. This is the many-to-many relationship
// with role context — a user might be an Administrator in one tenant
// but a regular member in another.
Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" };
context.Tenants.Add(tenant);
TenantRole role = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Administrator" };
context.TenantRoles.Add(role);
ApplicationUser user = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "admin@acme.com",
NormalizedUserName = "ADMIN@ACME.COM",
Email = "admin@acme.com",
NormalizedEmail = "ADMIN@ACME.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
context.Users.Add(user);
TenantMembership membership = new()
{
UserId = user.Id,
TenantId = tenant.Id,
RoleId = role.Id
};
context.TenantMemberships.Add(membership);
await context.SaveChangesAsync();
// Verify we can navigate from a user to their tenants and role.
TenantMembership? retrieved = await context.TenantMemberships
.Include(m => m.Tenant)
.Include(m => m.Role)
.FirstOrDefaultAsync(m => m.UserId == user.Id);
retrieved.Should().NotBeNull();
retrieved!.Tenant.Name.Should().Be("Acme");
retrieved.Role.Name.Should().Be("Administrator");
}
[Fact]
public async Task User_CanBelongToMultipleTenants()
{
// A user might work across several organizations. Each membership
// is independent — different tenants, potentially different roles.
ApplicationUser user = new()
{
Id = Guid.NewGuid().ToString(),
UserName = "user@example.com",
NormalizedUserName = "USER@EXAMPLE.COM",
Email = "user@example.com",
NormalizedEmail = "USER@EXAMPLE.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
context.Users.Add(user);
Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Tenant A", Slug = "tenant-a" };
Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Tenant B", Slug = "tenant-b" };
context.Tenants.AddRange(tenantA, tenantB);
TenantRole adminRole = new() { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Administrator" };
TenantRole memberRole = new() { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Member" };
context.TenantRoles.AddRange(adminRole, memberRole);
context.TenantMemberships.AddRange(
new TenantMembership { UserId = user.Id, TenantId = tenantA.Id, RoleId = adminRole.Id },
new TenantMembership { UserId = user.Id, TenantId = tenantB.Id, RoleId = memberRole.Id }
);
await context.SaveChangesAsync();
List<TenantMembership> memberships = await context.TenantMemberships
.Where(m => m.UserId == user.Id)
.ToListAsync();
memberships.Should().HaveCount(2);
}
public void Dispose()
{
context.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,28 @@
using EntKube.Web.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
/// <summary>
/// A test-only IDbContextFactory that produces ApplicationDbContext instances
/// sharing the same in-memory SQLite connection. This ensures all contexts
/// created by the factory see the same seeded test data.
/// </summary>
public sealed class TestDbContextFactory : IDbContextFactory<ApplicationDbContext>
{
private readonly SqliteConnection connection;
public TestDbContextFactory(SqliteConnection connection)
{
this.connection = connection;
}
public ApplicationDbContext CreateDbContext()
{
DbContextOptions<ApplicationDbContext> options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
return new ApplicationDbContext(options);
}
}

View File

@@ -0,0 +1,177 @@
using EntKube.Web.Services;
using FluentAssertions;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for the envelope encryption service that powers the secrets vault.
/// The service uses AES-256-GCM at both layers: root key → DEK, DEK → secret values.
/// </summary>
public class VaultEncryptionServiceTests
{
// A stable 32-byte root key for testing purposes.
private static readonly byte[] TestRootKey = Convert.FromBase64String(
"dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
private readonly VaultEncryptionService sut = new(TestRootKey);
// --- Data Key Generation ---
[Fact]
public void GenerateDataKey_ReturnsThirtyTwoBytes()
{
// A data encryption key should be 256 bits (32 bytes) for AES-256.
byte[] dataKey = sut.GenerateDataKey();
dataKey.Should().HaveCount(32);
}
[Fact]
public void GenerateDataKey_ProducesUniqueKeysEachCall()
{
// Every call should produce a cryptographically random key.
byte[] key1 = sut.GenerateDataKey();
byte[] key2 = sut.GenerateDataKey();
key1.Should().NotEqual(key2);
}
// --- Sealing and Unsealing Data Keys ---
[Fact]
public void SealDataKey_ProducesNonEmptyCiphertextAndNonce()
{
// Sealing a DEK with the root key should produce ciphertext + nonce.
byte[] dataKey = sut.GenerateDataKey();
(byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(dataKey);
encryptedKey.Should().NotBeEmpty();
nonce.Should().NotBeEmpty();
encryptedKey.Should().NotEqual(dataKey);
}
[Fact]
public void UnsealDataKey_RecoversOriginalKey()
{
// Unsealing a previously sealed DEK should return the exact same key.
byte[] originalKey = sut.GenerateDataKey();
(byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(originalKey);
byte[] recovered = sut.UnsealDataKey(encryptedKey, nonce);
recovered.Should().Equal(originalKey);
}
[Fact]
public void UnsealDataKey_WithWrongRootKey_Throws()
{
// If the root key is different, unsealing must fail.
byte[] dataKey = sut.GenerateDataKey();
(byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(dataKey);
// Create a service with a different root key.
byte[] wrongRoot = new byte[32];
Array.Fill(wrongRoot, (byte)0xFF);
VaultEncryptionService wrongService = new(wrongRoot);
Action act = () => wrongService.UnsealDataKey(encryptedKey, nonce);
act.Should().Throw<Exception>();
}
// --- Encrypting and Decrypting Secret Values ---
[Fact]
public void Encrypt_ProducesCiphertextDifferentFromPlaintext()
{
// Encrypting a secret value should produce unreadable ciphertext.
byte[] dataKey = sut.GenerateDataKey();
string plaintext = "super-secret-database-password";
(byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, plaintext);
ciphertext.Should().NotBeEmpty();
nonce.Should().NotBeEmpty();
System.Text.Encoding.UTF8.GetString(ciphertext).Should().NotBe(plaintext);
}
[Fact]
public void Decrypt_RecoversOriginalPlaintext()
{
// Decrypting should return the exact original secret value.
byte[] dataKey = sut.GenerateDataKey();
string original = "my-api-key-12345!@#$%";
(byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, original);
string recovered = sut.Decrypt(dataKey, ciphertext, nonce);
recovered.Should().Be(original);
}
[Fact]
public void Decrypt_WithWrongDataKey_Throws()
{
// Using the wrong DEK to decrypt must fail — tenant isolation.
byte[] dataKey1 = sut.GenerateDataKey();
byte[] dataKey2 = sut.GenerateDataKey();
string plaintext = "tenant-a-secret";
(byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey1, plaintext);
Action act = () => sut.Decrypt(dataKey2, ciphertext, nonce);
act.Should().Throw<Exception>();
}
[Fact]
public void Encrypt_SamePlaintext_ProducesDifferentCiphertext()
{
// Each encryption uses a unique nonce so identical plaintexts
// produce different ciphertexts — preventing pattern analysis.
byte[] dataKey = sut.GenerateDataKey();
string plaintext = "repeated-secret";
(byte[] ciphertext1, byte[] nonce1) = sut.Encrypt(dataKey, plaintext);
(byte[] ciphertext2, byte[] nonce2) = sut.Encrypt(dataKey, plaintext);
ciphertext1.Should().NotEqual(ciphertext2);
nonce1.Should().NotEqual(nonce2);
}
[Fact]
public void Encrypt_EmptyString_CanBeDecrypted()
{
// Edge case: empty secrets should encrypt/decrypt cleanly.
byte[] dataKey = sut.GenerateDataKey();
(byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, "");
string recovered = sut.Decrypt(dataKey, ciphertext, nonce);
recovered.Should().BeEmpty();
}
[Fact]
public void Encrypt_LargeValue_CanBeDecrypted()
{
// Secrets like certificates or multi-line configs can be large.
byte[] dataKey = sut.GenerateDataKey();
string large = new string('X', 100_000);
(byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, large);
string recovered = sut.Decrypt(dataKey, ciphertext, nonce);
recovered.Should().Be(large);
}
}

View File

@@ -0,0 +1,539 @@
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();
}
}

View File

@@ -0,0 +1,171 @@
using EntKube.Web.Services;
using FluentAssertions;
namespace EntKube.Web.Tests;
/// <summary>
/// Tests for the YamlFormMerger utility that takes form field values
/// (key-value pairs with dot-notation paths) and merges them into a
/// YAML string. This is how form-based configuration gets applied on
/// top of the raw YAML that users can also edit directly.
/// </summary>
public class YamlFormMergerTests
{
// ──────── Simple top-level paths ────────
[Fact]
public void MergeFormValues_WithSimplePath_SetsValue()
{
// A form field targeting "rootUser" should set the top-level key.
string baseYaml = """
rootUser: admin
rootPassword: changeme
""";
Dictionary<string, string> formValues = new() { ["rootUser"] = "minio-admin" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("rootUser: minio-admin");
}
[Fact]
public void MergeFormValues_WithNestedPath_SetsNestedValue()
{
// A dot-notation path like "grafana.adminPassword" should set the
// nested YAML property grafana → adminPassword.
string baseYaml = """
grafana:
enabled: true
adminPassword: admin
""";
Dictionary<string, string> formValues = new() { ["grafana.adminPassword"] = "s3cret!" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("adminPassword: s3cret!");
}
[Fact]
public void MergeFormValues_WithDeeplyNestedPath_SetsValue()
{
// Paths can be arbitrarily deep, like "prometheus.prometheusSpec.retention".
string baseYaml = """
prometheus:
prometheusSpec:
retention: 15d
resources:
requests:
memory: 512Mi
""";
Dictionary<string, string> formValues = new() { ["prometheus.prometheusSpec.retention"] = "30d" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("retention: 30d");
result.Should().NotContain("retention: 15d");
}
[Fact]
public void MergeFormValues_WithEmptyBaseYaml_CreatesStructure()
{
// When the base YAML is empty, the merger should create the nested
// structure from scratch based on the dot-notation path.
string baseYaml = "";
Dictionary<string, string> formValues = new() { ["service.type"] = "LoadBalancer" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("service:");
result.Should().Contain("type: LoadBalancer");
}
[Fact]
public void MergeFormValues_WithMultipleValues_SetsAll()
{
// Multiple form fields should all be applied.
string baseYaml = """
rootUser: admin
rootPassword: changeme
persistence:
size: 50Gi
""";
Dictionary<string, string> formValues = new()
{
["rootUser"] = "operator",
["rootPassword"] = "strong-pass",
["persistence.size"] = "100Gi"
};
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("rootUser: operator");
result.Should().Contain("rootPassword: strong-pass");
result.Should().Contain("size: 100Gi");
}
[Fact]
public void MergeFormValues_WithNewPath_AddsToExistingStructure()
{
// If the path doesn't exist in the base YAML but parent nodes do,
// the merger should add the missing leaf without destroying siblings.
string baseYaml = """
grafana:
enabled: true
""";
Dictionary<string, string> formValues = new() { ["grafana.adminPassword"] = "secret" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("enabled: true");
result.Should().Contain("adminPassword: secret");
}
[Fact]
public void MergeFormValues_WithBooleanValue_WritesUnquoted()
{
// Boolean-like values (true/false) should remain unquoted in YAML.
string baseYaml = """
alertmanager:
enabled: true
""";
Dictionary<string, string> formValues = new() { ["alertmanager.enabled"] = "false" };
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("enabled: false");
result.Should().NotContain("enabled: 'false'");
result.Should().NotContain("enabled: \"false\"");
}
[Fact]
public void MergeFormValues_WithEmptyFormValues_ReturnsBaseYamlUnchanged()
{
// If no form values are provided, the YAML comes back untouched.
string baseYaml = """
rootUser: admin
rootPassword: changeme
""";
Dictionary<string, string> formValues = new();
string result = YamlFormMerger.MergeFormValues(baseYaml, formValues);
result.Should().Contain("rootUser: admin");
result.Should().Contain("rootPassword: changeme");
}
}