Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns #4

Merged
wamplerj merged 2 commits from unit-test-refactor into main 2026-07-04 21:40:48 -07:00
62 changed files with 1406 additions and 2458 deletions

View File

@@ -1,32 +1,33 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync( public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default) int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync( public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default) int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync( public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default) int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync( private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct) IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
@@ -63,6 +64,6 @@ public class AuditLogQueryService(MicCheckDbContext db)
user != null ? user.FirstName + " " + user.LastName : null)) user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct); .ToListAsync(ct);
return (total, items); return new PagedResult<AuditLogResponse>(total, items);
} }
} }

View File

@@ -1,59 +1,59 @@
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common; using MicCheck.Api.Common;
using MicCheck.Api.Environments; using MicCheck.Api.Environments;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("api/v1/organisation/{id}/audit-logs")] [HttpGet("api/v1/organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id, int id,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/project/{projectId}/audit-logs")] [HttpGet("api/v1/project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId, int projectId,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("api/v1/environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey, string apiKey,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
} }

View File

@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit; 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common;
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);

View File

@@ -1,8 +1,3 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>( public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);

View File

@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKey public class ApiKey
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Key { get; set; } public required string Key { get; init; }
public required string Prefix { get; set; } public required string Prefix { get; init; }
public required string Name { get; set; } public required string Name { get; init; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

@@ -13,10 +13,9 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var (key, rawKey) = await apiKeyService.CreateAsync( var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
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"); }).WithName("CreateApiKey");

View File

@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.ApiKeys; 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<ApiKeyCreationResult> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{ {
var rawKey = ApiKeyHasher.GenerateKey(); var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(MicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (apiKey, rawKey); return new ApiKeyCreationResult(apiKey, rawKey);
} }
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,3 +48,5 @@ public class ApiKeyService(MicCheckDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public record ApiKeyCreationResult(ApiKey Key, string RawKey);

View File

@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "ApiKey"; public const string SchemeName = "ApiKey";

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "EnvironmentKey"; public const string SchemeName = "EnvironmentKey";

View File

@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService( public class AuthService(
MicCheckDbContext db, IMicCheckDbContext db,
ITokenService tokenService, ITokenService tokenService,
IPasswordHasher<User> passwordHasher) IPasswordHasher<User> passwordHasher)
{ {
@@ -81,7 +81,9 @@ public class AuthService(
}); });
await db.SaveChangesAsync(ct); 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 accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken(); var refreshTokenValue = GenerateSecureToken();

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirementHandler( public class ProjectPermissionRequirementHandler(
MicCheckDbContext db, IMicCheckDbContext db,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement> : AuthorizationHandler<ProjectPermissionRequirement>
{ {

View File

@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class DatabaseSeeder public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{ {
/// <summary> /// <summary>
/// Deterministic credentials for the development/QA seed admin user. Only ever created when /// 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 /// Used by the API integration suite and the Playwright admin e2e suite to authenticate
/// without depending on per-run registration. /// without depending on per-run registration.
/// </summary> /// </summary>
public const string SeedAdminEmail = "admin@miccheck.local"; private const string SeedAdminEmail = "admin@miccheck.local";
public const string SeedAdminPassword = "MicCheckQa!2026";
private const string SeedAdminPassword = "MicCheckQa!2026";
/// <summary>Deterministic environment key for the seeded Development environment, so /// <summary>Deterministic environment key for the seeded Development environment, so
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary> /// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; private const string SeedDevelopmentEnvironmentKey = "env-qa-development";
private readonly MicCheckDbContext _db;
private readonly IPasswordHasher<User> _passwordHasher;
public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{
_db = db;
_passwordHasher = passwordHasher;
}
public async Task SeedAsync(CancellationToken ct = default) public async Task SeedAsync(CancellationToken ct = default)
{ {
if (await _db.Organizations.AnyAsync(ct)) if (await db.Organizations.AnyAsync(ct))
return; return;
var organization = new Organization var organization = new Organization
@@ -41,8 +33,8 @@ public class DatabaseSeeder
Name = "Default", Name = "Default",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Organizations.Add(organization); db.Organizations.Add(organization);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var project = new Project var project = new Project
{ {
@@ -50,13 +42,13 @@ public class DatabaseSeeder
OrganizationId = organization.Id, OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Projects.Add(project); db.Projects.Add(project);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" }; var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames) foreach (var name in environmentNames)
{ {
_db.Environments.Add(new AppEnvironment db.Environments.Add(new AppEnvironment
{ {
Name = name, Name = name,
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}", 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 var adminUser = new User
{ {
@@ -76,17 +68,17 @@ public class DatabaseSeeder
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
PasswordHash = string.Empty PasswordHash = string.Empty
}; };
adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword); adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword);
_db.Users.Add(adminUser); db.Users.Add(adminUser);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
_db.OrganizationUsers.Add(new OrganizationUser db.OrganizationUsers.Add(new OrganizationUser
{ {
OrganizationId = organization.Id, OrganizationId = organization.Id,
UserId = adminUser.Id, UserId = adminUser.Id,
Role = OrganizationRole.Admin, Role = OrganizationRole.Admin,
IsPrimary = true IsPrimary = true
}); });
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }

View File

@@ -13,7 +13,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext public class MicCheckDbContext : DbContext, IMicCheckDbContext
{ {
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { } public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
@@ -42,3 +42,31 @@ public class MicCheckDbContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly); => modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
} }
public interface IMicCheckDbContext
{
DbSet<Organization> Organizations { get; }
DbSet<OrganizationUser> OrganizationUsers { get; }
DbSet<Project> Projects { get; }
DbSet<AppEnvironment> Environments { get; }
DbSet<Feature> Features { get; }
DbSet<FeatureState> FeatureStates { get; }
DbSet<FeatureSegment> FeatureSegments { get; }
DbSet<Tag> Tags { get; }
DbSet<Segment> Segments { get; }
DbSet<SegmentRule> SegmentRules { get; }
DbSet<SegmentCondition> SegmentConditions { get; }
DbSet<Identity> Identities { get; }
DbSet<IdentityTrait> IdentityTraits { get; }
DbSet<AuditLog> AuditLogs { get; }
DbSet<Webhook> Webhooks { get; }
DbSet<WebhookDeliveryLog> WebhookDeliveryLogs { get; }
DbSet<ApiKey> ApiKeys { get; }
DbSet<User> Users { get; }
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<UserProjectPermission> UserProjectPermissions { get; }
DbSet<FeatureUsageDaily> FeatureUsageDaily { get; }
int SaveChanges();
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -5,7 +5,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentDocumentService(MicCheckDbContext db) public class EnvironmentDocumentService(IMicCheckDbContext db)
{ {
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default) public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
{ {

View File

@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentService(MicCheckDbContext db, AuditService auditService) public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {

View File

@@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(logs.ToList()); return Ok(result.Items.ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("api/v1/environment/{apiKey}/identities")]
@@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results)); return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureEvaluationService( public class FeatureEvaluationService(
MicCheckDbContext db, IMicCheckDbContext db,
SegmentEvaluator segmentEvaluator, SegmentEvaluator segmentEvaluator,
FlagCache flagCache, FlagCache flagCache,
FeatureUsageMetrics usageMetrics) FeatureUsageMetrics usageMetrics)

View File

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureSegmentService(MicCheckDbContext db) public class FeatureSegmentService(IMicCheckDbContext db)
{ {
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync( public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
int featureId, int environmentId, CancellationToken ct = default) int featureId, int environmentId, CancellationToken ct = default)

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; 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; private const int MaxFeaturesPerProject = 400;

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; 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<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{ {

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(MicCheckDbContext db) public class FeatureUsageQueryService(IMicCheckDbContext db)
{ {
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default) public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{ {

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class TagService(MicCheckDbContext db) public class TagService(IMicCheckDbContext db)
{ {
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {

View File

@@ -1,12 +1,13 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities; namespace MicCheck.Api.Identities;
public class AdminIdentityService(MicCheckDbContext db) public class AdminIdentityService(IMicCheckDbContext db)
{ {
public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync( public async Task<PagedResult<Identity>> ListAsync(
int environmentId, int page, int pageSize, CancellationToken ct = default) int environmentId, int page, int pageSize, CancellationToken ct = default)
{ {
var query = db.Identities var query = db.Identities
@@ -20,7 +21,7 @@ public class AdminIdentityService(MicCheckDbContext db)
.Take(pageSize) .Take(pageSize)
.ToListAsync(ct); .ToListAsync(ct);
return (total, items); return new PagedResult<Identity>(total, items);
} }
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default) public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities; namespace MicCheck.Api.Identities;
public class IdentityResolutionService(MicCheckDbContext db) public class IdentityResolutionService(IMicCheckDbContext db)
{ {
public async Task<Identity> ResolveAsync( public async Task<Identity> ResolveAsync(
int environmentId, int environmentId,

View File

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public class OrganizationService(MicCheckDbContext db, AuditService auditService) public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default) public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{ {

View File

@@ -152,6 +152,7 @@ try
builder.Services.AddDbContext<MicCheckDbContext>(options => builder.Services.AddDbContext<MicCheckDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
var app = builder.Build(); var app = builder.Build();

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public class ProjectService(MicCheckDbContext db, AuditService auditService) public class ProjectService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{ {

View File

@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null); public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public class SegmentService(MicCheckDbContext db, AuditService auditService) public class SegmentService(IMicCheckDbContext db, AuditService auditService)
{ {
private const int MaxSegmentsPerProject = 100; private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100; private const int MaxConditionsPerSegment = 100;

View File

@@ -4,28 +4,28 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Users; namespace MicCheck.Api.Users;
public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHasher) public class UserService(IMicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{ {
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) => public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync( public async Task<ProfileUpdateResult> UpdateProfileAsync(
int userId, string firstName, string lastName, string email, CancellationToken ct = default) int userId, string firstName, string lastName, string email, CancellationToken ct = default)
{ {
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); 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)) if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{ {
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct); 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.FirstName = firstName;
user.LastName = lastName; user.LastName = lastName;
user.Email = email; user.Email = email;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (user, false); return new ProfileUpdateResult(user, EmailConflict: false);
} }
public async Task<bool> ChangePasswordAsync( public async Task<bool> ChangePasswordAsync(
@@ -42,3 +42,5 @@ public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHas
return true; return true;
} }
} }
public record ProfileUpdateResult(User? User, bool EmailConflict);

View File

@@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase
string.IsNullOrWhiteSpace(request.Email)) string.IsNullOrWhiteSpace(request.Email))
return BadRequest("First name, last name, and email are required."); 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); userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
if (emailConflict) return Conflict("Email is already in use."); if (result.EmailConflict) return Conflict("Email is already in use.");
return user is null ? NotFound() : Ok(UserResponse.From(user)); return result.User is null ? NotFound() : Ok(UserResponse.From(result.User));
} }
[HttpPost("me/change-password")] [HttpPost("me/change-password")]

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
public class WebhookDispatcher(MicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger<WebhookDispatcher> logger) public class WebhookDispatcher(IMicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger<WebhookDispatcher> logger)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
public class WebhookService(MicCheckDbContext db) public class WebhookService(IMicCheckDbContext db)
{ {
public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{ {

View File

@@ -1,268 +1,158 @@
using System.Net; using MicCheck.Api.Audit;
using System.Net.Http.Json; using MicCheck.Api.Data;
using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Environments;
using MicCheck.Api.Data; using MicCheck.Api.Features;
using MicCheck.Api.Features; using MicCheck.Api.Organizations;
using MicCheck.Api.Organizations; using MicCheck.Api.Projects;
using MicCheck.Api.Projects; using MicCheck.Api.Segments;
using Microsoft.Extensions.DependencyInjection; using MicCheck.Api.Tests.Unit.TestSupport;
using NUnit.Framework; using MicCheck.Api.Webhooks;
using AppEnvironment = MicCheck.Api.Environments.Environment; using Microsoft.AspNetCore.Mvc;
using Moq;
namespace MicCheck.Api.Tests.Unit.Admin; using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
[TestFixture]
public class AdminApiIntegrationTests namespace MicCheck.Api.Tests.Unit.Admin;
{
private MicCheckWebApplicationFactory _factory = null!; [TestFixture]
private string _rawApiKey = null!; public class AdminApiIntegrationTests
{
[SetUp] private Mock<IMicCheckDbContext> _db = null!;
public void SetUp() private List<Project> _projects = null!;
{ private List<Feature> _features = null!;
_factory = new MicCheckWebApplicationFactory(); private List<FeatureState> _featureStates = null!;
_rawApiKey = SeedOrganizationWithApiKey(); private List<AppEnvironment> _environments = null!;
} private List<Segment> _segments = null!;
private Mock<AuditService> _auditService = null!;
[TearDown] private int _organizationId;
public void TearDown() => _factory.Dispose();
[SetUp]
private string SeedOrganizationWithApiKey() public void SetUp()
{ {
using var scope = _factory.Services.CreateScope(); _db = new Mock<IMicCheckDbContext>();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
_db.SetupDbSetWithGeneratedIds(c => c.Organizations, [
var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }; new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
db.Organizations.Add(org); ]);
db.SaveChanges(); _organizationId = 1;
var rawKey = ApiKeyHasher.GenerateKey(); _projects = [];
var hashedKey = ApiKeyHasher.Hash(rawKey); _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects);
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey; _features = [];
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
db.ApiKeys.Add(new ApiKey _featureStates = [];
{ _db.SetupDbSet(c => c.FeatureStates, _featureStates);
Key = hashedKey, _environments = [];
Prefix = prefix, _db.SetupDbSetWithGeneratedIds(c => c.Environments, _environments);
Name = "Test Key", _segments = [];
OrganizationId = org.Id, _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
IsActive = true, _db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, []);
CreatedAt = DateTimeOffset.UtcNow _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
});
db.SaveChanges(); var webhookQueue = new WebhookQueue();
_auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
return rawKey; _auditService.Setup(a => a.LogAsync(
} It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
private HttpClient CreateAuthenticatedClient() It.IsAny<string?>(), It.IsAny<CancellationToken>()))
{ .Returns(Task.CompletedTask);
var client = _factory.CreateClient(); }
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
return client; private Project AddProject(string name = "My Project")
} {
var project = new Project
[Test] {
public async Task WhenCreatingAProject_ThenProjectIsReturned() Name = name,
{ OrganizationId = _organizationId,
var client = CreateAuthenticatedClient(); CreatedAt = DateTimeOffset.UtcNow
};
using var scope = _factory.Services.CreateScope(); _db.Object.Projects.Add(project);
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>(); return project;
var org = db.Organizations.First(); }
var response = await client.PostAsJsonAsync("/api/v1/projects", new [Test]
{ public async Task WhenCreatingAProject_ThenProjectIsReturned()
name = "My Project", {
organizationId = org.Id var controller = new ProjectsController(new ProjectService(_db.Object, _auditService.Object));
});
var result = await controller.Create(new CreateProjectRequest("My Project", _organizationId), CancellationToken.None);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
} Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
}
[Test]
public async Task WhenCreatingAFeature_ThenFeatureIsReturned() [Test]
{ public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
var client = CreateAuthenticatedClient(); {
var project = AddProject();
using var scope = _factory.Services.CreateScope(); var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue()));
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First(); var result = await controller.Create(project.Id,
new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null), CancellationToken.None);
var project = new Project
{ Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
Name = "My Project", }
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow [Test]
}; public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
db.Projects.Add(project); {
db.SaveChanges(); var project = AddProject();
_db.Object.Environments.Add(new AppEnvironment
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new {
{ Name = "Production",
name = "dark_mode", ApiKey = "env-key-prod",
type = "Standard", ProjectId = project.Id,
initialValue = (string?)null, CreatedAt = DateTimeOffset.UtcNow
description = (string?)null });
}); var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue()));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); await controller.Create(project.Id,
} new CreateFeatureRequest("flag_x", FeatureType.Standard, null, null), CancellationToken.None);
[Test] var feature = _features.First(f => f.Name == "flag_x");
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments() var states = _featureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
{
var client = CreateAuthenticatedClient(); Assert.That(states, Has.Count.EqualTo(1));
}
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>(); [Test]
var org = db.Organizations.First(); public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
{
var project = new Project var project = AddProject();
{ _db.Object.Features.Add(new Feature
Name = "My Project", {
OrganizationId = org.Id, Name = "existing_flag",
CreatedAt = DateTimeOffset.UtcNow ProjectId = project.Id,
}; CreatedAt = DateTimeOffset.UtcNow
db.Projects.Add(project); });
db.SaveChanges(); var controller = new EnvironmentsController(
new EnvironmentService(_db.Object, _auditService.Object),
db.Environments.Add(new AppEnvironment new WebhookService(_db.Object));
{
Name = "Production", var result = await controller.Create(new CreateEnvironmentRequest("Staging", project.Id), CancellationToken.None);
ApiKey = "env-key-prod",
ProjectId = project.Id, Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
CreatedAt = DateTimeOffset.UtcNow
}); var feature = _features.First(f => f.Name == "existing_flag");
db.SaveChanges(); var env = _environments.First(e => e.ProjectId == project.Id);
var states = _featureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
{ Assert.That(states, Has.Count.EqualTo(1));
name = "flag_x", }
type = "Standard",
initialValue = (string?)null, [Test]
description = (string?)null public async Task WhenCreatingASegment_ThenSegmentIsReturned()
}); {
var project = AddProject();
using var verifyScope = _factory.Services.CreateScope(); var controller = new SegmentsController(new SegmentService(_db.Object, _auditService.Object));
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var feature = verifyDb.Features.First(f => f.Name == "flag_x"); var result = await controller.Create(project.Id, new CreateSegmentRequest(
var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList(); "Premium Users",
[
Assert.That(states, Has.Count.EqualTo(1)); new CreateSegmentRuleRequest(
} "All",
[new CreateSegmentConditionRequest("plan", "Equal", "premium")])
[Test] ]), CancellationToken.None);
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
{ Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
var client = CreateAuthenticatedClient(); }
}
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
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<MicCheckDbContext>();
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<MicCheckDbContext>();
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<MicCheckDbContext>();
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));
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -1,9 +1,9 @@
using System.Text.Json; using System.Text.Json;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
@@ -12,34 +12,31 @@ namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture] [TestFixture]
public class AuditServiceTests public class AuditServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<AuditLog> _auditLogs = null!;
private AuditService _service = null!; private AuditService _service = null!;
private WebhookQueue _queue = null!; private WebhookQueue _queue = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString()) _auditLogs = [];
.Options; _db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_db = new MicCheckDbContext(options);
_queue = new WebhookQueue(); _queue = new WebhookQueue();
var httpContextAccessor = new Mock<IHttpContextAccessor>(); var httpContextAccessor = new Mock<IHttpContextAccessor>();
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] [Test]
public async Task WhenRecordingWithNoBefore_ThenChangesIsNull() public async Task WhenRecordingWithNoBefore_ThenChangesIsNull()
{ {
await _service.RecordAsync("Feature", "1", "created", organizationId: 1); await _service.RecordAsync("Feature", "1", "created", organizationId: 1);
var log = await _db.AuditLogs.FirstAsync(); var log = _auditLogs.First();
Assert.That(log.Changes, Is.Null); Assert.That(log.Changes, Is.Null);
} }
@@ -49,7 +46,7 @@ public class AuditServiceTests
var after = new { Name = "dark_mode", Enabled = true }; var after = new { Name = "dark_mode", Enabled = true };
await _service.RecordAsync("Feature", "1", "created", organizationId: 1, after: after); 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); Assert.That(log.Changes, Is.Not.Null);
var changes = JsonDocument.Parse(log.Changes!).RootElement; var changes = JsonDocument.Parse(log.Changes!).RootElement;
@@ -67,7 +64,7 @@ public class AuditServiceTests
await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1, await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1,
before: before, after: after); before: before, after: after);
var log = await _db.AuditLogs.FirstAsync(); var log = _auditLogs.First();
var changes = JsonDocument.Parse(log.Changes!).RootElement; var changes = JsonDocument.Parse(log.Changes!).RootElement;
Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False); Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False);
@@ -83,7 +80,7 @@ public class AuditServiceTests
"Segment", "5", "deleted", "Segment", "5", "deleted",
organizationId: 10, projectId: 20, environmentId: 30); 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.ResourceType, Is.EqualTo("Segment"));
Assert.That(log.ResourceId, Is.EqualTo("5")); Assert.That(log.ResourceId, Is.EqualTo("5"));
Assert.That(log.Action, Is.EqualTo("deleted")); Assert.That(log.Action, Is.EqualTo("deleted"));

View File

@@ -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<string> SeedActiveApiKeyAsync(int organizationId = 1)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
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<MicCheckDbContext>();
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<MicCheckDbContext>();
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));
}
}

View File

@@ -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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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));
}
}

View File

@@ -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<IHttpContextAccessor> _httpContextAccessor = null!;
private ProjectPermissionRequirementHandler _handler = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
_httpContextAccessor = new Mock<IHttpContextAccessor>();
_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);
}
}

