396 lines
13 KiB
C#
396 lines
13 KiB
C#
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; }
|
|
}
|
|
}
|