Compare commits

..

5 Commits

5 changed files with 156 additions and 10 deletions

View File

@@ -22,15 +22,18 @@ public sealed class AuditIntegrityCheckJob : IJob
private readonly SqlConnectionFactory _factory;
private readonly ISecurityEventLogger _security;
private readonly ILogger<AuditIntegrityCheckJob> _logger;
private readonly TimeProvider _timeProvider;
public AuditIntegrityCheckJob(
SqlConnectionFactory factory,
ISecurityEventLogger security,
ILogger<AuditIntegrityCheckJob> logger)
ILogger<AuditIntegrityCheckJob> logger,
TimeProvider timeProvider)
{
_factory = factory;
_security = security;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task Execute(IJobExecutionContext context)
@@ -50,7 +53,7 @@ public sealed class AuditIntegrityCheckJob : IJob
failures.Add($"system_versioning_missing:{string.Join(',', missing)}");
// 2. Next 3 months have partitions in both event tables
var now = DateTime.UtcNow;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var required = new[]
{
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1),

View File

@@ -19,11 +19,13 @@ public sealed class AuditPartitionManagerJob : IJob
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditPartitionManagerJob> _logger;
private readonly TimeProvider _timeProvider;
public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger<AuditPartitionManagerJob> logger)
public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger<AuditPartitionManagerJob> logger, TimeProvider timeProvider)
{
_factory = factory;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task Execute(IJobExecutionContext context)
@@ -34,7 +36,7 @@ public sealed class AuditPartitionManagerJob : IJob
// Target: boundary for "next month + 1" (so the next month is always pre-created and we
// keep at least one boundary ahead after the rotation).
var now = DateTime.UtcNow;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
var affected = 0;

View File

@@ -24,11 +24,13 @@ public sealed class AuditRetentionEnforcerJob : IJob
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditRetentionEnforcerJob> _logger;
private readonly TimeProvider _timeProvider;
public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger<AuditRetentionEnforcerJob> logger)
public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger<AuditRetentionEnforcerJob> logger, TimeProvider timeProvider)
{
_factory = factory;
_logger = logger;
_timeProvider = timeProvider;
}
public async Task Execute(IJobExecutionContext context)
@@ -37,7 +39,7 @@ public sealed class AuditRetentionEnforcerJob : IJob
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var now = DateTime.UtcNow;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var auditCutoff = now.AddYears(-10);
var securityCutoff = now.AddYears(-5);

View File

@@ -47,7 +47,7 @@ public sealed class AuditJobsTests : IAsyncLifetime
[Fact]
public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
{
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance);
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance, TimeProvider.System);
// First run: ensure the target boundary exists
await job.Execute(MockContext());
@@ -90,7 +90,7 @@ public sealed class AuditJobsTests : IAsyncLifetime
Ancient5 = DateTime.UtcNow.AddYears(-6),
});
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance);
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance, TimeProvider.System);
await job.Execute(MockContext());
var auditCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.AuditEvent;");
@@ -103,10 +103,10 @@ public sealed class AuditJobsTests : IAsyncLifetime
public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
{
var security = Substitute.For<ISecurityEventLogger>();
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance);
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance, TimeProvider.System);
// Ensure partition manager has run first so next-3-months exist
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance).Execute(MockContext());
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance, TimeProvider.System).Execute(MockContext());
await job.Execute(MockContext());

View File