View File

@@ -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<string, string?>
{
["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);
}
}

View File

@@ -1,142 +1,120 @@
using System.Net; using System.Text.Encodings.Web;
using System.Net.Http.Headers; using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.Extensions.DependencyInjection; using MicCheck.Api.Tests.Unit.TestSupport;
using NUnit.Framework; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
[TestFixture] using Moq;
public class ApiKeyAuthenticationHandlerTests using NUnit.Framework;
{
private MicCheckWebApplicationFactory _factory = null!; namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication;
[SetUp] [TestFixture]
public void SetUp() public class ApiKeyAuthenticationHandlerTests
{ {
_factory = new MicCheckWebApplicationFactory(); private List<ApiKey> _apiKeys = null!;
} private Mock<IMicCheckDbContext> _db = null!;
[TearDown] [SetUp]
public void TearDown() => _factory.Dispose(); public void SetUp()
{
private async Task<string> SeedActiveApiKeyAsync(int organizationId = 1) _db = new Mock<IMicCheckDbContext>();
{ _apiKeys = [];
using var scope = _factory.Services.CreateScope(); _db.SetupDbSet(c => c.ApiKeys, _apiKeys);
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>(); }
var rawKey = ApiKeyHasher.GenerateKey(); private async Task<AuthenticateResult> AuthenticateAsync(string? authorizationHeader)
db.ApiKeys.Add(new ApiKey {
{ var optionsMonitor = new Mock<IOptionsMonitor<AuthenticationSchemeOptions>>();
Key = ApiKeyHasher.Hash(rawKey), optionsMonitor.Setup(o => o.Get(It.IsAny<string>())).Returns(new AuthenticationSchemeOptions());
Prefix = rawKey[..8],
Name = "Test Key", var handler = new ApiKeyAuthenticationHandler(optionsMonitor.Object, NullLoggerFactory.Instance, UrlEncoder.Default, _db.Object);
OrganizationId = organizationId,
IsActive = true, var httpContext = new DefaultHttpContext();
CreatedAt = DateTimeOffset.UtcNow if (authorizationHeader is not null)
}); httpContext.Request.Headers.Authorization = authorizationHeader;
await db.SaveChangesAsync();
var scheme = new AuthenticationScheme(ApiKeyAuthenticationHandler.SchemeName, null, typeof(ApiKeyAuthenticationHandler));
return rawKey; await handler.InitializeAsync(scheme, httpContext);
}
return await handler.AuthenticateAsync();
[Test] }
public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned()
{ private ApiKey AddApiKey(string rawKey, int organizationId = 1, bool isActive = true, DateTimeOffset? expiresAt = null)
var client = _factory.CreateClient(); {
var apiKey = new ApiKey
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); {
Key = ApiKeyHasher.Hash(rawKey),
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Prefix = rawKey[..8],
} Name = "Test Key",
OrganizationId = organizationId,
[Test] IsActive = isActive,
public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenUnauthorizedIsReturned() ExpiresAt = expiresAt,
{ CreatedAt = DateTimeOffset.UtcNow
var client = _factory.CreateClient(); };
client.DefaultRequestHeaders.Authorization = _apiKeys.Add(apiKey);
new AuthenticationHeaderValue("Bearer", "not-a-jwt"); return apiKey;
}
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
[Test]
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); public async Task WhenAuthorizationHeaderIsAbsent_ThenNoResultIsReturned()
} {
var result = await AuthenticateAsync(null);
[Test]
public async Task WhenApiKeyIsInvalid_ThenUnauthorizedIsReturned() Assert.That(result.None, Is.True);
{ }
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key"); [Test]
public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenNoResultIsReturned()
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); {
var result = await AuthenticateAsync("Bearer not-a-jwt");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} Assert.That(result.None, Is.True);
}
[Test]
public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned() [Test]
{ public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
using var scope = _factory.Services.CreateScope(); {
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>(); var result = await AuthenticateAsync("Api-Key not-a-real-key");
var rawKey = ApiKeyHasher.GenerateKey(); Assert.That(result.Succeeded, Is.False);
db.ApiKeys.Add(new ApiKey }
{
Key = ApiKeyHasher.Hash(rawKey), [Test]
Prefix = rawKey[..8], public async Task WhenApiKeyIsInactive_ThenAuthenticationFails()
Name = "Inactive Key", {
OrganizationId = 1, var rawKey = ApiKeyHasher.GenerateKey();
IsActive = false, AddApiKey(rawKey, isActive: false);
CreatedAt = DateTimeOffset.UtcNow
}); var result = await AuthenticateAsync($"Api-Key {rawKey}");
await db.SaveChangesAsync();
Assert.That(result.Succeeded, Is.False);
var client = _factory.CreateClient(); }
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
[Test]
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); public async Task WhenApiKeyIsExpired_ThenAuthenticationFails()
{
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); var rawKey = ApiKeyHasher.GenerateKey();
} AddApiKey(rawKey, expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
[Test] var result = await AuthenticateAsync($"Api-Key {rawKey}");
public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned()
{ Assert.That(result.Succeeded, Is.False);
using var scope = _factory.Services.CreateScope(); }
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
[Test]
var rawKey = ApiKeyHasher.GenerateKey(); public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationClaims()
db.ApiKeys.Add(new ApiKey {
{ var rawKey = ApiKeyHasher.GenerateKey();
Key = ApiKeyHasher.Hash(rawKey), AddApiKey(rawKey, organizationId: 42);
Prefix = rawKey[..8],
Name = "Expired Key", var result = await AuthenticateAsync($"Api-Key {rawKey}");
OrganizationId = 1,
IsActive = true, Assert.That(result.Succeeded, Is.True);
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), Assert.That(result.Principal!.FindFirst("OrganizationId")!.Value, Is.EqualTo("42"));
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10) Assert.That(result.Principal!.FindFirst("OrganizationRole")!.Value, Is.EqualTo("Admin"));
}); }
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));
}
}

