using System.Text.Json; using Campaign_Tracker.Server.Audit; using Microsoft.Extensions.Logging.Abstractions; namespace Campaign_Tracker.Server.Tests; public sealed class AuditServiceTests : IDisposable { private readonly string _tmpDir; private readonly AppendOnlyFileAuditService _service; public AuditServiceTests() { _tmpDir = Path.Combine(Path.GetTempPath(), $"ct-audit-test-{Guid.NewGuid()}"); _service = new AppendOnlyFileAuditService(_tmpDir, NullLogger.Instance); } public void Dispose() { if (Directory.Exists(_tmpDir)) { Directory.Delete(_tmpDir, recursive: true); } } // AC #1 — required fields are written to the file [Fact] public void Record_WritesAllRequiredFieldsToFile_AC1() { var at = new DateTimeOffset(2026, 5, 5, 10, 0, 0, TimeSpan.Zero); var auditEvent = new AuditEvent( AuditEventType.SessionLogin, "alice@example.test", "authentication", "success", "trace-abc", at); _service.Record(auditEvent); var filePath = _service.GetDailyFilePath(at); Assert.True(File.Exists(filePath)); var line = File.ReadAllLines(filePath).Single(); Assert.Contains("SESSION_LOGIN", line); Assert.Contains("alice@example.test", line); Assert.Contains("authentication", line); Assert.Contains("success", line); Assert.Contains("trace-abc", line); Assert.Contains("2026-05-05", line); } [Fact] public void Record_NormalizesTimestampToUtc_AC1() { var at = new DateTimeOffset(2026, 5, 5, 23, 59, 0, TimeSpan.FromHours(5)); // UTC+5 var auditEvent = new AuditEvent( AuditEventType.SessionLogout, "bob", "authentication/logout", "success", "t1", at); _service.Record(auditEvent); var filePath = _service.GetDailyFilePath(at); var line = File.ReadAllLines(filePath).Single(); using var doc = JsonDocument.Parse(line); var recorded = doc.RootElement.GetProperty("recordedAt").GetDateTimeOffset(); Assert.Equal(TimeSpan.Zero, recorded.Offset); Assert.Equal(at.ToUniversalTime(), recorded); } [Theory] [InlineData(null, "actor", "resource", "success", "trace")] [InlineData("", "actor", "resource", "success", "trace")] [InlineData("SESSION_LOGIN", "", "resource", "success", "trace")] [InlineData("SESSION_LOGIN", "actor", " ", "success", "trace")] [InlineData("SESSION_LOGIN", "actor", "resource", "", "trace")] [InlineData("SESSION_LOGIN", "actor", "resource", "success", "")] public void Record_RejectsIncompleteAuditEvents_AC1( string? eventType, string actor, string resource, string outcome, string traceIdentifier) { var auditEvent = new AuditEvent( eventType!, actor, resource, outcome, traceIdentifier, DateTimeOffset.UtcNow); Assert.ThrowsAny(() => _service.Record(auditEvent)); } // AC #2 — files are never deleted (365-day retention) [Fact] public void Record_NeverDeletesFiles_FilesAccumulateForRetention_AC2() { var day1 = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero); var day2 = new DateTimeOffset(2026, 5, 5, 0, 0, 0, TimeSpan.Zero); _service.Record(new AuditEvent("X", "u", "r", "ok", "t", day1)); _service.Record(new AuditEvent("X", "u", "r", "ok", "t", day2)); // Both daily files are present — none deleted Assert.True(File.Exists(_service.GetDailyFilePath(day1))); Assert.True(File.Exists(_service.GetDailyFilePath(day2))); Assert.Equal(2, Directory.GetFiles(_tmpDir, "*.jsonl").Length); } // AC #3 — shared service contract: GetRecent returns what was recorded [Fact] public void GetRecent_ReturnsRecordedEvents_SharedServiceSatisfied_AC3() { var at = DateTimeOffset.UtcNow; _service.Record(new AuditEvent(AuditEventType.SessionLogin, "alice", "auth", "success", "t1", at)); _service.Record(new AuditEvent(AuditEventType.SessionLogout, "bob", "auth/logout", "success", "t2", at)); var recent = _service.GetRecent(); Assert.Equal(2, recent.Count); Assert.Contains(recent, e => e.ActorIdentity == "alice" && e.EventType == AuditEventType.SessionLogin); Assert.Contains(recent, e => e.ActorIdentity == "bob" && e.EventType == AuditEventType.SessionLogout); } [Fact] public void GetRecent_WithNegativeCount_ThrowsArgumentOutOfRange() { Assert.Throws(() => _service.GetRecent(-1)); } // AC #4 — append-only: interface has no update or delete methods [Fact] public void IAuditService_HasNoUpdateOrDeleteMethods_AppendOnlyInterface_AC4() { var publicMethods = typeof(IAuditService).GetMethods() .Select(m => m.Name) .ToArray(); Assert.DoesNotContain(publicMethods, name => name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Update", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Modify", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Purge", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Record_MultipleEvents_AllAppendedToFile_NoPreviousLineOverwritten_AC4() { var at = new DateTimeOffset(2026, 5, 5, 12, 0, 0, TimeSpan.Zero); _service.Record(new AuditEvent("E1", "user1", "r", "ok", "t1", at)); _service.Record(new AuditEvent("E2", "user2", "r", "ok", "t2", at)); _service.Record(new AuditEvent("E3", "user3", "r", "ok", "t3", at)); var lines = File.ReadAllLines(_service.GetDailyFilePath(at)); Assert.Equal(3, lines.Length); Assert.Contains("E1", lines[0]); Assert.Contains("E2", lines[1]); Assert.Contains("E3", lines[2]); } // AC #5 — audit service unavailable: Record throws, caller is blocked [Fact] public void Record_ThrowsAuditServiceUnavailableException_WhenDirectoryIsDeleted_AC5() { // Record one event to ensure directory exists, then delete it to simulate unavailability. var at = DateTimeOffset.UtcNow; _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at)); Directory.Delete(_tmpDir, recursive: true); Assert.Throws(() => _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at))); } [Fact] public void Record_WhenThrown_ExceptionInnerCauseIsFileSystemException_AC5() { var at = DateTimeOffset.UtcNow; _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at)); Directory.Delete(_tmpDir, recursive: true); var ex = Assert.Throws(() => _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at))); Assert.NotNull(ex.InnerException); Assert.IsAssignableFrom(ex.InnerException); } }