diff --git a/backend/Platform.Audit.Tests/AuditDraftExamples.cs b/backend/Platform.Audit.Tests/AuditDraftExamples.cs new file mode 100644 index 0000000..d20d80a --- /dev/null +++ b/backend/Platform.Audit.Tests/AuditDraftExamples.cs @@ -0,0 +1,100 @@ +using Platform.Audit.Contracts; + +namespace Platform.Audit.Tests; + +// One valid draft per catalog event type, with a matching trusted context. +// Synthetic identifiers only; no real accounts or courses. +internal static class AuditDraftExamples +{ + public static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid TargetAccountId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid ServiceId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + public static readonly Guid CourseId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + public static readonly Guid DelegationId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + + public const string CorrelationId = "corr-0001"; + + public static AuditContext UserContext { get; } = new(AuditActor.ForUser(UserId), CorrelationId); + public static AuditContext ServiceContext { get; } = new(AuditActor.ForService(ServiceId), CorrelationId); + public static AuditContext AnonymousContext { get; } = new(AuditActor.Anonymous, CorrelationId); + + public static AuditResourceRef TargetAccount { get; } = + new(AuditResourceTypes.Account, TargetAccountId.ToString()); + + public static AuditResourceRef Course { get; } = + new(AuditResourceTypes.Course, CourseId.ToString()); + + public static IEnumerable<(AuditEventDraft Draft, AuditContext Context)> ValidPerEventType() + { + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLogin, AuditOutcome.Success, Resource: TargetAccount), + UserContext); + + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLogout, AuditOutcome.Success), + UserContext); + + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLoginThrottled, AuditOutcome.Denied), + AnonymousContext); + + yield return (StatusChange(), UserContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.CourseMemberRoleChanged, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("oldRole", "student"), ("newRole", "assistant"))), + UserContext); + + yield return (Delegation(AuditEventTypes.CourseDelegationGranted), UserContext); + yield return (Delegation(AuditEventTypes.CourseDelegationRevoked), UserContext); + yield return (Delegation(AuditEventTypes.CourseDelegationExpired), ServiceContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.AccessDenied, + AuditOutcome.Denied, + Details: Details( + ("action", "audit.events.read"), + ("denialReason", "no_policy"), + ("policyVersion", "v0"))), + UserContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.AuditEventsViewed, + AuditOutcome.Success, + CourseId, + Course, + Details( + ("filterType", "auth.login"), + ("filterOutcome", "denied"), + ("filterFrom", "2026-11-01T00:00:00Z"), + ("filterTo", "2026-11-08T00:00:00Z"), + ("resultCount", "3"))), + UserContext); + } + + public static AuditEventDraft StatusChange( + AuditOutcome outcome = AuditOutcome.Success, + IReadOnlyDictionary? details = null) => + new( + AuditEventTypes.AccountStatusChanged, + outcome, + Resource: TargetAccount, + Details: details ?? Details(("oldStatus", "active"), ("newStatus", "blocked"))); + + public static IReadOnlyDictionary Details(params (string Key, string Value)[] entries) => + entries.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal); + + private static AuditEventDraft Delegation(string eventType) => + new( + eventType, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("action", "audit.events.read"), ("delegationId", DelegationId.ToString()))); +} diff --git a/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs b/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs new file mode 100644 index 0000000..7e3deaf --- /dev/null +++ b/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs @@ -0,0 +1,293 @@ +using NUnit.Framework; +using Platform.Audit.Application; +using Platform.Audit.Contracts; +using static Platform.Audit.Tests.AuditDraftExamples; + +namespace Platform.Audit.Tests; + +// T03 (AUD-01): a draft outside the catalog is rejected with a stable code. +[TestFixture] +public sealed class AuditDraftValidatorTests +{ + private readonly AuditDraftValidator _validator = new(); + + private static IEnumerable ValidExamples() => + AuditDraftExamples.ValidPerEventType() + .Select(example => new TestCaseData(example.Draft, example.Context) + .SetArgDisplayNames(example.Draft.EventType)); + + [TestCaseSource(nameof(ValidExamples))] + public void Validate_WhenDraftMatchesCatalog_AcceptsIt(AuditEventDraft draft, AuditContext context) + { + Assert.That(_validator.Validate(draft, context), Is.EqualTo(AuditRejectionCode.None)); + } + + [Test] + public void Validate_WhenFailedLoginIsAnonymousWithReason_AcceptsIt() + { + var draft = new AuditEventDraft( + AuditEventTypes.AuthLogin, + AuditOutcome.Failure, + Details: Details(("reasonCode", "invalid_credentials"))); + + Assert.That(_validator.Validate(draft, AnonymousContext), Is.EqualTo(AuditRejectionCode.None)); + } + + // Event type + + [TestCase("auth.unknown")] + [TestCase("AUTH.LOGIN")] + [TestCase("auth.login ")] + [TestCase("")] + public void Validate_WhenEventTypeIsNotInCatalog_RejectsUnknownEventType(string eventType) + { + var draft = new AuditEventDraft(eventType, AuditOutcome.Success); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.UnknownEventType)); + } + + // Outcome + + [TestCase(AuditEventTypes.AuthLogout, AuditOutcome.Failure)] + [TestCase(AuditEventTypes.AuthLogin, AuditOutcome.Denied)] + [TestCase(AuditEventTypes.AuditEventsViewed, AuditOutcome.Denied)] + [TestCase(AuditEventTypes.AccessDenied, AuditOutcome.Success)] + public void Validate_WhenOutcomeIsNotAllowedForType_RejectsOutcome(string eventType, AuditOutcome outcome) + { + var draft = new AuditEventDraft(eventType, outcome); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.OutcomeNotAllowed)); + } + + [Test] + public void Validate_WhenOutcomeIsUndefinedEnumValue_RejectsOutcome() + { + var draft = StatusChange((AuditOutcome)99); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.OutcomeNotAllowed)); + } + + // Actor and correlation + + [Test] + public void Validate_WhenUserActorHasNoId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.User, null), CorrelationId); + + Assert.That(_validator.Validate(StatusChange(), context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenUserActorHasEmptyId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.User, Guid.Empty), CorrelationId); + + Assert.That(_validator.Validate(StatusChange(), context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenAnonymousActorHasId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.Anonymous, UserId), CorrelationId); + var draft = new AuditEventDraft(AuditEventTypes.AuthLoginThrottled, AuditOutcome.Denied); + + Assert.That(_validator.Validate(draft, context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenActorKindIsNotAllowedForOutcome_RejectsContext() + { + // A successful status change always has an authenticated actor. + Assert.That(_validator.Validate(StatusChange(), AnonymousContext), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenExpiryIsNotFromServiceActor_RejectsContext() + { + var draft = new AuditEventDraft( + AuditEventTypes.CourseDelegationExpired, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("action", "audit.events.read"), ("delegationId", DelegationId.ToString()))); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [TestCase("")] + [TestCase("corr 0001")] + [TestCase("corr-0001\nforged line")] + [TestCase("