using System; using System.Net.Http; using Docker.DotNet; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using StellaOps.EvidenceLocker.Core.Configuration; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Infrastructure.Db; using Xunit; using StellaOps.TestKit; namespace StellaOps.EvidenceLocker.Tests; public sealed class DatabaseMigrationTests : IAsyncLifetime { private readonly PostgreSqlTestcontainer _postgres; private EvidenceLockerDataSource? _dataSource; private IEvidenceLockerMigrationRunner? _migrationRunner; private string? _skipReason; public DatabaseMigrationTests() { _postgres = new TestcontainersBuilder() .WithDatabase(new PostgreSqlTestcontainerConfiguration { Database = "evidence_locker_tests", Username = "postgres", Password = "postgres" }) .WithCleanUp(true) .Build(); } [Trait("Category", TestCategories.Integration)] [Fact] public async Task ApplyAsync_CreatesExpectedSchemaAndPolicies() { if (_skipReason is not null) { Assert.Skip(_skipReason); } var cancellationToken = CancellationToken.None; await _migrationRunner!.ApplyAsync(cancellationToken); await using var connection = await _dataSource!.OpenConnectionAsync(cancellationToken); await using var tablesCommand = new NpgsqlCommand( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'evidence_locker' ORDER BY table_name;", connection); var tables = new List(); await using (var reader = await tablesCommand.ExecuteReaderAsync(cancellationToken)) { while (await reader.ReadAsync(cancellationToken)) { tables.Add(reader.GetString(0)); } } Assert.Contains("evidence_artifacts", tables); Assert.Contains("evidence_bundles", tables); Assert.Contains("evidence_holds", tables); Assert.Contains("evidence_schema_version", tables); await using var versionCommand = new NpgsqlCommand( "SELECT COUNT(*) FROM evidence_locker.evidence_schema_version WHERE version = 1;", connection); var applied = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken) ?? 0L); Assert.Equal(1, applied); var tenant = TenantId.FromGuid(Guid.NewGuid()); await using var tenantConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken); await using var insertCommand = new NpgsqlCommand(@" INSERT INTO evidence_locker.evidence_bundles (bundle_id, tenant_id, kind, status, root_hash, storage_key) VALUES (@bundle, @tenant, 1, 3, @hash, @key);", tenantConnection); insertCommand.Parameters.AddWithValue("bundle", Guid.NewGuid()); insertCommand.Parameters.AddWithValue("tenant", tenant.Value); insertCommand.Parameters.AddWithValue("hash", new string('a', 64)); insertCommand.Parameters.AddWithValue("key", $"tenants/{tenant.Value:N}/bundles/test/resource"); await insertCommand.ExecuteNonQueryAsync(cancellationToken); await using var isolationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken); await using var selectCommand = new NpgsqlCommand( "SELECT COUNT(*) FROM evidence_locker.evidence_bundles;", isolationConnection); var visibleCount = Convert.ToInt64(await selectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L); Assert.Equal(1, visibleCount); await using var otherTenantConnection = await _dataSource.OpenConnectionAsync(TenantId.FromGuid(Guid.NewGuid()), cancellationToken); await using var otherSelectCommand = new NpgsqlCommand( "SELECT COUNT(*) FROM evidence_locker.evidence_bundles;", otherTenantConnection); var otherVisible = Convert.ToInt64(await otherSelectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L); Assert.Equal(0, otherVisible); await using var violationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken); await using var violationCommand = new NpgsqlCommand(@" INSERT INTO evidence_locker.evidence_bundles (bundle_id, tenant_id, kind, status, root_hash, storage_key) VALUES (@bundle, @tenant, 1, 3, @hash, @key);", violationConnection); violationCommand.Parameters.AddWithValue("bundle", Guid.NewGuid()); violationCommand.Parameters.AddWithValue("tenant", Guid.NewGuid()); violationCommand.Parameters.AddWithValue("hash", new string('b', 64)); violationCommand.Parameters.AddWithValue("key", "tenants/other/bundles/resource"); await Assert.ThrowsAsync(() => violationCommand.ExecuteNonQueryAsync(cancellationToken)); } public async ValueTask InitializeAsync() { try { await _postgres.StartAsync(); } catch (HttpRequestException ex) { _skipReason = $"Docker endpoint unavailable: {ex.Message}"; return; } catch (Docker.DotNet.DockerApiException ex) { _skipReason = $"Docker API error: {ex.Message}"; return; } var databaseOptions = new DatabaseOptions { ConnectionString = _postgres.ConnectionString, ApplyMigrationsAtStartup = false }; _dataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger.Instance); _migrationRunner = new EvidenceLockerMigrationRunner(_dataSource, NullLogger.Instance); } public async ValueTask DisposeAsync() { if (_skipReason is not null) { return; } if (_dataSource is not null) { await _dataSource.DisposeAsync(); } await _postgres.DisposeAsync(); } }