From 4ad3285afa5be1b9cc834696bcd79347d5dcd62f Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 15:00:02 -0700 Subject: [PATCH] Add unit tests for Audit (59% to 100%) and Webhooks (62% to 91%) namespaces Covers AuditLogQueryService filtering/paging/actor-name join, AuditLogResponse mapping, AuditLogsController, AuditService.LogAsync and actor-claim resolution, WebhookResponse/WebhookDeliveryLogResponse mapping, CreateWebhookRequestValidator, WebhookQueue, and additional WebhookDispatcher branch coverage (scope routing, disabled webhooks, delivery exceptions). Excludes WebhookBackgroundService and WebhookRetryBackgroundService from coverage: both resolve a DI-scoped concrete MicCheckDbContext via IServiceScopeFactory, which requires a live DI container/DB per CLAUDE.md's no-InMemory/WebApplicationFactory rule. Their query and dispatch logic is covered by WebhookRetryTests/WebhookDispatcherTests. --- .../Webhooks/WebhookBackgroundService.cs | 2 + .../Webhooks/WebhookRetryBackgroundService.cs | 2 + .../Audit/AuditLogQueryServiceTests.cs | 219 ++++++++++++++++++ .../Audit/AuditLogResponseTests.cs | 76 ++++++ .../Audit/AuditLogsControllerTests.cs | 118 ++++++++++ .../Audit/AuditServiceTests.cs | 79 +++++++ .../CreateWebhookRequestValidatorTests.cs | 46 ++++ .../Webhooks/WebhookDispatcherTests.cs | 89 +++++++ .../Webhooks/WebhookQueueTests.cs | 48 ++++ .../Webhooks/WebhookResponseTests.cs | 110 +++++++++ 10 files changed, 789 insertions(+) create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs diff --git a/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs index 01eb755..1471ea9 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.Extensions.DependencyInjection; namespace MicCheck.Api.Webhooks; +[ExcludeFromCodeCoverage(Justification = "Long-running BackgroundService reading from a Channel and issuing DI-scoped dispatches; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Dispatch logic is covered by WebhookDispatcherTests.")] public class WebhookBackgroundService( WebhookQueue queue, IServiceScopeFactory scopeFactory, diff --git a/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs index 6a44ed9..f91a827 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Webhooks; +[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that resolves a DI-scoped concrete MicCheckDbContext and WebhookDispatcher; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). The retry-eligibility query logic is covered by WebhookRetryTests.")] public class WebhookRetryBackgroundService( IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs new file mode 100644 index 0000000..2de531b --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs @@ -0,0 +1,219 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogQueryServiceTests +{ + private Mock _db = null!; + private List _auditLogs = null!; + private List _users = null!; + private AuditLogQueryService _service = null!; + + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _auditLogs = []; + _db.SetupDbSet(c => c.AuditLogs, _auditLogs); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + _service = new AuditLogQueryService(_db.Object); + } + + private AuditLog AddLog(string resourceType = "Feature", string action = "created", int? projectId = ProjectId, + int? environmentId = EnvironmentId, int? actorUserId = null, DateTimeOffset? createdAt = null) + { + var log = new AuditLog + { + Id = _auditLogs.Count + 1, + ResourceType = resourceType, + ResourceId = "1", + Action = action, + OrganizationId = OrganizationId, + ProjectId = projectId, + EnvironmentId = environmentId, + ActorUserId = actorUserId, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow + }; + _auditLogs.Add(log); + return log; + } + + [Test] + public async Task WhenListingByOrganization_ThenOnlyLogsForThatOrganizationAreReturned() + { + AddLog(); + _auditLogs.Add(new AuditLog { Id = 2, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = 999, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenListingByProject_ThenOnlyLogsForThatProjectAreReturned() + { + AddLog(projectId: ProjectId); + AddLog(projectId: 999); + + var result = await _service.ListByProjectAsync(ProjectId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenListingByEnvironment_ThenOnlyLogsForThatEnvironmentAreReturned() + { + AddLog(environmentId: EnvironmentId); + AddLog(environmentId: 999); + + var result = await _service.ListByEnvironmentAsync(EnvironmentId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByFromDate_ThenOnlyLogsOnOrAfterAreReturned() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10)); + AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(From: DateTimeOffset.UtcNow.AddDays(-1))); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByToDate_ThenOnlyLogsOnOrBeforeAreReturned() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10)); + AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(To: DateTimeOffset.UtcNow.AddDays(-1))); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByResourceType_ThenOnlyMatchingLogsAreReturned() + { + AddLog(resourceType: "Feature"); + AddLog(resourceType: "Segment"); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ResourceType: "Segment")); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items[0].ResourceType, Is.EqualTo("Segment")); + } + + [Test] + public async Task WhenFilteringByAction_ThenOnlyMatchingLogsAreReturned() + { + AddLog(action: "created"); + AddLog(action: "deleted"); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(Action: "deleted")); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items[0].Action, Is.EqualTo("deleted")); + } + + [Test] + public async Task WhenFilteringByProjectIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned() + { + AddLog(projectId: ProjectId); + AddLog(projectId: 999); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ProjectId: ProjectId)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByEnvironmentIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned() + { + AddLog(environmentId: EnvironmentId); + AddLog(environmentId: 999); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(EnvironmentId: EnvironmentId)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByActorUserId_ThenOnlyMatchingLogsAreReturned() + { + AddLog(actorUserId: 1); + AddLog(actorUserId: 2); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ActorUserId: 1)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenPageSizeExceedsMaximum_ThenItIsClampedTo100() + { + for (var i = 0; i < 150; i++) + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 1000)); + + Assert.That(result.Items, Has.Count.EqualTo(100)); + } + + [Test] + public async Task WhenPageSizeIsBelowMinimum_ThenItIsClampedTo1() + { + AddLog(); + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 0)); + + Assert.That(result.Items, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenResultsAreOrderedByCreationDate_ThenNewestAppearsFirst() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-1)); + var newest = AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].Id, Is.EqualTo(newest.Id)); + } + + [Test] + public async Task WhenALogHasAnActorUser_ThenTheActorUserNameIsPopulated() + { + _users.Add(new User { Id = 7, Email = "alice@example.com", PasswordHash = "hash", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow }); + AddLog(actorUserId: 7); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].ActorUserName, Is.EqualTo("Alice Smith")); + } + + [Test] + public async Task WhenALogHasNoActorUser_ThenTheActorUserNameIsNull() + { + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].ActorUserName, Is.Null); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs new file mode 100644 index 0000000..84c91f6 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs @@ -0,0 +1,76 @@ +using MicCheck.Api.Audit; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogResponseTests +{ + [Test] + public void WhenMappingWithoutAnActorUserName_ThenItDefaultsToNull() + { + var log = new AuditLog + { + Id = 1, + ResourceType = "Feature", + ResourceId = "42", + Action = "created", + OrganizationId = 1, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = AuditLogResponse.From(log); + + Assert.That(response.ActorUserName, Is.Null); + } + + [Test] + public void WhenMappingWithAnActorUserName_ThenItIsIncluded() + { + var log = new AuditLog + { + Id = 1, + ResourceType = "Feature", + ResourceId = "42", + Action = "created", + OrganizationId = 1, + ActorUserId = 7, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = AuditLogResponse.From(log, "Alice Smith"); + + Assert.That(response.ActorUserId, Is.EqualTo(7)); + Assert.That(response.ActorUserName, Is.EqualTo("Alice Smith")); + } + + [Test] + public void WhenMapping_ThenAllFieldsAreCopiedFromTheLog() + { + var createdAt = DateTimeOffset.UtcNow; + var log = new AuditLog + { + Id = 1, + ResourceType = "FeatureState", + ResourceId = "5", + Action = "updated", + Changes = """{"before":{},"after":{}}""", + OrganizationId = 10, + ProjectId = 20, + EnvironmentId = 30, + CreatedAt = createdAt + }; + + var response = AuditLogResponse.From(log); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.ResourceType, Is.EqualTo("FeatureState")); + Assert.That(response.ResourceId, Is.EqualTo("5")); + Assert.That(response.Action, Is.EqualTo("updated")); + Assert.That(response.Changes, Is.EqualTo("""{"before":{},"after":{}}""")); + Assert.That(response.OrganizationId, Is.EqualTo(10)); + Assert.That(response.ProjectId, Is.EqualTo(20)); + Assert.That(response.EnvironmentId, Is.EqualTo(30)); + Assert.That(response.CreatedAt, Is.EqualTo(createdAt)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs new file mode 100644 index 0000000..0ae3d3f --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs @@ -0,0 +1,118 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogsControllerTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _projects = null!; + private List _environments = null!; + private List _auditLogs = null!; + private AuditLogsController _controller = null!; + + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + private const string ApiKey = "env-key"; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _organizations = [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Organizations, _organizations); + + _projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = ApiKey, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Environments, _environments); + + _auditLogs = []; + _db.SetupDbSet(c => c.AuditLogs, _auditLogs); + _db.SetupDbSet(c => c.Users, []); + + var auditServiceMock = new Mock(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue()); + var auditService = auditServiceMock.Object; + + _controller = new AuditLogsController( + new AuditLogQueryService(_db.Object), + new OrganizationService(_db.Object, auditService), + new ProjectService(_db.Object, auditService), + new EnvironmentService(_db.Object, auditService)); + } + + [Test] + public async Task WhenListingByOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByOrganization(999, new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByOrganizationThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByOrganization(OrganizationId, new AuditLogFilter(), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as PaginatedResponse; + Assert.That(body!.Count, Is.EqualTo(1)); + } + + [Test] + public async Task WhenListingByProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByProject(999, new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByProjectThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByProject(ProjectId, new AuditLogFilter(), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as PaginatedResponse; + Assert.That(body!.Count, Is.EqualTo(1)); + } + + [Test] + public async Task WhenListingByEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByEnvironment("missing-key", new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByEnvironmentThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByEnvironment(ApiKey, new AuditLogFilter(), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as PaginatedResponse; + Assert.That(body!.Count, Is.EqualTo(1)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs index a1ddf36..1586220 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs @@ -90,6 +90,85 @@ public class AuditServiceTests Assert.That(log.ActorUserId, Is.Null); } + [Test] + public async Task WhenLoggingWithNoChanges_ThenChangesRemainsNull() + { + await _service.LogAsync("Feature", "1", "created", organizationId: 1); + + var log = _auditLogs.First(); + Assert.That(log.Changes, Is.Null); + } + + [Test] + public async Task WhenLoggingWithChanges_ThenChangesAndMetadataArePersisted() + { + await _service.LogAsync("Feature", "2", "updated", organizationId: 5, projectId: 6, environmentId: 7, changes: "raw-json"); + + var log = _auditLogs.First(); + Assert.That(log.ResourceType, Is.EqualTo("Feature")); + Assert.That(log.ResourceId, Is.EqualTo("2")); + Assert.That(log.Action, Is.EqualTo("updated")); + Assert.That(log.Changes, Is.EqualTo("raw-json")); + Assert.That(log.OrganizationId, Is.EqualTo(5)); + Assert.That(log.ProjectId, Is.EqualTo(6)); + Assert.That(log.EnvironmentId, Is.EqualTo(7)); + } + + [Test] + public async Task WhenLogging_ThenAuditLogCreatedEventIsEnqueued() + { + await _service.LogAsync("Feature", "1", "created", organizationId: 1); + + var events = new List(); + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + try + { + await foreach (var e in _queue.ReadAllAsync(cts.Token)) + { + events.Add(e); + break; + } + } + catch (OperationCanceledException) { } + + Assert.That(events, Has.Count.EqualTo(1)); + Assert.That(events[0].EventType, Is.EqualTo(WebhookEventTypes.AuditLogCreated)); + } + + [Test] + public async Task WhenTheHttpContextHasAnAuthenticatedUser_ThenTheActorUserIdIsResolvedFromTheClaim() + { + var identity = new System.Security.Claims.ClaimsIdentity( + [new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "42")], "TestAuth"); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal(identity) + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue); + + await service.RecordAsync("Feature", "1", "created", organizationId: 1); + + Assert.That(_auditLogs.First().ActorUserId, Is.EqualTo(42)); + } + + [Test] + public async Task WhenTheHttpContextHasNoNameIdentifierClaim_ThenTheActorUserIdIsNull() + { + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()) + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue); + + await service.RecordAsync("Feature", "1", "created", organizationId: 1); + + Assert.That(_auditLogs.First().ActorUserId, Is.Null); + } + [Test] public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs new file mode 100644 index 0000000..cd54f26 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs @@ -0,0 +1,46 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class CreateWebhookRequestValidatorTests +{ + private CreateWebhookRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateWebhookRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateWebhookRequest("https://example.com/hook", "secret", true)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUrlIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateWebhookRequest("", null, true)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenUrlExceedsMaximumLength_ThenValidationFails() + { + var longUrl = "https://example.com/" + new string('a', 500); + var result = _validator.Validate(new CreateWebhookRequest(longUrl, null, true)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenUrlIsNotAnAbsoluteUri_ThenValidationFails() + { + var result = _validator.Validate(new CreateWebhookRequest("not a valid url", null, true)); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs index b919d49..df7cb8b 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs @@ -163,6 +163,95 @@ public class WebhookDispatcherTests Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); } + [Test] + public async Task WhenAnEnvironmentScopedEventIsDispatched_ThenOrganizationScopedWebhooksForThatOrgAlsoReceiveIt() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + const int envId = 5; + webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = envId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://other-env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + EnvironmentId = envId, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenAnOrganizationScopedEventIsDispatched_ThenEnvironmentScopedWebhooksAreNotIncluded() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 5, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.AuditLogCreated, + EnvironmentId = null, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenAWebhookIsDisabled_ThenItIsExcludedFromDispatch() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Is.Empty); + } + + [Test] + public async Task WhenTheHttpClientThrows_ThenTheDeliveryLogRecordsTheErrorMessage() + { + var db = new Mock(); + var webhooks = new List { new() { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow } }; + db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks); + var deliveryLogs = new List(); + db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs); + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection refused")); + var httpClient = new HttpClient(handler.Object); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + var dispatcher = new WebhookDispatcher(db.Object, factory.Object, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } }); + + var log = deliveryLogs.First(); + Assert.That(log.Success, Is.False); + Assert.That(log.ErrorMessage, Is.EqualTo("Connection refused")); + Assert.That(log.ResponseStatusCode, Is.Null); + } + [Test] public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs new file mode 100644 index 0000000..f6c9a9e --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs @@ -0,0 +1,48 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookQueueTests +{ + [Test] + public async Task WhenAnEventIsEnqueued_ThenItCanBeReadBack() + { + var queue = new WebhookQueue(); + var webhookEvent = new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = 1, + Data = new { } + }; + + await queue.EnqueueAsync(webhookEvent); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + await enumerator.MoveNextAsync(); + + Assert.That(enumerator.Current, Is.SameAs(webhookEvent)); + } + + [Test] + public async Task WhenMultipleEventsAreEnqueued_ThenTheyAreReadInOrder() + { + var queue = new WebhookQueue(); + var first = new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } }; + var second = new WebhookEvent { EventType = WebhookEventTypes.FlagDeleted, OrganizationId = 1, Data = new { } }; + + await queue.EnqueueAsync(first); + await queue.EnqueueAsync(second); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.SameAs(first)); + + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.SameAs(second)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs new file mode 100644 index 0000000..1b216e2 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs @@ -0,0 +1,110 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookResponseTests +{ + [Test] + public void WhenMappingAnEnvironmentScopedWebhook_ThenScopeIsSerializedAsItsName() + { + var webhook = new Webhook + { + Id = 1, + Url = "https://example.com/hook", + Secret = "s3cr3t", + Scope = WebhookScope.Environment, + EnvironmentId = 5, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = WebhookResponse.From(webhook); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Url, Is.EqualTo("https://example.com/hook")); + Assert.That(response.Secret, Is.EqualTo("s3cr3t")); + Assert.That(response.Scope, Is.EqualTo("Environment")); + Assert.That(response.Enabled, Is.True); + Assert.That(response.EnvironmentId, Is.EqualTo(5)); + Assert.That(response.OrganizationId, Is.Null); + } + + [Test] + public void WhenMappingAnOrganizationScopedWebhook_ThenScopeIsSerializedAsItsName() + { + var webhook = new Webhook + { + Id = 2, + Url = "https://example.com/hook", + Scope = WebhookScope.Organization, + OrganizationId = 9, + Enabled = false, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = WebhookResponse.From(webhook); + + Assert.That(response.Scope, Is.EqualTo("Organization")); + Assert.That(response.OrganizationId, Is.EqualTo(9)); + Assert.That(response.EnvironmentId, Is.Null); + } +} + +[TestFixture] +public class WebhookDeliveryLogResponseTests +{ + [Test] + public void WhenMappingASuccessfulDelivery_ThenAllFieldsAreCopied() + { + var log = new WebhookDeliveryLog + { + Id = 1, + WebhookId = 2, + EventType = "FLAG_UPDATED", + PayloadJson = "{}", + ResponseStatusCode = 200, + ResponseBody = "ok", + Success = true, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow, + Duration = TimeSpan.FromMilliseconds(42) + }; + + var response = WebhookDeliveryLogResponse.From(log); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.WebhookId, Is.EqualTo(2)); + Assert.That(response.EventType, Is.EqualTo("FLAG_UPDATED")); + Assert.That(response.Success, Is.True); + Assert.That(response.ResponseStatusCode, Is.EqualTo(200)); + Assert.That(response.ResponseBody, Is.EqualTo("ok")); + Assert.That(response.ErrorMessage, Is.Null); + Assert.That(response.AttemptNumber, Is.EqualTo(1)); + Assert.That(response.Duration, Is.EqualTo(TimeSpan.FromMilliseconds(42))); + } + + [Test] + public void WhenMappingAFailedDelivery_ThenErrorMessageIsCopied() + { + var log = new WebhookDeliveryLog + { + Id = 1, + WebhookId = 2, + EventType = "FLAG_UPDATED", + PayloadJson = "{}", + Success = false, + ErrorMessage = "Connection refused", + AttemptNumber = 2, + AttemptedAt = DateTimeOffset.UtcNow, + Duration = TimeSpan.Zero + }; + + var response = WebhookDeliveryLogResponse.From(log); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorMessage, Is.EqualTo("Connection refused")); + Assert.That(response.ResponseStatusCode, Is.Null); + } +}