From b4a666ebf051b03d3b28a3be420bd2d0ad453260 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 19:54:22 -0700 Subject: [PATCH 1/2] Refactor nunit tests off EF InMemory provider and web server Extract IMicCheckDbContext so DB access can be mocked with Moq instead of the InMemory provider, per project testing guidelines. Add shared async DbSet test doubles (TestSupport/) to back the mocks. Rewrite HTTP-pipeline tests (WebApplicationFactory) to exercise controllers and auth handlers directly instead of running the web server. Drop DbContext/seeder tests that only made sense against a real provider, and remove stale duplicate test files left over from a namespace move. --- .../Audit/AuditLogQueryService.cs | 2 +- src/api/MicCheck.Api/Audit/AuditService.cs | 2 +- .../Common/Security/ApiKeys/ApiKeyService.cs | 2 +- .../ApiKeyAuthenticationHandler.cs | 2 +- .../EnvironmentKeyAuthenticationHandler.cs | 2 +- .../Security/Authorization/AuthService.cs | 6 +- .../ProjectPermissionRequirementHandler.cs | 2 +- .../MicCheck.Api/Data/MicCheckDbContext.cs | 30 +- .../EnvironmentDocumentService.cs | 2 +- .../Environments/EnvironmentService.cs | 2 +- .../Features/FeatureEvaluationService.cs | 2 +- .../Features/FeatureSegmentService.cs | 2 +- .../MicCheck.Api/Features/FeatureService.cs | 2 +- .../Features/FeatureStateService.cs | 2 +- .../Features/FeatureUsageQueryService.cs | 2 +- src/api/MicCheck.Api/Features/TagService.cs | 2 +- .../Identities/AdminIdentityService.cs | 2 +- .../Identities/IdentityResolutionService.cs | 2 +- .../Organizations/OrganizationService.cs | 2 +- src/api/MicCheck.Api/Program.cs | 1 + .../MicCheck.Api/Projects/ProjectService.cs | 2 +- .../MicCheck.Api/Segments/SegmentService.cs | 2 +- src/api/MicCheck.Api/Users/UserService.cs | 2 +- .../Webhooks/WebhookDispatcher.cs | 2 +- .../MicCheck.Api/Webhooks/WebhookService.cs | 2 +- .../Admin/AdminApiIntegrationTests.cs | 426 +++++++----------- .../ApiKeys/ApiKeyHasherTests.cs | 72 --- .../ApiKeys/ApiKeyTests.cs | 53 --- .../Audit/AuditServiceTests.cs | 27 +- .../ApiKeyAuthenticationHandlerTests.cs | 142 ------ .../Authorization/AuthEndpointsTests.cs | 185 -------- ...rojectPermissionRequirementHandlerTests.cs | 180 -------- .../Authorization/TokenServiceTests.cs | 103 ----- .../ApiKeyAuthenticationHandlerTests.cs | 262 +++++------ ...nvironmentKeyAuthenticationHandlerTests.cs | 73 +++ .../Authorization/AuthEndpointsTests.cs | 185 -------- .../Authorization/AuthServiceTests.cs | 219 +++++++++ ...rojectPermissionRequirementHandlerTests.cs | 353 +++++++-------- .../Data/DatabaseSeederTests.cs | 91 ---- .../Data/MicCheckDbContextTests.cs | 224 --------- .../Environments/EnvironmentServiceTests.cs | 94 ++-- .../Features/FeatureEvaluationServiceTests.cs | 117 +++-- .../Features/FeatureServiceTests.cs | 93 ++-- .../Features/FeatureUsageQueryServiceTests.cs | 21 +- .../Features/FlagsApiIntegrationTests.cs | 170 ++++--- .../MicCheck.Api.Tests.Unit.csproj | 2 - .../MicCheckWebApplicationFactory.cs | 37 -- .../Segments/SegmentServiceTests.cs | 86 ++-- .../TestSupport/MockDbSetFactory.cs | 56 +++ .../TestSupport/TestAsyncEnumerable.cs | 29 ++ .../TestSupport/TestAsyncQueryProvider.cs | 43 ++ .../Webhooks/WebhookDispatcherTests.cs | 89 ++-- .../Webhooks/WebhookRetryTests.cs | 120 ++--- 53 files changed, 1293 insertions(+), 2340 deletions(-) delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthEndpointsTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs delete mode 100644 tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs delete mode 100755 tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/TestSupport/MockDbSetFactory.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncEnumerable.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncQueryProvider.cs diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index aa480a6..5cd3a77 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Audit; -public class AuditLogQueryService(MicCheckDbContext db) +public class AuditLogQueryService(IMicCheckDbContext db) { public async Task<(int Total, IReadOnlyList Items)> ListByOrganizationAsync( int organizationId, AuditLogFilter filter, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index fd027c5..a8fd5b4 100755 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks; namespace MicCheck.Api.Audit; -public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) +public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) { private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs index a343597..c94bb50 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Common.Security.ApiKeys; -public class ApiKeyService(MicCheckDbContext db) +public class ApiKeyService(IMicCheckDbContext db) { public async Task<(ApiKey Key, string RawKey)> CreateAsync( int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs b/src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs index cae4780..535e1eb 100755 --- a/src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs +++ b/src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Options; namespace MicCheck.Api.Common.Security.Authentication; -public class ApiKeyAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) +public class ApiKeyAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) : AuthenticationHandler(options, logger, encoder) { public const string SchemeName = "ApiKey"; diff --git a/src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs b/src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs index 42ba736..9aa6aee 100755 --- a/src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs +++ b/src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Options; namespace MicCheck.Api.Common.Security.Authentication; -public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) +public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) : AuthenticationHandler(options, logger, encoder) { public const string SchemeName = "EnvironmentKey"; diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs b/src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs index 141b479..4829735 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Common.Security.Authorization; public class AuthService( - MicCheckDbContext db, + IMicCheckDbContext db, ITokenService tokenService, IPasswordHasher passwordHasher) { @@ -81,7 +81,9 @@ public class AuthService( }); await db.SaveChangesAsync(ct); - await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct); + var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct); + foreach (var organizationUser in organizationUsers) + user.Organizations.Add(organizationUser); var accessToken = tokenService.GenerateToken(user); var refreshTokenValue = GenerateSecureToken(); diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs index 00a4237..7737647 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Common.Security.Authorization; public class ProjectPermissionRequirementHandler( - MicCheckDbContext db, + IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor) : AuthorizationHandler { diff --git a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs index fac0cc6..9e10b97 100755 --- a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs +++ b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs @@ -13,7 +13,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Data; -public class MicCheckDbContext : DbContext +public class MicCheckDbContext : DbContext, IMicCheckDbContext { public MicCheckDbContext(DbContextOptions options) : base(options) { } @@ -42,3 +42,31 @@ public class MicCheckDbContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly); } + +public interface IMicCheckDbContext +{ + DbSet Organizations { get; } + DbSet OrganizationUsers { get; } + DbSet Projects { get; } + DbSet Environments { get; } + DbSet Features { get; } + DbSet FeatureStates { get; } + DbSet FeatureSegments { get; } + DbSet Tags { get; } + DbSet Segments { get; } + DbSet SegmentRules { get; } + DbSet SegmentConditions { get; } + DbSet Identities { get; } + DbSet IdentityTraits { get; } + DbSet AuditLogs { get; } + DbSet Webhooks { get; } + DbSet WebhookDeliveryLogs { get; } + DbSet ApiKeys { get; } + DbSet Users { get; } + DbSet RefreshTokens { get; } + DbSet UserProjectPermissions { get; } + DbSet FeatureUsageDaily { get; } + + int SaveChanges(); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs index 8c69cb0..be0d1d4 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs @@ -5,7 +5,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Environments; -public class EnvironmentDocumentService(MicCheckDbContext db) +public class EnvironmentDocumentService(IMicCheckDbContext db) { public async Task GetAsync(int environmentId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Environments/EnvironmentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentService.cs index 5fe015d..991e2eb 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentService.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentService.cs @@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Environments; -public class EnvironmentService(MicCheckDbContext db, AuditService auditService) +public class EnvironmentService(IMicCheckDbContext db, AuditService auditService) { public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs index 53798b5..dd8666e 100755 --- a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs +++ b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; public class FeatureEvaluationService( - MicCheckDbContext db, + IMicCheckDbContext db, SegmentEvaluator segmentEvaluator, FlagCache flagCache, FeatureUsageMetrics usageMetrics) diff --git a/src/api/MicCheck.Api/Features/FeatureSegmentService.cs b/src/api/MicCheck.Api/Features/FeatureSegmentService.cs index a9efc7f..c49afe8 100755 --- a/src/api/MicCheck.Api/Features/FeatureSegmentService.cs +++ b/src/api/MicCheck.Api/Features/FeatureSegmentService.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureSegmentService(MicCheckDbContext db) +public class FeatureSegmentService(IMicCheckDbContext db) { public async Task> ListByFeatureAsync( int featureId, int environmentId, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs index 9dccd5e..77793b9 100755 --- a/src/api/MicCheck.Api/Features/FeatureService.cs +++ b/src/api/MicCheck.Api/Features/FeatureService.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) +public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) { private const int MaxFeaturesPerProject = 400; diff --git a/src/api/MicCheck.Api/Features/FeatureStateService.cs b/src/api/MicCheck.Api/Features/FeatureStateService.cs index a684952..4325be4 100755 --- a/src/api/MicCheck.Api/Features/FeatureStateService.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) +public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) { public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs b/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs index feb3373..f2608ed 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs +++ b/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureUsageQueryService(MicCheckDbContext db) +public class FeatureUsageQueryService(IMicCheckDbContext db) { public async Task GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Features/TagService.cs b/src/api/MicCheck.Api/Features/TagService.cs index a2713ef..c22e516 100755 --- a/src/api/MicCheck.Api/Features/TagService.cs +++ b/src/api/MicCheck.Api/Features/TagService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class TagService(MicCheckDbContext db) +public class TagService(IMicCheckDbContext db) { public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs index 5a673d2..dd6933c 100755 --- a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs +++ b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Identities; -public class AdminIdentityService(MicCheckDbContext db) +public class AdminIdentityService(IMicCheckDbContext db) { public async Task<(int Total, IReadOnlyList Items)> ListAsync( int environmentId, int page, int pageSize, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs b/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs index 5a324bb..5387b0c 100755 --- a/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs +++ b/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Identities; -public class IdentityResolutionService(MicCheckDbContext db) +public class IdentityResolutionService(IMicCheckDbContext db) { public async Task ResolveAsync( int environmentId, diff --git a/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs index 1419762..8d61e7f 100755 --- a/src/api/MicCheck.Api/Organizations/OrganizationService.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Organizations; -public class OrganizationService(MicCheckDbContext db, AuditService auditService) +public class OrganizationService(IMicCheckDbContext db, AuditService auditService) { public async Task> ListForUserAsync(int userId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index 61dcde7..8c0bc49 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -152,6 +152,7 @@ try builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); + builder.Services.AddScoped(sp => sp.GetRequiredService()); var app = builder.Build(); diff --git a/src/api/MicCheck.Api/Projects/ProjectService.cs b/src/api/MicCheck.Api/Projects/ProjectService.cs index 66e6251..de54cb7 100755 --- a/src/api/MicCheck.Api/Projects/ProjectService.cs +++ b/src/api/MicCheck.Api/Projects/ProjectService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Projects; -public class ProjectService(MicCheckDbContext db, AuditService auditService) +public class ProjectService(IMicCheckDbContext db, AuditService auditService) { public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Segments/SegmentService.cs b/src/api/MicCheck.Api/Segments/SegmentService.cs index e111340..e7fb359 100755 --- a/src/api/MicCheck.Api/Segments/SegmentService.cs +++ b/src/api/MicCheck.Api/Segments/SegmentService.cs @@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList Conditions, IReadOnlyList? ChildRules = null); -public class SegmentService(MicCheckDbContext db, AuditService auditService) +public class SegmentService(IMicCheckDbContext db, AuditService auditService) { private const int MaxSegmentsPerProject = 100; private const int MaxConditionsPerSegment = 100; diff --git a/src/api/MicCheck.Api/Users/UserService.cs b/src/api/MicCheck.Api/Users/UserService.cs index d731e48..d0022c9 100755 --- a/src/api/MicCheck.Api/Users/UserService.cs +++ b/src/api/MicCheck.Api/Users/UserService.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Users; -public class UserService(MicCheckDbContext db, IPasswordHasher passwordHasher) +public class UserService(IMicCheckDbContext db, IPasswordHasher passwordHasher) { public async Task FindByIdAsync(int userId, CancellationToken ct = default) => await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); diff --git a/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs b/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs index d1c3e8d..e24df99 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Webhooks; -public class WebhookDispatcher(MicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger logger) +public class WebhookDispatcher(IMicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/src/api/MicCheck.Api/Webhooks/WebhookService.cs b/src/api/MicCheck.Api/Webhooks/WebhookService.cs index 5b77709..a0389ec 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookService.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Webhooks; -public class WebhookService(MicCheckDbContext db) +public class WebhookService(IMicCheckDbContext db) { public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs index b1664d1..37b3b1e 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs @@ -1,268 +1,158 @@ -using System.Net; -using System.Net.Http.Json; -using MicCheck.Api.Common.Security.ApiKeys; -using MicCheck.Api.Data; -using MicCheck.Api.Features; -using MicCheck.Api.Organizations; -using MicCheck.Api.Projects; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using AppEnvironment = MicCheck.Api.Environments.Environment; - -namespace MicCheck.Api.Tests.Unit.Admin; - -[TestFixture] -public class AdminApiIntegrationTests -{ - private MicCheckWebApplicationFactory _factory = null!; - private string _rawApiKey = null!; - - [SetUp] - public void SetUp() - { - _factory = new MicCheckWebApplicationFactory(); - _rawApiKey = SeedOrganizationWithApiKey(); - } - - [TearDown] - public void TearDown() => _factory.Dispose(); - - private string SeedOrganizationWithApiKey() - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }; - db.Organizations.Add(org); - db.SaveChanges(); - - var rawKey = ApiKeyHasher.GenerateKey(); - var hashedKey = ApiKeyHasher.Hash(rawKey); - var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey; - - db.ApiKeys.Add(new ApiKey - { - Key = hashedKey, - Prefix = prefix, - Name = "Test Key", - OrganizationId = org.Id, - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }); - db.SaveChanges(); - - return rawKey; - } - - private HttpClient CreateAuthenticatedClient() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}"); - return client; - } - - [Test] - public async Task WhenCreatingAProject_ThenProjectIsReturned() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var response = await client.PostAsJsonAsync("/api/v1/projects", new - { - name = "My Project", - organizationId = org.Id - }); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); - } - - [Test] - public async Task WhenCreatingAFeature_ThenFeatureIsReturned() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var project = new Project - { - Name = "My Project", - OrganizationId = org.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Projects.Add(project); - db.SaveChanges(); - - var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new - { - name = "dark_mode", - type = "Standard", - initialValue = (string?)null, - description = (string?)null - }); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); - } - - [Test] - public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var project = new Project - { - Name = "My Project", - OrganizationId = org.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Projects.Add(project); - db.SaveChanges(); - - db.Environments.Add(new AppEnvironment - { - Name = "Production", - ApiKey = "env-key-prod", - ProjectId = project.Id, - CreatedAt = DateTimeOffset.UtcNow - }); - db.SaveChanges(); - - await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new - { - name = "flag_x", - type = "Standard", - initialValue = (string?)null, - description = (string?)null - }); - - using var verifyScope = _factory.Services.CreateScope(); - var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); - var feature = verifyDb.Features.First(f => f.Name == "flag_x"); - var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList(); - - Assert.That(states, Has.Count.EqualTo(1)); - } - - [Test] - public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var project = new Project - { - Name = "My Project", - OrganizationId = org.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Projects.Add(project); - db.Features.Add(new Feature - { - Name = "existing_flag", - ProjectId = project.Id, - CreatedAt = DateTimeOffset.UtcNow - }); - db.SaveChanges(); - - var response = await client.PostAsJsonAsync("/api/v1/environments", new - { - name = "Staging", - projectId = project.Id - }); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); - - using var verifyScope = _factory.Services.CreateScope(); - var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); - var feature = verifyDb.Features.First(f => f.Name == "existing_flag"); - var env = verifyDb.Environments.First(e => e.ProjectId == project.Id); - var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList(); - - Assert.That(states, Has.Count.EqualTo(1)); - } - - [Test] - public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/v1/projects?organizationId=1"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var project = new Project - { - Name = "My Project", - OrganizationId = org.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Projects.Add(project); - db.SaveChanges(); - - var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new - { - name = "invalid name with spaces!", - type = "Standard" - }); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); - } - - [Test] - public async Task WhenCreatingASegment_ThenSegmentIsReturned() - { - var client = CreateAuthenticatedClient(); - - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var org = db.Organizations.First(); - - var project = new Project - { - Name = "My Project", - OrganizationId = org.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Projects.Add(project); - db.SaveChanges(); - - var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/segments", new - { - name = "Premium Users", - rules = new[] - { - new - { - type = "All", - conditions = new[] - { - new { property = "plan", @operator = "Equal", value = "premium" } - } - } - } - }); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); - } -} +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Webhooks; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Admin; + +[TestFixture] +public class AdminApiIntegrationTests +{ + private Mock _db = null!; + private List _projects = null!; + private List _features = null!; + private List _featureStates = null!; + private List _environments = null!; + private List _segments = null!; + private Mock _auditService = null!; + private int _organizationId; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSetWithGeneratedIds(c => c.Organizations, [ + new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _organizationId = 1; + + _projects = []; + _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects); + _features = []; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + _environments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Environments, _environments); + _segments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + _db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, []); + _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []); + + var webhookQueue = new WebhookQueue(); + _auditService = new Mock(_db.Object, null!, webhookQueue); + _auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + private Project AddProject(string name = "My Project") + { + var project = new Project + { + Name = name, + OrganizationId = _organizationId, + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Object.Projects.Add(project); + return project; + } + + [Test] + public async Task WhenCreatingAProject_ThenProjectIsReturned() + { + var controller = new ProjectsController(new ProjectService(_db.Object, _auditService.Object)); + + var result = await controller.Create(new CreateProjectRequest("My Project", _organizationId), CancellationToken.None); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task WhenCreatingAFeature_ThenFeatureIsReturned() + { + var project = AddProject(); + var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue())); + + var result = await controller.Create(project.Id, + new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null), CancellationToken.None); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments() + { + var project = AddProject(); + _db.Object.Environments.Add(new AppEnvironment + { + Name = "Production", + ApiKey = "env-key-prod", + ProjectId = project.Id, + CreatedAt = DateTimeOffset.UtcNow + }); + var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue())); + + await controller.Create(project.Id, + new CreateFeatureRequest("flag_x", FeatureType.Standard, null, null), CancellationToken.None); + + var feature = _features.First(f => f.Name == "flag_x"); + var states = _featureStates.Where(fs => fs.FeatureId == feature.Id).ToList(); + + Assert.That(states, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures() + { + var project = AddProject(); + _db.Object.Features.Add(new Feature + { + Name = "existing_flag", + ProjectId = project.Id, + CreatedAt = DateTimeOffset.UtcNow + }); + var controller = new EnvironmentsController( + new EnvironmentService(_db.Object, _auditService.Object), + new WebhookService(_db.Object)); + + var result = await controller.Create(new CreateEnvironmentRequest("Staging", project.Id), CancellationToken.None); + + Assert.That(result.Result, Is.TypeOf()); + + var feature = _features.First(f => f.Name == "existing_flag"); + var env = _environments.First(e => e.ProjectId == project.Id); + var states = _featureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList(); + + Assert.That(states, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingASegment_ThenSegmentIsReturned() + { + var project = AddProject(); + var controller = new SegmentsController(new SegmentService(_db.Object, _auditService.Object)); + + var result = await controller.Create(project.Id, new CreateSegmentRequest( + "Premium Users", + [ + new CreateSegmentRuleRequest( + "All", + [new CreateSegmentConditionRequest("plan", "Equal", "premium")]) + ]), CancellationToken.None); + + Assert.That(result.Result, Is.TypeOf()); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs deleted file mode 100755 index 0a0c780..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using MicCheck.Api.Common.Security.ApiKeys; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.ApiKeys; - -[TestFixture] -public class ApiKeyHasherTests -{ - [Test] - public void WhenKeyIsHashed_ThenHashIsDeterministic() - { - var key = "test-api-key"; - - var hash1 = ApiKeyHasher.Hash(key); - var hash2 = ApiKeyHasher.Hash(key); - - Assert.That(hash1, Is.EqualTo(hash2)); - } - - [Test] - public void WhenKeyIsHashed_ThenHashIsLowercaseHex() - { - var hash = ApiKeyHasher.Hash("any-key"); - - Assert.That(hash, Does.Match("^[0-9a-f]{64}$")); - } - - [Test] - public void WhenDifferentKeysAreHashed_ThenHashesDiffer() - { - var hash1 = ApiKeyHasher.Hash("key-one"); - var hash2 = ApiKeyHasher.Hash("key-two"); - - Assert.That(hash1, Is.Not.EqualTo(hash2)); - } - - [Test] - public void WhenGenerateKeyIsCalled_ThenKeyIsNotEmpty() - { - var key = ApiKeyHasher.GenerateKey(); - - Assert.That(key, Is.Not.Null.And.Not.Empty); - } - - [Test] - public void WhenGenerateKeyIsCalled_ThenEachKeyIsUnique() - { - var key1 = ApiKeyHasher.GenerateKey(); - var key2 = ApiKeyHasher.GenerateKey(); - - Assert.That(key1, Is.Not.EqualTo(key2)); - } - - [Test] - public void WhenGenerateKeyIsCalled_ThenKeyIsBase64UrlSafe() - { - var key = ApiKeyHasher.GenerateKey(); - - Assert.That(key, Does.Not.Contain("+")); - Assert.That(key, Does.Not.Contain("/")); - Assert.That(key, Does.Not.Contain("=")); - } - - [Test] - public void WhenGenerateKeyIsHashed_ThenHashCanBeUsedToVerify() - { - var rawKey = ApiKeyHasher.GenerateKey(); - var hash = ApiKeyHasher.Hash(rawKey); - - Assert.That(ApiKeyHasher.Hash(rawKey), Is.EqualTo(hash)); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs b/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs deleted file mode 100755 index 42c151d..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using NUnit.Framework; -using MicCheck.Api.Common.Security.ApiKeys; - -namespace MicCheck.Api.Tests.Unit.ApiKeys; - -[TestFixture] -public class ApiKeyTests -{ - [Test] - public void WhenAnApiKeyIsCreated_ThenRequiredFieldsAreSet() - { - var apiKey = new ApiKey - { - Key = "hashed-value", - Prefix = "abc12345", - Name = "CI Pipeline Key", - OrganizationId = 1 - }; - - Assert.That(apiKey.Key, Is.EqualTo("hashed-value")); - Assert.That(apiKey.Prefix, Is.EqualTo("hashed-value".Substring(0, 8)).Or.EqualTo("abc12345")); - Assert.That(apiKey.Name, Is.EqualTo("CI Pipeline Key")); - Assert.That(apiKey.OrganizationId, Is.EqualTo(1)); - } - - [Test] - public void WhenAnApiKeyIsCreated_ThenIsActiveDefaultsToFalse() - { - var apiKey = new ApiKey - { - Key = "hashed-value", - Prefix = "abc12345", - Name = "Test Key", - OrganizationId = 1 - }; - - Assert.That(apiKey.IsActive, Is.False); - } - - [Test] - public void WhenAnApiKeyIsCreated_ThenExpiresAtDefaultsToNull() - { - var apiKey = new ApiKey - { - Key = "hashed-value", - Prefix = "abc12345", - Name = "Test Key", - OrganizationId = 1 - }; - - Assert.That(apiKey.ExpiresAt, Is.Null); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs index 5047d4a..a1ddf36 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using MicCheck.Api.Audit; using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; using MicCheck.Api.Webhooks; using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; using Moq; using NUnit.Framework; @@ -12,34 +12,31 @@ namespace MicCheck.Api.Tests.Unit.Audit; [TestFixture] public class AuditServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _auditLogs = null!; private AuditService _service = null!; private WebhookQueue _queue = null!; [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); + _db = new Mock(); + _auditLogs = []; + _db.SetupDbSet(c => c.AuditLogs, _auditLogs); _queue = new WebhookQueue(); var httpContextAccessor = new Mock(); - httpContextAccessor.Setup(x => x.HttpContext).Returns((Microsoft.AspNetCore.Http.HttpContext?)null); + httpContextAccessor.Setup(x => x.HttpContext).Returns((HttpContext?)null); - _service = new AuditService(_db, httpContextAccessor.Object, _queue); + _service = new AuditService(_db.Object, httpContextAccessor.Object, _queue); } - [TearDown] - public void TearDown() => _db.Dispose(); - [Test] public async Task WhenRecordingWithNoBefore_ThenChangesIsNull() { await _service.RecordAsync("Feature", "1", "created", organizationId: 1); - var log = await _db.AuditLogs.FirstAsync(); + var log = _auditLogs.First(); Assert.That(log.Changes, Is.Null); } @@ -49,7 +46,7 @@ public class AuditServiceTests var after = new { Name = "dark_mode", Enabled = true }; await _service.RecordAsync("Feature", "1", "created", organizationId: 1, after: after); - var log = await _db.AuditLogs.FirstAsync(); + var log = _auditLogs.First(); Assert.That(log.Changes, Is.Not.Null); var changes = JsonDocument.Parse(log.Changes!).RootElement; @@ -67,7 +64,7 @@ public class AuditServiceTests await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1, before: before, after: after); - var log = await _db.AuditLogs.FirstAsync(); + var log = _auditLogs.First(); var changes = JsonDocument.Parse(log.Changes!).RootElement; Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False); @@ -83,7 +80,7 @@ public class AuditServiceTests "Segment", "5", "deleted", organizationId: 10, projectId: 20, environmentId: 30); - var log = await _db.AuditLogs.FirstAsync(); + var log = _auditLogs.First(); Assert.That(log.ResourceType, Is.EqualTo("Segment")); Assert.That(log.ResourceId, Is.EqualTo("5")); Assert.That(log.Action, Is.EqualTo("deleted")); diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs deleted file mode 100755 index b58efb0..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using MicCheck.Api.Common.Security.ApiKeys; -using MicCheck.Api.Data; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Authentication; - -[TestFixture] -public class ApiKeyAuthenticationHandlerTests -{ - private MicCheckWebApplicationFactory _factory = null!; - - [SetUp] - public void SetUp() - { - _factory = new MicCheckWebApplicationFactory(); - } - - [TearDown] - public void TearDown() => _factory.Dispose(); - - private async Task SeedActiveApiKeyAsync(int organizationId = 1) - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Test Key", - OrganizationId = organizationId, - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await db.SaveChangesAsync(); - - return rawKey; - } - - [Test] - public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "not-a-jwt"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsInvalid_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned() - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Inactive Key", - OrganizationId = 1, - IsActive = false, - CreatedAt = DateTimeOffset.UtcNow - }); - await db.SaveChangesAsync(); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned() - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Expired Key", - OrganizationId = 1, - IsActive = true, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), - CreatedAt = DateTimeOffset.UtcNow.AddDays(-10) - }); - await db.SaveChangesAsync(); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsValid_ThenOkIsReturned() - { - var rawKey = await SeedActiveApiKeyAsync(organizationId: 1); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs deleted file mode 100755 index 251437e..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using MicCheck.Api.Common.Security.Authorization; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Authorization; - -[TestFixture] -public class AuthEndpointsTests -{ - private MicCheckWebApplicationFactory _factory = null!; - - [SetUp] - public void SetUp() - { - _factory = new MicCheckWebApplicationFactory(); - } - - [TearDown] - public void TearDown() => _factory.Dispose(); - - [Test] - public async Task WhenRegisteringWithValidDetails_ThenOkIsReturnedWithTokens() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); - } - - [Test] - public async Task WhenRegisteringWithDuplicateEmail_ThenConflictIsReturned() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); - } - - [Test] - public async Task WhenLoginWithValidCredentials_ThenOkIsReturnedWithTokens() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "bob@example.com", "SecurePass1!", "Bob", "Jones", "BobCo")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("bob@example.com", "SecurePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); - } - - [Test] - public async Task WhenLoginWithWrongPassword_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "carol@example.com", "CorrectPass1!", "Carol", "White", "CarolCo")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("carol@example.com", "WrongPassword")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoginWithUnknownEmail_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("nobody@example.com", "SomePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoginWithEmptyEmail_ThenBadRequestIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("", "SomePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - } - - [Test] - public async Task WhenRefreshingWithValidToken_ThenOkIsReturnedWithNewTokens() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "dave@example.com", "SecurePass1!", "Dave", "Brown", "DaveCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody!.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.EqualTo(registerBody.RefreshToken)); - } - - [Test] - public async Task WhenRefreshingWithInvalidToken_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest("not-a-valid-refresh-token")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenRefreshingWithRevokedToken_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "eve@example.com", "SecurePass1!", "Eve", "Davis", "EveCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody!.RefreshToken)); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoggingOut_ThenNoContentIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "frank@example.com", "SecurePass1!", "Frank", "Green", "FrankCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/logout", - new LogoutRequest(registerBody!.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); - } - - [Test] - public async Task WhenLoggingOutAndRefreshing_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "grace@example.com", "SecurePass1!", "Grace", "Black", "GraceCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - await client.PostAsJsonAsync("/api/v1/auth/logout", - new LogoutRequest(registerBody!.RefreshToken)); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs deleted file mode 100755 index f2c323d..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using MicCheck.Api.Common.Security.Authorization; -using MicCheck.Api.Data; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Moq; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Authorization; - -[TestFixture] -public class ProjectPermissionRequirementHandlerTests -{ - private MicCheckDbContext _db = null!; - private Mock _httpContextAccessor = null!; - private ProjectPermissionRequirementHandler _handler = null!; - - [SetUp] - public void SetUp() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); - - _httpContextAccessor = new Mock(); - _handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - private static AuthorizationHandlerContext CreateContext( - ClaimsPrincipal user, - ProjectPermission permission = ProjectPermission.ViewProject) - { - var requirement = new ProjectPermissionRequirement(permission); - return new AuthorizationHandlerContext([requirement], user, null); - } - - private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims) - { - return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); - } - - private void SetupRouteProjectId(int projectId) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.RouteValues["projectId"] = projectId.ToString(); - _httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); - } - - [Test] - public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds() - { - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1"), - new Claim("OrganizationRole", "Admin")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 1, - ProjectId = 10, - Permissions = [ProjectPermission.ViewProject] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(10); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1")); - - var context = CreateContext(user, ProjectPermission.ViewProject); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 2, - ProjectId = 20, - IsAdmin = true, - Permissions = [] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(20); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "2")); - - var context = CreateContext(user, ProjectPermission.DeleteFeature); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 3, - ProjectId = 30, - Permissions = [ProjectPermission.ViewProject] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(30); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "3")); - - var context = CreateContext(user, ProjectPermission.DeleteFeature); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails() - { - SetupRouteProjectId(99); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "5")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenUserIdClaimIsMissing_ThenRequirementFails() - { - var user = CreateUserWithClaims(); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails() - { - _httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs deleted file mode 100755 index 36fa883..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using MicCheck.Api.Common.Security.Authorization; -using MicCheck.Api.Organizations; -using MicCheck.Api.Users; -using Microsoft.Extensions.Configuration; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Authorization; - -[TestFixture] -public class TokenServiceTests -{ - private IConfiguration _configuration = null!; - private TokenService _tokenService = null!; - - [SetUp] - public void SetUp() - { - _configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Jwt:SecretKey"] = "test-secret-key-that-is-long-enough-32c", - ["Jwt:Issuer"] = "MicCheck", - ["Jwt:Audience"] = "MicCheck", - ["Jwt:ExpiryMinutes"] = "60", - }) - .Build(); - - _tokenService = new TokenService(_configuration); - } - - private static User BuildUser(int id = 1, string email = "alice@example.com") => - new() - { - Email = email, - FirstName = "Alice", - LastName = "Smith", - PasswordHash = "hashed", - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }; - - [Test] - public void WhenAUserIsProvided_ThenATokenIsReturned() - { - var result = _tokenService.GenerateToken(BuildUser()); - - Assert.That(result.Token, Is.Not.Null.And.Not.Empty); - } - - [Test] - public void WhenATokenIsGenerated_ThenItContainsTheUserEmailClaim() - { - var user = BuildUser(email: "bob@example.com"); - - var result = _tokenService.GenerateToken(user); - - var handler = new JwtSecurityTokenHandler(); - var jwt = handler.ReadJwtToken(result.Token); - - Assert.That(jwt.Claims.Any(c => c.Type == JwtRegisteredClaimNames.Email && c.Value == "bob@example.com"), - Is.True); - } - - [Test] - public void WhenATokenIsGenerated_ThenItHasAFutureExpiry() - { - var result = _tokenService.GenerateToken(BuildUser()); - - Assert.That(result.ExpiresAt, Is.GreaterThan(DateTime.UtcNow)); - } - - [Test] - public void WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer() - { - var result = _tokenService.GenerateToken(BuildUser()); - - var handler = new JwtSecurityTokenHandler(); - var jwt = handler.ReadJwtToken(result.Token); - - Assert.That(jwt.Issuer, Is.EqualTo("MicCheck")); - } - - [Test] - public void WhenUserBelongsToOrganizations_ThenTokenContainsOrganizationClaims() - { - var user = BuildUser(); - user.Organizations.Add(new OrganizationUser - { - OrganizationId = 42, - UserId = user.Id, - Role = OrganizationRole.Admin - }); - - var result = _tokenService.GenerateToken(user); - - var handler = new JwtSecurityTokenHandler(); - var jwt = handler.ReadJwtToken(result.Token); - - Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationId" && c.Value == "42"), Is.True); - Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationRole" && c.Value == "Admin"), Is.True); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs index 545e5c2..92b7bad 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -1,142 +1,120 @@ -using System.Net; -using System.Net.Http.Headers; -using MicCheck.Api.Common.Security.ApiKeys; -using MicCheck.Api.Data; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication; - -[TestFixture] -public class ApiKeyAuthenticationHandlerTests -{ - private MicCheckWebApplicationFactory _factory = null!; - - [SetUp] - public void SetUp() - { - _factory = new MicCheckWebApplicationFactory(); - } - - [TearDown] - public void TearDown() => _factory.Dispose(); - - private async Task SeedActiveApiKeyAsync(int organizationId = 1) - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Test Key", - OrganizationId = organizationId, - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await db.SaveChangesAsync(); - - return rawKey; - } - - [Test] - public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "not-a-jwt"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsInvalid_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned() - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Inactive Key", - OrganizationId = 1, - IsActive = false, - CreatedAt = DateTimeOffset.UtcNow - }); - await db.SaveChangesAsync(); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned() - { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var rawKey = ApiKeyHasher.GenerateKey(); - db.ApiKeys.Add(new ApiKey - { - Key = ApiKeyHasher.Hash(rawKey), - Prefix = rawKey[..8], - Name = "Expired Key", - OrganizationId = 1, - IsActive = true, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), - CreatedAt = DateTimeOffset.UtcNow.AddDays(-10) - }); - await db.SaveChangesAsync(); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenApiKeyIsValid_ThenOkIsReturned() - { - var rawKey = await SeedActiveApiKeyAsync(organizationId: 1); - - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); - - var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - } -} +using System.Text.Encodings.Web; +using MicCheck.Api.Common.Security.ApiKeys; +using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication; + +[TestFixture] +public class ApiKeyAuthenticationHandlerTests +{ + private List _apiKeys = null!; + private Mock _db = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _apiKeys = []; + _db.SetupDbSet(c => c.ApiKeys, _apiKeys); + } + + private async Task AuthenticateAsync(string? authorizationHeader) + { + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(o => o.Get(It.IsAny())).Returns(new AuthenticationSchemeOptions()); + + var handler = new ApiKeyAuthenticationHandler(optionsMonitor.Object, NullLoggerFactory.Instance, UrlEncoder.Default, _db.Object); + + var httpContext = new DefaultHttpContext(); + if (authorizationHeader is not null) + httpContext.Request.Headers.Authorization = authorizationHeader; + + var scheme = new AuthenticationScheme(ApiKeyAuthenticationHandler.SchemeName, null, typeof(ApiKeyAuthenticationHandler)); + await handler.InitializeAsync(scheme, httpContext); + + return await handler.AuthenticateAsync(); + } + + private ApiKey AddApiKey(string rawKey, int organizationId = 1, bool isActive = true, DateTimeOffset? expiresAt = null) + { + var apiKey = new ApiKey + { + Key = ApiKeyHasher.Hash(rawKey), + Prefix = rawKey[..8], + Name = "Test Key", + OrganizationId = organizationId, + IsActive = isActive, + ExpiresAt = expiresAt, + CreatedAt = DateTimeOffset.UtcNow + }; + _apiKeys.Add(apiKey); + return apiKey; + } + + [Test] + public async Task WhenAuthorizationHeaderIsAbsent_ThenNoResultIsReturned() + { + var result = await AuthenticateAsync(null); + + Assert.That(result.None, Is.True); + } + + [Test] + public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenNoResultIsReturned() + { + var result = await AuthenticateAsync("Bearer not-a-jwt"); + + Assert.That(result.None, Is.True); + } + + [Test] + public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails() + { + var result = await AuthenticateAsync("Api-Key not-a-real-key"); + + Assert.That(result.Succeeded, Is.False); + } + + [Test] + public async Task WhenApiKeyIsInactive_ThenAuthenticationFails() + { + var rawKey = ApiKeyHasher.GenerateKey(); + AddApiKey(rawKey, isActive: false); + + var result = await AuthenticateAsync($"Api-Key {rawKey}"); + + Assert.That(result.Succeeded, Is.False); + } + + [Test] + public async Task WhenApiKeyIsExpired_ThenAuthenticationFails() + { + var rawKey = ApiKeyHasher.GenerateKey(); + AddApiKey(rawKey, expiresAt: DateTimeOffset.UtcNow.AddDays(-1)); + + var result = await AuthenticateAsync($"Api-Key {rawKey}"); + + Assert.That(result.Succeeded, Is.False); + } + + [Test] + public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationClaims() + { + var rawKey = ApiKeyHasher.GenerateKey(); + AddApiKey(rawKey, organizationId: 42); + + var result = await AuthenticateAsync($"Api-Key {rawKey}"); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Principal!.FindFirst("OrganizationId")!.Value, Is.EqualTo("42")); + Assert.That(result.Principal!.FindFirst("OrganizationRole")!.Value, Is.EqualTo("Admin")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs new file mode 100644 index 0000000..15d9d0c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs @@ -0,0 +1,73 @@ +using System.Text.Encodings.Web; +using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication; + +[TestFixture] +public class EnvironmentKeyAuthenticationHandlerTests +{ + private List _environments = null!; + private Mock _db = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _environments = []; + _db.SetupDbSet(c => c.Environments, _environments); + } + + private async Task AuthenticateAsync(string? environmentKeyHeader) + { + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(o => o.Get(It.IsAny())).Returns(new AuthenticationSchemeOptions()); + + var handler = new EnvironmentKeyAuthenticationHandler(optionsMonitor.Object, NullLoggerFactory.Instance, UrlEncoder.Default, _db.Object); + + var httpContext = new DefaultHttpContext(); + if (environmentKeyHeader is not null) + httpContext.Request.Headers["X-Environment-Key"] = environmentKeyHeader; + + var scheme = new AuthenticationScheme(EnvironmentKeyAuthenticationHandler.SchemeName, null, typeof(EnvironmentKeyAuthenticationHandler)); + await handler.InitializeAsync(scheme, httpContext); + + return await handler.AuthenticateAsync(); + } + + [Test] + public async Task WhenEnvironmentKeyHeaderIsAbsent_ThenNoResultIsReturned() + { + var result = await AuthenticateAsync(null); + + Assert.That(result.None, Is.True); + } + + [Test] + public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() + { + var result = await AuthenticateAsync("not-a-real-key"); + + Assert.That(result.Succeeded, Is.False); + } + + [Test] + public async Task WhenEnvironmentKeyIsValid_ThenAuthenticationSucceedsWithEnvironmentClaims() + { + _environments.Add(new AppEnvironment { Id = 7, Name = "Prod", ApiKey = "valid-env-key", ProjectId = 3, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await AuthenticateAsync("valid-env-key"); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Principal!.FindFirst("EnvironmentId")!.Value, Is.EqualTo("7")); + Assert.That(result.Principal!.FindFirst("ProjectId")!.Value, Is.EqualTo("3")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthEndpointsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthEndpointsTests.cs deleted file mode 100755 index bb30c84..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthEndpointsTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using MicCheck.Api.Common.Security.Authorization; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization; - -[TestFixture] -public class AuthEndpointsTests -{ - private MicCheckWebApplicationFactory _factory = null!; - - [SetUp] - public void SetUp() - { - _factory = new MicCheckWebApplicationFactory(); - } - - [TearDown] - public void TearDown() => _factory.Dispose(); - - [Test] - public async Task WhenRegisteringWithValidDetails_ThenOkIsReturnedWithTokens() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); - } - - [Test] - public async Task WhenRegisteringWithDuplicateEmail_ThenConflictIsReturned() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); - } - - [Test] - public async Task WhenLoginWithValidCredentials_ThenOkIsReturnedWithTokens() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "bob@example.com", "SecurePass1!", "Bob", "Jones", "BobCo")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("bob@example.com", "SecurePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); - } - - [Test] - public async Task WhenLoginWithWrongPassword_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "carol@example.com", "CorrectPass1!", "Carol", "White", "CarolCo")); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("carol@example.com", "WrongPassword")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoginWithUnknownEmail_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("nobody@example.com", "SomePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoginWithEmptyEmail_ThenBadRequestIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/login", - new LoginRequest("", "SomePass1!")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - } - - [Test] - public async Task WhenRefreshingWithValidToken_ThenOkIsReturnedWithNewTokens() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "dave@example.com", "SecurePass1!", "Dave", "Brown", "DaveCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody!.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); - Assert.That(body?.RefreshToken, Is.Not.EqualTo(registerBody.RefreshToken)); - } - - [Test] - public async Task WhenRefreshingWithInvalidToken_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest("not-a-valid-refresh-token")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenRefreshingWithRevokedToken_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "eve@example.com", "SecurePass1!", "Eve", "Davis", "EveCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody!.RefreshToken)); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenLoggingOut_ThenNoContentIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "frank@example.com", "SecurePass1!", "Frank", "Green", "FrankCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - var response = await client.PostAsJsonAsync("/api/v1/auth/logout", - new LogoutRequest(registerBody!.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); - } - - [Test] - public async Task WhenLoggingOutAndRefreshing_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( - "grace@example.com", "SecurePass1!", "Grace", "Black", "GraceCo")); - var registerBody = await registerResponse.Content.ReadFromJsonAsync(); - - await client.PostAsJsonAsync("/api/v1/auth/logout", - new LogoutRequest(registerBody!.RefreshToken)); - - var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", - new RefreshRequest(registerBody.RefreshToken)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs new file mode 100644 index 0000000..0c117b4 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs @@ -0,0 +1,219 @@ +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Identity; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization; + +[TestFixture] +public class AuthServiceTests +{ + private Mock _db = null!; + private List _users = null!; + private List _organizations = null!; + private List _organizationUsers = null!; + private List _refreshTokens = null!; + private PasswordHasher _passwordHasher = null!; + private AuthService _service = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _users = []; + _db.SetupDbSetWithGeneratedIds(c => c.Users, _users); + _organizations = []; + _db.SetupDbSetWithGeneratedIds(c => c.Organizations, _organizations); + _organizationUsers = []; + _db.SetupDbSet(c => c.OrganizationUsers, _organizationUsers); + _refreshTokens = []; + _db.SetupDbSetWithGeneratedIds(c => c.RefreshTokens, _refreshTokens); + + _passwordHasher = new PasswordHasher(); + + var tokenService = new Mock(); + tokenService.Setup(t => t.GenerateToken(It.IsAny())) + .Returns(new TokenResponse("access-token", DateTime.UtcNow.AddHours(1))); + + _service = new AuthService(_db.Object, tokenService.Object, _passwordHasher); + } + + private User AddUser(string email, string password, bool isActive = true) + { + var user = new User + { + Email = email, + FirstName = "First", + LastName = "Last", + IsActive = isActive, + CreatedAt = DateTimeOffset.UtcNow, + PasswordHash = string.Empty + }; + user.PasswordHash = _passwordHasher.HashPassword(user, password); + _db.Object.Users.Add(user); + return user; + } + + [Test] + public async Task WhenRegisteringWithValidDetails_ThenTokensAreReturned() + { + var response = await _service.RegisterAsync( + "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp"); + + Assert.That(response, Is.Not.Null); + Assert.That(response!.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(response.RefreshToken, Is.Not.Null.And.Not.Empty); + Assert.That(_users, Has.Count.EqualTo(1)); + Assert.That(_organizations, Has.Count.EqualTo(1)); + Assert.That(_organizationUsers.Single().Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public async Task WhenRegisteringWithDuplicateEmail_ThenNullIsReturned() + { + AddUser("alice@example.com", "SecurePass1!"); + + var response = await _service.RegisterAsync( + "alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenLoginWithValidCredentials_ThenTokensAreReturned() + { + AddUser("bob@example.com", "SecurePass1!"); + + var response = await _service.LoginAsync("bob@example.com", "SecurePass1!"); + + Assert.That(response, Is.Not.Null); + Assert.That(response!.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(response.RefreshToken, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task WhenLoginWithWrongPassword_ThenNullIsReturned() + { + AddUser("carol@example.com", "CorrectPass1!"); + + var response = await _service.LoginAsync("carol@example.com", "WrongPassword"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenLoginWithUnknownEmail_ThenNullIsReturned() + { + var response = await _service.LoginAsync("nobody@example.com", "SomePass1!"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenRefreshingWithValidToken_ThenNewTokensAreReturnedAndOldTokenIsRevoked() + { + var user = AddUser("dave@example.com", "SecurePass1!"); + var refreshToken = new RefreshToken + { + Token = "valid-refresh-token", + UserId = user.Id, + User = user, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Object.RefreshTokens.Add(refreshToken); + + var response = await _service.RefreshAsync("valid-refresh-token"); + + Assert.That(response, Is.Not.Null); + Assert.That(response!.RefreshToken, Is.Not.EqualTo("valid-refresh-token")); + Assert.That(refreshToken.IsRevoked, Is.True); + } + + [Test] + public async Task WhenRefreshingWithInvalidToken_ThenNullIsReturned() + { + var response = await _service.RefreshAsync("not-a-valid-refresh-token"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenRefreshingWithRevokedToken_ThenNullIsReturned() + { + var user = AddUser("eve@example.com", "SecurePass1!"); + _db.Object.RefreshTokens.Add(new RefreshToken + { + Token = "revoked-token", + UserId = user.Id, + User = user, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + IsRevoked = true, + CreatedAt = DateTimeOffset.UtcNow + }); + + var response = await _service.RefreshAsync("revoked-token"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenRefreshingWithExpiredToken_ThenNullIsReturned() + { + var user = AddUser("frank@example.com", "SecurePass1!"); + _db.Object.RefreshTokens.Add(new RefreshToken + { + Token = "expired-token", + UserId = user.Id, + User = user, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), + CreatedAt = DateTimeOffset.UtcNow.AddDays(-31) + }); + + var response = await _service.RefreshAsync("expired-token"); + + Assert.That(response, Is.Null); + } + + [Test] + public async Task WhenLoggingOut_ThenTokenIsRevoked() + { + var user = AddUser("grace@example.com", "SecurePass1!"); + var refreshToken = new RefreshToken + { + Token = "logout-token", + UserId = user.Id, + User = user, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Object.RefreshTokens.Add(refreshToken); + + await _service.LogoutAsync("logout-token"); + + Assert.That(refreshToken.IsRevoked, Is.True); + } + + [Test] + public async Task WhenLoggingOutAndRefreshing_ThenNullIsReturned() + { + var user = AddUser("henry@example.com", "SecurePass1!"); + _db.Object.RefreshTokens.Add(new RefreshToken + { + Token = "logout-then-refresh-token", + UserId = user.Id, + User = user, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }); + + await _service.LogoutAsync("logout-then-refresh-token"); + var response = await _service.RefreshAsync("logout-then-refresh-token"); + + Assert.That(response, Is.Null); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/ProjectPermissionRequirementHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/ProjectPermissionRequirementHandlerTests.cs index 0d2c8b3..1b25a32 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/ProjectPermissionRequirementHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/ProjectPermissionRequirementHandlerTests.cs @@ -1,180 +1,173 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using MicCheck.Api.Common.Security.Authorization; -using MicCheck.Api.Data; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Moq; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization; - -[TestFixture] -public class ProjectPermissionRequirementHandlerTests -{ - private MicCheckDbContext _db = null!; - private Mock _httpContextAccessor = null!; - private ProjectPermissionRequirementHandler _handler = null!; - - [SetUp] - public void SetUp() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); - - _httpContextAccessor = new Mock(); - _handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - private static AuthorizationHandlerContext CreateContext( - ClaimsPrincipal user, - ProjectPermission permission = ProjectPermission.ViewProject) - { - var requirement = new ProjectPermissionRequirement(permission); - return new AuthorizationHandlerContext([requirement], user, null); - } - - private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims) - { - return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); - } - - private void SetupRouteProjectId(int projectId) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.RouteValues["projectId"] = projectId.ToString(); - _httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); - } - - [Test] - public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds() - { - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1"), - new Claim("OrganizationRole", "Admin")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 1, - ProjectId = 10, - Permissions = [ProjectPermission.ViewProject] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(10); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1")); - - var context = CreateContext(user, ProjectPermission.ViewProject); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 2, - ProjectId = 20, - IsAdmin = true, - Permissions = [] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(20); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "2")); - - var context = CreateContext(user, ProjectPermission.DeleteFeature); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.True); - } - - [Test] - public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails() - { - _db.UserProjectPermissions.Add(new UserProjectPermission - { - UserId = 3, - ProjectId = 30, - Permissions = [ProjectPermission.ViewProject] - }); - await _db.SaveChangesAsync(); - - SetupRouteProjectId(30); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "3")); - - var context = CreateContext(user, ProjectPermission.DeleteFeature); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails() - { - SetupRouteProjectId(99); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "5")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenUserIdClaimIsMissing_ThenRequirementFails() - { - var user = CreateUserWithClaims(); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } - - [Test] - public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails() - { - _httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); - - var user = CreateUserWithClaims( - new Claim(JwtRegisteredClaimNames.Sub, "1")); - - var context = CreateContext(user); - - await _handler.HandleAsync(context); - - Assert.That(context.HasSucceeded, Is.False); - } -} +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization; + +[TestFixture] +public class ProjectPermissionRequirementHandlerTests +{ + private List _userProjectPermissions = null!; + private Mock _httpContextAccessor = null!; + private ProjectPermissionRequirementHandler _handler = null!; + + [SetUp] + public void SetUp() + { + var db = new Mock(); + _userProjectPermissions = []; + db.SetupDbSet(c => c.UserProjectPermissions, _userProjectPermissions); + + _httpContextAccessor = new Mock(); + _handler = new ProjectPermissionRequirementHandler(db.Object, _httpContextAccessor.Object); + } + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + ProjectPermission permission = ProjectPermission.ViewProject) + { + var requirement = new ProjectPermissionRequirement(permission); + return new AuthorizationHandlerContext([requirement], user, null); + } + + private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims) + { + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); + } + + private void SetupRouteProjectId(int projectId) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["projectId"] = projectId.ToString(); + _httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); + } + + [Test] + public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds() + { + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1"), + new Claim("OrganizationRole", "Admin")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds() + { + _userProjectPermissions.Add(new UserProjectPermission + { + UserId = 1, + ProjectId = 10, + Permissions = [ProjectPermission.ViewProject] + }); + + SetupRouteProjectId(10); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1")); + + var context = CreateContext(user, ProjectPermission.ViewProject); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted() + { + _userProjectPermissions.Add(new UserProjectPermission + { + UserId = 2, + ProjectId = 20, + IsAdmin = true, + Permissions = [] + }); + + SetupRouteProjectId(20); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "2")); + + var context = CreateContext(user, ProjectPermission.DeleteFeature); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails() + { + _userProjectPermissions.Add(new UserProjectPermission + { + UserId = 3, + ProjectId = 30, + Permissions = [ProjectPermission.ViewProject] + }); + + SetupRouteProjectId(30); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "3")); + + var context = CreateContext(user, ProjectPermission.DeleteFeature); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails() + { + SetupRouteProjectId(99); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "5")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenUserIdClaimIsMissing_ThenRequirementFails() + { + var user = CreateUserWithClaims(); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails() + { + _httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs deleted file mode 100644 index 45914fb..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using MicCheck.Api.Data; -using MicCheck.Api.Organizations; -using MicCheck.Api.Users; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using NUnit.Framework; - -namespace MicCheck.Api.Tests.Unit.Data; - -[TestFixture] -public class DatabaseSeederTests -{ - private MicCheckDbContext _db = null!; - private DatabaseSeeder _seeder = null!; - - [SetUp] - public void SetUp() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); - _seeder = new DatabaseSeeder(_db, new PasswordHasher()); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - [Test] - public async Task WhenTheDatabaseIsEmpty_ThenSeedingCreatesADeterministicAdminUser() - { - await _seeder.SeedAsync(); - - var admin = await _db.Users.SingleOrDefaultAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); - - Assert.That(admin, Is.Not.Null); - Assert.That(admin!.IsActive, Is.True); - } - - [Test] - public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminPasswordVerifiesAgainstTheKnownCredential() - { - await _seeder.SeedAsync(); - - var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); - var hasher = new PasswordHasher(); - - var result = hasher.VerifyHashedPassword(admin, admin.PasswordHash, DatabaseSeeder.SeedAdminPassword); - - Assert.That(result, Is.Not.EqualTo(PasswordVerificationResult.Failed)); - } - - [Test] - public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminIsAnAdminOfTheDefaultOrganization() - { - await _seeder.SeedAsync(); - - var organization = await _db.Organizations.SingleAsync(o => o.Name == "Default"); - var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); - var membership = await _db.OrganizationUsers - .SingleAsync(ou => ou.OrganizationId == organization.Id && ou.UserId == admin.Id); - - Assert.That(membership.Role, Is.EqualTo(OrganizationRole.Admin)); - Assert.That(membership.IsPrimary, Is.True); - } - - [Test] - public async Task WhenTheDatabaseIsEmpty_ThenTheSeededDevelopmentEnvironmentHasTheFixedApiKey() - { - await _seeder.SeedAsync(); - - var development = await _db.Environments.SingleAsync(e => e.Name == "Development"); - var staging = await _db.Environments.SingleAsync(e => e.Name == "Staging"); - var production = await _db.Environments.SingleAsync(e => e.Name == "Production"); - - Assert.That(development.ApiKey, Is.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); - Assert.That(staging.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); - Assert.That(production.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); - } - - [Test] - public async Task WhenSeedingRunsTwice_ThenItDoesNotCreateDuplicateOrganizationsOrUsers() - { - await _seeder.SeedAsync(); - await _seeder.SeedAsync(); - - Assert.That(await _db.Organizations.CountAsync(), Is.EqualTo(1)); - Assert.That(await _db.Users.CountAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail), Is.EqualTo(1)); - Assert.That(await _db.Environments.CountAsync(), Is.EqualTo(3)); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs deleted file mode 100755 index 8e52315..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs +++ /dev/null @@ -1,224 +0,0 @@ -using NUnit.Framework; -using MicCheck.Api.Data; -using MicCheck.Api.Features; -using MicCheck.Api.Organizations; -using MicCheck.Api.Projects; -using MicCheck.Api.Segments; -using MicCheck.Api.Identities; -using MicCheck.Api.Audit; -using MicCheck.Api.Webhooks; -using MicCheck.Api.Common.Security.ApiKeys; -using MicCheck.Api.Users; -using Microsoft.EntityFrameworkCore; -using AppEnvironment = MicCheck.Api.Environments.Environment; - -namespace MicCheck.Api.Tests.Unit.Data; - -[TestFixture] -public class MicCheckDbContextTests -{ - private MicCheckDbContext _db = null!; - - [SetUp] - public void SetUp() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - [Test] - public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById() - { - var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow }; - _db.Organizations.Add(organization); - await _db.SaveChangesAsync(); - - var retrieved = await _db.Organizations.FindAsync(organization.Id); - - Assert.That(retrieved, Is.Not.Null); - Assert.That(retrieved!.Name, Is.EqualTo("Acme")); - } - - [Test] - public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization() - { - var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow }; - _db.Organizations.Add(organization); - await _db.SaveChangesAsync(); - - var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow }; - _db.Projects.Add(project); - await _db.SaveChangesAsync(); - - var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync(); - - Assert.That(projects, Has.Count.EqualTo(1)); - Assert.That(projects[0].Name, Is.EqualTo("My Project")); - } - - [Test] - public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey() - { - var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }; - _db.Projects.Add(project); - await _db.SaveChangesAsync(); - - var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow }; - _db.Environments.Add(env); - await _db.SaveChangesAsync(); - - var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc"); - - Assert.That(retrieved, Is.Not.Null); - Assert.That(retrieved!.Name, Is.EqualTo("Production")); - } - - [Test] - public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject() - { - var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; - _db.Features.Add(feature); - await _db.SaveChangesAsync(); - - var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync(); - - Assert.That(features, Has.Count.EqualTo(1)); - Assert.That(features[0].Name, Is.EqualTo("dark_mode")); - } - - [Test] - public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment() - { - var state = new FeatureState - { - FeatureId = 1, - EnvironmentId = 1, - Enabled = true, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }; - _db.FeatureStates.Add(state); - await _db.SaveChangesAsync(); - - var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync(); - - Assert.That(states, Has.Count.EqualTo(1)); - Assert.That(states[0].Enabled, Is.True); - } - - [Test] - public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded() - { - var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; - _db.Segments.Add(segment); - await _db.SaveChangesAsync(); - - var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; - _db.SegmentRules.Add(rule); - await _db.SaveChangesAsync(); - - var loaded = await _db.Segments - .Include(s => s.Rules) - .FirstAsync(s => s.Id == segment.Id); - - Assert.That(loaded.Rules, Has.Count.EqualTo(1)); - } - - [Test] - public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded() - { - var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow }; - _db.Identities.Add(identity); - await _db.SaveChangesAsync(); - - _db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" }); - await _db.SaveChangesAsync(); - - var loaded = await _db.Identities - .Include(i => i.Traits) - .FirstAsync(i => i.Id == identity.Id); - - Assert.That(loaded.Traits, Has.Count.EqualTo(1)); - Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan")); - } - - [Test] - public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization() - { - _db.AuditLogs.Add(new AuditLog - { - ResourceType = "Feature", - ResourceId = "1", - Action = "Created", - OrganizationId = 42, - CreatedAt = DateTimeOffset.UtcNow - }); - await _db.SaveChangesAsync(); - - var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync(); - - Assert.That(logs, Has.Count.EqualTo(1)); - } - - [Test] - public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment() - { - _db.Webhooks.Add(new Webhook - { - Url = "https://example.com/hook", - Scope = WebhookScope.Environment, - EnvironmentId = 5, - CreatedAt = DateTimeOffset.UtcNow - }); - await _db.SaveChangesAsync(); - - var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync(); - - Assert.That(webhooks, Has.Count.EqualTo(1)); - } - - [Test] - public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey() - { - _db.ApiKeys.Add(new ApiKey - { - Key = "hashed-value-xyz", - Prefix = "hashed-va", - Name = "CI Key", - OrganizationId = 1, - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await _db.SaveChangesAsync(); - - var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz"); - - Assert.That(key, Is.Not.Null); - Assert.That(key!.IsActive, Is.True); - } - - [Test] - public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail() - { - _db.Users.Add(new User - { - Email = "alice@example.com", - PasswordHash = "hashed", - FirstName = "Alice", - LastName = "Smith", - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await _db.SaveChangesAsync(); - - var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com"); - - Assert.That(user, Is.Not.Null); - Assert.That(user!.FirstName, Is.EqualTo("Alice")); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs index 114c846..23205f6 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs @@ -2,7 +2,10 @@ using MicCheck.Api.Audit; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; -using Microsoft.EntityFrameworkCore; +using MicCheck.Api.Identities; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; using Moq; using NUnit.Framework; using AppEnvironment = MicCheck.Api.Environments.Environment; @@ -12,7 +15,11 @@ namespace MicCheck.Api.Tests.Unit.Environments; [TestFixture] public class EnvironmentServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _environments = null!; + private List _features = null!; + private List _featureStates = null!; + private List _identities = null!; private EnvironmentService _service = null!; private const int OrganizationId = 1; private const int ProjectId = 1; @@ -20,60 +27,49 @@ public class EnvironmentServiceTests [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _db.SetupDbSet(c => c.Projects, [ + new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _environments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Environments, _environments); + _features = []; + _db.SetupDbSet(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + _identities = []; + _db.SetupDbSet(c => c.Identities, _identities); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db, null!, webhookQueue); + var auditService = new Mock(_db.Object, null!, webhookQueue); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - _service = new EnvironmentService(_db, auditService.Object); - - SeedBaseData(); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - private void SeedBaseData() - { - _db.Organizations.Add(new MicCheck.Api.Organizations.Organization - { - Id = OrganizationId, - Name = "Test Org", - CreatedAt = DateTimeOffset.UtcNow - }); - _db.Projects.Add(new MicCheck.Api.Projects.Project - { - Id = ProjectId, - Name = "Test Project", - OrganizationId = OrganizationId, - CreatedAt = DateTimeOffset.UtcNow - }); - _db.SaveChanges(); + _service = new EnvironmentService(_db.Object, auditService.Object); } [Test] public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature() { - _db.Features.Add(new Feature + _features.Add(new Feature { + Id = 1, Name = "feature_a", ProjectId = ProjectId, InitialValue = "hello", CreatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var environment = await _service.CreateAsync(ProjectId, "Staging"); - var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == environment.Id).ToListAsync(); + var states = _featureStates.Where(fs => fs.EnvironmentId == environment.Id).ToList(); Assert.That(states, Has.Count.EqualTo(1)); Assert.That(states[0].Value, Is.EqualTo("hello")); } @@ -92,11 +88,10 @@ public class EnvironmentServiceTests { var source = await _service.CreateAsync(ProjectId, "Production"); - var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Features.Add(feature); - _db.SaveChanges(); + var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _features.Add(feature); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = source.Id, @@ -105,13 +100,10 @@ public class EnvironmentServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var cloned = await _service.CloneAsync(source.ApiKey, "Staging"); - var clonedStates = await _db.FeatureStates - .Where(fs => fs.EnvironmentId == cloned.Id) - .ToListAsync(); + var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList(); Assert.That(clonedStates, Has.Count.EqualTo(1)); Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value")); @@ -123,20 +115,19 @@ public class EnvironmentServiceTests { var source = await _service.CreateAsync(ProjectId, "Production"); - var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Features.Add(feature); - _db.SaveChanges(); + var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _features.Add(feature); - var identity = new MicCheck.Api.Identities.Identity + var identity = new Identity { + Id = 1, Identifier = "user-1", EnvironmentId = source.Id, CreatedAt = DateTimeOffset.UtcNow }; - _db.Identities.Add(identity); - _db.SaveChanges(); + _identities.Add(identity); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = source.Id, @@ -145,13 +136,10 @@ public class EnvironmentServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var cloned = await _service.CloneAsync(source.ApiKey, "Staging"); - var clonedStates = await _db.FeatureStates - .Where(fs => fs.EnvironmentId == cloned.Id) - .ToListAsync(); + var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList(); Assert.That(clonedStates, Is.Empty); } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs index 0cdf2e8..86073e5 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs @@ -3,7 +3,7 @@ using MicCheck.Api.Data; using MicCheck.Api.Features; using MicCheck.Api.Identities; using MicCheck.Api.Segments; -using Microsoft.EntityFrameworkCore; +using MicCheck.Api.Tests.Unit.TestSupport; using Microsoft.Extensions.Caching.Memory; using Moq; using NUnit.Framework; @@ -14,7 +14,13 @@ namespace MicCheck.Api.Tests.Unit.Features; [TestFixture] public class FeatureEvaluationServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _environments = null!; + private List _features = null!; + private List _featureStates = null!; + private List _identities = null!; + private List _segments = null!; + private List _featureSegments = null!; private FeatureEvaluationService _service = null!; private FeatureUsageMetrics _usageMetrics = null!; private const int EnvironmentId = 1; @@ -23,52 +29,49 @@ public class FeatureEvaluationServiceTests [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); + _db = new Mock(); + + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key-test", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }]; + var environmentsSet = MockDbSetFactory.Create(_environments); + environmentsSet.Setup(m => m.FindAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0])); + _db.Setup(c => c.Environments).Returns(environmentsSet.Object); + + _features = []; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _db.SetupDbSet(c => c.IdentityTraits, []); + _segments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + _db.SetupDbSet(c => c.SegmentRules, []); + _db.SetupDbSet(c => c.SegmentConditions, []); + _featureSegments = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments); var meterFactory = new Mock(); meterFactory.Setup(f => f.Create(It.IsAny())).Returns(new Meter("test")); _usageMetrics = new FeatureUsageMetrics(meterFactory.Object); var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions())); - _service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics); - - SeedBaseData(); + _service = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), cache, _usageMetrics); } [TearDown] - public void TearDown() - { - _db.Dispose(); - _usageMetrics.Dispose(); - } + public void TearDown() => _usageMetrics.Dispose(); - private void SeedBaseData() + private Feature AddFeature(string name, bool defaultEnabled = false) { - _db.Environments.Add(new AppEnvironment - { - Id = EnvironmentId, - Name = "Production", - ApiKey = "env-key-test", - ProjectId = ProjectId, - CreatedAt = DateTimeOffset.UtcNow - }); - _db.SaveChanges(); - } - - private MicCheck.Api.Features.Feature AddFeature(string name, bool defaultEnabled = false) - { - var feature = new MicCheck.Api.Features.Feature + var feature = new Feature { Name = name, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow, DefaultEnabled = defaultEnabled }; - _db.Features.Add(feature); - _db.SaveChanges(); + _db.Object.Features.Add(feature); return feature; } @@ -83,8 +86,7 @@ public class FeatureEvaluationServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; - _db.FeatureStates.Add(fs); - _db.SaveChanges(); + _featureStates.Add(fs); return fs; } @@ -136,10 +138,9 @@ public class FeatureEvaluationServiceTests EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Identities.Add(identity); - _db.SaveChanges(); + _db.Object.Identities.Add(identity); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = EnvironmentId, @@ -149,7 +150,6 @@ public class FeatureEvaluationServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip"); @@ -161,11 +161,10 @@ public class FeatureEvaluationServiceTests public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed() { var feature = AddFeature("premium_feature"); - var envDefault = AddEnvironmentDefault(feature.Id, enabled: false); + AddEnvironmentDefault(feature.Id, enabled: false); var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Segments.Add(segment); - _db.SaveChanges(); + _db.Object.Segments.Add(segment); var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; rule.Conditions.Add(new SegmentCondition @@ -175,8 +174,7 @@ public class FeatureEvaluationServiceTests Operator = SegmentConditionOperator.Equal, Value = "premium" }); - _db.SegmentRules.Add(rule); - _db.SaveChanges(); + segment.Rules.Add(rule); var featureSegment = new FeatureSegment { @@ -185,10 +183,9 @@ public class FeatureEvaluationServiceTests EnvironmentId = EnvironmentId, Priority = 1 }; - _db.FeatureSegments.Add(featureSegment); - _db.SaveChanges(); + _db.Object.FeatureSegments.Add(featureSegment); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = EnvironmentId, @@ -198,7 +195,6 @@ public class FeatureEvaluationServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var results = await _service.EvaluateForIdentityAsync( EnvironmentId, "user-123", @@ -215,8 +211,7 @@ public class FeatureEvaluationServiceTests AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value"); var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Segments.Add(segment); - _db.SaveChanges(); + _db.Object.Segments.Add(segment); var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; rule.Conditions.Add(new SegmentCondition @@ -226,8 +221,7 @@ public class FeatureEvaluationServiceTests Operator = SegmentConditionOperator.Equal, Value = "premium" }); - _db.SegmentRules.Add(rule); - _db.SaveChanges(); + segment.Rules.Add(rule); var featureSegment = new FeatureSegment { @@ -236,10 +230,9 @@ public class FeatureEvaluationServiceTests EnvironmentId = EnvironmentId, Priority = 1 }; - _db.FeatureSegments.Add(featureSegment); - _db.SaveChanges(); + _db.Object.FeatureSegments.Add(featureSegment); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = EnvironmentId, @@ -249,7 +242,6 @@ public class FeatureEvaluationServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var results = await _service.EvaluateForIdentityAsync( EnvironmentId, "user-123", @@ -266,8 +258,7 @@ public class FeatureEvaluationServiceTests AddEnvironmentDefault(feature.Id, enabled: false); var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Segments.Add(segment); - _db.SaveChanges(); + _db.Object.Segments.Add(segment); var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; rule.Conditions.Add(new SegmentCondition @@ -277,8 +268,7 @@ public class FeatureEvaluationServiceTests Operator = SegmentConditionOperator.IsSet, Value = "" }); - _db.SegmentRules.Add(rule); - _db.SaveChanges(); + segment.Rules.Add(rule); var featureSegment = new FeatureSegment { @@ -287,10 +277,9 @@ public class FeatureEvaluationServiceTests EnvironmentId = EnvironmentId, Priority = 1 }; - _db.FeatureSegments.Add(featureSegment); - _db.SaveChanges(); + _db.Object.FeatureSegments.Add(featureSegment); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = EnvironmentId, @@ -307,8 +296,7 @@ public class FeatureEvaluationServiceTests EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }; - _db.Identities.Add(identity); - _db.SaveChanges(); + _db.Object.Identities.Add(identity); identity.Traits.Add(new IdentityTrait { @@ -317,7 +305,7 @@ public class FeatureEvaluationServiceTests Value = "US" }); - _db.FeatureStates.Add(new FeatureState + _featureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = EnvironmentId, @@ -327,7 +315,6 @@ public class FeatureEvaluationServiceTests CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); var results = await _service.EvaluateForIdentityAsync( EnvironmentId, "user-special", diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs index d878e26..fc53292 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs @@ -1,8 +1,10 @@ using MicCheck.Api.Audit; +using MicCheck.Api.Common; using MicCheck.Api.Data; using MicCheck.Api.Features; -using MicCheck.Api.Common; -using Microsoft.EntityFrameworkCore; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; using Moq; using NUnit.Framework; using AppEnvironment = MicCheck.Api.Environments.Environment; @@ -12,7 +14,11 @@ namespace MicCheck.Api.Tests.Unit.Features; [TestFixture] public class FeatureServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _projects = null!; + private List _features = null!; + private List _featureStates = null!; + private List _tags = null!; private FeatureService _service = null!; private const int OrganizationId = 1; private const int ProjectId = 1; @@ -21,51 +27,32 @@ public class FeatureServiceTests [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _projects = [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + _db.SetupDbSet(c => c.Environments, [ + new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "test-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _features = []; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + _tags = []; + _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db, null!, webhookQueue); + var auditService = new Mock(_db.Object, null!, webhookQueue); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - _service = new FeatureService(_db, auditService.Object, webhookQueue); - - SeedBaseData(); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - private void SeedBaseData() - { - _db.Organizations.Add(new MicCheck.Api.Organizations.Organization - { - Id = OrganizationId, - Name = "Test Org", - CreatedAt = DateTimeOffset.UtcNow - }); - _db.Projects.Add(new MicCheck.Api.Projects.Project - { - Id = ProjectId, - Name = "Test Project", - OrganizationId = OrganizationId, - CreatedAt = DateTimeOffset.UtcNow - }); - _db.Environments.Add(new AppEnvironment - { - Id = EnvironmentId, - Name = "Production", - ApiKey = "test-key", - ProjectId = ProjectId, - CreatedAt = DateTimeOffset.UtcNow - }); - _db.SaveChanges(); + _service = new FeatureService(_db.Object, auditService.Object, webhookQueue); } [Test] @@ -73,7 +60,7 @@ public class FeatureServiceTests { var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null); - var states = await _db.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToListAsync(); + var states = _featureStates.Where(fs => fs.FeatureId == feature.Id).ToList(); Assert.That(states, Has.Count.EqualTo(1)); Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId)); } @@ -83,23 +70,23 @@ public class FeatureServiceTests { var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, "initial-val", null); - var state = await _db.FeatureStates.FirstAsync(fs => fs.FeatureId == feature.Id); + var state = _featureStates.First(fs => fs.FeatureId == feature.Id); Assert.That(state.Value, Is.EqualTo("initial-val")); } [Test] - public async Task WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException() + public void WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException() { for (var i = 0; i < 400; i++) { - _db.Features.Add(new Feature + _features.Add(new Feature { + Id = i + 1, Name = $"feature_{i}", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); } - _db.SaveChanges(); Assert.ThrowsAsync(() => _service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null)); @@ -123,7 +110,7 @@ public class FeatureServiceTests await _service.DeleteAsync(feature.Id); - var found = await _db.Features.FirstOrDefaultAsync(f => f.Id == feature.Id); + var found = _features.FirstOrDefault(f => f.Id == feature.Id); Assert.That(found, Is.Null); } @@ -143,8 +130,7 @@ public class FeatureServiceTests { var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; - _db.Tags.Add(tag); - await _db.SaveChangesAsync(); + _db.Object.Tags.Add(tag); var updated = await _service.AssignTagAsync(feature.Id, tag.Id); @@ -156,8 +142,7 @@ public class FeatureServiceTests { var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; - _db.Tags.Add(tag); - await _db.SaveChangesAsync(); + _db.Object.Tags.Add(tag); await _service.AssignTagAsync(feature.Id, tag.Id); var updated = await _service.AssignTagAsync(feature.Id, tag.Id); @@ -168,7 +153,7 @@ public class FeatureServiceTests [Test] public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown() { - _db.Projects.Add(new MicCheck.Api.Projects.Project + _projects.Add(new Project { Id = 2, Name = "Other Project", @@ -177,8 +162,7 @@ public class FeatureServiceTests }); var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 }; - _db.Tags.Add(foreignTag); - await _db.SaveChangesAsync(); + _db.Object.Tags.Add(foreignTag); Assert.ThrowsAsync(() => _service.AssignTagAsync(feature.Id, foreignTag.Id)); } @@ -188,8 +172,7 @@ public class FeatureServiceTests { var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; - _db.Tags.Add(tag); - await _db.SaveChangesAsync(); + _db.Object.Tags.Add(tag); await _service.AssignTagAsync(feature.Id, tag.Id); var updated = await _service.RemoveTagAsync(feature.Id, tag.Id); diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs index f25fa29..966bbf0 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs @@ -1,6 +1,7 @@ using MicCheck.Api.Data; using MicCheck.Api.Features; -using Microsoft.EntityFrameworkCore; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; using NUnit.Framework; namespace MicCheck.Api.Tests.Unit.Features; @@ -8,26 +9,23 @@ namespace MicCheck.Api.Tests.Unit.Features; [TestFixture] public class FeatureUsageQueryServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _usage = null!; private FeatureUsageQueryService _service = null!; private const int EnvironmentId = 1; [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); - _service = new FeatureUsageQueryService(_db); + _db = new Mock(); + _usage = []; + _db.SetupDbSet(c => c.FeatureUsageDaily, _usage); + _service = new FeatureUsageQueryService(_db.Object); } - [TearDown] - public void TearDown() => _db.Dispose(); - private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count) { - _db.FeatureUsageDaily.Add(new FeatureUsageDaily + _usage.Add(new FeatureUsageDaily { EnvironmentId = environmentId, FeatureId = featureId, @@ -36,7 +34,6 @@ public class FeatureUsageQueryServiceTests Count = count, UpdatedAt = DateTimeOffset.UtcNow }); - _db.SaveChanges(); } [Test] diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs index d7187e0..689f31d 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs @@ -1,8 +1,15 @@ -using System.Net; -using System.Net.Http.Json; +using System.Security.Claims; using MicCheck.Api.Data; +using MicCheck.Api.Environments; using MicCheck.Api.Features; -using Microsoft.Extensions.DependencyInjection; +using MicCheck.Api.Identities; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Moq; using NUnit.Framework; using AppEnvironment = MicCheck.Api.Environments.Environment; @@ -11,127 +18,110 @@ namespace MicCheck.Api.Tests.Unit.Features; [TestFixture] public class FlagsApiIntegrationTests { - private MicCheckWebApplicationFactory _factory = null!; - private string _envApiKey = null!; + private Mock _db = null!; + private List _environments = null!; + private List _projects = null!; + private List _features = null!; + private List _featureStates = null!; + private List _identities = null!; + private AppEnvironment _environment = null!; [SetUp] public void SetUp() { - _factory = new MicCheckWebApplicationFactory(); - _envApiKey = SeedEnvironmentWithFlag(); + _db = new Mock(); + + _projects = [new Project { Id = 1, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + + _environment = new AppEnvironment + { + Id = 1, + Name = "Test Env", + ApiKey = "test-env-key", + ProjectId = 1, + CreatedAt = DateTimeOffset.UtcNow + }; + _environments = [_environment]; + var environmentsSet = MockDbSetFactory.Create(_environments); + environmentsSet.Setup(m => m.FindAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0])); + _db.Setup(c => c.Environments).Returns(environmentsSet.Object); + + _features = [new Feature { Id = 1, Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + + _featureStates = [ + new FeatureState { FeatureId = 1, EnvironmentId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow } + ]; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _db.SetupDbSet(c => c.IdentityTraits, []); + _db.SetupDbSetWithGeneratedIds(c => c.Segments, []); + _db.SetupDbSet(c => c.SegmentRules, []); + _db.SetupDbSet(c => c.SegmentConditions, []); + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, []); } - [TearDown] - public void TearDown() => _factory.Dispose(); - - private string SeedEnvironmentWithFlag() + private static ControllerBase WithEnvironmentClaim(ControllerBase controller, int environmentId) { - using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var apiKey = $"test-env-key-{Guid.NewGuid():N}"; - - var project = new MicCheck.Api.Projects.Project + var identity = new ClaimsIdentity([new Claim("EnvironmentId", environmentId.ToString())], "EnvironmentKey"); + controller.ControllerContext = new ControllerContext { - Name = "Test Project", - OrganizationId = 1, - CreatedAt = DateTimeOffset.UtcNow + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; - db.Projects.Add(project); - db.SaveChanges(); + return controller; + } - var environment = new AppEnvironment - { - Name = "Test Env", - ApiKey = apiKey, - ProjectId = project.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Environments.Add(environment); - db.SaveChanges(); + private FeatureEvaluationService CreateEvaluationService() => + new(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), CreateUsageMetrics()); - var feature = new Feature - { - Name = "dark_mode", - ProjectId = project.Id, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Features.Add(feature); - db.SaveChanges(); - - db.FeatureStates.Add(new FeatureState - { - FeatureId = feature.Id, - EnvironmentId = environment.Id, - Enabled = true, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }); - db.SaveChanges(); - - return apiKey; + private static FeatureUsageMetrics CreateUsageMetrics() + { + var meterFactory = new Mock(); + meterFactory.Setup(f => f.Create(It.IsAny())) + .Returns(new System.Diagnostics.Metrics.Meter("test")); + return new FeatureUsageMetrics(meterFactory.Object); } [Test] public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned() { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + var controller = (FlagsController)WithEnvironmentClaim( + new FlagsController(CreateEvaluationService()), _environment.Id); - var response = await client.GetAsync("/api/v1/flags/"); + var result = await controller.GetAll(CancellationToken.None); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - - var flags = await response.Content.ReadFromJsonAsync>(); + var flags = (result.Result as OkObjectResult)?.Value as IReadOnlyList; Assert.That(flags, Has.Count.EqualTo(1)); Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode")); Assert.That(flags[0].Enabled, Is.True); } - [Test] - public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/v1/flags/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - - [Test] - public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key"); - - var response = await client.GetAsync("/api/v1/flags/"); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - } - [Test] public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned() { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + var controller = (IdentitiesController)WithEnvironmentClaim( + new IdentitiesController(CreateEvaluationService(), new IdentityResolutionService(_db.Object)), + _environment.Id); - var response = await client.PostAsJsonAsync("/api/v1/identity/", new - { - identifier = "user-123", - traits = new[] { new { trait_key = "plan", trait_value = "premium" } } - }); + var result = await controller.Identify( + new IdentityRequest("user-123", [new TraitInput("plan", "premium")]), CancellationToken.None); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(result.Result, Is.TypeOf()); } [Test] public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned() { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + var controller = (EnvironmentDocumentController)WithEnvironmentClaim( + new EnvironmentDocumentController(new EnvironmentDocumentService(_db.Object)), + _environment.Id); - var response = await client.GetAsync("/api/v1/environment-document/"); + var result = await controller.Get(CancellationToken.None); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(result.Result, Is.TypeOf()); } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj index 71dba73..a2015b0 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj +++ b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj @@ -11,8 +11,6 @@ - - diff --git a/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs b/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs deleted file mode 100755 index 8a5aa18..0000000 --- a/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MicCheck.Api.Data; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace MicCheck.Api.Tests.Unit; - -public class MicCheckWebApplicationFactory : WebApplicationFactory -{ - private static readonly IServiceProvider InMemoryEfServiceProvider = - new ServiceCollection() - .AddEntityFrameworkInMemoryDatabase() - .BuildServiceProvider(); - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - var dbName = Guid.NewGuid().ToString(); - - builder.UseEnvironment("Testing"); - builder.ConfigureServices(services => - { - var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(MicCheckDbContext)); - if (contextDescriptor is not null) - services.Remove(contextDescriptor); - - var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); - if (optionsDescriptor is not null) - services.Remove(optionsDescriptor); - - services.AddDbContext(options => - options - .UseInMemoryDatabase(dbName) - .UseInternalServiceProvider(InMemoryEfServiceProvider)); - }); - } -} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs index fbc92e4..1cbb161 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs @@ -1,8 +1,10 @@ using MicCheck.Api.Audit; using MicCheck.Api.Common; using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; using MicCheck.Api.Segments; -using Microsoft.EntityFrameworkCore; +using MicCheck.Api.Tests.Unit.TestSupport; using Moq; using NUnit.Framework; @@ -11,7 +13,10 @@ namespace MicCheck.Api.Tests.Unit.Segments; [TestFixture] public class SegmentServiceTests { - private MicCheckDbContext _db = null!; + private Mock _db = null!; + private List _segments = null!; + private List _segmentRules = null!; + private List _segmentConditions = null!; private SegmentService _service = null!; private const int OrganizationId = 1; private const int ProjectId = 1; @@ -19,43 +24,30 @@ public class SegmentServiceTests [SetUp] public void SetUp() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new MicCheckDbContext(options); + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _db.SetupDbSet(c => c.Projects, [ + new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _segments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + _segmentRules = []; + _db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, _segmentRules); + _segmentConditions = []; + _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db, null!, webhookQueue); + var auditService = new Mock(_db.Object, null!, webhookQueue); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - _service = new SegmentService(_db, auditService.Object); - - SeedBaseData(); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - private void SeedBaseData() - { - _db.Organizations.Add(new MicCheck.Api.Organizations.Organization - { - Id = OrganizationId, - Name = "Test Org", - CreatedAt = DateTimeOffset.UtcNow - }); - _db.Projects.Add(new MicCheck.Api.Projects.Project - { - Id = ProjectId, - Name = "Test Project", - OrganizationId = OrganizationId, - CreatedAt = DateTimeOffset.UtcNow - }); - _db.SaveChanges(); + _service = new SegmentService(_db.Object, auditService.Object); } private static SegmentRuleDefinition SingleConditionRule( @@ -71,36 +63,33 @@ public class SegmentServiceTests var rules = new[] { SingleConditionRule() }; var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules); - var loaded = await _db.Segments - .Include(s => s.Rules) - .ThenInclude(r => r.Conditions) - .FirstAsync(s => s.Id == segment.Id); + var persistedRules = _segmentRules.Where(r => r.SegmentId == segment.Id).ToList(); - Assert.That(loaded.Name, Is.EqualTo("Premium Users")); - Assert.That(loaded.Rules, Has.Count.EqualTo(1)); - Assert.That(loaded.Rules.First().Conditions, Has.Count.EqualTo(1)); + Assert.That(segment.Name, Is.EqualTo("Premium Users")); + Assert.That(persistedRules, Has.Count.EqualTo(1)); + Assert.That(persistedRules[0].Conditions, Has.Count.EqualTo(1)); } [Test] - public async Task WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException() + public void WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException() { for (var i = 0; i < 100; i++) { - _db.Segments.Add(new Segment + _segments.Add(new Segment { + Id = i + 1, Name = $"segment_{i}", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); } - _db.SaveChanges(); Assert.ThrowsAsync(() => _service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()])); } [Test] - public async Task WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException() + public void WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException() { var conditions = Enumerable.Range(0, 101) .Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val")) @@ -119,14 +108,11 @@ public class SegmentServiceTests var newRules = new[] { SingleConditionRule("country") }; var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules); - var loaded = await _db.Segments - .Include(s => s.Rules) - .ThenInclude(r => r.Conditions) - .FirstAsync(s => s.Id == updated.Id); + var persistedRules = _segmentRules.Where(r => r.SegmentId == updated.Id).ToList(); - Assert.That(loaded.Name, Is.EqualTo("Updated Segment")); - Assert.That(loaded.Rules, Has.Count.EqualTo(1)); - Assert.That(loaded.Rules.First().Conditions.First().Property, Is.EqualTo("country")); + Assert.That(updated.Name, Is.EqualTo("Updated Segment")); + Assert.That(persistedRules, Has.Count.EqualTo(1)); + Assert.That(persistedRules[0].Conditions.First().Property, Is.EqualTo("country")); } [Test] @@ -136,7 +122,7 @@ public class SegmentServiceTests await _service.DeleteAsync(segment.Id); - var found = await _db.Segments.FirstOrDefaultAsync(s => s.Id == segment.Id); + var found = _segments.FirstOrDefault(s => s.Id == segment.Id); Assert.That(found, Is.Null); } diff --git a/tests/api/MicCheck.Api.Tests.Unit/TestSupport/MockDbSetFactory.cs b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/MockDbSetFactory.cs new file mode 100644 index 0000000..cd25f4a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/MockDbSetFactory.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace MicCheck.Api.Tests.Unit.TestSupport; + +public static class MockDbSetFactory +{ + public static Mock> Create(List data) where T : class + { + var queryable = data.AsQueryable(); + var mockSet = new Mock>(); + + mockSet.As>() + .Setup(m => m.GetAsyncEnumerator(It.IsAny())) + .Returns(() => new TestAsyncEnumerator(data.GetEnumerator())); + + mockSet.As>().Setup(m => m.Provider).Returns(() => new TestAsyncQueryProvider(data.AsQueryable().Provider)); + mockSet.As>().Setup(m => m.Expression).Returns(() => data.AsQueryable().Expression); + mockSet.As>().Setup(m => m.ElementType).Returns(queryable.ElementType); + mockSet.As>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator()); + + mockSet.Setup(m => m.Add(It.IsAny())).Callback(item => data.Add(item)); + mockSet.Setup(m => m.AddRange(It.IsAny>())).Callback>(items => data.AddRange(items)); + mockSet.Setup(m => m.Remove(It.IsAny())).Callback(item => data.Remove(item)); + mockSet.Setup(m => m.RemoveRange(It.IsAny>())).Callback>(items => + { + foreach (var item in items.ToList()) + data.Remove(item); + }); + + return mockSet; + } + + public static void SetupDbSet(this Mock context, Expression>> selector, List data) + where T : class => + context.Setup(selector).Returns(Create(data).Object); + + /// + /// Sets up a DbSet whose Add() simulates database-generated identity assignment (needed because + /// Moq does not run SaveChanges and entity Id properties are commonly init-only). + /// + public static void SetupDbSetWithGeneratedIds(this Mock context, Expression>> selector, List data) + where T : class + { + var idProperty = typeof(T).GetProperty("Id") ?? throw new InvalidOperationException($"{typeof(T).Name} has no Id property."); + var mockSet = Create(data); + mockSet.Setup(m => m.Add(It.IsAny())).Callback(item => + { + idProperty.SetValue(item, data.Count + 1); + data.Add(item); + }); + context.Setup(selector).Returns(mockSet.Object); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncEnumerable.cs b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncEnumerable.cs new file mode 100644 index 0000000..a69d145 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncEnumerable.cs @@ -0,0 +1,29 @@ +using System.Collections; +using System.Linq.Expressions; + +namespace MicCheck.Api.Tests.Unit.TestSupport; + +public class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable +{ + public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } + + public TestAsyncEnumerable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); +} + +public sealed class TestAsyncEnumerator(IEnumerator inner) : IAsyncEnumerator +{ + public T Current => inner.Current; + + public ValueTask MoveNextAsync() => ValueTask.FromResult(inner.MoveNext()); + + public ValueTask DisposeAsync() + { + inner.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncQueryProvider.cs b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncQueryProvider.cs new file mode 100644 index 0000000..9d54938 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/TestSupport/TestAsyncQueryProvider.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace MicCheck.Api.Tests.Unit.TestSupport; + +public class TestAsyncQueryProvider(IQueryProvider innerProvider) : IAsyncQueryProvider +{ + public IQueryable CreateQuery(Expression expression) => + new TestAsyncEnumerable(StripIncludes(expression)); + + public IQueryable CreateQuery(Expression expression) => + new TestAsyncEnumerable(StripIncludes(expression)); + + public object? Execute(Expression expression) => + innerProvider.Execute(StripIncludes(expression)); + + public TResult Execute(Expression expression) => + innerProvider.Execute(StripIncludes(expression)); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + var stripped = StripIncludes(expression); + var resultType = typeof(TResult).GetGenericArguments() is [var inner] ? inner : typeof(TResult); + var executionResult = typeof(IQueryProvider) + .GetMethod(nameof(IQueryProvider.Execute), 1, [typeof(Expression)])! + .MakeGenericMethod(resultType) + .Invoke(innerProvider, [stripped]); + + return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(resultType) + .Invoke(null, [executionResult])!; + } + + private static Expression StripIncludes(Expression expression) => new IncludeStripVisitor().Visit(expression); + + private sealed class IncludeStripVisitor : ExpressionVisitor + { + protected override Expression VisitMethodCall(MethodCallExpression node) => + node.Method.Name is "Include" or "ThenInclude" + ? Visit(node.Arguments[0]) + : base.VisitMethodCall(node); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs index 09b3ca8..b919d49 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs @@ -2,8 +2,8 @@ using System.Net; using System.Security.Cryptography; using System.Text; using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; using MicCheck.Api.Webhooks; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; @@ -52,28 +52,24 @@ public class WebhookDispatcherTests [Test] public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader() { - var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); + var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); - var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; - db.Organizations.Add(org); - db.SaveChanges(); - - db.Webhooks.Add(new Webhook + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com/hook", Secret = "test-secret", Scope = WebhookScope.Organization, - OrganizationId = org.Id, + OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); - db.SaveChanges(); - var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); var webhookEvent = new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, - OrganizationId = org.Id, + OrganizationId = orgId, Data = new { test = true } }; @@ -86,28 +82,24 @@ public class WebhookDispatcherTests [Test] public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded() { - var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); + var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); - var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; - db.Organizations.Add(org); - db.SaveChanges(); - - db.Webhooks.Add(new Webhook + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com/hook", Secret = null, Scope = WebhookScope.Organization, - OrganizationId = org.Id, + OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); - db.SaveChanges(); - var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, - OrganizationId = org.Id, + OrganizationId = orgId, Data = new { } }); @@ -117,31 +109,27 @@ public class WebhookDispatcherTests [Test] public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess() { - var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); - var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; - db.Organizations.Add(org); - db.SaveChanges(); - - db.Webhooks.Add(new Webhook + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com/hook", Scope = WebhookScope.Organization, - OrganizationId = org.Id, + OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); - db.SaveChanges(); - var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, - OrganizationId = org.Id, + OrganizationId = orgId, Data = new { } }); - var log = db.WebhookDeliveryLogs.First(); + var log = deliveryLogs.First(); Assert.That(log.Success, Is.True); Assert.That(log.ResponseStatusCode, Is.EqualTo(200)); Assert.That(log.AttemptNumber, Is.EqualTo(1)); @@ -150,31 +138,27 @@ public class WebhookDispatcherTests [Test] public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure() { - var (db, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError); + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError); - var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; - db.Organizations.Add(org); - db.SaveChanges(); - - db.Webhooks.Add(new Webhook + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com/hook", Scope = WebhookScope.Organization, - OrganizationId = org.Id, + OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); - db.SaveChanges(); - var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, - OrganizationId = org.Id, + OrganizationId = orgId, Data = new { } }); - var log = db.WebhookDeliveryLogs.First(); + var log = deliveryLogs.First(); Assert.That(log.Success, Is.False); Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); } @@ -182,9 +166,9 @@ public class WebhookDispatcherTests [Test] public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() { - var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + var (db, _, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); - var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, @@ -192,16 +176,17 @@ public class WebhookDispatcherTests Data = new { } }); - Assert.That(db.WebhookDeliveryLogs.Count(), Is.EqualTo(0)); + Assert.That(deliveryLogs, Is.Empty); } - private static (MicCheckDbContext Db, IHttpClientFactory Factory, List CapturedRequests) + private static (Mock Db, List Webhooks, List DeliveryLogs, IHttpClientFactory Factory, List CapturedRequests) SetUpDispatcher(HttpStatusCode responseStatus) { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var db = new MicCheckDbContext(options); + var db = new Mock(); + var webhooks = new List(); + db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks); + var deliveryLogs = new List(); + db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs); var capturedRequests = new List(); var handler = new Mock(); @@ -222,6 +207,6 @@ public class WebhookDispatcherTests var factory = new Mock(); factory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - return (db, factory.Object, capturedRequests); + return (db, webhooks, deliveryLogs, factory.Object, capturedRequests); } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs index 2592775..778ef32 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs @@ -1,6 +1,8 @@ using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; using MicCheck.Api.Webhooks; using Microsoft.EntityFrameworkCore; +using Moq; using NUnit.Framework; namespace MicCheck.Api.Tests.Unit.Webhooks; @@ -24,28 +26,33 @@ public class WebhookRetryTests Assert.That(delay.TotalMinutes, Is.EqualTo(30)); } + private static Mock CreateDb(List deliveryLogs) + { + var db = new Mock(); + db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs); + return db; + } + [Test] public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - using var db = new MicCheckDbContext(options); - - db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + var deliveryLogs = new List { - WebhookId = 1, - EventType = WebhookEventTypes.FlagUpdated, - PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", - Success = false, - AttemptNumber = 1, - AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6), - Duration = TimeSpan.FromMilliseconds(100) - }); - await db.SaveChangesAsync(); + new() + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", + Success = false, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6), + Duration = TimeSpan.FromMilliseconds(100) + } + }; + var db = CreateDb(deliveryLogs); var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); - var eligible = await db.WebhookDeliveryLogs + var eligible = await db.Object.WebhookDeliveryLogs .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter) .ToListAsync(); @@ -55,25 +62,23 @@ public class WebhookRetryTests [Test] public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - using var db = new MicCheckDbContext(options); - - db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + var deliveryLogs = new List { - WebhookId = 1, - EventType = WebhookEventTypes.FlagUpdated, - PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", - Success = false, - AttemptNumber = 1, - AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1), - Duration = TimeSpan.FromMilliseconds(100) - }); - await db.SaveChangesAsync(); + new() + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", + Success = false, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Duration = TimeSpan.FromMilliseconds(100) + } + }; + var db = CreateDb(deliveryLogs); var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); - var eligible = await db.WebhookDeliveryLogs + var eligible = await db.Object.WebhookDeliveryLogs .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter) .ToListAsync(); @@ -83,37 +88,34 @@ public class WebhookRetryTests [Test] public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - using var db = new MicCheckDbContext(options); - var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10); - db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + var deliveryLogs = new List { - WebhookId = 1, - EventType = WebhookEventTypes.FlagUpdated, - PayloadJson = "{}", - Success = false, - AttemptNumber = 1, - AttemptedAt = firstAttemptedAt, - Duration = TimeSpan.Zero - }); + new() + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = "{}", + Success = false, + AttemptNumber = 1, + AttemptedAt = firstAttemptedAt, + Duration = TimeSpan.Zero + }, + new() + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = "{}", + Success = false, + AttemptNumber = 2, + AttemptedAt = firstAttemptedAt.AddMinutes(5), + Duration = TimeSpan.Zero + } + }; + var db = CreateDb(deliveryLogs); - db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog - { - WebhookId = 1, - EventType = WebhookEventTypes.FlagUpdated, - PayloadJson = "{}", - Success = false, - AttemptNumber = 2, - AttemptedAt = firstAttemptedAt.AddMinutes(5), - Duration = TimeSpan.Zero - }); - await db.SaveChangesAsync(); - - var alreadyRetried = await db.WebhookDeliveryLogs + var alreadyRetried = await db.Object.WebhookDeliveryLogs .AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt); Assert.That(alreadyRetried, Is.True); -- 2.49.1 From 643188ca9e18757350cff489acd17c1057ba05a4 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 21:21:29 -0700 Subject: [PATCH 2/2] Replace tuple return types with named records CLAUDE.md prohibits tuples as return types. Add PagedResult, ProfileUpdateResult, and ApiKeyCreationResult records and update all callers (AuditLogQueryService, AdminIdentityService, UserService, ApiKeyService and their controllers/endpoints). --- .../Audit/AuditLogQueryService.cs | 11 +- .../MicCheck.Api/Audit/AuditLogsController.cs | 118 +++++++++--------- src/api/MicCheck.Api/Common/PagedResult.cs | 3 + .../MicCheck.Api/Common/PaginatedResponse.cs | 7 +- .../Common/Security/ApiKeys/ApiKey.cs | 8 +- .../Security/ApiKeys/ApiKeyEndpoints.cs | 5 +- .../Common/Security/ApiKeys/ApiKeyService.cs | 6 +- src/api/MicCheck.Api/Data/DatabaseSeeder.cs | 42 +++---- .../Environments/EnvironmentsController.cs | 10 +- .../Identities/AdminIdentityService.cs | 5 +- src/api/MicCheck.Api/Projects/.gitkeep | 0 src/api/MicCheck.Api/Users/UserService.cs | 10 +- src/api/MicCheck.Api/Users/UsersController.cs | 6 +- 13 files changed, 113 insertions(+), 118 deletions(-) create mode 100644 src/api/MicCheck.Api/Common/PagedResult.cs delete mode 100755 src/api/MicCheck.Api/Projects/.gitkeep diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index 5cd3a77..cf4bb52 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -1,3 +1,4 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; @@ -5,28 +6,28 @@ namespace MicCheck.Api.Audit; public class AuditLogQueryService(IMicCheckDbContext db) { - public async Task<(int Total, IReadOnlyList Items)> ListByOrganizationAsync( + public async Task> ListByOrganizationAsync( int organizationId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task<(int Total, IReadOnlyList Items)> ListByProjectAsync( + public async Task> ListByProjectAsync( int projectId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.ProjectId == projectId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task<(int Total, IReadOnlyList Items)> ListByEnvironmentAsync( + public async Task> ListByEnvironmentAsync( int environmentId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); return await ApplyFilterAndPageAsync(query, filter, ct); } - private async Task<(int Total, IReadOnlyList Items)> ApplyFilterAndPageAsync( + private async Task> ApplyFilterAndPageAsync( IQueryable query, AuditLogFilter filter, CancellationToken ct) { if (filter.From.HasValue) @@ -63,6 +64,6 @@ public class AuditLogQueryService(IMicCheckDbContext db) user != null ? user.FirstName + " " + user.LastName : null)) .ToListAsync(ct); - return (total, items); + return new PagedResult(total, items); } } diff --git a/src/api/MicCheck.Api/Audit/AuditLogsController.cs b/src/api/MicCheck.Api/Audit/AuditLogsController.cs index fd760e7..cc5311e 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogsController.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogsController.cs @@ -1,59 +1,59 @@ -using MicCheck.Api.Common.Security.Authorization; -using MicCheck.Api.Common; -using MicCheck.Api.Environments; -using MicCheck.Api.Projects; -using MicCheck.Api.Organizations; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; - -namespace MicCheck.Api.Audit; - -[ApiController] -[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] -[EnableRateLimiting("AdminApi")] -public class AuditLogsController( - AuditLogQueryService auditLogQueryService, - OrganizationService organizationService, - ProjectService projectService, - EnvironmentService environmentService) : ControllerBase -{ - [HttpGet("api/v1/organisation/{id}/audit-logs")] - public async Task>> ListByOrganization( - int id, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var org = await organizationService.FindByIdAsync(id, ct); - if (org is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } - - [HttpGet("api/v1/project/{projectId}/audit-logs")] - public async Task>> ListByProject( - int projectId, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var project = await projectService.FindByIdAsync(projectId, ct); - if (project is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } - - [HttpGet("api/v1/environment/{apiKey}/audit-logs")] - public async Task>> ListByEnvironment( - string apiKey, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); - if (environment is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } -} +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common; +using MicCheck.Api.Environments; +using MicCheck.Api.Projects; +using MicCheck.Api.Organizations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace MicCheck.Api.Audit; + +[ApiController] +[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] +[EnableRateLimiting("AdminApi")] +public class AuditLogsController( + AuditLogQueryService auditLogQueryService, + OrganizationService organizationService, + ProjectService projectService, + EnvironmentService environmentService) : ControllerBase +{ + [HttpGet("api/v1/organisation/{id}/audit-logs")] + public async Task>> ListByOrganization( + int id, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } + + [HttpGet("api/v1/project/{projectId}/audit-logs")] + public async Task>> ListByProject( + int projectId, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var project = await projectService.FindByIdAsync(projectId, ct); + if (project is null) return NotFound(); + + var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } + + [HttpGet("api/v1/environment/{apiKey}/audit-logs")] + public async Task>> ListByEnvironment( + string apiKey, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); + if (environment is null) return NotFound(); + + var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } +} diff --git a/src/api/MicCheck.Api/Common/PagedResult.cs b/src/api/MicCheck.Api/Common/PagedResult.cs new file mode 100644 index 0000000..8ba99ab --- /dev/null +++ b/src/api/MicCheck.Api/Common/PagedResult.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Common; + +public record PagedResult(int Total, IReadOnlyList Items); diff --git a/src/api/MicCheck.Api/Common/PaginatedResponse.cs b/src/api/MicCheck.Api/Common/PaginatedResponse.cs index 07c72aa..cb42ca3 100755 --- a/src/api/MicCheck.Api/Common/PaginatedResponse.cs +++ b/src/api/MicCheck.Api/Common/PaginatedResponse.cs @@ -1,8 +1,3 @@ namespace MicCheck.Api.Common; -public record PaginatedResponse( - int Count, - string? Next, - string? Previous, - IReadOnlyList Results -); +public record PaginatedResponse(int Count, string? Next, string? Previous, IReadOnlyList Results); diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs index 8a7df52..06b3b22 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs @@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys; public class ApiKey { public int Id { get; init; } - public required string Key { get; set; } - public required string Prefix { get; set; } - public required string Name { get; set; } + public required string Key { get; init; } + public required string Prefix { get; init; } + public required string Name { get; init; } public int OrganizationId { get; init; } public bool IsActive { get; set; } - public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset CreatedAt { get; init; } } diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs index e5dd6ae..65131e5 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs @@ -13,10 +13,9 @@ public static class ApiKeyEndpoints group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => { - var (key, rawKey) = await apiKeyService.CreateAsync( - organizationId, request.Name, request.ExpiresAt, ct); + var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct); - return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt)); + return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt)); }).WithName("CreateApiKey"); diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs index c94bb50..551cafe 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs @@ -5,7 +5,7 @@ namespace MicCheck.Api.Common.Security.ApiKeys; public class ApiKeyService(IMicCheckDbContext db) { - public async Task<(ApiKey Key, string RawKey)> CreateAsync( + public async Task CreateAsync( int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) { var rawKey = ApiKeyHasher.GenerateKey(); @@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db) db.ApiKeys.Add(apiKey); await db.SaveChangesAsync(ct); - return (apiKey, rawKey); + return new ApiKeyCreationResult(apiKey, rawKey); } public async Task> ListAsync(int organizationId, CancellationToken ct = default) @@ -48,3 +48,5 @@ public class ApiKeyService(IMicCheckDbContext db) await db.SaveChangesAsync(ct); } } + +public record ApiKeyCreationResult(ApiKey Key, string RawKey); diff --git a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs index 82df6a7..7dc0570 100755 --- a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs +++ b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs @@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Data; -public class DatabaseSeeder +public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher passwordHasher) { /// /// Deterministic credentials for the development/QA seed admin user. Only ever created when @@ -15,25 +15,17 @@ public class DatabaseSeeder /// Used by the API integration suite and the Playwright admin e2e suite to authenticate /// without depending on per-run registration. /// - public const string SeedAdminEmail = "admin@miccheck.local"; - public const string SeedAdminPassword = "MicCheckQa!2026"; + private const string SeedAdminEmail = "admin@miccheck.local"; + + private const string SeedAdminPassword = "MicCheckQa!2026"; /// Deterministic environment key for the seeded Development environment, so /// HTTP-only integration/e2e tests can read flags without a direct DB connection. - public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; - - private readonly MicCheckDbContext _db; - private readonly IPasswordHasher _passwordHasher; - - public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher passwordHasher) - { - _db = db; - _passwordHasher = passwordHasher; - } + private const string SeedDevelopmentEnvironmentKey = "env-qa-development"; public async Task SeedAsync(CancellationToken ct = default) { - if (await _db.Organizations.AnyAsync(ct)) + if (await db.Organizations.AnyAsync(ct)) return; var organization = new Organization @@ -41,8 +33,8 @@ public class DatabaseSeeder Name = "Default", CreatedAt = DateTimeOffset.UtcNow }; - _db.Organizations.Add(organization); - await _db.SaveChangesAsync(ct); + db.Organizations.Add(organization); + await db.SaveChangesAsync(ct); var project = new Project { @@ -50,13 +42,13 @@ public class DatabaseSeeder OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow }; - _db.Projects.Add(project); - await _db.SaveChangesAsync(ct); + db.Projects.Add(project); + await db.SaveChangesAsync(ct); var environmentNames = new[] { "Development", "Staging", "Production" }; foreach (var name in environmentNames) { - _db.Environments.Add(new AppEnvironment + db.Environments.Add(new AppEnvironment { Name = name, ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}", @@ -65,7 +57,7 @@ public class DatabaseSeeder }); } - await _db.SaveChangesAsync(ct); + await db.SaveChangesAsync(ct); var adminUser = new User { @@ -76,17 +68,17 @@ public class DatabaseSeeder CreatedAt = DateTimeOffset.UtcNow, PasswordHash = string.Empty }; - adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword); - _db.Users.Add(adminUser); - await _db.SaveChangesAsync(ct); + adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword); + db.Users.Add(adminUser); + await db.SaveChangesAsync(ct); - _db.OrganizationUsers.Add(new OrganizationUser + db.OrganizationUsers.Add(new OrganizationUser { OrganizationId = organization.Id, UserId = adminUser.Id, Role = OrganizationRole.Admin, IsPrimary = true }); - await _db.SaveChangesAsync(ct); + await db.SaveChangesAsync(ct); } } diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs index 6508f2d..fb8c2b6 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs @@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); - var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); - return Ok(logs.ToList()); + var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); + return Ok(result.Items.ToList()); } [HttpGet("api/v1/environment/{apiKey}/identities")] @@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); - var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); - var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); - return Ok(new PaginatedResponse(total, null, null, results)); + var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); + var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); + return Ok(new PaginatedResponse(result.Total, null, null, results)); } [HttpPost("api/v1/environment/{apiKey}/identities")] diff --git a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs index dd6933c..f43be2e 100755 --- a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs +++ b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs @@ -1,3 +1,4 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; using MicCheck.Api.Features; using Microsoft.EntityFrameworkCore; @@ -6,7 +7,7 @@ namespace MicCheck.Api.Identities; public class AdminIdentityService(IMicCheckDbContext db) { - public async Task<(int Total, IReadOnlyList Items)> ListAsync( + public async Task> ListAsync( int environmentId, int page, int pageSize, CancellationToken ct = default) { var query = db.Identities @@ -20,7 +21,7 @@ public class AdminIdentityService(IMicCheckDbContext db) .Take(pageSize) .ToListAsync(ct); - return (total, items); + return new PagedResult(total, items); } public async Task CreateAsync(int environmentId, string identifier, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Projects/.gitkeep b/src/api/MicCheck.Api/Projects/.gitkeep deleted file mode 100755 index e69de29..0000000 diff --git a/src/api/MicCheck.Api/Users/UserService.cs b/src/api/MicCheck.Api/Users/UserService.cs index d0022c9..8b6733b 100755 --- a/src/api/MicCheck.Api/Users/UserService.cs +++ b/src/api/MicCheck.Api/Users/UserService.cs @@ -9,23 +9,23 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher passwordHa public async Task FindByIdAsync(int userId, CancellationToken ct = default) => await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); - public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync( + public async Task UpdateProfileAsync( int userId, string firstName, string lastName, string email, CancellationToken ct = default) { var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); - if (user is null) return (null, false); + if (user is null) return new ProfileUpdateResult(null, EmailConflict: false); if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) { var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct); - if (emailTaken) return (null, true); + if (emailTaken) return new ProfileUpdateResult(null, EmailConflict: true); } user.FirstName = firstName; user.LastName = lastName; user.Email = email; await db.SaveChangesAsync(ct); - return (user, false); + return new ProfileUpdateResult(user, EmailConflict: false); } public async Task ChangePasswordAsync( @@ -42,3 +42,5 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher passwordHa return true; } } + +public record ProfileUpdateResult(User? User, bool EmailConflict); diff --git a/src/api/MicCheck.Api/Users/UsersController.cs b/src/api/MicCheck.Api/Users/UsersController.cs index 520de74..09bf4e1 100755 --- a/src/api/MicCheck.Api/Users/UsersController.cs +++ b/src/api/MicCheck.Api/Users/UsersController.cs @@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase string.IsNullOrWhiteSpace(request.Email)) return BadRequest("First name, last name, and email are required."); - var (user, emailConflict) = await userService.UpdateProfileAsync( + var result = await userService.UpdateProfileAsync( userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct); - if (emailConflict) return Conflict("Email is already in use."); - return user is null ? NotFound() : Ok(UserResponse.From(user)); + if (result.EmailConflict) return Conflict("Email is already in use."); + return result.User is null ? NotFound() : Ok(UserResponse.From(result.User)); } [HttpPost("me/change-password")] -- 2.49.1