From 20188c61a212b00d071c62b3fc6fb66b72cfc985 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 21:40:47 -0700 Subject: [PATCH] Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Extract `IMicCheckDbContext` and mock DB access with Moq instead of the EF InMemory provider, per CLAUDE.md testing guidelines. - Rewrite HTTP-pipeline tests to exercise controllers/auth handlers directly instead of `WebApplicationFactory`. - Remove tuple return types across the API in favor of named records (`PagedResult`, `ProfileUpdateResult`, `ApiKeyCreationResult`). ## Test plan - [x] `dotnet build` (full solution) - [x] `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 187/187 pass - [x] `dotnet test tests/api/MicCheck.Api.Tests.Integration` — 8/8 pass (live API + Postgres via Aspire) - [x] Verified Aspire AppHost/dashboard starts and API responds Reviewed-on: https://git.wampler.us/wamplerj/mic-check/pulls/4 --- .../Audit/AuditLogQueryService.cs | 13 +- .../MicCheck.Api/Audit/AuditLogsController.cs | 118 ++--- src/api/MicCheck.Api/Audit/AuditService.cs | 2 +- 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 | 8 +- .../ApiKeyAuthenticationHandler.cs | 2 +- .../EnvironmentKeyAuthenticationHandler.cs | 2 +- .../Security/Authorization/AuthService.cs | 6 +- .../ProjectPermissionRequirementHandler.cs | 2 +- src/api/MicCheck.Api/Data/DatabaseSeeder.cs | 42 +- .../MicCheck.Api/Data/MicCheckDbContext.cs | 30 +- .../EnvironmentDocumentService.cs | 2 +- .../Environments/EnvironmentService.cs | 2 +- .../Environments/EnvironmentsController.cs | 10 +- .../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 | 7 +- .../Identities/IdentityResolutionService.cs | 2 +- .../Organizations/OrganizationService.cs | 2 +- src/api/MicCheck.Api/Program.cs | 1 + src/api/MicCheck.Api/Projects/.gitkeep | 0 .../MicCheck.Api/Projects/ProjectService.cs | 2 +- .../MicCheck.Api/Segments/SegmentService.cs | 2 +- src/api/MicCheck.Api/Users/UserService.cs | 12 +- src/api/MicCheck.Api/Users/UsersController.cs | 6 +- .../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 ++--- 62 files changed, 1406 insertions(+), 2458 deletions(-) create mode 100644 src/api/MicCheck.Api/Common/PagedResult.cs delete mode 100755 src/api/MicCheck.Api/Projects/.gitkeep 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..cf4bb52 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -1,32 +1,33 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; 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( + 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(MicCheckDbContext 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/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/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 a343597..551cafe 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs @@ -3,9 +3,9 @@ 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( + public async Task CreateAsync( int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) { var rawKey = ApiKeyHasher.GenerateKey(); @@ -26,7 +26,7 @@ public class ApiKeyService(MicCheckDbContext 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(MicCheckDbContext db) await db.SaveChangesAsync(ct); } } + +public record ApiKeyCreationResult(ApiKey Key, string RawKey); 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/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/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/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/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..f43be2e 100755 --- a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs +++ b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs @@ -1,12 +1,13 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; using MicCheck.Api.Features; 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( + public async Task> ListAsync( int environmentId, int page, int pageSize, CancellationToken ct = default) { var query = db.Identities @@ -20,7 +21,7 @@ public class AdminIdentityService(MicCheckDbContext 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/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/.gitkeep b/src/api/MicCheck.Api/Projects/.gitkeep deleted file mode 100755 index e69de29..0000000 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..8b6733b 100755 --- a/src/api/MicCheck.Api/Users/UserService.cs +++ b/src/api/MicCheck.Api/Users/UserService.cs @@ -4,28 +4,28 @@ 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); - 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(MicCheckDbContext db, IPasswordHasher passwordHas 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")] 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);