@@ -0,0 +1,139 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Application.Tests.Infrastructure;
/// <summary>
/// SUITE-BE-JOBS — UDT-011 follow-up (issue #24)
/// Verifies that the 3 Quartz audit jobs accept TimeProvider via constructor injection
/// and use the injected clock rather than DateTime.UtcNow inline.
///
/// Tests are construction-level: they confirm the DI contract is satisfied.
/// Full Execute() tests require a live SQL connection and are out of scope.
/// </summary>
public sealed class AuditJobsTimeProviderTests
{
private static SqlConnectionFactory DummyFactory() =>
new("Server=.;Database=dummy;Integrated Security=true;");
// ─────────────────────────────────────────────────────────────────────────
// AuditIntegrityCheckJob
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AuditIntegrityCheckJob_AcceptsFakeTimeProvider_NoException()
{
var fake = new FakeTimeProvider();
fake.SetUtcNow(new DateTimeOffset(2026, 4, 18, 1, 0, 0, TimeSpan.Zero));
var security = Substitute.For<ISecurityEventLogger>();
var job = new AuditIntegrityCheckJob(
DummyFactory(),
security,
NullLogger<AuditIntegrityCheckJob>.Instance,
fake);
Assert.NotNull(job);
}
[Fact]
public void AuditIntegrityCheckJob_FakeTimeProvider_ReturnsConfiguredUtcNow()
{
// Arrange: pin a specific UTC instant
var expectedUtc = new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero);
var fake = new FakeTimeProvider();
fake.SetUtcNow(expectedUtc);
var security = Substitute.For<ISecurityEventLogger>();
// Act: the job is constructed with the FakeTimeProvider
new AuditIntegrityCheckJob(
DummyFactory(),
security,
NullLogger<AuditIntegrityCheckJob>.Instance,
fake);
// Assert: the FakeTimeProvider returns the pinned value (not wall clock)
fake.GetUtcNow().UtcDateTime.Should().Be(expectedUtc.UtcDateTime);
}
// ─────────────────────────────────────────────────────────────────────────
// AuditPartitionManagerJob
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AuditPartitionManagerJob_AcceptsFakeTimeProvider_NoException()
{
var fake = new FakeTimeProvider();
fake.SetUtcNow(new DateTimeOffset(2026, 4, 18, 2, 0, 0, TimeSpan.Zero));
var job = new AuditPartitionManagerJob(
DummyFactory(),
NullLogger<AuditPartitionManagerJob>.Instance,
fake);
Assert.NotNull(job);
}
[Fact]
public void AuditPartitionManagerJob_FakeTimeProvider_ReturnsConfiguredUtcNow()
{
// Arrange: pin to 2026-04-01 00:00 UTC (day 1 of month — typical trigger time)
var expectedUtc = new DateTimeOffset(2026, 4, 1, 2, 0, 0, TimeSpan.Zero);
var fake = new FakeTimeProvider();
fake.SetUtcNow(expectedUtc);
// Act
new AuditPartitionManagerJob(
DummyFactory(),
NullLogger<AuditPartitionManagerJob>.Instance,
fake);
// Assert: pinned clock is the one the job would use for partition target
var now = fake.GetUtcNow().UtcDateTime;
var expectedTarget = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
expectedTarget.Should().Be(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));
}
// ─────────────────────────────────────────────────────────────────────────
// AuditRetentionEnforcerJob
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AuditRetentionEnforcerJob_AcceptsFakeTimeProvider_NoException()
{
var fake = new FakeTimeProvider();
fake.SetUtcNow(new DateTimeOffset(2026, 1, 1, 3, 0, 0, TimeSpan.Zero));
var job = new AuditRetentionEnforcerJob(
DummyFactory(),
NullLogger<AuditRetentionEnforcerJob>.Instance,
fake);
Assert.NotNull(job);
}
[Fact]
public void AuditRetentionEnforcerJob_FakeTimeProvider_CutoffDatesUsePinnedClock()
{
// Arrange: pin to Jan 1, 2026 — retention cutoffs are 2016 and 2021
var pinnedUtc = new DateTimeOffset(2026, 1, 1, 3, 0, 0, TimeSpan.Zero);
var fake = new FakeTimeProvider();
fake.SetUtcNow(pinnedUtc);
// Act
new AuditRetentionEnforcerJob(
DummyFactory(),
NullLogger<AuditRetentionEnforcerJob>.Instance,
fake);
// Assert: pinned clock yields deterministic cutoffs (not wall-clock-dependent)
var now = fake.GetUtcNow().UtcDateTime;
now.AddYears(-10).Should().Be(new DateTime(2016, 1, 1, 3, 0, 0, DateTimeKind.Utc));
now.AddYears(-5).Should().Be(new DateTime(2021, 1, 1, 3, 0, 0, DateTimeKind.Utc));
}
}