Mark subproject as dirty in CMKS - Infra
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user