View File

@@ -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<AppEnvironment> _environments = null!;
private Mock<IMicCheckDbContext> _db = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [];
_db.SetupDbSet(c => c.Environments, _environments);
}
private async Task<AuthenticateResult> AuthenticateAsync(string? environmentKeyHeader)
{
var optionsMonitor = new Mock<IOptionsMonitor<AuthenticationSchemeOptions>>();
optionsMonitor.Setup(o => o.Get(It.IsAny<string>())).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"));
}
}

View File

@@ -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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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<LoginResponse>();
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));
}
}

View File

@@ -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<IMicCheckDbContext> _db = null!;
private List<User> _users = null!;
private List<Organization> _organizations = null!;
private List<OrganizationUser> _organizationUsers = null!;
private List<RefreshToken> _refreshTokens = null!;
private PasswordHasher<User> _passwordHasher = null!;
private AuthService _service = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_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<User>();
var tokenService = new Mock<ITokenService>();
tokenService.Setup(t => t.GenerateToken(It.IsAny<User>()))
.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);
}
}

View File

@@ -1,180 +1,173 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.AspNetCore.Authorization; using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Http;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization; namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
[TestFixture] [TestFixture]
public class ProjectPermissionRequirementHandlerTests public class ProjectPermissionRequirementHandlerTests
{ {
private MicCheckDbContext _db = null!; private List<UserProjectPermission> _userProjectPermissions = null!;
private Mock<IHttpContextAccessor> _httpContextAccessor = null!; private Mock<IHttpContextAccessor> _httpContextAccessor = null!;
private ProjectPermissionRequirementHandler _handler = null!; private ProjectPermissionRequirementHandler _handler = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() var db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString()) _userProjectPermissions = [];
.Options; db.SetupDbSet(c => c.UserProjectPermissions, _userProjectPermissions);
_db = new MicCheckDbContext(options);
_httpContextAccessor = new Mock<IHttpContextAccessor>();
_httpContextAccessor = new Mock<IHttpContextAccessor>(); _handler = new ProjectPermissionRequirementHandler(db.Object, _httpContextAccessor.Object);
_handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object); }
}
private static AuthorizationHandlerContext CreateContext(
[TearDown] ClaimsPrincipal user,
public void TearDown() => _db.Dispose(); ProjectPermission permission = ProjectPermission.ViewProject)
{
private static AuthorizationHandlerContext CreateContext( var requirement = new ProjectPermissionRequirement(permission);
ClaimsPrincipal user, return new AuthorizationHandlerContext([requirement], user, null);
ProjectPermission permission = ProjectPermission.ViewProject) }
{
var requirement = new ProjectPermissionRequirement(permission); private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
return new AuthorizationHandlerContext([requirement], user, null); {
} return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
}
private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
{ private void SetupRouteProjectId(int projectId)
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); {
} var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["projectId"] = projectId.ToString();
private void SetupRouteProjectId(int projectId) _httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext);
{ }
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["projectId"] = projectId.ToString(); [Test]
_httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds()
} {
var user = CreateUserWithClaims(
[Test] new Claim(JwtRegisteredClaimNames.Sub, "1"),
public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds() new Claim("OrganizationRole", "Admin"));
{
var user = CreateUserWithClaims( var context = CreateContext(user);
new Claim(JwtRegisteredClaimNames.Sub, "1"),
new Claim("OrganizationRole", "Admin")); await _handler.HandleAsync(context);
var context = CreateContext(user); Assert.That(context.HasSucceeded, Is.True);
}
await _handler.HandleAsync(context);
[Test]
Assert.That(context.HasSucceeded, Is.True); public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
} {
_userProjectPermissions.Add(new UserProjectPermission
[Test] {
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds() UserId = 1,
{ ProjectId = 10,
_db.UserProjectPermissions.Add(new UserProjectPermission Permissions = [ProjectPermission.ViewProject]
{ });
UserId = 1,
ProjectId = 10, SetupRouteProjectId(10);
Permissions = [ProjectPermission.ViewProject]
}); var user = CreateUserWithClaims(
await _db.SaveChangesAsync(); new Claim(JwtRegisteredClaimNames.Sub, "1"));
SetupRouteProjectId(10); var context = CreateContext(user, ProjectPermission.ViewProject);
var user = CreateUserWithClaims( await _handler.HandleAsync(context);
new Claim(JwtRegisteredClaimNames.Sub, "1"));
Assert.That(context.HasSucceeded, Is.True);
var context = CreateContext(user, ProjectPermission.ViewProject); }
await _handler.HandleAsync(context); [Test]
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
Assert.That(context.HasSucceeded, Is.True); {
} _userProjectPermissions.Add(new UserProjectPermission
{
[Test] UserId = 2,
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted() ProjectId = 20,
{ IsAdmin = true,
_db.UserProjectPermissions.Add(new UserProjectPermission Permissions = []
{ });
UserId = 2,
ProjectId = 20, SetupRouteProjectId(20);
IsAdmin = true,
Permissions = [] var user = CreateUserWithClaims(
}); new Claim(JwtRegisteredClaimNames.Sub, "2"));
await _db.SaveChangesAsync();
var context = CreateContext(user, ProjectPermission.DeleteFeature);
SetupRouteProjectId(20);
await _handler.HandleAsync(context);
var user = CreateUserWithClaims(
new Claim(JwtRegisteredClaimNames.Sub, "2")); Assert.That(context.HasSucceeded, Is.True);
}
var context = CreateContext(user, ProjectPermission.DeleteFeature);
[Test]
await _handler.HandleAsync(context); public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
{
Assert.That(context.HasSucceeded, Is.True); _userProjectPermissions.Add(new UserProjectPermission
} {
UserId = 3,
[Test] ProjectId = 30,
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails() Permissions = [ProjectPermission.ViewProject]
{ });
_db.UserProjectPermissions.Add(new UserProjectPermission
{ SetupRouteProjectId(30);
UserId = 3,
ProjectId = 30, var user = CreateUserWithClaims(
Permissions = [ProjectPermission.ViewProject] new Claim(JwtRegisteredClaimNames.Sub, "3"));
});
await _db.SaveChangesAsync(); var context = CreateContext(user, ProjectPermission.DeleteFeature);
SetupRouteProjectId(30); await _handler.HandleAsync(context);
var user = CreateUserWithClaims( Assert.That(context.HasSucceeded, Is.False);
new Claim(JwtRegisteredClaimNames.Sub, "3")); }
var context = CreateContext(user, ProjectPermission.DeleteFeature); [Test]
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails()
await _handler.HandleAsync(context); {
SetupRouteProjectId(99);
Assert.That(context.HasSucceeded, Is.False);
} var user = CreateUserWithClaims(
new Claim(JwtRegisteredClaimNames.Sub, "5"));
[Test]
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails() var context = CreateContext(user);
{
SetupRouteProjectId(99); await _handler.HandleAsync(context);
var user = CreateUserWithClaims( Assert.That(context.HasSucceeded, Is.False);
new Claim(JwtRegisteredClaimNames.Sub, "5")); }
var context = CreateContext(user); [Test]
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
await _handler.HandleAsync(context); {
var user = CreateUserWithClaims();
Assert.That(context.HasSucceeded, Is.False);
} var context = CreateContext(user);
[Test] await _handler.HandleAsync(context);
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
{ Assert.That(context.HasSucceeded, Is.False);
var user = CreateUserWithClaims(); }
var context = CreateContext(user); [Test]
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails()
await _handler.HandleAsync(context); {
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext());
Assert.That(context.HasSucceeded, Is.False);
} var user = CreateUserWithClaims(
new Claim(JwtRegisteredClaimNames.Sub, "1"));
[Test]
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails() var context = CreateContext(user);
{
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); await _handler.HandleAsync(context);
var user = CreateUserWithClaims( Assert.That(context.HasSucceeded, Is.False);
new Claim(JwtRegisteredClaimNames.Sub, "1")); }
}
var context = CreateContext(user);
await _handler.HandleAsync(context);
Assert.That(context.HasSucceeded, Is.False);
}
}

