diff --git a/badges/coverage.svg b/badges/coverage.svg index 1f0065c..8679a05 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -123,7 +123,7 @@ Coverage Coverage - 69.2%69.2% + 68.9%68.9% diff --git a/src/api/MicCheck.Api/Audit/AuditLog.cs b/src/api/MicCheck.Api/Audit/AuditLog.cs index 683aee1..39bc405 100755 --- a/src/api/MicCheck.Api/Audit/AuditLog.cs +++ b/src/api/MicCheck.Api/Audit/AuditLog.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Audit; -public class AuditLog +public record AuditLog { public int Id { get; init; } public required string ResourceType { get; init; } diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index cf4bb52..7175790 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit; public class AuditLogQueryService(IMicCheckDbContext db) { - public async Task> ListByOrganizationAsync( - int organizationId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByProjectAsync( - int projectId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.ProjectId == projectId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByEnvironmentAsync( - int environmentId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); return await ApplyFilterAndPageAsync(query, filter, ct); } - private async Task> ApplyFilterAndPageAsync( - IQueryable query, AuditLogFilter filter, CancellationToken ct) + private async Task> ApplyFilterAndPageAsync(IQueryable query, AuditLogFilter filter, CancellationToken ct) { if (filter.From.HasValue) query = query.Where(l => l.CreatedAt >= filter.From.Value); @@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db) return new PagedResult(total, items); } -} +} \ No newline at end of file diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index a8fd5b4..721321e 100755 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks; namespace MicCheck.Api.Audit; -public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) +public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public virtual async Task RecordAsync( + public async Task RecordAsync( string resourceType, string resourceId, string action, @@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex }, JsonOptions); } - var actorUserId = ResolveActorUserId(); + var actorUserId = GetCurrentUserId(); var log = new AuditLog { @@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex } // Backward-compatible overload used by existing callers - public virtual async Task LogAsync( + public async Task LogAsync( string resourceType, string resourceId, string action, @@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex string? changes = null, CancellationToken ct = default) { - var actorUserId = ResolveActorUserId(); + var actorUserId = GetCurrentUserId(); var log = new AuditLog { @@ -112,9 +112,33 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex }); } - private int? ResolveActorUserId() + private int? GetCurrentUserId() { var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return int.TryParse(claim, out var id) ? id : null; } } + +public interface IAuditService +{ + Task RecordAsync( + string resourceType, + string resourceId, + string action, + int organizationId, + int? projectId = null, + int? environmentId = null, + object? before = null, + object? after = null, + CancellationToken ct = default); + + Task LogAsync( + string resourceType, + string resourceId, + string action, + int organizationId, + int? projectId = null, + int? environmentId = null, + string? changes = null, + CancellationToken ct = default); +} diff --git a/src/api/MicCheck.Api/Audit/DependencyRegistration.cs b/src/api/MicCheck.Api/Audit/DependencyRegistration.cs new file mode 100644 index 0000000..b159052 --- /dev/null +++ b/src/api/MicCheck.Api/Audit/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Audit; + +public static class DependencyRegistration +{ + public static IServiceCollection AddAuditServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Common/DependencyRegistration.cs b/src/api/MicCheck.Api/Common/DependencyRegistration.cs new file mode 100644 index 0000000..44cb592 --- /dev/null +++ b/src/api/MicCheck.Api/Common/DependencyRegistration.cs @@ -0,0 +1,65 @@ +using System.Text; +using MicCheck.Api.Common.Security.ApiKeys; +using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace MicCheck.Api.Common; + +public static class DependencyRegistration +{ + public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication() + .AddScheme( + EnvironmentKeyAuthenticationHandler.SchemeName, _ => { }) + .AddScheme( + ApiKeyAuthenticationHandler.SchemeName, _ => { }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidAudience = configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)) + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy => + policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName) + .RequireClaim("EnvironmentId")); + + options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireClaim("OrganizationRole", "Admin")); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddModelValidatorsFromAssemblyContaining(); + services.Configure(options => + { + options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState); + }); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Common/Guard.cs b/src/api/MicCheck.Api/Common/Guard.cs new file mode 100644 index 0000000..cdc387c --- /dev/null +++ b/src/api/MicCheck.Api/Common/Guard.cs @@ -0,0 +1,40 @@ +namespace MicCheck.Api.Common; + +public static class Guard +{ + public static void Null(T t, string parameterName) where T : class + { + if (t is null) + throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null"); + } + + public static void Empty(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Empty(IEnumerable collection, string parameterName) + { + if (collection == null || !collection.Any()) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Negative(int value, string parameterName) + { + if (value < 0) + throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero"); + } + + public static void NegativeOrZero(int value, string parameterName) + { + if (value <= 0) + throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero"); + } + + public static void Default(T value, string parameterName) + { + if (EqualityComparer.Default.Equals(value, default)) + throw new ArgumentException($"{parameterName} can not be a default value", parameterName); + } +} diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs index 503447a..f98f995 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs @@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization; namespace MicCheck.Api.Common.Security.Authorization; -public class ProjectPermissionRequirement : IAuthorizationRequirement -{ - public ProjectPermission Permission { get; } - - public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission; -} +public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement; diff --git a/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs b/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs new file mode 100644 index 0000000..182f135 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs @@ -0,0 +1,13 @@ +namespace MicCheck.Api.Common.Validation; + +public interface IModelValidator +{ + ValidationResult Validate(object model); +} + +public interface IModelValidator : IModelValidator +{ + ValidationResult Validate(T model); + + ValidationResult IModelValidator.Validate(object model) => Validate((T)model); +} diff --git a/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs b/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs new file mode 100644 index 0000000..c5ad851 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace MicCheck.Api.Common.Validation; + +public class ModelValidationActionFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) continue; + + var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType()); + if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue; + + var result = validator.Validate(argument); + foreach (var error in result.Errors) + context.ModelState.AddModelError(error.PropertyName, error.Message); + } + + if (!context.ModelState.IsValid) + context.Result = ValidationProblemResponseFactory.Create(context.ModelState); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs b/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs new file mode 100644 index 0000000..3a1957b --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MicCheck.Api.Common.Validation; + +public static class ModelValidatorServiceCollectionExtensions +{ + public static IServiceCollection AddModelValidatorsFromAssemblyContaining(this IServiceCollection services) + { + var registrations = typeof(TMarker).Assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsInterface) + .SelectMany(type => type.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>)) + .Select(i => (Interface: i, Implementation: type))); + + foreach (var (@interface, implementation) in registrations) + services.AddScoped(@interface, implementation); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs b/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs new file mode 100644 index 0000000..168fbb2 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace MicCheck.Api.Common.Validation; + +public static class ValidationProblemResponseFactory +{ + public static IActionResult Create(ModelStateDictionary modelState) + { + var errors = modelState + .Where(e => e.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + return new UnprocessableEntityObjectResult(new { errors }); + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs b/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs new file mode 100644 index 0000000..3f4add5 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs @@ -0,0 +1,14 @@ +namespace MicCheck.Api.Common.Validation; + +public record ValidationError(string PropertyName, string Message); + +public class ValidationResult +{ + private readonly List _errors = []; + + public IReadOnlyList Errors => _errors; + public bool IsValid => _errors.Count == 0; + public bool IsInvalid => _errors.Count > 0; + + public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message)); +} diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs index 087ca85..e9aafbe 100644 --- a/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs @@ -1,4 +1,4 @@ -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/api/MicCheck.Api/Data/DependencyRegistration.cs b/src/api/MicCheck.Api/Data/DependencyRegistration.cs new file mode 100644 index 0000000..9721092 --- /dev/null +++ b/src/api/MicCheck.Api/Data/DependencyRegistration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Data; + +public static class DependencyRegistration +{ + public static IServiceCollection AddDataServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + var connectionString = configuration.GetConnectionString("miccheck") + ?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl + ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) + : configuration.GetConnectionString("DefaultConnection")!); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs index 9e10b97..c4f0b9b 100755 --- a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs +++ b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs @@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Audit; using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Organizations; using MicCheck.Api.Projects; diff --git a/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs index ba8b0aa..7d5a63f 100755 --- a/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record CloneEnvironmentRequest(string Name); -public class CloneEnvironmentRequestValidator : AbstractValidator +public class CloneEnvironmentRequestValidator : IModelValidator { - public CloneEnvironmentRequestValidator() + public ValidationResult Validate(CloneEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs index d8ac7c9..9466e6f 100755 --- a/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs @@ -1,14 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record CreateEnvironmentRequest(string Name, int ProjectId); -public class CreateEnvironmentRequestValidator : AbstractValidator +public class CreateEnvironmentRequestValidator : IModelValidator { - public CreateEnvironmentRequestValidator() + public ValidationResult Validate(CreateEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.ProjectId).GreaterThan(0); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.ProjectId <= 0) + result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0."); + + return result; } } diff --git a/src/api/MicCheck.Api/Environments/DependencyRegistration.cs b/src/api/MicCheck.Api/Environments/DependencyRegistration.cs new file mode 100644 index 0000000..f5f5a8c --- /dev/null +++ b/src/api/MicCheck.Api/Environments/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Environments; + +public static class DependencyRegistration +{ + public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentService.cs index 991e2eb..dcba660 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentService.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentService.cs @@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Environments; -public class EnvironmentService(IMicCheckDbContext db, AuditService auditService) +public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs index e659afb..7b8ed76 100755 --- a/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record UpdateEnvironmentRequest(string Name); -public class UpdateEnvironmentRequestValidator : AbstractValidator +public class UpdateEnvironmentRequestValidator : IModelValidator { - public UpdateEnvironmentRequestValidator() + public ValidationResult Validate(UpdateEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs index 3c7ec70..376b484 100755 --- a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs @@ -1,26 +1,28 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; -public record CreateFeatureRequest( - string Name, - FeatureType Type, - string? InitialValue, - string? Description -); +public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description); -public class CreateFeatureRequestValidator : AbstractValidator +public class CreateFeatureRequestValidator : IModelValidator { - public CreateFeatureRequestValidator() - { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(150) - .Matches("^[a-zA-Z0-9_-]+$") - .WithMessage("Name may only contain letters, digits, underscores, and hyphens."); + private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); - RuleFor(x => x.InitialValue) - .MaximumLength(20_000) - .When(x => x.InitialValue is not null); + public ValidationResult Validate(CreateFeatureRequest model) + { + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 150) + result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer."); + else if (!NamePattern.IsMatch(model.Name)) + result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens."); + + if (model.InitialValue is not null && model.InitialValue.Length > 20_000) + result.AddError(nameof(model.InitialValue), "'Initial Value' must be 20000 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/CreateTagRequest.cs b/src/api/MicCheck.Api/Features/CreateTagRequest.cs index 705cb09..ecec153 100755 --- a/src/api/MicCheck.Api/Features/CreateTagRequest.cs +++ b/src/api/MicCheck.Api/Features/CreateTagRequest.cs @@ -1,16 +1,30 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; public record CreateTagRequest(string Label, string Color); -public class CreateTagRequestValidator : AbstractValidator +public class CreateTagRequestValidator : IModelValidator { - public CreateTagRequestValidator() + private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$"); + + public ValidationResult Validate(CreateTagRequest model) { - RuleFor(x => x.Label).NotEmpty().MaximumLength(100); - RuleFor(x => x.Color).NotEmpty().MaximumLength(20) - .Matches("^#[0-9A-Fa-f]{3,6}$") - .WithMessage("Color must be a valid hex color (e.g. #FF0000)."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Label)) + result.AddError(nameof(model.Label), "'Label' must not be empty."); + else if (model.Label.Length > 100) + result.AddError(nameof(model.Label), "'Label' must be 100 characters or fewer."); + + if (string.IsNullOrEmpty(model.Color)) + result.AddError(nameof(model.Color), "'Color' must not be empty."); + else if (model.Color.Length > 20) + result.AddError(nameof(model.Color), "'Color' must be 20 characters or fewer."); + else if (!ColorPattern.IsMatch(model.Color)) + result.AddError(nameof(model.Color), "Color must be a valid hex color (e.g. #FF0000)."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/DependencyRegistration.cs b/src/api/MicCheck.Api/Features/DependencyRegistration.cs new file mode 100644 index 0000000..7e24f7c --- /dev/null +++ b/src/api/MicCheck.Api/Features/DependencyRegistration.cs @@ -0,0 +1,20 @@ +using MicCheck.Api.Features.Usage; + +namespace MicCheck.Api.Features; + +public static class DependencyRegistration +{ + public static IServiceCollection AddFeaturesServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddFeatureUsageServices(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs index dd8666e..1e5e595 100755 --- a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs +++ b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs @@ -1,4 +1,5 @@ using MicCheck.Api.Data; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Segments; using Microsoft.EntityFrameworkCore; diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs index 77793b9..bbfd99f 100755 --- a/src/api/MicCheck.Api/Features/FeatureService.cs +++ b/src/api/MicCheck.Api/Features/FeatureService.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) +public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue) { private const int MaxFeaturesPerProject = 400; @@ -23,13 +23,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct); } - public async Task CreateAsync( - int projectId, - string name, - FeatureType type, - string? initialValue, - string? description, - CancellationToken ct = default) + public async Task CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default) { var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct); if (count >= MaxFeaturesPerProject) @@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We return feature; } - public async Task UpdateAsync( - int id, string name, string? description, CancellationToken ct = default) + public async Task UpdateAsync(int id, string name, string? description, CancellationToken ct = default) { var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct) ?? throw new KeyNotFoundException($"Feature {id} not found."); @@ -162,11 +155,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We EventType = WebhookEventTypes.FlagDeleted, EnvironmentId = env.Id, OrganizationId = project.OrganizationId, - Data = new FlagDeletedData( - null, - DateTimeOffset.UtcNow, - new FeatureSummary(feature.Id, feature.Name)) - }); + Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) }); } } } diff --git a/src/api/MicCheck.Api/Features/FeatureStateResult.cs b/src/api/MicCheck.Api/Features/FeatureStateResult.cs index 636ecbd..d371606 100755 --- a/src/api/MicCheck.Api/Features/FeatureStateResult.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateResult.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Features; -public class FeatureStateResult +public record FeatureStateResult { public required Feature Feature { get; init; } public bool Enabled { get; init; } diff --git a/src/api/MicCheck.Api/Features/FeatureStateService.cs b/src/api/MicCheck.Api/Features/FeatureStateService.cs index 4325be4..a509023 100755 --- a/src/api/MicCheck.Api/Features/FeatureStateService.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) +public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService auditService) { public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs deleted file mode 100644 index 30ed719..0000000 --- a/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MicCheck.Api.Features; - -public class FeatureUsageDaily -{ - public int Id { get; init; } - public int EnvironmentId { get; init; } - public int FeatureId { get; init; } - public required string FeatureName { get; set; } - public DateOnly UsageDate { get; init; } - public long Count { get; set; } - public DateTimeOffset UpdatedAt { get; set; } -} diff --git a/src/api/MicCheck.Api/Features/FlagCache.cs b/src/api/MicCheck.Api/Features/FlagCache.cs index 536c9a5..db3303c 100755 --- a/src/api/MicCheck.Api/Features/FlagCache.cs +++ b/src/api/MicCheck.Api/Features/FlagCache.cs @@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache) return flags; } - public void Set(int environmentId, IReadOnlyList flags) - => cache.Set(CacheKey(environmentId), flags, CacheDuration); + public void Set(int environmentId, IReadOnlyList flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration); - public void Invalidate(int environmentId) - => cache.Remove(CacheKey(environmentId)); + public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId)); private static string CacheKey(int environmentId) => $"flags:{environmentId}"; } diff --git a/src/api/MicCheck.Api/Features/Tag.cs b/src/api/MicCheck.Api/Features/Tag.cs index e848dbc..f004d7e 100755 --- a/src/api/MicCheck.Api/Features/Tag.cs +++ b/src/api/MicCheck.Api/Features/Tag.cs @@ -1,9 +1,9 @@ namespace MicCheck.Api.Features; -public class Tag +public record Tag { public int Id { get; init; } - public required string Label { get; set; } - public required string Color { get; set; } + public required string Label { get; init; } + public required string Color { get; init; } public int ProjectId { get; init; } } diff --git a/src/api/MicCheck.Api/Features/TagsController.cs b/src/api/MicCheck.Api/Features/TagsController.cs index 709bcb4..a5e390b 100755 --- a/src/api/MicCheck.Api/Features/TagsController.cs +++ b/src/api/MicCheck.Api/Features/TagsController.cs @@ -8,23 +8,24 @@ namespace MicCheck.Api.Features; [ApiController] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [EnableRateLimiting("AdminApi")] +[Route("api/v1/project/{projectId}")] public class TagsController(TagService tagService) : ControllerBase { - [HttpGet("api/v1/project/{projectId}/tags")] + [HttpGet("tags")] public async Task>> List(int projectId, CancellationToken ct) { var tags = await tagService.ListByProjectAsync(projectId, ct); return Ok(tags.Select(TagResponse.From).ToList()); } - [HttpPost("api/v1/project/{projectId}/tags")] + [HttpPost("tags")] public async Task> Create(int projectId, CreateTagRequest request, CancellationToken ct) { var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct); return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag)); } - [HttpDelete("api/v1/project/{projectId}/tag/{id}")] + [HttpDelete("tag/{id}")] public async Task Delete(int projectId, int id, CancellationToken ct) { var tag = await tagService.FindByIdAsync(id, ct); diff --git a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs index 451733d..4dde7e0 100755 --- a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs @@ -1,20 +1,25 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; -public record UpdateFeatureRequest( - string Name, - string? Description -); +public record UpdateFeatureRequest(string Name, string? Description); -public class UpdateFeatureRequestValidator : AbstractValidator +public class UpdateFeatureRequestValidator : IModelValidator { - public UpdateFeatureRequestValidator() + private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); + + public ValidationResult Validate(UpdateFeatureRequest model) { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(150) - .Matches("^[a-zA-Z0-9_-]+$") - .WithMessage("Name may only contain letters, digits, underscores, and hyphens."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 150) + result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer."); + else if (!NamePattern.IsMatch(model.Name)) + result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs b/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs new file mode 100644 index 0000000..68bb57d --- /dev/null +++ b/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs @@ -0,0 +1,13 @@ +namespace MicCheck.Api.Features.Usage; + +public static class DependencyRegistration +{ + public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs similarity index 75% rename from src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs index 370437d..5fbf11c 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate); diff --git a/src/api/MicCheck.Api/Features/FeatureUsageController.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs similarity index 96% rename from src/api/MicCheck.Api/Features/FeatureUsageController.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs index 09bbc95..7430eeb 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageController.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Caching.Memory; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; [ApiController] [Route("api/v1/environment/{environmentId}/usage")] diff --git a/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs new file mode 100644 index 0000000..114975a --- /dev/null +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Features.Usage; + +public record FeatureUsageDaily +{ + public int Id { get; init; } + public int EnvironmentId { get; init; } + public int FeatureId { get; init; } + public required string FeatureName { get; init; } + public DateOnly UsageDate { get; init; } + public long Count { get; init; } + public DateTimeOffset UpdatedAt { get; init; } +} diff --git a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs similarity index 98% rename from src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs index f69a807..6b14e85 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; [ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")] public class FeatureUsageFlushBackgroundService( diff --git a/src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs similarity index 97% rename from src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs index fd44b4b..2ab2a51 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.Metrics; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public class FeatureUsageMetrics : IDisposable { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs similarity index 97% rename from src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs index f2608ed..e8f3429 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs @@ -1,7 +1,7 @@ using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public class FeatureUsageQueryService(IMicCheckDbContext db) { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageResponses.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs similarity index 51% rename from src/api/MicCheck.Api/Features/FeatureUsageResponses.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs index 201d8b5..b067b80 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageResponses.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs @@ -1,9 +1,7 @@ -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public record TopFeatureUsage(int FeatureId, string FeatureName, long Count); public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList Features); -public record DashboardUsageResponse( - IReadOnlyList TopFeaturesLastDay, - IReadOnlyList DailyUsage); +public record DashboardUsageResponse(IReadOnlyList TopFeaturesLastDay, IReadOnlyList DailyUsage); diff --git a/src/api/MicCheck.Api/Identities/DependencyRegistration.cs b/src/api/MicCheck.Api/Identities/DependencyRegistration.cs new file mode 100644 index 0000000..4f1efea --- /dev/null +++ b/src/api/MicCheck.Api/Identities/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Identities; + +public static class DependencyRegistration +{ + public static IServiceCollection AddIdentitiesServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/MicCheck.Api.csproj b/src/api/MicCheck.Api/MicCheck.Api.csproj index da34818..98bae35 100755 --- a/src/api/MicCheck.Api/MicCheck.Api.csproj +++ b/src/api/MicCheck.Api/MicCheck.Api.csproj @@ -9,7 +9,6 @@ - diff --git a/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs b/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs index 1632da8..1994b03 100644 --- a/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs +++ b/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs @@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations b.ToTable("FeatureStates"); }); - modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => + modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs index f1e18f0..6e18fbe 100755 --- a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs +++ b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs @@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations b.ToTable("FeatureStates"); }); - modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => + modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs index 32f755b..488b1e7 100755 --- a/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs +++ b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record CreateOrganizationRequest(string Name); -public class CreateOrganizationRequestValidator : AbstractValidator +public class CreateOrganizationRequestValidator : IModelValidator { - public CreateOrganizationRequestValidator() + public ValidationResult Validate(CreateOrganizationRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs b/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs new file mode 100644 index 0000000..e474831 --- /dev/null +++ b/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs @@ -0,0 +1,11 @@ +namespace MicCheck.Api.Organizations; + +public static class DependencyRegistration +{ + public static IServiceCollection AddOrganizationsServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs index a0ca37e..ed5f2ac 100755 --- a/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs +++ b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs @@ -1,15 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record InviteUserRequest(int UserId, string Role); -public class InviteUserRequestValidator : AbstractValidator +public class InviteUserRequestValidator : IModelValidator { - public InviteUserRequestValidator() + public ValidationResult Validate(InviteUserRequest model) { - RuleFor(x => x.UserId).GreaterThan(0); - RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse(r, true, out _)) - .WithMessage("Role must be 'User' or 'Admin'."); + var result = new ValidationResult(); + + if (model.UserId <= 0) + result.AddError(nameof(model.UserId), "'User Id' must be greater than 0."); + + if (string.IsNullOrEmpty(model.Role)) + result.AddError(nameof(model.Role), "'Role' must not be empty."); + else if (!Enum.TryParse(model.Role, true, out _)) + result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'."); + + return result; } } diff --git a/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs index f0c04db..962d2fd 100755 --- a/src/api/MicCheck.Api/Organizations/OrganizationService.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Organizations; -public class OrganizationService(IMicCheckDbContext db, AuditService auditService) +public class OrganizationService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListForUserAsync(int userId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs index 16a951f..30c0795 100755 --- a/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs +++ b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record UpdateOrganizationRequest(string Name); -public class UpdateOrganizationRequestValidator : AbstractValidator +public class UpdateOrganizationRequestValidator : IModelValidator { - public UpdateOrganizationRequestValidator() + public ValidationResult Validate(UpdateOrganizationRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index 8c0bc49..fa7569a 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -1,11 +1,9 @@ -using System.Text; using System.Threading.RateLimiting; -using FluentValidation; -using FluentValidation.AspNetCore; -using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Audit; -using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Common; +using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; @@ -15,14 +13,7 @@ using MicCheck.Api.Projects; using MicCheck.Api.Segments; using MicCheck.Api.Users; using MicCheck.Api.Webhooks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; using Serilog; @@ -42,44 +33,12 @@ try .WriteTo.Console()); builder.Services.AddOpenApi(); - builder.Services.AddControllers() + builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add( new System.Text.Json.Serialization.JsonStringEnumConverter())); - builder.Services.AddAuthentication() - .AddScheme( - EnvironmentKeyAuthenticationHandler.SchemeName, _ => { }) - .AddScheme( - ApiKeyAuthenticationHandler.SchemeName, _ => { }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateIssuerSigningKey = true, - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!)) - }; - }); - - builder.Services.AddAuthorization(options => - { - options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy => - policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName) - .RequireClaim("EnvironmentId")); - - options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy => - policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) - .RequireAuthenticatedUser()); - - options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy => - policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) - .RequireClaim("OrganizationRole", "Admin")); - }); + builder.Services.AddCommonServices(builder.Configuration); builder.Services.AddHttpContextAccessor(); @@ -92,67 +51,20 @@ try limiter.QueueLimit = 0; })); - builder.Services.AddFluentValidationAutoValidation(); - builder.Services.AddValidatorsFromAssemblyContaining(); - - builder.Services.Configure(options => - { - options.InvalidModelStateResponseFactory = context => - { - var errors = context.ModelState - .Where(e => e.Value?.Errors.Count > 0) - .ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); - return new UnprocessableEntityObjectResult(new { errors }); - }; - }); - builder.Services.AddMemoryCache(); builder.Services.AddMetrics(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped, PasswordHasher>(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddHostedService(); - builder.Services.AddHttpClient("Webhooks", client => - client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0")); + builder.Services.AddAuditServices(); + builder.Services.AddIdentitiesServices(); + builder.Services.AddEnvironmentsServices(); + builder.Services.AddSegmentsServices(); + builder.Services.AddOrganizationsServices(); + builder.Services.AddProjectsServices(); + builder.Services.AddFeaturesServices(); + builder.Services.AddUsersServices(); + builder.Services.AddWebhooksServices(); - var connectionString = builder.Configuration.GetConnectionString("miccheck") - ?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl - ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) - : builder.Configuration.GetConnectionString("DefaultConnection")!); - - builder.Services.AddDbContext(options => - options.UseNpgsql(connectionString)); - builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddDataServices(builder.Configuration); var app = builder.Build(); diff --git a/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs index 155c34f..23b114d 100755 --- a/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs +++ b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs @@ -1,14 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Projects; public record CreateProjectRequest(string Name, int OrganizationId); -public class CreateProjectRequestValidator : AbstractValidator +public class CreateProjectRequestValidator : IModelValidator { - public CreateProjectRequestValidator() + public ValidationResult Validate(CreateProjectRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.OrganizationId).GreaterThan(0); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.OrganizationId <= 0) + result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0."); + + return result; } } diff --git a/src/api/MicCheck.Api/Projects/DependencyRegistration.cs b/src/api/MicCheck.Api/Projects/DependencyRegistration.cs new file mode 100644 index 0000000..81929fd --- /dev/null +++ b/src/api/MicCheck.Api/Projects/DependencyRegistration.cs @@ -0,0 +1,11 @@ +namespace MicCheck.Api.Projects; + +public static class DependencyRegistration +{ + public static IServiceCollection AddProjectsServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Projects/ProjectService.cs b/src/api/MicCheck.Api/Projects/ProjectService.cs index de54cb7..9e2e4b4 100755 --- a/src/api/MicCheck.Api/Projects/ProjectService.cs +++ b/src/api/MicCheck.Api/Projects/ProjectService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Projects; -public class ProjectService(IMicCheckDbContext db, AuditService auditService) +public class ProjectService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs index 047d948..b800408 100755 --- a/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs +++ b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs @@ -1,17 +1,25 @@ -using FluentValidation; -using MicCheck.Api.Common.Security.Authorization; - -namespace MicCheck.Api.Projects; - -public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List Permissions); - -public class SetUserPermissionsRequestValidator : AbstractValidator -{ - public SetUserPermissionsRequestValidator() - { - RuleFor(x => x.UserId).GreaterThan(0); - RuleForEach(x => x.Permissions) - .Must(p => Enum.TryParse(p, true, out _)) - .WithMessage("Invalid permission value."); - } -} +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; + +namespace MicCheck.Api.Projects; + +public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List Permissions); + +public class SetUserPermissionsRequestValidator : IModelValidator +{ + public ValidationResult Validate(SetUserPermissionsRequest model) + { + var result = new ValidationResult(); + + if (model.UserId <= 0) + result.AddError(nameof(model.UserId), "'User Id' must be greater than 0."); + + foreach (var permission in model.Permissions) + { + if (!Enum.TryParse(permission, true, out _)) + result.AddError(nameof(model.Permissions), "Invalid permission value."); + } + + return result; + } +} diff --git a/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs index 9ce7213..76ad2e6 100755 --- a/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs +++ b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Projects; public record UpdateProjectRequest(string Name, bool HideDisabledFlags); -public class UpdateProjectRequestValidator : AbstractValidator +public class UpdateProjectRequestValidator : IModelValidator { - public UpdateProjectRequestValidator() + public ValidationResult Validate(UpdateProjectRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs index 6661251..3d6429b 100755 --- a/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs +++ b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Segments; @@ -16,24 +16,38 @@ public record CreateSegmentRequest( string Name, IReadOnlyList Rules); -public class CreateSegmentRequestValidator : AbstractValidator +public class CreateSegmentRequestValidator : IModelValidator { - public CreateSegmentRequestValidator() + public ValidationResult Validate(CreateSegmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.Rules).NotNull(); - RuleForEach(x => x.Rules).ChildRules(rule => + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.Rules is null) { - rule.RuleFor(r => r.Type) - .Must(t => Enum.TryParse(t, true, out _)) - .WithMessage("Rule type must be 'All', 'Any', or 'None'."); - rule.RuleForEach(r => r.Conditions).ChildRules(cond => + result.AddError(nameof(model.Rules), "'Rules' must not be empty."); + return result; + } + + foreach (var rule in model.Rules) + { + if (!Enum.TryParse(rule.Type, true, out _)) + result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'."); + + foreach (var condition in rule.Conditions) { - cond.RuleFor(c => c.Property).NotEmpty(); - cond.RuleFor(c => c.Operator) - .Must(o => Enum.TryParse(o, true, out _)) - .WithMessage("Invalid operator."); - }); - }); + if (string.IsNullOrEmpty(condition.Property)) + result.AddError(nameof(model.Rules), "'Property' must not be empty."); + + if (!Enum.TryParse(condition.Operator, true, out _)) + result.AddError(nameof(model.Rules), "Invalid operator."); + } + } + + return result; } } diff --git a/src/api/MicCheck.Api/Segments/DependencyRegistration.cs b/src/api/MicCheck.Api/Segments/DependencyRegistration.cs new file mode 100644 index 0000000..df7c80f --- /dev/null +++ b/src/api/MicCheck.Api/Segments/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Segments; + +public static class DependencyRegistration +{ + public static IServiceCollection AddSegmentsServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Segments/SegmentService.cs b/src/api/MicCheck.Api/Segments/SegmentService.cs index e7fb359..545a59b 100755 --- a/src/api/MicCheck.Api/Segments/SegmentService.cs +++ b/src/api/MicCheck.Api/Segments/SegmentService.cs @@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList Conditions, IReadOnlyList? ChildRules = null); -public class SegmentService(IMicCheckDbContext db, AuditService auditService) +public class SegmentService(IMicCheckDbContext db, IAuditService auditService) { private const int MaxSegmentsPerProject = 100; private const int MaxConditionsPerSegment = 100; diff --git a/src/api/MicCheck.Api/Users/DependencyRegistration.cs b/src/api/MicCheck.Api/Users/DependencyRegistration.cs new file mode 100644 index 0000000..7229e37 --- /dev/null +++ b/src/api/MicCheck.Api/Users/DependencyRegistration.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +namespace MicCheck.Api.Users; + +public static class DependencyRegistration +{ + public static IServiceCollection AddUsersServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped, PasswordHasher>(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs index 85b36e5..4e024b5 100755 --- a/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs +++ b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs @@ -1,14 +1,22 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Webhooks; public record CreateWebhookRequest(string Url, string? Secret, bool Enabled); -public class CreateWebhookRequestValidator : AbstractValidator +public class CreateWebhookRequestValidator : IModelValidator { - public CreateWebhookRequestValidator() + public ValidationResult Validate(CreateWebhookRequest model) { - RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _)) - .WithMessage("Url must be a valid absolute URL."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Url)) + result.AddError(nameof(model.Url), "'Url' must not be empty."); + else if (model.Url.Length > 500) + result.AddError(nameof(model.Url), "'Url' must be 500 characters or fewer."); + else if (!Uri.TryCreate(model.Url, UriKind.Absolute, out _)) + result.AddError(nameof(model.Url), "Url must be a valid absolute URL."); + + return result; } } diff --git a/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs b/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs new file mode 100644 index 0000000..c240f4b --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs @@ -0,0 +1,17 @@ +namespace MicCheck.Api.Webhooks; + +public static class DependencyRegistration +{ + public static IServiceCollection AddWebhooksServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHttpClient("Webhooks", client => + client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0")); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs index 0f603d0..76a5fb5 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Webhooks; -public class WebhookEvent +public record WebhookEvent { public required string EventType { get; init; } public int? EnvironmentId { get; init; } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs index 37b3b1e..af19876 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs @@ -23,7 +23,7 @@ public class AdminApiIntegrationTests private List _featureStates = null!; private List _environments = null!; private List _segments = null!; - private Mock _auditService = null!; + private Mock _auditService = null!; private int _organizationId; [SetUp] @@ -50,7 +50,7 @@ public class AdminApiIntegrationTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []); var webhookQueue = new WebhookQueue(); - _auditService = new Mock(_db.Object, null!, webhookQueue); + _auditService = new Mock(); _auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs index 0ae3d3f..38848c6 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs @@ -46,7 +46,7 @@ public class AuditLogsControllerTests _db.SetupDbSet(c => c.AuditLogs, _auditLogs); _db.SetupDbSet(c => c.Users, []); - var auditServiceMock = new Mock(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue()); + var auditServiceMock = new Mock(); var auditService = auditServiceMock.Object; _controller = new AuditLogsController( diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs index d7591f1..1da20dd 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs @@ -45,7 +45,7 @@ public class EnvironmentServiceTests _db.SetupDbSet(c => c.Identities, _identities); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs index 6fb5401..59a407c 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs @@ -113,7 +113,7 @@ public class EnvironmentsControllerTests _db.SetupDbSet(c => c.Users, []); var webhookQueue = new WebhookQueue(); - var auditServiceMock = new Mock(_db.Object, null!, webhookQueue); + var auditServiceMock = new Mock(); auditServiceMock.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs index 86073e5..f757f83 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics.Metrics; using MicCheck.Api.Data; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Segments; using MicCheck.Api.Tests.Unit.TestSupport; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs index 45ec63c..0c0b530 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs @@ -45,7 +45,7 @@ public class FeatureServiceTests _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs index 834991d..92a639d 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs @@ -43,7 +43,7 @@ public class FeatureStateServiceTests _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); var webhookQueue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -154,7 +154,7 @@ public class FeatureStateServiceTests var state = AddState(); var queue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, queue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs index 36456da..43c4f54 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs @@ -41,7 +41,7 @@ public class FeaturesControllerTests _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs index 689f31d..b1a4261 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Projects; using MicCheck.Api.Segments; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs similarity index 97% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs index 051f809..df57bcf 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs @@ -1,12 +1,12 @@ using MicCheck.Api.Data; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Tests.Unit.TestSupport; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageControllerTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs similarity index 96% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs index 8d95b06..3dcc650 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics.Metrics; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageMetricsTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs similarity index 98% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs index 966bbf0..ce75b41 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs @@ -1,10 +1,10 @@ using MicCheck.Api.Data; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Tests.Unit.TestSupport; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageQueryServiceTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs index 7b66443..25c7833 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Projects; using MicCheck.Api.Segments; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs index 023fe5d..ca9980a 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs @@ -41,7 +41,7 @@ public class OrganizationServiceTests _db.SetupDbSet(c => c.Users, _users); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs index 6c7956a..585bf1b 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs @@ -49,7 +49,7 @@ public class OrganizationsControllerTests _db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks); var webhookQueue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs index 1c04bfc..707a89c 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs @@ -32,7 +32,7 @@ public class ProjectServiceTests _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs index 33c67e4..524f8c2 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs @@ -34,7 +34,7 @@ public class ProjectsControllerTests _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs index 51bb9f1..232b6ab 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs @@ -40,7 +40,7 @@ public class SegmentServiceTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs index b85c22e..b143d13 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs @@ -41,7 +41,7 @@ public class SegmentsControllerTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),