You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

196 line
7.2KB

  1. using System.Text.Json;
  2. using Campaign_Tracker.Server.Audit;
  3. using Microsoft.Extensions.Logging.Abstractions;
  4. namespace Campaign_Tracker.Server.Tests;
  5. public sealed class AuditServiceTests : IDisposable
  6. {
  7. private readonly string _tmpDir;
  8. private readonly AppendOnlyFileAuditService _service;
  9. public AuditServiceTests()
  10. {
  11. _tmpDir = Path.Combine(Path.GetTempPath(), $"ct-audit-test-{Guid.NewGuid()}");
  12. _service = new AppendOnlyFileAuditService(_tmpDir, NullLogger<AppendOnlyFileAuditService>.Instance);
  13. }
  14. public void Dispose()
  15. {
  16. if (Directory.Exists(_tmpDir))
  17. {
  18. Directory.Delete(_tmpDir, recursive: true);
  19. }
  20. }
  21. // AC #1 — required fields are written to the file
  22. [Fact]
  23. public void Record_WritesAllRequiredFieldsToFile_AC1()
  24. {
  25. var at = new DateTimeOffset(2026, 5, 5, 10, 0, 0, TimeSpan.Zero);
  26. var auditEvent = new AuditEvent(
  27. AuditEventType.SessionLogin,
  28. "alice@example.test",
  29. "authentication",
  30. "success",
  31. "trace-abc",
  32. at);
  33. _service.Record(auditEvent);
  34. var filePath = _service.GetDailyFilePath(at);
  35. Assert.True(File.Exists(filePath));
  36. var line = File.ReadAllLines(filePath).Single();
  37. Assert.Contains("SESSION_LOGIN", line);
  38. Assert.Contains("alice@example.test", line);
  39. Assert.Contains("authentication", line);
  40. Assert.Contains("success", line);
  41. Assert.Contains("trace-abc", line);
  42. Assert.Contains("2026-05-05", line);
  43. }
  44. [Fact]
  45. public void Record_NormalizesTimestampToUtc_AC1()
  46. {
  47. var at = new DateTimeOffset(2026, 5, 5, 23, 59, 0, TimeSpan.FromHours(5)); // UTC+5
  48. var auditEvent = new AuditEvent(
  49. AuditEventType.SessionLogout, "bob", "authentication/logout", "success", "t1", at);
  50. _service.Record(auditEvent);
  51. var filePath = _service.GetDailyFilePath(at);
  52. var line = File.ReadAllLines(filePath).Single();
  53. using var doc = JsonDocument.Parse(line);
  54. var recorded = doc.RootElement.GetProperty("recordedAt").GetDateTimeOffset();
  55. Assert.Equal(TimeSpan.Zero, recorded.Offset);
  56. Assert.Equal(at.ToUniversalTime(), recorded);
  57. }
  58. [Theory]
  59. [InlineData(null, "actor", "resource", "success", "trace")]
  60. [InlineData("", "actor", "resource", "success", "trace")]
  61. [InlineData("SESSION_LOGIN", "", "resource", "success", "trace")]
  62. [InlineData("SESSION_LOGIN", "actor", " ", "success", "trace")]
  63. [InlineData("SESSION_LOGIN", "actor", "resource", "", "trace")]
  64. [InlineData("SESSION_LOGIN", "actor", "resource", "success", "")]
  65. public void Record_RejectsIncompleteAuditEvents_AC1(
  66. string? eventType,
  67. string actor,
  68. string resource,
  69. string outcome,
  70. string traceIdentifier)
  71. {
  72. var auditEvent = new AuditEvent(
  73. eventType!,
  74. actor,
  75. resource,
  76. outcome,
  77. traceIdentifier,
  78. DateTimeOffset.UtcNow);
  79. Assert.ThrowsAny<ArgumentException>(() => _service.Record(auditEvent));
  80. }
  81. // AC #2 — files are never deleted (365-day retention)
  82. [Fact]
  83. public void Record_NeverDeletesFiles_FilesAccumulateForRetention_AC2()
  84. {
  85. var day1 = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero);
  86. var day2 = new DateTimeOffset(2026, 5, 5, 0, 0, 0, TimeSpan.Zero);
  87. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", day1));
  88. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", day2));
  89. // Both daily files are present — none deleted
  90. Assert.True(File.Exists(_service.GetDailyFilePath(day1)));
  91. Assert.True(File.Exists(_service.GetDailyFilePath(day2)));
  92. Assert.Equal(2, Directory.GetFiles(_tmpDir, "*.jsonl").Length);
  93. }
  94. // AC #3 — shared service contract: GetRecent returns what was recorded
  95. [Fact]
  96. public void GetRecent_ReturnsRecordedEvents_SharedServiceSatisfied_AC3()
  97. {
  98. var at = DateTimeOffset.UtcNow;
  99. _service.Record(new AuditEvent(AuditEventType.SessionLogin, "alice", "auth", "success", "t1", at));
  100. _service.Record(new AuditEvent(AuditEventType.SessionLogout, "bob", "auth/logout", "success", "t2", at));
  101. var recent = _service.GetRecent();
  102. Assert.Equal(2, recent.Count);
  103. Assert.Contains(recent, e => e.ActorIdentity == "alice" && e.EventType == AuditEventType.SessionLogin);
  104. Assert.Contains(recent, e => e.ActorIdentity == "bob" && e.EventType == AuditEventType.SessionLogout);
  105. }
  106. [Fact]
  107. public void GetRecent_WithNegativeCount_ThrowsArgumentOutOfRange()
  108. {
  109. Assert.Throws<ArgumentOutOfRangeException>(() => _service.GetRecent(-1));
  110. }
  111. // AC #4 — append-only: interface has no update or delete methods
  112. [Fact]
  113. public void IAuditService_HasNoUpdateOrDeleteMethods_AppendOnlyInterface_AC4()
  114. {
  115. var publicMethods = typeof(IAuditService).GetMethods()
  116. .Select(m => m.Name)
  117. .ToArray();
  118. Assert.DoesNotContain(publicMethods, name =>
  119. name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
  120. name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase) ||
  121. name.StartsWith("Update", StringComparison.OrdinalIgnoreCase) ||
  122. name.StartsWith("Modify", StringComparison.OrdinalIgnoreCase) ||
  123. name.StartsWith("Purge", StringComparison.OrdinalIgnoreCase));
  124. }
  125. [Fact]
  126. public void Record_MultipleEvents_AllAppendedToFile_NoPreviousLineOverwritten_AC4()
  127. {
  128. var at = new DateTimeOffset(2026, 5, 5, 12, 0, 0, TimeSpan.Zero);
  129. _service.Record(new AuditEvent("E1", "user1", "r", "ok", "t1", at));
  130. _service.Record(new AuditEvent("E2", "user2", "r", "ok", "t2", at));
  131. _service.Record(new AuditEvent("E3", "user3", "r", "ok", "t3", at));
  132. var lines = File.ReadAllLines(_service.GetDailyFilePath(at));
  133. Assert.Equal(3, lines.Length);
  134. Assert.Contains("E1", lines[0]);
  135. Assert.Contains("E2", lines[1]);
  136. Assert.Contains("E3", lines[2]);
  137. }
  138. // AC #5 — audit service unavailable: Record throws, caller is blocked
  139. [Fact]
  140. public void Record_ThrowsAuditServiceUnavailableException_WhenDirectoryIsDeleted_AC5()
  141. {
  142. // Record one event to ensure directory exists, then delete it to simulate unavailability.
  143. var at = DateTimeOffset.UtcNow;
  144. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at));
  145. Directory.Delete(_tmpDir, recursive: true);
  146. Assert.Throws<AuditServiceUnavailableException>(() =>
  147. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at)));
  148. }
  149. [Fact]
  150. public void Record_WhenThrown_ExceptionInnerCauseIsFileSystemException_AC5()
  151. {
  152. var at = DateTimeOffset.UtcNow;
  153. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at));
  154. Directory.Delete(_tmpDir, recursive: true);
  155. var ex = Assert.Throws<AuditServiceUnavailableException>(() =>
  156. _service.Record(new AuditEvent("X", "u", "r", "ok", "t", at)));
  157. Assert.NotNull(ex.InnerException);
  158. Assert.IsAssignableFrom<IOException>(ex.InnerException);
  159. }
  160. }

Powered by TurnKey Linux.