View File

@@ -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<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
_seeder = new DatabaseSeeder(_db, new PasswordHasher<User>());
}
[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<User>();
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));
}
}

View File

@@ -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<MicCheckDbContext>()
.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"));
}
}

View File

@@ -2,7 +2,10 @@ using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Environments; using MicCheck.Api.Environments;
using MicCheck.Api.Features; 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 Moq;
using NUnit.Framework; using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment; using AppEnvironment = MicCheck.Api.Environments.Environment;
@@ -12,7 +15,11 @@ namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture] [TestFixture]
public class EnvironmentServiceTests public class EnvironmentServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private EnvironmentService _service = null!; private EnvironmentService _service = null!;
private const int OrganizationId = 1; private const int OrganizationId = 1;
private const int ProjectId = 1; private const int ProjectId = 1;
@@ -20,60 +27,49 @@ public class EnvironmentServiceTests
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; _db.SetupDbSet(c => c.Organizations, [
_db = new MicCheckDbContext(options); 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 webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<AuditService>(_db, null!, webhookQueue); var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync( auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>())) It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
_service = new EnvironmentService(_db, auditService.Object); _service = new EnvironmentService(_db.Object, 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();
} }
[Test] [Test]
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature() public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature()
{ {
_db.Features.Add(new Feature _features.Add(new Feature
{ {
Id = 1,
Name = "feature_a", Name = "feature_a",
ProjectId = ProjectId, ProjectId = ProjectId,
InitialValue = "hello", InitialValue = "hello",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var environment = await _service.CreateAsync(ProjectId, "Staging"); 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, Has.Count.EqualTo(1));
Assert.That(states[0].Value, Is.EqualTo("hello")); Assert.That(states[0].Value, Is.EqualTo("hello"));
} }
@@ -92,11 +88,10 @@ public class EnvironmentServiceTests
{ {
var source = await _service.CreateAsync(ProjectId, "Production"); var source = await _service.CreateAsync(ProjectId, "Production");
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Features.Add(feature); _features.Add(feature);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = source.Id, EnvironmentId = source.Id,
@@ -105,13 +100,10 @@ public class EnvironmentServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var cloned = await _service.CloneAsync(source.ApiKey, "Staging"); var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
var clonedStates = await _db.FeatureStates var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList();
.Where(fs => fs.EnvironmentId == cloned.Id)
.ToListAsync();
Assert.That(clonedStates, Has.Count.EqualTo(1)); Assert.That(clonedStates, Has.Count.EqualTo(1));
Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value")); Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value"));
@@ -123,20 +115,19 @@ public class EnvironmentServiceTests
{ {
var source = await _service.CreateAsync(ProjectId, "Production"); var source = await _service.CreateAsync(ProjectId, "Production");
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Features.Add(feature); _features.Add(feature);
_db.SaveChanges();
var identity = new MicCheck.Api.Identities.Identity var identity = new Identity
{ {
Id = 1,
Identifier = "user-1", Identifier = "user-1",
EnvironmentId = source.Id, EnvironmentId = source.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Identities.Add(identity); _identities.Add(identity);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = source.Id, EnvironmentId = source.Id,
@@ -145,13 +136,10 @@ public class EnvironmentServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var cloned = await _service.CloneAsync(source.ApiKey, "Staging"); var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
var clonedStates = await _db.FeatureStates var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList();
.Where(fs => fs.EnvironmentId == cloned.Id)
.ToListAsync();
Assert.That(clonedStates, Is.Empty); Assert.That(clonedStates, Is.Empty);
} }

View File

@@ -3,7 +3,7 @@ using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore; using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
@@ -14,7 +14,13 @@ namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture] [TestFixture]
public class FeatureEvaluationServiceTests public class FeatureEvaluationServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private List<Segment> _segments = null!;
private List<FeatureSegment> _featureSegments = null!;
private FeatureEvaluationService _service = null!; private FeatureEvaluationService _service = null!;
private FeatureUsageMetrics _usageMetrics = null!; private FeatureUsageMetrics _usageMetrics = null!;
private const int EnvironmentId = 1; private const int EnvironmentId = 1;
@@ -23,52 +29,49 @@ public class FeatureEvaluationServiceTests
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key-test", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
_db = new MicCheckDbContext(options); var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.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<IMeterFactory>(); var meterFactory = new Mock<IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test")); meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object); _usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions())); var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics); _service = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), cache, _usageMetrics);
SeedBaseData();
} }
[TearDown] [TearDown]
public void TearDown() public void TearDown() => _usageMetrics.Dispose();
{
_db.Dispose();
_usageMetrics.Dispose();
}
private void SeedBaseData() private Feature AddFeature(string name, bool defaultEnabled = false)
{ {
_db.Environments.Add(new AppEnvironment var feature = new Feature
{
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
{ {
Name = name, Name = name,
ProjectId = ProjectId, ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
DefaultEnabled = defaultEnabled DefaultEnabled = defaultEnabled
}; };
_db.Features.Add(feature); _db.Object.Features.Add(feature);
_db.SaveChanges();
return feature; return feature;
} }
@@ -83,8 +86,7 @@ public class FeatureEvaluationServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}; };
_db.FeatureStates.Add(fs); _featureStates.Add(fs);
_db.SaveChanges();
return fs; return fs;
} }
@@ -136,10 +138,9 @@ public class FeatureEvaluationServiceTests
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Identities.Add(identity); _db.Object.Identities.Add(identity);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
@@ -149,7 +150,6 @@ public class FeatureEvaluationServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip"); var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip");
@@ -161,11 +161,10 @@ public class FeatureEvaluationServiceTests
public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed() public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed()
{ {
var feature = AddFeature("premium_feature"); 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 }; var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Segments.Add(segment); _db.Object.Segments.Add(segment);
_db.SaveChanges();
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition rule.Conditions.Add(new SegmentCondition
@@ -175,8 +174,7 @@ public class FeatureEvaluationServiceTests
Operator = SegmentConditionOperator.Equal, Operator = SegmentConditionOperator.Equal,
Value = "premium" Value = "premium"
}); });
_db.SegmentRules.Add(rule); segment.Rules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment var featureSegment = new FeatureSegment
{ {
@@ -185,10 +183,9 @@ public class FeatureEvaluationServiceTests
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
Priority = 1 Priority = 1
}; };
_db.FeatureSegments.Add(featureSegment); _db.Object.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
@@ -198,7 +195,6 @@ public class FeatureEvaluationServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var results = await _service.EvaluateForIdentityAsync( var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-123", EnvironmentId, "user-123",
@@ -215,8 +211,7 @@ public class FeatureEvaluationServiceTests
AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value"); AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value");
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Segments.Add(segment); _db.Object.Segments.Add(segment);
_db.SaveChanges();
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition rule.Conditions.Add(new SegmentCondition
@@ -226,8 +221,7 @@ public class FeatureEvaluationServiceTests
Operator = SegmentConditionOperator.Equal, Operator = SegmentConditionOperator.Equal,
Value = "premium" Value = "premium"
}); });
_db.SegmentRules.Add(rule); segment.Rules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment var featureSegment = new FeatureSegment
{ {
@@ -236,10 +230,9 @@ public class FeatureEvaluationServiceTests
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
Priority = 1 Priority = 1
}; };
_db.FeatureSegments.Add(featureSegment); _db.Object.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
@@ -249,7 +242,6 @@ public class FeatureEvaluationServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var results = await _service.EvaluateForIdentityAsync( var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-123", EnvironmentId, "user-123",
@@ -266,8 +258,7 @@ public class FeatureEvaluationServiceTests
AddEnvironmentDefault(feature.Id, enabled: false); AddEnvironmentDefault(feature.Id, enabled: false);
var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Segments.Add(segment); _db.Object.Segments.Add(segment);
_db.SaveChanges();
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition rule.Conditions.Add(new SegmentCondition
@@ -277,8 +268,7 @@ public class FeatureEvaluationServiceTests
Operator = SegmentConditionOperator.IsSet, Operator = SegmentConditionOperator.IsSet,
Value = "" Value = ""
}); });
_db.SegmentRules.Add(rule); segment.Rules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment var featureSegment = new FeatureSegment
{ {
@@ -287,10 +277,9 @@ public class FeatureEvaluationServiceTests
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
Priority = 1 Priority = 1
}; };
_db.FeatureSegments.Add(featureSegment); _db.Object.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
@@ -307,8 +296,7 @@ public class FeatureEvaluationServiceTests
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Identities.Add(identity); _db.Object.Identities.Add(identity);
_db.SaveChanges();
identity.Traits.Add(new IdentityTrait identity.Traits.Add(new IdentityTrait
{ {
@@ -317,7 +305,7 @@ public class FeatureEvaluationServiceTests
Value = "US" Value = "US"
}); });
_db.FeatureStates.Add(new FeatureState _featureStates.Add(new FeatureState
{ {
FeatureId = feature.Id, FeatureId = feature.Id,
EnvironmentId = EnvironmentId, EnvironmentId = EnvironmentId,
@@ -327,7 +315,6 @@ public class FeatureEvaluationServiceTests
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
var results = await _service.EvaluateForIdentityAsync( var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-special", EnvironmentId, "user-special",

View File

@@ -1,8 +1,10 @@
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Common; using MicCheck.Api.Organizations;
using Microsoft.EntityFrameworkCore; using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment; using AppEnvironment = MicCheck.Api.Environments.Environment;
@@ -12,7 +14,11 @@ namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture] [TestFixture]
public class FeatureServiceTests public class FeatureServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Tag> _tags = null!;
private FeatureService _service = null!; private FeatureService _service = null!;
private const int OrganizationId = 1; private const int OrganizationId = 1;
private const int ProjectId = 1; private const int ProjectId = 1;
@@ -21,51 +27,32 @@ public class FeatureServiceTests
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; _db.SetupDbSet(c => c.Organizations, [
_db = new MicCheckDbContext(options); 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 webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<AuditService>(_db, null!, webhookQueue); var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.RecordAsync( auditService.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>())) It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
_service = new FeatureService(_db, auditService.Object, webhookQueue); _service = new FeatureService(_db.Object, 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();
} }
[Test] [Test]
@@ -73,7 +60,7 @@ public class FeatureServiceTests
{ {
var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null); 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, Has.Count.EqualTo(1));
Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId)); 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 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")); Assert.That(state.Value, Is.EqualTo("initial-val"));
} }
[Test] [Test]
public async Task WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException() public void WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException()
{ {
for (var i = 0; i < 400; i++) for (var i = 0; i < 400; i++)
{ {
_db.Features.Add(new Feature _features.Add(new Feature
{ {
Id = i + 1,
Name = $"feature_{i}", Name = $"feature_{i}",
ProjectId = ProjectId, ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
} }
_db.SaveChanges();
Assert.ThrowsAsync<DomainException>(() => Assert.ThrowsAsync<DomainException>(() =>
_service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null)); _service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null));
@@ -123,7 +110,7 @@ public class FeatureServiceTests
await _service.DeleteAsync(feature.Id); 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); Assert.That(found, Is.Null);
} }
@@ -143,8 +130,7 @@ public class FeatureServiceTests
{ {
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag); _db.Object.Tags.Add(tag);
await _db.SaveChangesAsync();
var updated = await _service.AssignTagAsync(feature.Id, tag.Id); 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 feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag); _db.Object.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id); await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.AssignTagAsync(feature.Id, tag.Id); var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
@@ -168,7 +153,7 @@ public class FeatureServiceTests
[Test] [Test]
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown() public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
{ {
_db.Projects.Add(new MicCheck.Api.Projects.Project _projects.Add(new Project
{ {
Id = 2, Id = 2,
Name = "Other Project", Name = "Other Project",
@@ -177,8 +162,7 @@ public class FeatureServiceTests
}); });
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 }; var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
_db.Tags.Add(foreignTag); _db.Object.Tags.Add(foreignTag);
await _db.SaveChangesAsync();
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id)); Assert.ThrowsAsync<DomainException>(() => _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 feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag); _db.Object.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id); await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id); var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);

