diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs new file mode 100644 index 0000000..cf5682f --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -0,0 +1,163 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +/// +/// EF Core implementation of . See the interface +/// for the append-only contract; this class only adds notes on the data-access +/// strategy used by each method. +/// +public class AuditLogRepository : IAuditLogRepository +{ + private readonly ScadaLinkDbContext _context; + + public AuditLogRepository(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Issues a single IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…) + /// via . + /// Bypasses the EF change tracker so the row never enters a tracked state and + /// the enum-as-string conversion is done explicitly in C# (the columns are + /// declared varchar(32) via HasConversion<string>() in + /// ). + /// + public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) + { + if (evt is null) + { + throw new ArgumentNullException(nameof(evt)); + } + + // Enum columns are stored as varchar(32) (HasConversion()), so do + // the conversion in C# rather than relying on parameter type inference — + // SqlClient would otherwise bind enums as int by default. + var channel = evt.Channel.ToString(); + var kind = evt.Kind.ToString(); + var status = evt.Status.ToString(); + var forwardState = evt.ForwardState?.ToString(); + + // FormattableString interpolation parameterises every value (no concatenation), + // so this is safe against injection even for the string columns. + await _context.Database.ExecuteSqlInterpolatedAsync( + $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) +INSERT INTO dbo.AuditLog + (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, + SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, + HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, + ResponseSummary, PayloadTruncated, Extra, ForwardState) +VALUES + ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, + {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, + {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, + {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", + ct); + } + + /// + /// Builds an AsNoTracking queryable over , applies + /// every non-null filter predicate, and pages by keyset on + /// (OccurredAtUtc DESC, EventId DESC). The keyset clause is expressed + /// directly (occurred < after || (occurred == after && eventId.CompareTo(afterId) < 0)) + /// — EF Core 10 translates against SQL Server's + /// uniqueidentifier sort order. + /// + public async Task> QueryAsync( + AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) + { + if (filter is null) + { + throw new ArgumentNullException(nameof(filter)); + } + + if (paging is null) + { + throw new ArgumentNullException(nameof(paging)); + } + + var query = _context.Set().AsNoTracking(); + + if (filter.Channel is { } channel) + { + query = query.Where(e => e.Channel == channel); + } + + if (filter.Kind is { } kind) + { + query = query.Where(e => e.Kind == kind); + } + + if (filter.Status is { } status) + { + query = query.Where(e => e.Status == status); + } + + if (!string.IsNullOrEmpty(filter.SourceSiteId)) + { + var siteId = filter.SourceSiteId; + query = query.Where(e => e.SourceSiteId == siteId); + } + + if (!string.IsNullOrEmpty(filter.Target)) + { + var target = filter.Target; + query = query.Where(e => e.Target == target); + } + + if (!string.IsNullOrEmpty(filter.Actor)) + { + var actor = filter.Actor; + query = query.Where(e => e.Actor == actor); + } + + if (filter.CorrelationId is { } correlationId) + { + query = query.Where(e => e.CorrelationId == correlationId); + } + + if (filter.FromUtc is { } fromUtc) + { + query = query.Where(e => e.OccurredAtUtc >= fromUtc); + } + + if (filter.ToUtc is { } toUtc) + { + query = query.Where(e => e.OccurredAtUtc <= toUtc); + } + + // Keyset cursor on (OccurredAtUtc desc, EventId desc). + if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId) + { + query = query.Where(e => + e.OccurredAtUtc < afterOccurred + || (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0)); + } + + return await query + .OrderByDescending(e => e.OccurredAtUtc) + .ThenByDescending(e => e.EventId) + .Take(paging.PageSize) + .ToListAsync(ct); + } + + /// + /// M1 honest contract: throws . The + /// UX_AuditLog_EventId unique index is non-aligned with + /// ps_AuditLog_Month (it lives on [PRIMARY] to keep + /// cheap), and SQL Server rejects + /// ALTER TABLE … SWITCH PARTITION when a non-aligned index is present. + /// The drop-and-rebuild dance that makes the switch legal ships with the M6 + /// purge actor. + /// + public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) + { + throw new NotSupportedException( + "AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " + + "unique index; the drop-and-rebuild dance ships in M6 (purge actor)."); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs index 9eca319..b7ba6b4 100644 --- a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs new file mode 100644 index 0000000..6bf8db4 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -0,0 +1,267 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Repositories; + +/// +/// Bundle D (#23 M1) integration tests for . Uses +/// the same as the Bundle C migration tests so +/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a +/// real partitioned schema. Tests scope all queries by a per-test +/// SourceSiteId guid suffix so they neither collide with one another nor +/// require cleanup. +/// +public class AuditLogRepositoryTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AuditLogRepositoryTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)); + await repo.InsertIfNotExistsAsync(evt); + + // Re-read in a fresh context so we exercise the persisted row, not the + // (already-bypassed) change tracker. + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Equal(evt.EventId, loaded[0].EventId); + } + + [SkippableFact] + public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc); + var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first"); + await repo.InsertIfNotExistsAsync(first); + + // Same EventId, different payload — first-write-wins, the second call is silently a no-op. + var second = first with { ErrorMessage = "second-should-be-ignored" }; + await repo.InsertIfNotExistsAsync(second); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Equal("first", loaded[0].ErrorMessage); + } + + [SkippableFact] + public async Task QueryAsync_ReturnsRowsInOccurredDescOrder() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10))); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20))); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteId: siteId), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(3, rows.Count); + Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc); + Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc); + } + + [SkippableFact] + public async Task QueryAsync_FilterByChannel() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification)); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel)); + } + + [SkippableFact] + public async Task QueryAsync_FilterBySourceSiteId() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var otherSiteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1))); + await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2))); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteId: siteId), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId)); + } + + [SkippableFact] + public async Task QueryAsync_FilterByTimeRange() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30))); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2))); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter( + SourceSiteId: siteId, + FromUtc: t0.AddMinutes(10), + ToUtc: t0.AddHours(1)), + new AuditLogPaging(PageSize: 10)); + + Assert.Single(rows); + Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc); + } + + [SkippableFact] + public async Task QueryAsync_Keyset_NextPageStartsAfterCursor() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc); + // Five rows at one-minute intervals. Page-size 2 → page 1 returns minutes 4,3. + // Cursor (minutes 3) → page 2 returns minutes 2,1. Cursor (minutes 1) → page 3 returns minute 0. + for (var i = 0; i < 5; i++) + { + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i))); + } + + var page1 = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteId: siteId), + new AuditLogPaging(PageSize: 2)); + + Assert.Equal(2, page1.Count); + Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc); + Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc); + + var cursor = page1[^1]; + var page2 = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteId: siteId), + new AuditLogPaging( + PageSize: 2, + AfterOccurredAtUtc: cursor.OccurredAtUtc, + AfterEventId: cursor.EventId)); + + Assert.Equal(2, page2.Count); + Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc); + Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc); + + var cursor2 = page2[^1]; + var page3 = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteId: siteId), + new AuditLogPaging( + PageSize: 2, + AfterOccurredAtUtc: cursor2.OccurredAtUtc, + AfterEventId: cursor2.EventId)); + + Assert.Single(page3); + Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc); + } + + [SkippableFact] + public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // The partition-switch path is intentionally blocked in M1 because + // UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships + // with the M6 purge actor. + var ex = await Assert.ThrowsAsync( + () => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc))); + + Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // --- helpers ------------------------------------------------------------ + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + private static string NewSiteId() => + "test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private static AuditEvent NewEvent( + string siteId, + DateTime occurredAtUtc, + AuditChannel channel = AuditChannel.ApiOutbound, + AuditKind kind = AuditKind.ApiCall, + AuditStatus status = AuditStatus.Delivered, + string? errorMessage = null) => + new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = occurredAtUtc, + Channel = channel, + Kind = kind, + Status = status, + SourceSiteId = siteId, + ErrorMessage = errorMessage, + }; +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs index c745a86..11baf14 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs @@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository)); Assert.Contains(services, d => d.ServiceType == typeof(IAuditService)); Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator)); + Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository)); } // The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly