UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14
@@ -15,6 +15,7 @@
|
|||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||||
|
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Test dependencies -->
|
<!-- Test dependencies -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using SIGCM2.Api.HealthChecks;
|
|||||||
using SIGCM2.Api.Middleware;
|
using SIGCM2.Api.Middleware;
|
||||||
using SIGCM2.Application;
|
using SIGCM2.Application;
|
||||||
using SIGCM2.Infrastructure;
|
using SIGCM2.Infrastructure;
|
||||||
|
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
using SIGCM2.Api.Filters;
|
using SIGCM2.Api.Filters;
|
||||||
|
|
||||||
// Bootstrap logger — before DI is built
|
// Bootstrap logger — before DI is built
|
||||||
@@ -25,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
|||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
|
// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
|
||||||
|
// Disabled in Testing environment to keep integration tests deterministic.
|
||||||
|
if (!builder.Environment.IsEnvironment("Testing"))
|
||||||
|
builder.Services.AddAuditMaintenance(builder.Configuration);
|
||||||
|
|
||||||
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
|
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-010 (#REQ-AUD-6) — weekly integrity verification:
|
||||||
|
/// - Validates SYSTEM_VERSIONING is ON in all cataloged tables.
|
||||||
|
/// - Validates partitions exist for the next 3 months on both event tables.
|
||||||
|
/// - Emits a SecurityEvent 'system.integrity_alert' with Result=failure when any
|
||||||
|
/// check fails; logs success otherwise.
|
||||||
|
/// - Intended schedule: cron '0 0 1 ? * SUN' (every Sunday at 01:00 UTC).
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class AuditIntegrityCheckJob : IJob
|
||||||
|
{
|
||||||
|
public const string CronSchedule = "0 0 1 ? * SUN";
|
||||||
|
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
private readonly ISecurityEventLogger _security;
|
||||||
|
private readonly ILogger<AuditIntegrityCheckJob> _logger;
|
||||||
|
|
||||||
|
public AuditIntegrityCheckJob(
|
||||||
|
SqlConnectionFactory factory,
|
||||||
|
ISecurityEventLogger security,
|
||||||
|
ILogger<AuditIntegrityCheckJob> logger)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_security = security;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
await using var conn = _factory.CreateConnection();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
// 1. SYSTEM_VERSIONING still ON
|
||||||
|
var missing = (await conn.QueryAsync<string>("""
|
||||||
|
SELECT name FROM sys.tables
|
||||||
|
WHERE name IN ('Usuario','Rol','Permiso','RolPermiso') AND temporal_type <> 2;
|
||||||
|
""")).ToList();
|
||||||
|
if (missing.Any())
|
||||||
|
failures.Add($"system_versioning_missing:{string.Join(',', missing)}");
|
||||||
|
|
||||||
|
// 2. Next 3 months have partitions in both event tables
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var required = new[]
|
||||||
|
{
|
||||||
|
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1),
|
||||||
|
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2),
|
||||||
|
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(3),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
|
||||||
|
{
|
||||||
|
var existingBoundaries = (await conn.QueryAsync<DateTime>("""
|
||||||
|
SELECT CAST(prv.value AS DATETIME2(3))
|
||||||
|
FROM sys.partition_functions pf
|
||||||
|
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
|
||||||
|
WHERE pf.name = @Name;
|
||||||
|
""", new { Name = pf })).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var req in required)
|
||||||
|
{
|
||||||
|
if (!existingBoundaries.Contains(req))
|
||||||
|
failures.Add($"partition_missing:{pf}:{req:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.Any())
|
||||||
|
{
|
||||||
|
_logger.LogError("AuditIntegrityCheckJob detected {Count} integrity failures: {Failures}",
|
||||||
|
failures.Count, string.Join(" | ", failures));
|
||||||
|
|
||||||
|
await _security.LogAsync(
|
||||||
|
action: "system.integrity_alert",
|
||||||
|
result: "failure",
|
||||||
|
actorUserId: null,
|
||||||
|
failureReason: "integrity_check_failed",
|
||||||
|
metadata: new { failures, checkedAt = now },
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AuditIntegrityCheckJob completed — all checks passed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
|
|
||||||
|
/// UDT-010 (#REQ-AUD-6) — DI extension to register Quartz + the 3 audit maintenance jobs.
|
||||||
|
/// Call from Program.cs: builder.Services.AddAuditMaintenance(builder.Configuration).
|
||||||
|
public static class AuditMaintenanceRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAuditMaintenance(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
var partitionKey = new JobKey(nameof(AuditPartitionManagerJob));
|
||||||
|
q.AddJob<AuditPartitionManagerJob>(j => j.WithIdentity(partitionKey));
|
||||||
|
q.AddTrigger(t => t
|
||||||
|
.ForJob(partitionKey)
|
||||||
|
.WithIdentity($"{nameof(AuditPartitionManagerJob)}-trigger")
|
||||||
|
.WithCronSchedule(AuditPartitionManagerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
|
||||||
|
|
||||||
|
var retentionKey = new JobKey(nameof(AuditRetentionEnforcerJob));
|
||||||
|
q.AddJob<AuditRetentionEnforcerJob>(j => j.WithIdentity(retentionKey));
|
||||||
|
q.AddTrigger(t => t
|
||||||
|
.ForJob(retentionKey)
|
||||||
|
.WithIdentity($"{nameof(AuditRetentionEnforcerJob)}-trigger")
|
||||||
|
.WithCronSchedule(AuditRetentionEnforcerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
|
||||||
|
|
||||||
|
var integrityKey = new JobKey(nameof(AuditIntegrityCheckJob));
|
||||||
|
q.AddJob<AuditIntegrityCheckJob>(j => j.WithIdentity(integrityKey));
|
||||||
|
q.AddTrigger(t => t
|
||||||
|
.ForJob(integrityKey)
|
||||||
|
.WithIdentity($"{nameof(AuditIntegrityCheckJob)}-trigger")
|
||||||
|
.WithCronSchedule(AuditIntegrityCheckJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddQuartzHostedService(opts => opts.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-010 (#REQ-AUD-6) — monthly maintenance:
|
||||||
|
/// - Extends the forward boundary of pf_AuditEvent_Monthly and pf_SecurityEvent_Monthly
|
||||||
|
/// so the next month always has a partition ready (SPLIT RANGE).
|
||||||
|
/// - Intended schedule: cron '0 0 2 1 * ?' (day 1 each month at 02:00 UTC).
|
||||||
|
/// - Idempotent: only splits if the target boundary does not yet exist.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class AuditPartitionManagerJob : IJob
|
||||||
|
{
|
||||||
|
public const string CronSchedule = "0 0 2 1 * ?";
|
||||||
|
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
private readonly ILogger<AuditPartitionManagerJob> _logger;
|
||||||
|
|
||||||
|
public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger<AuditPartitionManagerJob> logger)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
await using var conn = _factory.CreateConnection();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
// 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 target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
|
||||||
|
|
||||||
|
var affected = 0;
|
||||||
|
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
|
||||||
|
{
|
||||||
|
var exists = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sys.partition_functions pf
|
||||||
|
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
|
||||||
|
WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
|
||||||
|
""", new { Name = pf, Target = target });
|
||||||
|
|
||||||
|
if (exists == 0)
|
||||||
|
{
|
||||||
|
// Parameterized partition function name would require dynamic SQL; whitelisted above.
|
||||||
|
var sql = $"ALTER PARTITION FUNCTION {pf}() SPLIT RANGE (@Target);";
|
||||||
|
await conn.ExecuteAsync(sql, new { Target = target });
|
||||||
|
affected++;
|
||||||
|
_logger.LogInformation("Partition boundary {Boundary:yyyy-MM-dd} added to {Function}", target, pf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AuditPartitionManagerJob completed — {Affected} partition function(s) extended (target: {Target:yyyy-MM-dd})",
|
||||||
|
affected, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-010 (#REQ-AUD-6, #REQ-SEC-5) — annual retention enforcement:
|
||||||
|
/// - Purges rows from dbo.AuditEvent older than 10 years.
|
||||||
|
/// - Purges rows from dbo.SecurityEvent older than 5 years.
|
||||||
|
/// - Temporal history tables are purged automatically by the engine via
|
||||||
|
/// HISTORY_RETENTION_PERIOD = 10 YEARS configured in V010.
|
||||||
|
/// - Intended schedule: cron '0 0 3 1 1 ?' (Jan 1 at 03:00 UTC).
|
||||||
|
///
|
||||||
|
/// Row-based DELETE is the conservative choice for the first generation of this
|
||||||
|
/// job — avoids requiring filegroup switching logic. When volumes warrant, the
|
||||||
|
/// job can be upgraded to SWITCH OUT + DROP for partition-level drop.
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public sealed class AuditRetentionEnforcerJob : IJob
|
||||||
|
{
|
||||||
|
public const string CronSchedule = "0 0 3 1 1 ?";
|
||||||
|
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
private readonly ILogger<AuditRetentionEnforcerJob> _logger;
|
||||||
|
|
||||||
|
public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger<AuditRetentionEnforcerJob> logger)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
await using var conn = _factory.CreateConnection();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var auditCutoff = now.AddYears(-10);
|
||||||
|
var securityCutoff = now.AddYears(-5);
|
||||||
|
|
||||||
|
var auditDeleted = await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.AuditEvent WHERE OccurredAt < @Cutoff;",
|
||||||
|
new { Cutoff = auditCutoff });
|
||||||
|
|
||||||
|
var securityDeleted = await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.SecurityEvent WHERE OccurredAt < @Cutoff;",
|
||||||
|
new { Cutoff = securityCutoff });
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AuditRetentionEnforcerJob completed — AuditEvent purged {AuditDeleted} rows (< {AuditCutoff:yyyy-MM-dd}), " +
|
||||||
|
"SecurityEvent purged {SecurityDeleted} rows (< {SecurityCutoff:yyyy-MM-dd})",
|
||||||
|
auditDeleted, auditCutoff, securityDeleted, securityCutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using Quartz;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
|
||||||
|
|
||||||
|
/// UDT-010 Batch 11 — audit maintenance jobs integration tests.
|
||||||
|
/// Executes each IJob directly (no Quartz scheduler needed) against SIGCM2_Test.
|
||||||
|
[Collection("Database")]
|
||||||
|
public sealed class AuditJobsTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string ConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private SqlConnection _connection = null!;
|
||||||
|
private SqlConnectionFactory _factory = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_connection = new SqlConnection(ConnectionString);
|
||||||
|
await _connection.OpenAsync();
|
||||||
|
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
|
||||||
|
_factory = new SqlConnectionFactory(ConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
|
||||||
|
await _connection.CloseAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IJobExecutionContext MockContext()
|
||||||
|
{
|
||||||
|
var ctx = Substitute.For<IJobExecutionContext>();
|
||||||
|
ctx.CancellationToken.Returns(CancellationToken.None);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
|
||||||
|
{
|
||||||
|
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance);
|
||||||
|
|
||||||
|
// First run: ensure the target boundary exists
|
||||||
|
await job.Execute(MockContext());
|
||||||
|
|
||||||
|
// Compute target as the job does
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
|
||||||
|
|
||||||
|
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
|
||||||
|
{
|
||||||
|
var count = await _connection.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sys.partition_functions pf
|
||||||
|
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
|
||||||
|
WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
|
||||||
|
""", new { Name = pf, Target = target });
|
||||||
|
count.Should().Be(1, $"{pf} should have boundary {target:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second run must not throw (idempotent)
|
||||||
|
await job.Execute(MockContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetentionEnforcer_PurgesAuditEventOlderThan10Years_AndSecurityOlderThan5Years()
|
||||||
|
{
|
||||||
|
await _connection.ExecuteAsync("""
|
||||||
|
INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
|
||||||
|
VALUES
|
||||||
|
(@Ancient, 1, 'x.y', 'T', '1'), -- should be purged
|
||||||
|
(@Recent, 1, 'x.y', 'T', '2'); -- should stay
|
||||||
|
INSERT INTO dbo.SecurityEvent (OccurredAt, ActorUserId, Action, Result)
|
||||||
|
VALUES
|
||||||
|
(@Ancient5, 1, 'login', 'success'), -- should be purged
|
||||||
|
(@Recent, 1, 'login', 'success'); -- should stay
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
Ancient = DateTime.UtcNow.AddYears(-11),
|
||||||
|
Recent = DateTime.UtcNow.AddDays(-1),
|
||||||
|
Ancient5 = DateTime.UtcNow.AddYears(-6),
|
||||||
|
});
|
||||||
|
|
||||||
|
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance);
|
||||||
|
await job.Execute(MockContext());
|
||||||
|
|
||||||
|
var auditCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.AuditEvent;");
|
||||||
|
var securityCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.SecurityEvent;");
|
||||||
|
auditCount.Should().Be(1);
|
||||||
|
securityCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
|
||||||
|
{
|
||||||
|
var security = Substitute.For<ISecurityEventLogger>();
|
||||||
|
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance);
|
||||||
|
|
||||||
|
// Ensure partition manager has run first so next-3-months exist
|
||||||
|
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance).Execute(MockContext());
|
||||||
|
|
||||||
|
await job.Execute(MockContext());
|
||||||
|
|
||||||
|
await security.DidNotReceive().LogAsync(
|
||||||
|
action: "system.integrity_alert",
|
||||||
|
result: Arg.Any<string>(),
|
||||||
|
actorUserId: Arg.Any<int?>(),
|
||||||
|
attemptedUsername: Arg.Any<string?>(),
|
||||||
|
sessionId: Arg.Any<Guid?>(),
|
||||||
|
failureReason: Arg.Any<string?>(),
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user