View File

@@ -1,6 +1,7 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore; using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features; namespace MicCheck.Api.Tests.Unit.Features;
@@ -8,26 +9,23 @@ namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture] [TestFixture]
public class FeatureUsageQueryServiceTests public class FeatureUsageQueryServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<FeatureUsageDaily> _usage = null!;
private FeatureUsageQueryService _service = null!; private FeatureUsageQueryService _service = null!;
private const int EnvironmentId = 1; private const int EnvironmentId = 1;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString()) _usage = [];
.Options; _db.SetupDbSet(c => c.FeatureUsageDaily, _usage);
_db = new MicCheckDbContext(options); _service = new FeatureUsageQueryService(_db.Object);
_service = new FeatureUsageQueryService(_db);
} }
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count) private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
{ {
_db.FeatureUsageDaily.Add(new FeatureUsageDaily _usage.Add(new FeatureUsageDaily
{ {
EnvironmentId = environmentId, EnvironmentId = environmentId,
FeatureId = featureId, FeatureId = featureId,
@@ -36,7 +34,6 @@ public class FeatureUsageQueryServiceTests
Count = count, Count = count,
UpdatedAt = DateTimeOffset.UtcNow UpdatedAt = DateTimeOffset.UtcNow
}); });
_db.SaveChanges();
} }
[Test] [Test]

