|
- 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<AppendOnlyFileAuditService>.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<ArgumentException>(() => _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<ArgumentOutOfRangeException>(() => _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<AuditServiceUnavailableException>(() =>
- _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<AuditServiceUnavailableException>(() =>
- _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at)));
-
- Assert.NotNull(ex.InnerException);
- Assert.IsAssignableFrom<IOException>(ex.InnerException);
- }
- }
|