Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4)
All checks were successful
CI / build-and-push (push) Successful in 48s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 20s

## Summary
- Extract `IMicCheckDbContext` and mock DB access with Moq instead of the EF InMemory provider, per CLAUDE.md testing guidelines.
- Rewrite HTTP-pipeline tests to exercise controllers/auth handlers directly instead of `WebApplicationFactory`.
- Remove tuple return types across the API in favor of named records (`PagedResult<T>`, `ProfileUpdateResult`, `ApiKeyCreationResult`).

## Test plan
- [x] `dotnet build` (full solution)
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 187/187 pass
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Integration` — 8/8 pass (live API + Postgres via Aspire)
- [x] Verified Aspire AppHost/dashboard starts and API responds

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-04 21:40:47 -07:00
parent 1556b486d2
commit 20188c61a2
62 changed files with 1406 additions and 2458 deletions

View File

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

View File

@@ -27,8 +27,8 @@ public class AuditLogsController(
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
}
[HttpGet("api/v1/project/{projectId}/audit-logs")]
@@ -40,8 +40,8 @@ public class AuditLogsController(
var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
}
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
@@ -53,7 +53,7 @@ public class AuditLogsController(
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
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;
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{
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;
public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);
public record PaginatedResponse<T>(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 int Id { get; init; }
public required string Key { get; set; }
public required string Prefix { get; set; }
public required string Name { get; set; }
public required string Key { get; init; }
public required string Prefix { get; init; }
public required string Name { get; init; }
public int OrganizationId { get; init; }
public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -13,10 +13,9 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{
var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct);
var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt));
}).WithName("CreateApiKey");

View File

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

View File

@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
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)
{
public const string SchemeName = "ApiKey";

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
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)
{
public const string SchemeName = "EnvironmentKey";

View File

@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService(
MicCheckDbContext db,
IMicCheckDbContext db,
ITokenService tokenService,
IPasswordHasher<User> passwordHasher)
{
@@ -81,7 +81,9 @@ public class AuthService(
});
await db.SaveChangesAsync(ct);
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct);
foreach (var organizationUser in organizationUsers)
user.Organizations.Add(organizationUser);
var accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken();

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext
public class MicCheckDbContext : DbContext, IMicCheckDbContext
{
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
@@ -42,3 +42,31 @@ public class MicCheckDbContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
}
public interface IMicCheckDbContext
{
DbSet<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;
public class EnvironmentDocumentService(MicCheckDbContext db)
public class EnvironmentDocumentService(IMicCheckDbContext db)
{
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;
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)
{

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
{
private const int MaxFeaturesPerProject = 400;

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
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)
{

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
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)
{

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects;
public class ProjectService(MicCheckDbContext db, AuditService auditService)
public class ProjectService(IMicCheckDbContext db, AuditService auditService)
{
public async Task<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 class SegmentService(MicCheckDbContext db, AuditService auditService)
public class SegmentService(IMicCheckDbContext db, AuditService auditService)
{
private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100;

View File

@@ -4,28 +4,28 @@ using Microsoft.EntityFrameworkCore;
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) =>
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)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
if (user is null) return (null, false);
if (user is null) return new ProfileUpdateResult(null, EmailConflict: false);
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
if (emailTaken) return (null, true);
if (emailTaken) return new ProfileUpdateResult(null, EmailConflict: true);
}
user.FirstName = firstName;
user.LastName = lastName;
user.Email = email;
await db.SaveChangesAsync(ct);
return (user, false);
return new ProfileUpdateResult(user, EmailConflict: false);
}
public async Task<bool> ChangePasswordAsync(
@@ -42,3 +42,5 @@ public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHas
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))
return BadRequest("First name, last name, and email are required.");
var (user, emailConflict) = await userService.UpdateProfileAsync(
var result = await userService.UpdateProfileAsync(
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
if (emailConflict) return Conflict("Email is already in use.");
return user is null ? NotFound() : Ok(UserResponse.From(user));
if (result.EmailConflict) return Conflict("Email is already in use.");
return result.User is null ? NotFound() : Ok(UserResponse.From(result.User));
}
[HttpPost("me/change-password")]

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
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()
{

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
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)
{

View File

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

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

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

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

@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
@@ -13,25 +13,21 @@ namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
[TestFixture]
public class ProjectPermissionRequirementHandlerTests
{
private MicCheckDbContext _db = null!;
private List<UserProjectPermission> _userProjectPermissions = 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);
var db = new Mock<IMicCheckDbContext>();
_userProjectPermissions = [];
db.SetupDbSet(c => c.UserProjectPermissions, _userProjectPermissions);
_httpContextAccessor = new Mock<IHttpContextAccessor>();
_handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object);
_handler = new ProjectPermissionRequirementHandler(db.Object, _httpContextAccessor.Object);
}
[TearDown]
public void TearDown() => _db.Dispose();
private static AuthorizationHandlerContext CreateContext(
ClaimsPrincipal user,
ProjectPermission permission = ProjectPermission.ViewProject)
@@ -69,13 +65,12 @@ public class ProjectPermissionRequirementHandlerTests
[Test]
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
{
_db.UserProjectPermissions.Add(new UserProjectPermission
_userProjectPermissions.Add(new UserProjectPermission
{
UserId = 1,
ProjectId = 10,
Permissions = [ProjectPermission.ViewProject]
});
await _db.SaveChangesAsync();
SetupRouteProjectId(10);
@@ -92,14 +87,13 @@ public class ProjectPermissionRequirementHandlerTests
[Test]
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
{
_db.UserProjectPermissions.Add(new UserProjectPermission
_userProjectPermissions.Add(new UserProjectPermission
{
UserId = 2,
ProjectId = 20,
IsAdmin = true,
Permissions = []
});
await _db.SaveChangesAsync();
SetupRouteProjectId(20);
@@ -116,13 +110,12 @@ public class ProjectPermissionRequirementHandlerTests
[Test]
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
{
_db.UserProjectPermissions.Add(new UserProjectPermission
_userProjectPermissions.Add(new UserProjectPermission
{
UserId = 3,
ProjectId = 30,
Permissions = [ProjectPermission.ViewProject]
});
await _db.SaveChangesAsync();
SetupRouteProjectId(30);

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.Text;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
@@ -52,28 +52,24 @@ public class WebhookDispatcherTests
[Test]
public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader()
{
var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
db.Organizations.Add(org);
db.SaveChanges();
db.Webhooks.Add(new Webhook
const int orgId = 1;
webhooks.Add(new Webhook
{
Url = "https://example.com/hook",
Secret = "test-secret",
Scope = WebhookScope.Organization,
OrganizationId = org.Id,
OrganizationId = orgId,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
var webhookEvent = new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id,
OrganizationId = orgId,
Data = new { test = true }
};
@@ -86,28 +82,24 @@ public class WebhookDispatcherTests
[Test]
public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded()
{
var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
db.Organizations.Add(org);
db.SaveChanges();
db.Webhooks.Add(new Webhook
const int orgId = 1;
webhooks.Add(new Webhook
{
Url = "https://example.com/hook",
Secret = null,
Scope = WebhookScope.Organization,
OrganizationId = org.Id,
OrganizationId = orgId,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id,
OrganizationId = orgId,
Data = new { }
});
@@ -117,31 +109,27 @@ public class WebhookDispatcherTests
[Test]
public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess()
{
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
db.Organizations.Add(org);
db.SaveChanges();
db.Webhooks.Add(new Webhook
const int orgId = 1;
webhooks.Add(new Webhook
{
Url = "https://example.com/hook",
Scope = WebhookScope.Organization,
OrganizationId = org.Id,
OrganizationId = orgId,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id,
OrganizationId = orgId,
Data = new { }
});
var log = db.WebhookDeliveryLogs.First();
var log = deliveryLogs.First();
Assert.That(log.Success, Is.True);
Assert.That(log.ResponseStatusCode, Is.EqualTo(200));
Assert.That(log.AttemptNumber, Is.EqualTo(1));
@@ -150,31 +138,27 @@ public class WebhookDispatcherTests
[Test]
public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure()
{
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError);
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError);
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
db.Organizations.Add(org);
db.SaveChanges();
db.Webhooks.Add(new Webhook
const int orgId = 1;
webhooks.Add(new Webhook
{
Url = "https://example.com/hook",
Scope = WebhookScope.Organization,
OrganizationId = org.Id,
OrganizationId = orgId,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = org.Id,
OrganizationId = orgId,
Data = new { }
});
var log = db.WebhookDeliveryLogs.First();
var log = deliveryLogs.First();
Assert.That(log.Success, Is.False);
Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
}
@@ -182,9 +166,9 @@ public class WebhookDispatcherTests
[Test]
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
{
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
var (db, _, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
@@ -192,16 +176,17 @@ public class WebhookDispatcherTests
Data = new { }
});
Assert.That(db.WebhookDeliveryLogs.Count(), Is.EqualTo(0));
Assert.That(deliveryLogs, Is.Empty);
}
private static (MicCheckDbContext Db, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests)
private static (Mock<IMicCheckDbContext> Db, List<Webhook> Webhooks, List<WebhookDeliveryLog> DeliveryLogs, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests)
SetUpDispatcher(HttpStatusCode responseStatus)
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new MicCheckDbContext(options);
var db = new Mock<IMicCheckDbContext>();
var webhooks = new List<Webhook>();
db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks);
var deliveryLogs = new List<WebhookDeliveryLog>();
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
var capturedRequests = new List<HttpRequestMessage>();
var handler = new Mock<HttpMessageHandler>();
@@ -222,6 +207,6 @@ public class WebhookDispatcherTests
var factory = new Mock<IHttpClientFactory>();
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.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
@@ -24,15 +26,19 @@ public class WebhookRetryTests
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]
public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new MicCheckDbContext(options);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
var deliveryLogs = new List<WebhookDeliveryLog>
{
new()
{
WebhookId = 1,
EventType = WebhookEventTypes.FlagUpdated,
@@ -41,11 +47,12 @@ public class WebhookRetryTests
AttemptNumber = 1,
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6),
Duration = TimeSpan.FromMilliseconds(100)
});
await db.SaveChangesAsync();
}
};
var db = CreateDb(deliveryLogs);
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
var eligible = await db.WebhookDeliveryLogs
var eligible = await db.Object.WebhookDeliveryLogs
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
.ToListAsync();
@@ -55,12 +62,9 @@ public class WebhookRetryTests
[Test]
public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new MicCheckDbContext(options);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
var deliveryLogs = new List<WebhookDeliveryLog>
{
new()
{
WebhookId = 1,
EventType = WebhookEventTypes.FlagUpdated,
@@ -69,11 +73,12 @@ public class WebhookRetryTests
AttemptNumber = 1,
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
Duration = TimeSpan.FromMilliseconds(100)
});
await db.SaveChangesAsync();
}
};
var db = CreateDb(deliveryLogs);
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
var eligible = await db.WebhookDeliveryLogs
var eligible = await db.Object.WebhookDeliveryLogs
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
.ToListAsync();
@@ -83,14 +88,11 @@ public class WebhookRetryTests
[Test]
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);
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
var deliveryLogs = new List<WebhookDeliveryLog>
{
new()
{
WebhookId = 1,
EventType = WebhookEventTypes.FlagUpdated,
@@ -99,9 +101,8 @@ public class WebhookRetryTests
AttemptNumber = 1,
AttemptedAt = firstAttemptedAt,
Duration = TimeSpan.Zero
});
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
},
new()
{
WebhookId = 1,
EventType = WebhookEventTypes.FlagUpdated,
@@ -110,10 +111,11 @@ public class WebhookRetryTests
AttemptNumber = 2,
AttemptedAt = firstAttemptedAt.AddMinutes(5),
Duration = TimeSpan.Zero
});
await db.SaveChangesAsync();
}
};
var db = CreateDb(deliveryLogs);
var alreadyRetried = await db.WebhookDeliveryLogs
var alreadyRetried = await db.Object.WebhookDeliveryLogs
.AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt);
Assert.That(alreadyRetried, Is.True);