View File

@@ -1,8 +1,15 @@
using System.Net; using System.Security.Claims;
using System.Net.Http.Json;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features; 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 NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment; using AppEnvironment = MicCheck.Api.Environments.Environment;
@@ -11,127 +18,110 @@ namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture] [TestFixture]
public class FlagsApiIntegrationTests public class FlagsApiIntegrationTests
{ {
private MicCheckWebApplicationFactory _factory = null!; private Mock<IMicCheckDbContext> _db = null!;
private string _envApiKey = null!; private List<AppEnvironment> _environments = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private AppEnvironment _environment = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
_factory = new MicCheckWebApplicationFactory(); _db = new Mock<IMicCheckDbContext>();
_envApiKey = SeedEnvironmentWithFlag();
_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<object[]>(), It.IsAny<CancellationToken>()))
.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] private static ControllerBase WithEnvironmentClaim(ControllerBase controller, int environmentId)
public void TearDown() => _factory.Dispose();
private string SeedEnvironmentWithFlag()
{ {
using var scope = _factory.Services.CreateScope(); var identity = new ClaimsIdentity([new Claim("EnvironmentId", environmentId.ToString())], "EnvironmentKey");
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>(); controller.ControllerContext = new ControllerContext
var apiKey = $"test-env-key-{Guid.NewGuid():N}";
var project = new MicCheck.Api.Projects.Project
{ {
Name = "Test Project", HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
OrganizationId = 1,
CreatedAt = DateTimeOffset.UtcNow
}; };
db.Projects.Add(project); return controller;
db.SaveChanges(); }
var environment = new AppEnvironment private FeatureEvaluationService CreateEvaluationService() =>
{ new(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), CreateUsageMetrics());
Name = "Test Env",
ApiKey = apiKey,
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Environments.Add(environment);
db.SaveChanges();
var feature = new Feature private static FeatureUsageMetrics CreateUsageMetrics()
{ {
Name = "dark_mode", var meterFactory = new Mock<System.Diagnostics.Metrics.IMeterFactory>();
ProjectId = project.Id, meterFactory.Setup(f => f.Create(It.IsAny<System.Diagnostics.Metrics.MeterOptions>()))
CreatedAt = DateTimeOffset.UtcNow .Returns(new System.Diagnostics.Metrics.Meter("test"));
}; return new FeatureUsageMetrics(meterFactory.Object);
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;
} }
[Test] [Test]
public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned() public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned()
{ {
var client = _factory.CreateClient(); var controller = (FlagsController)WithEnvironmentClaim(
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); 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 = (result.Result as OkObjectResult)?.Value as IReadOnlyList<FlagResponse>;
var flags = await response.Content.ReadFromJsonAsync<List<FlagResponse>>();
Assert.That(flags, Has.Count.EqualTo(1)); Assert.That(flags, Has.Count.EqualTo(1));
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode")); Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
Assert.That(flags[0].Enabled, Is.True); 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] [Test]
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned() public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
{ {
var client = _factory.CreateClient(); var controller = (IdentitiesController)WithEnvironmentClaim(
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); new IdentitiesController(CreateEvaluationService(), new IdentityResolutionService(_db.Object)),
_environment.Id);
var response = await client.PostAsJsonAsync("/api/v1/identity/", new var result = await controller.Identify(
{ new IdentityRequest("user-123", [new TraitInput("plan", "premium")]), CancellationToken.None);
identifier = "user-123",
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
} }
[Test] [Test]
public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned() public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned()
{ {
var client = _factory.CreateClient(); var controller = (EnvironmentDocumentController)WithEnvironmentClaim(
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); 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<OkObjectResult>());
} }
} }

