using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Policy.Persistence.Postgres; using StellaOps.Policy.Persistence.Postgres.Models; using StellaOps.Policy.Persistence.Postgres.Repositories; using Xunit; using StellaOps.TestKit; namespace StellaOps.Policy.Persistence.Tests; [Collection(PolicyPostgresCollection.Name)] public sealed class ExceptionRepositoryTests : IAsyncLifetime { private readonly PolicyPostgresFixture _fixture; private readonly ExceptionRepository _repository; private readonly string _tenantId = Guid.NewGuid().ToString(); public ExceptionRepositoryTests(PolicyPostgresFixture fixture) { _fixture = fixture; var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; var dataSource = new PolicyDataSource(Options.Create(options), NullLogger.Instance); _repository = new ExceptionRepository(dataSource, NullLogger.Instance); } public Task InitializeAsync() => _fixture.TruncateAllTablesAsync(); public Task DisposeAsync() => Task.CompletedTask; [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAndGetById_RoundTripsException() { // Arrange var exception = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "legacy-root-container", Description = "Allow root containers for legacy app", RulePattern = "no-root-containers", ProjectId = "project-legacy", Reason = "Legacy application requires root access", Status = ExceptionStatus.Active, ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) }; // Act await _repository.CreateAsync(exception); var fetched = await _repository.GetByIdAsync(_tenantId, exception.Id); // Assert fetched.Should().NotBeNull(); fetched!.Id.Should().Be(exception.Id); fetched.Name.Should().Be("legacy-root-container"); fetched.Status.Should().Be(ExceptionStatus.Active); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByName_ReturnsCorrectException() { // Arrange var exception = CreateException("temp-waiver"); await _repository.CreateAsync(exception); // Act var fetched = await _repository.GetByNameAsync(_tenantId, "temp-waiver"); // Assert fetched.Should().NotBeNull(); fetched!.Id.Should().Be(exception.Id); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAll_ReturnsAllExceptionsForTenant() { // Arrange var exception1 = CreateException("exception1"); var exception2 = CreateException("exception2"); await _repository.CreateAsync(exception1); await _repository.CreateAsync(exception2); // Act var exceptions = await _repository.GetAllAsync(_tenantId); // Assert exceptions.Should().HaveCount(2); exceptions.Select(e => e.Name).Should().Contain(["exception1", "exception2"]); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetAll_FiltersByStatus() { // Arrange var activeException = CreateException("active"); var revokedException = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "revoked", Reason = "Test", Status = ExceptionStatus.Revoked }; await _repository.CreateAsync(activeException); await _repository.CreateAsync(revokedException); // Act var activeExceptions = await _repository.GetAllAsync(_tenantId, status: ExceptionStatus.Active); // Assert activeExceptions.Should().HaveCount(1); activeExceptions[0].Name.Should().Be("active"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetActiveForProject_ReturnsProjectExceptions() { // Arrange var projectException = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "project-exception", ProjectId = "project-123", Reason = "Project-specific waiver", Status = ExceptionStatus.Active }; var otherProjectException = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "other-exception", ProjectId = "project-456", Reason = "Other project waiver", Status = ExceptionStatus.Active }; await _repository.CreateAsync(projectException); await _repository.CreateAsync(otherProjectException); // Act var exceptions = await _repository.GetActiveForProjectAsync(_tenantId, "project-123"); // Assert exceptions.Should().HaveCount(1); exceptions[0].Name.Should().Be("project-exception"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetActiveForRule_ReturnsRuleExceptions() { // Arrange var ruleException = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "rule-exception", RulePattern = "no-root-containers", Reason = "Rule-specific waiver", Status = ExceptionStatus.Active }; await _repository.CreateAsync(ruleException); // Act var exceptions = await _repository.GetActiveForRuleAsync(_tenantId, "no-root-containers"); // Assert exceptions.Should().HaveCount(1); exceptions[0].Name.Should().Be("rule-exception"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Update_ModifiesException() { // Arrange var exception = CreateException("update-test"); await _repository.CreateAsync(exception); // Act var updated = new ExceptionEntity { Id = exception.Id, TenantId = _tenantId, Name = "update-test", Reason = "Updated reason", Description = "Updated description" }; var result = await _repository.UpdateAsync(updated); var fetched = await _repository.GetByIdAsync(_tenantId, exception.Id); // Assert result.Should().BeTrue(); fetched!.Reason.Should().Be("Updated reason"); fetched.Description.Should().Be("Updated description"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Approve_SetsApprovalDetails() { // Arrange var exception = CreateException("approve-test"); await _repository.CreateAsync(exception); // Act var result = await _repository.ApproveAsync(_tenantId, exception.Id, "admin@example.com"); var fetched = await _repository.GetByIdAsync(_tenantId, exception.Id); // Assert result.Should().BeTrue(); fetched!.ApprovedBy.Should().Be("admin@example.com"); fetched.ApprovedAt.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Revoke_SetsRevokedStatusAndDetails() { // Arrange var exception = CreateException("revoke-test"); await _repository.CreateAsync(exception); // Act var result = await _repository.RevokeAsync(_tenantId, exception.Id, "admin@example.com"); var fetched = await _repository.GetByIdAsync(_tenantId, exception.Id); // Assert result.Should().BeTrue(); fetched!.Status.Should().Be(ExceptionStatus.Revoked); fetched.RevokedBy.Should().Be("admin@example.com"); fetched.RevokedAt.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Expire_ExpiresOldExceptions() { // Arrange - Create an exception that expires in the past var expiredException = new ExceptionEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "expired", Reason = "Test", Status = ExceptionStatus.Active, ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) }; await _repository.CreateAsync(expiredException); // Act var count = await _repository.ExpireAsync(_tenantId); var fetched = await _repository.GetByIdAsync(_tenantId, expiredException.Id); // Assert count.Should().Be(1); fetched!.Status.Should().Be(ExceptionStatus.Expired); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Delete_RemovesException() { // Arrange var exception = CreateException("delete-test"); await _repository.CreateAsync(exception); // Act var result = await _repository.DeleteAsync(_tenantId, exception.Id); var fetched = await _repository.GetByIdAsync(_tenantId, exception.Id); // Assert result.Should().BeTrue(); fetched.Should().BeNull(); } private ExceptionEntity CreateException(string name) => new() { Id = Guid.NewGuid(), TenantId = _tenantId, Name = name, Reason = "Test exception", Status = ExceptionStatus.Active }; }