Mark subproject as dirty in CMKS - Infra
This commit is contained in:
216
tests/EntKube.Web.Tests/AppEntityTests.cs
Normal file
216
tests/EntKube.Web.Tests/AppEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
83
tests/EntKube.Web.Tests/ApplicationDbContextTests.cs
Normal file
83
tests/EntKube.Web.Tests/ApplicationDbContextTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
399
tests/EntKube.Web.Tests/CleuraS3ManagementTests.cs
Normal file
399
tests/EntKube.Web.Tests/CleuraS3ManagementTests.cs
Normal 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*");
|
||||
}
|
||||
}
|
||||
168
tests/EntKube.Web.Tests/ClusterEntityTests.cs
Normal file
168
tests/EntKube.Web.Tests/ClusterEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
846
tests/EntKube.Web.Tests/CnpgServiceTests.cs
Normal file
846
tests/EntKube.Web.Tests/CnpgServiceTests.cs
Normal 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 * * *");
|
||||
}
|
||||
}
|
||||
368
tests/EntKube.Web.Tests/ComponentCatalogTests.cs
Normal file
368
tests/EntKube.Web.Tests/ComponentCatalogTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
495
tests/EntKube.Web.Tests/ComponentLifecycleServiceTests.cs
Normal file
495
tests/EntKube.Web.Tests/ComponentLifecycleServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
395
tests/EntKube.Web.Tests/ComponentScanServiceTests.cs
Normal file
395
tests/EntKube.Web.Tests/ComponentScanServiceTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
236
tests/EntKube.Web.Tests/CustomerAccessServiceTests.cs
Normal file
236
tests/EntKube.Web.Tests/CustomerAccessServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
119
tests/EntKube.Web.Tests/CustomerEntityTests.cs
Normal file
119
tests/EntKube.Web.Tests/CustomerEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
417
tests/EntKube.Web.Tests/DeploymentServiceTests.cs
Normal file
417
tests/EntKube.Web.Tests/DeploymentServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
123
tests/EntKube.Web.Tests/EnvironmentEntityTests.cs
Normal file
123
tests/EntKube.Web.Tests/EnvironmentEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
385
tests/EntKube.Web.Tests/ExternalRouteServiceTests.cs
Normal file
385
tests/EntKube.Web.Tests/ExternalRouteServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
152
tests/EntKube.Web.Tests/GroupEntityTests.cs
Normal file
152
tests/EntKube.Web.Tests/GroupEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
155
tests/EntKube.Web.Tests/KubeconfigParserTests.cs
Normal file
155
tests/EntKube.Web.Tests/KubeconfigParserTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
220
tests/EntKube.Web.Tests/KubernetesOperationsServiceTests.cs
Normal file
220
tests/EntKube.Web.Tests/KubernetesOperationsServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
551
tests/EntKube.Web.Tests/PrometheusServiceTests.cs
Normal file
551
tests/EntKube.Web.Tests/PrometheusServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
29
tests/EntKube.Web.Tests/SlugGenerationTests.cs
Normal file
29
tests/EntKube.Web.Tests/SlugGenerationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
797
tests/EntKube.Web.Tests/StorageServiceTests.cs
Normal file
797
tests/EntKube.Web.Tests/StorageServiceTests.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
191
tests/EntKube.Web.Tests/TenantEntityTests.cs
Normal file
191
tests/EntKube.Web.Tests/TenantEntityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
28
tests/EntKube.Web.Tests/TestDbContextFactory.cs
Normal file
28
tests/EntKube.Web.Tests/TestDbContextFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
177
tests/EntKube.Web.Tests/VaultEncryptionServiceTests.cs
Normal file
177
tests/EntKube.Web.Tests/VaultEncryptionServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
539
tests/EntKube.Web.Tests/VaultServiceTests.cs
Normal file
539
tests/EntKube.Web.Tests/VaultServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
171
tests/EntKube.Web.Tests/YamlFormMergerTests.cs
Normal file
171
tests/EntKube.Web.Tests/YamlFormMergerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user