View File

@@ -11,8 +11,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.5.1" /> <PackageReference Include="NUnit" Version="4.5.1" />

View File

@@ -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<Program>
{
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<MicCheckDbContext>));
if (optionsDescriptor is not null)
services.Remove(optionsDescriptor);
services.AddDbContext<MicCheckDbContext>(options =>
options
.UseInMemoryDatabase(dbName)
.UseInternalServiceProvider(InMemoryEfServiceProvider));
});
}
}

View File

@@ -1,8 +1,10 @@
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common; using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore; using MicCheck.Api.Tests.Unit.TestSupport;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
@@ -11,7 +13,10 @@ namespace MicCheck.Api.Tests.Unit.Segments;
[TestFixture] [TestFixture]
public class SegmentServiceTests public class SegmentServiceTests
{ {
private MicCheckDbContext _db = null!; private Mock<IMicCheckDbContext> _db = null!;
private List<Segment> _segments = null!;
private List<SegmentRule> _segmentRules = null!;
private List<SegmentCondition> _segmentConditions = null!;
private SegmentService _service = null!; private SegmentService _service = null!;
private const int OrganizationId = 1; private const int OrganizationId = 1;
private const int ProjectId = 1; private const int ProjectId = 1;
@@ -19,43 +24,30 @@ public class SegmentServiceTests
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() _db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; _db.SetupDbSet(c => c.Organizations, [
_db = new MicCheckDbContext(options); 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 webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<AuditService>(_db, null!, webhookQueue); var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync( auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>())) It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
_service = new SegmentService(_db, auditService.Object); _service = new SegmentService(_db.Object, 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();
} }
private static SegmentRuleDefinition SingleConditionRule( private static SegmentRuleDefinition SingleConditionRule(
@@ -71,36 +63,33 @@ public class SegmentServiceTests
var rules = new[] { SingleConditionRule() }; var rules = new[] { SingleConditionRule() };
var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules); var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules);
var loaded = await _db.Segments var persistedRules = _segmentRules.Where(r => r.SegmentId == segment.Id).ToList();
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstAsync(s => s.Id == segment.Id);
Assert.That(loaded.Name, Is.EqualTo("Premium Users")); Assert.That(segment.Name, Is.EqualTo("Premium Users"));
Assert.That(loaded.Rules, Has.Count.EqualTo(1)); Assert.That(persistedRules, Has.Count.EqualTo(1));
Assert.That(loaded.Rules.First().Conditions, Has.Count.EqualTo(1)); Assert.That(persistedRules[0].Conditions, Has.Count.EqualTo(1));
} }
[Test] [Test]
public async Task WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException() public void WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException()
{ {
for (var i = 0; i < 100; i++) for (var i = 0; i < 100; i++)
{ {
_db.Segments.Add(new Segment _segments.Add(new Segment
{ {
Id = i + 1,
Name = $"segment_{i}", Name = $"segment_{i}",
ProjectId = ProjectId, ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
} }
_db.SaveChanges();
Assert.ThrowsAsync<DomainException>(() => Assert.ThrowsAsync<DomainException>(() =>
_service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()])); _service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()]));
} }
[Test] [Test]
public async Task WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException() public void WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException()
{ {
var conditions = Enumerable.Range(0, 101) var conditions = Enumerable.Range(0, 101)
.Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val")) .Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val"))
@@ -119,14 +108,11 @@ public class SegmentServiceTests
var newRules = new[] { SingleConditionRule("country") }; var newRules = new[] { SingleConditionRule("country") };
var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules); var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules);
var loaded = await _db.Segments var persistedRules = _segmentRules.Where(r => r.SegmentId == updated.Id).ToList();
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstAsync(s => s.Id == updated.Id);
Assert.That(loaded.Name, Is.EqualTo("Updated Segment")); Assert.That(updated.Name, Is.EqualTo("Updated Segment"));
Assert.That(loaded.Rules, Has.Count.EqualTo(1)); Assert.That(persistedRules, Has.Count.EqualTo(1));
Assert.That(loaded.Rules.First().Conditions.First().Property, Is.EqualTo("country")); Assert.That(persistedRules[0].Conditions.First().Property, Is.EqualTo("country"));
} }
[Test] [Test]
@@ -136,7 +122,7 @@ public class SegmentServiceTests
await _service.DeleteAsync(segment.Id); 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); Assert.That(found, Is.Null);
} }

View File

@@ -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<DbSet<T>> Create<T>(List<T> data) where T : class
{
var queryable = data.AsQueryable();
var mockSet = new Mock<DbSet<T>>();
mockSet.As<IAsyncEnumerable<T>>()
.Setup(m => m.GetAsyncEnumerator(It.IsAny<CancellationToken>()))
.Returns(() => new TestAsyncEnumerator<T>(data.GetEnumerator()));
mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(() => new TestAsyncQueryProvider<T>(data.AsQueryable().Provider));
mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(() => data.AsQueryable().Expression);
mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());
mockSet.Setup(m => m.Add(It.IsAny<T>())).Callback<T>(item => data.Add(item));
mockSet.Setup(m => m.AddRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(items => data.AddRange(items));
mockSet.Setup(m => m.Remove(It.IsAny<T>())).Callback<T>(item => data.Remove(item));
mockSet.Setup(m => m.RemoveRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(items =>
{
foreach (var item in items.ToList())
data.Remove(item);
});
return mockSet;
}
public static void SetupDbSet<T>(this Mock<IMicCheckDbContext> context, Expression<Func<IMicCheckDbContext, DbSet<T>>> selector, List<T> data)
where T : class =>
context.Setup(selector).Returns(Create(data).Object);
/// <summary>
/// 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).
/// </summary>
public static void SetupDbSetWithGeneratedIds<T>(this Mock<IMicCheckDbContext> context, Expression<Func<IMicCheckDbContext, DbSet<T>>> selector, List<T> 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<T>())).Callback<T>(item =>
{
idProperty.SetValue(item, data.Count + 1);
data.Add(item);
});
context.Setup(selector).Returns(mockSet.Object);
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections;
using System.Linq.Expressions;
namespace MicCheck.Api.Tests.Unit.TestSupport;
public class TestAsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
{
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
public TestAsyncEnumerable(Expression expression) : base(expression) { }
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
}
public sealed class TestAsyncEnumerator<T>(IEnumerator<T> inner) : IAsyncEnumerator<T>
{
public T Current => inner.Current;
public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(inner.MoveNext());
public ValueTask DisposeAsync()
{
inner.Dispose();
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,43 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;
namespace MicCheck.Api.Tests.Unit.TestSupport;
public class TestAsyncQueryProvider<T>(IQueryProvider innerProvider) : IAsyncQueryProvider
{
public IQueryable CreateQuery(Expression expression) =>
new TestAsyncEnumerable<T>(StripIncludes(expression));
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) =>
new TestAsyncEnumerable<TElement>(StripIncludes(expression));
public object? Execute(Expression expression) =>
innerProvider.Execute(StripIncludes(expression));
public TResult Execute<TResult>(Expression expression) =>
innerProvider.Execute<TResult>(StripIncludes(expression));
public TResult ExecuteAsync<TResult>(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);
}
}

