partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchemaIsolationServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Tests for schema isolation, RLS scaffolding, temporal table management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Tests;
|
||||
|
||||
internal sealed class TestSchemaIsolationMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSchemaTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
public class SchemaIsolationServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestSchemaIsolationMeterFactory _meterFactory = new();
|
||||
private readonly FakeSchemaTimeProvider _timeProvider = new();
|
||||
private readonly SchemaIsolationService _service;
|
||||
|
||||
public SchemaIsolationServiceTests()
|
||||
{
|
||||
_service = new SchemaIsolationService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAssignment
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GetAssignment_returns_correct_schema_name(AttestorSchema schema, string expectedName)
|
||||
{
|
||||
var assignment = _service.GetAssignment(schema);
|
||||
|
||||
assignment.SchemaName.Should().Be(expectedName);
|
||||
assignment.Schema.Should().Be(schema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_ProofChain_has_six_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.ProofChain);
|
||||
|
||||
assignment.Tables.Should().HaveCount(6);
|
||||
assignment.Tables.Should().Contain("sbom_entries");
|
||||
assignment.Tables.Should().Contain("dsse_envelopes");
|
||||
assignment.Tables.Should().Contain("spines");
|
||||
assignment.Tables.Should().Contain("trust_anchors");
|
||||
assignment.Tables.Should().Contain("rekor_entries");
|
||||
assignment.Tables.Should().Contain("audit_log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_Verdict_has_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.Verdict);
|
||||
|
||||
assignment.Tables.Should().Contain("verdict_ledger");
|
||||
assignment.Tables.Should().Contain("verdict_policies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_invalid_value_throws()
|
||||
{
|
||||
var act = () => _service.GetAssignment((AttestorSchema)999);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAllAssignments
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_returns_all_five_schemas()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
all.Should().HaveCount(5);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.ProofChain);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Attestor);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Verdict);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Watchlist);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_every_assignment_has_at_least_one_table()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
foreach (var a in all)
|
||||
{
|
||||
a.Tables.Should().NotBeEmpty($"schema {a.Schema} should have at least one table");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateProvisioningSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GenerateProvisioningSql_generates_create_schema(AttestorSchema schema, string schemaName)
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(schema);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Schema.Should().Be(schema);
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains($"CREATE SCHEMA IF NOT EXISTS {schemaName}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_grant_statement()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("GRANT USAGE"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("stellaops_app"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_default_privileges()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ALTER DEFAULT PRIVILEGES"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_comment()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Audit);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("COMMENT ON SCHEMA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_records_timestamp()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Timestamp.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_produces_four_statements()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetRlsPolicies
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_Verdict_returns_policies()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
policies.Should().NotBeEmpty();
|
||||
policies.Should().OnlyContain(p => p.Schema == AttestorSchema.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_ProofChain_returns_empty()
|
||||
{
|
||||
// ProofChain does not have tenant isolation (shared read-only data)
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.ProofChain);
|
||||
|
||||
policies.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_all_have_tenant_column()
|
||||
{
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
policies.Should().OnlyContain(p => p.TenantColumn == "tenant_id");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicyDefinition_UsingExpression_computed_correctly()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
var policy = policies.First();
|
||||
policy.UsingExpression.Should().Contain("tenant_id");
|
||||
policy.UsingExpression.Should().Contain("current_setting");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateRlsSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_generates_enable_and_policy()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ENABLE ROW LEVEL SECURITY"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("CREATE POLICY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_includes_force_rls()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("FORCE ROW LEVEL SECURITY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_ProofChain_returns_empty_statements()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Watchlist_generates_multiple_policies()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
var policyStatements = result.GeneratedStatements.Where(s => s.Contains("CREATE POLICY")).ToList();
|
||||
policyStatements.Should().HaveCountGreaterThan(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_uses_permissive_mode()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("AS PERMISSIVE"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetTemporalTables
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_returns_three_configs()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_verdict_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var verdict = tables.First(t => t.TableName.Contains("verdict_ledger"));
|
||||
verdict.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_noise_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var noise = tables.First(t => t.TableName.Contains("noise_ledger"));
|
||||
noise.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_watchlist_has_one_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var watchlist = tables.First(t => t.TableName.Contains("watched_identities"));
|
||||
watchlist.Retention.Should().Be(TemporalRetention.OneYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_all_have_history_table_names()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().OnlyContain(t => t.HistoryTableName.Contains("_history"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateTemporalTableSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_generates_alter_table_for_period_columns()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("sys_period_start") && s.Contains("sys_period_end"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_history_table()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TABLE IF NOT EXISTS") && s.Contains("_history"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_trigger_function()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE OR REPLACE FUNCTION") && s.Contains("RETURNS TRIGGER"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_attaches_trigger()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TRIGGER") && s.Contains("BEFORE UPDATE OR DELETE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_includes_retention_comment()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("retention:"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_produces_five_statements()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_null_config_throws()
|
||||
{
|
||||
var act = () => _service.GenerateTemporalTableSql(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetSummary
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_returns_complete_summary()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.Assignments.Should().HaveCount(5);
|
||||
summary.RlsPolicies.Should().NotBeEmpty();
|
||||
summary.TemporalTables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_ProvisionedCount_reflects_isProvisioned_flags()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// Default IsProvisioned is false for all assignments
|
||||
summary.ProvisionedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_RlsEnabledCount_counts_non_disabled_policies()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// All RLS policies are Permissive (not Disabled)
|
||||
summary.RlsEnabledCount.Should().Be(summary.RlsPolicies.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_records_timestamp()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.ComputedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Null-time-provider fallback
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system_default()
|
||||
{
|
||||
using var mf = new TestSchemaIsolationMeterFactory();
|
||||
var svc = new SchemaIsolationService(null, mf);
|
||||
|
||||
var result = svc.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new SchemaIsolationService(_timeProvider, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Cross-schema consistency checks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicies_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
foreach (var p in policies)
|
||||
{
|
||||
assignedSchemas.Should().Contain(p.Schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalTables_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
foreach (var t in tables)
|
||||
{
|
||||
assignedSchemas.Should().Contain(t.Schema);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_provisioning_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
var result2 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_rls_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
var result2 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_temporal_sql_for_same_config()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
var result1 = _service.GenerateTemporalTableSql(config);
|
||||
var result2 = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user