540 lines
17 KiB
C#
540 lines
17 KiB
C#
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();
|
|
}
|
|
}
|