View File

@@ -2,8 +2,8 @@ using System.Net;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Moq; using Moq;
using Moq.Protected; using Moq.Protected;
@@ -52,28 +52,24 @@ public class WebhookDispatcherTests
[Test] [Test]
public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader() 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 }; const int orgId = 1;
db.Organizations.Add(org); webhooks.Add(new Webhook
db.SaveChanges();
db.Webhooks.Add(new Webhook
{ {
Url = "https://example.com/hook", Url = "https://example.com/hook",
Secret = "test-secret", Secret = "test-secret",
Scope = WebhookScope.Organization, Scope = WebhookScope.Organization,
OrganizationId = org.Id, OrganizationId = orgId,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance); var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
var webhookEvent = new WebhookEvent var webhookEvent = new WebhookEvent
{ {
EventType = WebhookEventTypes.FlagUpdated, EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id, OrganizationId = orgId,
Data = new { test = true } Data = new { test = true }
}; };
@@ -86,28 +82,24 @@ public class WebhookDispatcherTests
[Test] [Test]
public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded() 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 }; const int orgId = 1;
db.Organizations.Add(org); webhooks.Add(new Webhook
db.SaveChanges();
db.Webhooks.Add(new Webhook
{ {
Url = "https://example.com/hook", Url = "https://example.com/hook",
Secret = null, Secret = null,
Scope = WebhookScope.Organization, Scope = WebhookScope.Organization,
OrganizationId = org.Id, OrganizationId = orgId,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance); var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent await dispatcher.DispatchAsync(new WebhookEvent
{ {
EventType = WebhookEventTypes.FlagUpdated, EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id, OrganizationId = orgId,
Data = new { } Data = new { }
}); });
@@ -117,31 +109,27 @@ public class WebhookDispatcherTests
[Test] [Test]
public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess() 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 }; const int orgId = 1;
db.Organizations.Add(org); webhooks.Add(new Webhook
db.SaveChanges();
db.Webhooks.Add(new Webhook
{ {
Url = "https://example.com/hook", Url = "https://example.com/hook",
Scope = WebhookScope.Organization, Scope = WebhookScope.Organization,
OrganizationId = org.Id, OrganizationId = orgId,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance); var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent await dispatcher.DispatchAsync(new WebhookEvent
{ {
EventType = WebhookEventTypes.FlagUpdated, EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id, OrganizationId = orgId,
Data = new { } Data = new { }
}); });
var log = db.WebhookDeliveryLogs.First(); var log = deliveryLogs.First();
Assert.That(log.Success, Is.True); Assert.That(log.Success, Is.True);
Assert.That(log.ResponseStatusCode, Is.EqualTo(200)); Assert.That(log.ResponseStatusCode, Is.EqualTo(200));
Assert.That(log.AttemptNumber, Is.EqualTo(1)); Assert.That(log.AttemptNumber, Is.EqualTo(1));
@@ -150,31 +138,27 @@ public class WebhookDispatcherTests
[Test] [Test]
public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure() 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 }; const int orgId = 1;
db.Organizations.Add(org); webhooks.Add(new Webhook
db.SaveChanges();
db.Webhooks.Add(new Webhook
{ {
Url = "https://example.com/hook", Url = "https://example.com/hook",
Scope = WebhookScope.Organization, Scope = WebhookScope.Organization,
OrganizationId = org.Id, OrganizationId = orgId,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance); var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent await dispatcher.DispatchAsync(new WebhookEvent
{ {
EventType = WebhookEventTypes.FlagUpdated, EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id, OrganizationId = orgId,
Data = new { } Data = new { }
}); });
var log = db.WebhookDeliveryLogs.First(); var log = deliveryLogs.First();
Assert.That(log.Success, Is.False); Assert.That(log.Success, Is.False);
Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
} }
@@ -182,9 +166,9 @@ public class WebhookDispatcherTests
[Test] [Test]
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() 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<WebhookDispatcher>.Instance); var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent await dispatcher.DispatchAsync(new WebhookEvent
{ {
EventType = WebhookEventTypes.FlagUpdated, EventType = WebhookEventTypes.FlagUpdated,
@@ -192,16 +176,17 @@ public class WebhookDispatcherTests
Data = new { } Data = new { }
}); });
Assert.That(db.WebhookDeliveryLogs.Count(), Is.EqualTo(0)); Assert.That(deliveryLogs, Is.Empty);
} }
private static (MicCheckDbContext Db, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests) private static (Mock<IMicCheckDbContext> Db, List<Webhook> Webhooks, List<WebhookDeliveryLog> DeliveryLogs, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests)
SetUpDispatcher(HttpStatusCode responseStatus) SetUpDispatcher(HttpStatusCode responseStatus)
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() var db = new Mock<IMicCheckDbContext>();
.UseInMemoryDatabase(Guid.NewGuid().ToString()) var webhooks = new List<Webhook>();
.Options; db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks);
var db = new MicCheckDbContext(options); var deliveryLogs = new List<WebhookDeliveryLog>();
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
var capturedRequests = new List<HttpRequestMessage>(); var capturedRequests = new List<HttpRequestMessage>();
var handler = new Mock<HttpMessageHandler>(); var handler = new Mock<HttpMessageHandler>();
@@ -222,6 +207,6 @@ public class WebhookDispatcherTests
var factory = new Mock<IHttpClientFactory>(); var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient); factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
return (db, factory.Object, capturedRequests); return (db, webhooks, deliveryLogs, factory.Object, capturedRequests);
} }
} }

View File

@@ -1,6 +1,8 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks; namespace MicCheck.Api.Tests.Unit.Webhooks;
@@ -24,28 +26,33 @@ public class WebhookRetryTests
Assert.That(delay.TotalMinutes, Is.EqualTo(30)); Assert.That(delay.TotalMinutes, Is.EqualTo(30));
} }
private static Mock<IMicCheckDbContext> CreateDb(List<WebhookDeliveryLog> deliveryLogs)
{
var db = new Mock<IMicCheckDbContext>();
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
return db;
}
[Test] [Test]
public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry() public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() var deliveryLogs = new List<WebhookDeliveryLog>
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new MicCheckDbContext(options);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
{ {
WebhookId = 1, new()
EventType = WebhookEventTypes.FlagUpdated, {
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", WebhookId = 1,
Success = false, EventType = WebhookEventTypes.FlagUpdated,
AttemptNumber = 1, PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6), Success = false,
Duration = TimeSpan.FromMilliseconds(100) AttemptNumber = 1,
}); AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6),
await db.SaveChangesAsync(); Duration = TimeSpan.FromMilliseconds(100)
}
};
var db = CreateDb(deliveryLogs);
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); 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) .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
.ToListAsync(); .ToListAsync();
@@ -55,25 +62,23 @@ public class WebhookRetryTests
[Test] [Test]
public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry() public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>() var deliveryLogs = new List<WebhookDeliveryLog>
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new MicCheckDbContext(options);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
{ {
WebhookId = 1, new()
EventType = WebhookEventTypes.FlagUpdated, {
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", WebhookId = 1,
Success = false, EventType = WebhookEventTypes.FlagUpdated,
AttemptNumber = 1, PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1), Success = false,
Duration = TimeSpan.FromMilliseconds(100) AttemptNumber = 1,
}); AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
await db.SaveChangesAsync(); Duration = TimeSpan.FromMilliseconds(100)
}
};
var db = CreateDb(deliveryLogs);
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); 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) .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
.ToListAsync(); .ToListAsync();
@@ -83,37 +88,34 @@ public class WebhookRetryTests
[Test] [Test]
public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain() public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain()
{ {
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new MicCheckDbContext(options);
var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10); var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog var deliveryLogs = new List<WebhookDeliveryLog>
{ {
WebhookId = 1, new()
EventType = WebhookEventTypes.FlagUpdated, {
PayloadJson = "{}", WebhookId = 1,
Success = false, EventType = WebhookEventTypes.FlagUpdated,
AttemptNumber = 1, PayloadJson = "{}",
AttemptedAt = firstAttemptedAt, Success = false,
Duration = TimeSpan.Zero 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 var alreadyRetried = await db.Object.WebhookDeliveryLogs
{
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
.AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt); .AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt);
Assert.That(alreadyRetried, Is.True); Assert.That(alreadyRetried, Is.True);