297 lines
11 KiB
C#
297 lines
11 KiB
C#
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 StellaOps.TestKit;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Policy.Persistence.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies that repositories correctly use the injected <see cref="TimeProvider"/>
|
|
/// instead of SQL NOW() for timestamp columns. Each test injects a
|
|
/// <see cref="FixedTimeProvider"/> set to a distinctive past date and asserts
|
|
/// that persisted timestamps match the fixed time, not wall-clock time.
|
|
/// </summary>
|
|
[Collection(PolicyPostgresCollection.Name)]
|
|
public sealed class TimeProviderIntegrationTests : IAsyncLifetime
|
|
{
|
|
/// <summary>
|
|
/// A distinctive past date that could never be confused with wall-clock time.
|
|
/// </summary>
|
|
private static readonly DateTimeOffset FixedTime =
|
|
new(2020, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private readonly PolicyPostgresFixture _fixture;
|
|
private readonly PolicyDataSource _dataSource;
|
|
private readonly FixedTimeProvider _fixedTimeProvider;
|
|
|
|
// Repositories under test (using fixed time)
|
|
private readonly EvaluationRunRepository _evalRunRepo;
|
|
private readonly ConflictRepository _conflictRepo;
|
|
|
|
// Seed repositories (using system time -- only needed for FK seeding)
|
|
private readonly PackRepository _packRepo;
|
|
private readonly PackVersionRepository _packVersionRepo;
|
|
|
|
private readonly string _tenantId = Guid.NewGuid().ToString();
|
|
private readonly Guid _packId = Guid.NewGuid();
|
|
private const int SeedPackVersion = 1;
|
|
|
|
public TimeProviderIntegrationTests(PolicyPostgresFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_fixedTimeProvider = new FixedTimeProvider(FixedTime);
|
|
|
|
var options = fixture.Fixture.CreateOptions();
|
|
options.SchemaName = fixture.SchemaName;
|
|
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
|
|
|
// Repositories that use the fixed time provider
|
|
_evalRunRepo = new EvaluationRunRepository(_dataSource, NullLogger<EvaluationRunRepository>.Instance, _fixedTimeProvider);
|
|
_conflictRepo = new ConflictRepository(_dataSource, NullLogger<ConflictRepository>.Instance, _fixedTimeProvider);
|
|
|
|
// Seed repositories -- use default (system) time; their timestamps are not under test
|
|
_packRepo = new PackRepository(_dataSource, NullLogger<PackRepository>.Instance);
|
|
_packVersionRepo = new PackVersionRepository(_dataSource, NullLogger<PackVersionRepository>.Instance);
|
|
}
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
await _fixture.TruncateAllTablesAsync();
|
|
|
|
// Seed a pack and pack version (required FK for evaluation runs)
|
|
var pack = new PackEntity
|
|
{
|
|
Id = _packId,
|
|
TenantId = _tenantId,
|
|
Name = "tp-pack",
|
|
DisplayName = "TimeProvider Test Pack",
|
|
ActiveVersion = SeedPackVersion,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
CreatedBy = "tests"
|
|
};
|
|
await _packRepo.CreateAsync(pack);
|
|
|
|
var packVersion = new PackVersionEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PackId = _packId,
|
|
Version = SeedPackVersion,
|
|
RulesHash = "seed-hash",
|
|
IsPublished = true,
|
|
PublishedAt = DateTimeOffset.UtcNow,
|
|
PublishedBy = "tests",
|
|
CreatedBy = "tests"
|
|
};
|
|
await _packVersionRepo.CreateAsync(packVersion);
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// EvaluationRunRepository -- MarkStartedAsync sets started_at via TimeProvider
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task MarkStarted_UsesTimeProvider_ForStartedAt()
|
|
{
|
|
// Arrange
|
|
var run = CreateEvalRun();
|
|
await _evalRunRepo.CreateAsync(run);
|
|
|
|
// Act
|
|
var result = await _evalRunRepo.MarkStartedAsync(_tenantId, run.Id);
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
|
|
var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id);
|
|
fetched.Should().NotBeNull();
|
|
fetched!.Status.Should().Be(EvaluationStatus.Running);
|
|
fetched.StartedAt.Should().NotBeNull();
|
|
|
|
// The started_at timestamp must match the fixed time, not the current wall-clock time.
|
|
fetched.StartedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1));
|
|
|
|
// Guard: the fixed time is far enough in the past that it cannot be confused with "now".
|
|
fetched.StartedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// EvaluationRunRepository -- MarkCompletedAsync sets completed_at via TimeProvider
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task MarkCompleted_UsesTimeProvider_ForCompletedAt()
|
|
{
|
|
// Arrange
|
|
var run = CreateEvalRun();
|
|
await _evalRunRepo.CreateAsync(run);
|
|
await _evalRunRepo.MarkStartedAsync(_tenantId, run.Id);
|
|
|
|
// Act
|
|
var result = await _evalRunRepo.MarkCompletedAsync(
|
|
_tenantId,
|
|
run.Id,
|
|
EvaluationResult.Pass,
|
|
score: 95.0m,
|
|
findingsCount: 3,
|
|
criticalCount: 0,
|
|
highCount: 1,
|
|
mediumCount: 1,
|
|
lowCount: 1,
|
|
durationMs: 250);
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
|
|
var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id);
|
|
fetched.Should().NotBeNull();
|
|
fetched!.Status.Should().Be(EvaluationStatus.Completed);
|
|
fetched.CompletedAt.Should().NotBeNull();
|
|
|
|
// The completed_at timestamp must match the fixed time.
|
|
fetched.CompletedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1));
|
|
fetched.CompletedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// EvaluationRunRepository -- MarkFailedAsync sets completed_at via TimeProvider
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task MarkFailed_UsesTimeProvider_ForCompletedAt()
|
|
{
|
|
// Arrange
|
|
var run = CreateEvalRun();
|
|
await _evalRunRepo.CreateAsync(run);
|
|
|
|
// Act
|
|
var result = await _evalRunRepo.MarkFailedAsync(_tenantId, run.Id, "Timeout during evaluation");
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
|
|
var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id);
|
|
fetched.Should().NotBeNull();
|
|
fetched!.Status.Should().Be(EvaluationStatus.Failed);
|
|
fetched.CompletedAt.Should().NotBeNull();
|
|
|
|
// The completed_at timestamp must match the fixed time.
|
|
fetched.CompletedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1));
|
|
fetched.CompletedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// ConflictRepository -- ResolveAsync sets resolved_at via TimeProvider
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task Resolve_UsesTimeProvider_ForResolvedAt()
|
|
{
|
|
// Arrange
|
|
var conflict = new ConflictEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = _tenantId,
|
|
ConflictType = "rule_overlap",
|
|
Severity = "high",
|
|
Status = "open",
|
|
Description = "Rules A and B overlap on scope X",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
CreatedBy = "tests"
|
|
};
|
|
await _conflictRepo.CreateAsync(conflict);
|
|
|
|
// Act
|
|
var result = await _conflictRepo.ResolveAsync(
|
|
_tenantId, conflict.Id, "Merged rules", "admin");
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
|
|
var fetched = await _conflictRepo.GetByIdAsync(_tenantId, conflict.Id);
|
|
fetched.Should().NotBeNull();
|
|
fetched!.Status.Should().Be("resolved");
|
|
fetched.ResolvedAt.Should().NotBeNull();
|
|
|
|
// The resolved_at timestamp must match the fixed time.
|
|
fetched.ResolvedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1));
|
|
fetched.ResolvedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// ConflictRepository -- DismissAsync sets resolved_at via TimeProvider
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task Dismiss_UsesTimeProvider_ForResolvedAt()
|
|
{
|
|
// Arrange
|
|
var conflict = new ConflictEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = _tenantId,
|
|
ConflictType = "precedence",
|
|
Severity = "low",
|
|
Status = "open",
|
|
Description = "Rule precedence ambiguity",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
CreatedBy = "tests"
|
|
};
|
|
await _conflictRepo.CreateAsync(conflict);
|
|
|
|
// Act
|
|
var result = await _conflictRepo.DismissAsync(_tenantId, conflict.Id, "operator");
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
|
|
var fetched = await _conflictRepo.GetByIdAsync(_tenantId, conflict.Id);
|
|
fetched.Should().NotBeNull();
|
|
fetched!.Status.Should().Be("dismissed");
|
|
fetched.ResolvedAt.Should().NotBeNull();
|
|
|
|
// The resolved_at timestamp must match the fixed time.
|
|
fetched.ResolvedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1));
|
|
fetched.ResolvedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
private EvaluationRunEntity CreateEvalRun() => new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = _tenantId,
|
|
ProjectId = "tp-project",
|
|
PackId = _packId,
|
|
PackVersion = SeedPackVersion,
|
|
Status = EvaluationStatus.Pending
|
|
};
|
|
|
|
/// <summary>
|
|
/// A <see cref="TimeProvider"/> that always returns a fixed UTC time.
|
|
/// Used to prove that repository methods obtain their timestamps from the
|
|
/// injected provider rather than from SQL <c>NOW()</c>.
|
|
/// </summary>
|
|
private sealed class FixedTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _fixedTime;
|
|
|
|
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
|
}
|
|
}
|