Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4)
## 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:
@@ -1,32 +1,33 @@
|
|||||||
|
using MicCheck.Api.Common;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MicCheck.Api.Audit;
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
public class AuditLogQueryService(MicCheckDbContext db)
|
public class AuditLogQueryService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
|
||||||
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
|
||||||
int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
|
||||||
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync(
|
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
|
||||||
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (filter.From.HasValue)
|
if (filter.From.HasValue)
|
||||||
@@ -63,6 +64,6 @@ public class AuditLogQueryService(MicCheckDbContext db)
|
|||||||
user != null ? user.FirstName + " " + user.LastName : null))
|
user != null ? user.FirstName + " " + user.LastName : null))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return (total, items);
|
return new PagedResult<AuditLogResponse>(total, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
using MicCheck.Api.Common.Security.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Common;
|
using MicCheck.Api.Common;
|
||||||
using MicCheck.Api.Environments;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Projects;
|
using MicCheck.Api.Projects;
|
||||||
using MicCheck.Api.Organizations;
|
using MicCheck.Api.Organizations;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace MicCheck.Api.Audit;
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||||
[EnableRateLimiting("AdminApi")]
|
[EnableRateLimiting("AdminApi")]
|
||||||
public class AuditLogsController(
|
public class AuditLogsController(
|
||||||
AuditLogQueryService auditLogQueryService,
|
AuditLogQueryService auditLogQueryService,
|
||||||
OrganizationService organizationService,
|
OrganizationService organizationService,
|
||||||
ProjectService projectService,
|
ProjectService projectService,
|
||||||
EnvironmentService environmentService) : ControllerBase
|
EnvironmentService environmentService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/v1/organisation/{id}/audit-logs")]
|
[HttpGet("api/v1/organisation/{id}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||||
int id,
|
int id,
|
||||||
[FromQuery] AuditLogFilter filter,
|
[FromQuery] AuditLogFilter filter,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var org = await organizationService.FindByIdAsync(id, ct);
|
var org = await organizationService.FindByIdAsync(id, ct);
|
||||||
if (org is null) return NotFound();
|
if (org is null) return NotFound();
|
||||||
|
|
||||||
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
|
var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
|
||||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/project/{projectId}/audit-logs")]
|
[HttpGet("api/v1/project/{projectId}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||||
int projectId,
|
int projectId,
|
||||||
[FromQuery] AuditLogFilter filter,
|
[FromQuery] AuditLogFilter filter,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||||
if (project is null) return NotFound();
|
if (project is null) return NotFound();
|
||||||
|
|
||||||
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
|
var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
|
||||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
|
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||||
string apiKey,
|
string apiKey,
|
||||||
[FromQuery] AuditLogFilter filter,
|
[FromQuery] AuditLogFilter filter,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
if (environment is null) return NotFound();
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
|
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
|
||||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Audit;
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
|
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|||||||
3
src/api/MicCheck.Api/Common/PagedResult.cs
Normal file
3
src/api/MicCheck.Api/Common/PagedResult.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Common;
|
||||||
|
|
||||||
|
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
namespace MicCheck.Api.Common;
|
namespace MicCheck.Api.Common;
|
||||||
|
|
||||||
public record PaginatedResponse<T>(
|
public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);
|
||||||
int Count,
|
|
||||||
string? Next,
|
|
||||||
string? Previous,
|
|
||||||
IReadOnlyList<T> Results
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
|
|||||||
public class ApiKey
|
public class ApiKey
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public required string Key { get; set; }
|
public required string Key { get; init; }
|
||||||
public required string Prefix { get; set; }
|
public required string Prefix { get; init; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; init; }
|
||||||
public int OrganizationId { get; init; }
|
public int OrganizationId { get; init; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public DateTimeOffset? ExpiresAt { get; set; }
|
public DateTimeOffset? ExpiresAt { get; init; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ public static class ApiKeyEndpoints
|
|||||||
|
|
||||||
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
|
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
|
||||||
organizationId, request.Name, request.ExpiresAt, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt));
|
||||||
|
|
||||||
}).WithName("CreateApiKey");
|
}).WithName("CreateApiKey");
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||||
|
|
||||||
public class ApiKeyService(MicCheckDbContext db)
|
public class ApiKeyService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
public async Task<ApiKeyCreationResult> CreateAsync(
|
||||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
var rawKey = ApiKeyHasher.GenerateKey();
|
||||||
@@ -26,7 +26,7 @@ public class ApiKeyService(MicCheckDbContext db)
|
|||||||
db.ApiKeys.Add(apiKey);
|
db.ApiKeys.Add(apiKey);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (apiKey, rawKey);
|
return new ApiKeyCreationResult(apiKey, rawKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
||||||
@@ -48,3 +48,5 @@ public class ApiKeyService(MicCheckDbContext db)
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ApiKeyCreationResult(ApiKey Key, string RawKey);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Common.Security.Authentication;
|
namespace MicCheck.Api.Common.Security.Authentication;
|
||||||
|
|
||||||
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
|
||||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
public const string SchemeName = "ApiKey";
|
public const string SchemeName = "ApiKey";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Common.Security.Authentication;
|
namespace MicCheck.Api.Common.Security.Authentication;
|
||||||
|
|
||||||
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
|
||||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
public const string SchemeName = "EnvironmentKey";
|
public const string SchemeName = "EnvironmentKey";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace MicCheck.Api.Common.Security.Authorization;
|
namespace MicCheck.Api.Common.Security.Authorization;
|
||||||
|
|
||||||
public class AuthService(
|
public class AuthService(
|
||||||
MicCheckDbContext db,
|
IMicCheckDbContext db,
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
IPasswordHasher<User> passwordHasher)
|
IPasswordHasher<User> passwordHasher)
|
||||||
{
|
{
|
||||||
@@ -81,7 +81,9 @@ public class AuthService(
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
|
var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct);
|
||||||
|
foreach (var organizationUser in organizationUsers)
|
||||||
|
user.Organizations.Add(organizationUser);
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateToken(user);
|
var accessToken = tokenService.GenerateToken(user);
|
||||||
var refreshTokenValue = GenerateSecureToken();
|
var refreshTokenValue = GenerateSecureToken();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace MicCheck.Api.Common.Security.Authorization;
|
namespace MicCheck.Api.Common.Security.Authorization;
|
||||||
|
|
||||||
public class ProjectPermissionRequirementHandler(
|
public class ProjectPermissionRequirementHandler(
|
||||||
MicCheckDbContext db,
|
IMicCheckDbContext db,
|
||||||
IHttpContextAccessor httpContextAccessor)
|
IHttpContextAccessor httpContextAccessor)
|
||||||
: AuthorizationHandler<ProjectPermissionRequirement>
|
: AuthorizationHandler<ProjectPermissionRequirement>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Data;
|
namespace MicCheck.Api.Data;
|
||||||
|
|
||||||
public class DatabaseSeeder
|
public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deterministic credentials for the development/QA seed admin user. Only ever created when
|
/// Deterministic credentials for the development/QA seed admin user. Only ever created when
|
||||||
@@ -15,25 +15,17 @@ public class DatabaseSeeder
|
|||||||
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate
|
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate
|
||||||
/// without depending on per-run registration.
|
/// without depending on per-run registration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string SeedAdminEmail = "admin@miccheck.local";
|
private const string SeedAdminEmail = "admin@miccheck.local";
|
||||||
public const string SeedAdminPassword = "MicCheckQa!2026";
|
|
||||||
|
private const string SeedAdminPassword = "MicCheckQa!2026";
|
||||||
|
|
||||||
/// <summary>Deterministic environment key for the seeded Development environment, so
|
/// <summary>Deterministic environment key for the seeded Development environment, so
|
||||||
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
|
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
|
||||||
public const string SeedDevelopmentEnvironmentKey = "env-qa-development";
|
private const string SeedDevelopmentEnvironmentKey = "env-qa-development";
|
||||||
|
|
||||||
private readonly MicCheckDbContext _db;
|
|
||||||
private readonly IPasswordHasher<User> _passwordHasher;
|
|
||||||
|
|
||||||
public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_passwordHasher = passwordHasher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SeedAsync(CancellationToken ct = default)
|
public async Task SeedAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (await _db.Organizations.AnyAsync(ct))
|
if (await db.Organizations.AnyAsync(ct))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var organization = new Organization
|
var organization = new Organization
|
||||||
@@ -41,8 +33,8 @@ public class DatabaseSeeder
|
|||||||
Name = "Default",
|
Name = "Default",
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Organizations.Add(organization);
|
db.Organizations.Add(organization);
|
||||||
await _db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var project = new Project
|
var project = new Project
|
||||||
{
|
{
|
||||||
@@ -50,13 +42,13 @@ public class DatabaseSeeder
|
|||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Projects.Add(project);
|
db.Projects.Add(project);
|
||||||
await _db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var environmentNames = new[] { "Development", "Staging", "Production" };
|
var environmentNames = new[] { "Development", "Staging", "Production" };
|
||||||
foreach (var name in environmentNames)
|
foreach (var name in environmentNames)
|
||||||
{
|
{
|
||||||
_db.Environments.Add(new AppEnvironment
|
db.Environments.Add(new AppEnvironment
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}",
|
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}",
|
||||||
@@ -65,7 +57,7 @@ public class DatabaseSeeder
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var adminUser = new User
|
var adminUser = new User
|
||||||
{
|
{
|
||||||
@@ -76,17 +68,17 @@ public class DatabaseSeeder
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
PasswordHash = string.Empty
|
PasswordHash = string.Empty
|
||||||
};
|
};
|
||||||
adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword);
|
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword);
|
||||||
_db.Users.Add(adminUser);
|
db.Users.Add(adminUser);
|
||||||
await _db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
_db.OrganizationUsers.Add(new OrganizationUser
|
db.OrganizationUsers.Add(new OrganizationUser
|
||||||
{
|
{
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = adminUser.Id,
|
UserId = adminUser.Id,
|
||||||
Role = OrganizationRole.Admin,
|
Role = OrganizationRole.Admin,
|
||||||
IsPrimary = true
|
IsPrimary = true
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Data;
|
namespace MicCheck.Api.Data;
|
||||||
|
|
||||||
public class MicCheckDbContext : DbContext
|
public class MicCheckDbContext : DbContext, IMicCheckDbContext
|
||||||
{
|
{
|
||||||
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
|
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
|
||||||
|
|
||||||
@@ -42,3 +42,31 @@ public class MicCheckDbContext : DbContext
|
|||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IMicCheckDbContext
|
||||||
|
{
|
||||||
|
DbSet<Organization> Organizations { get; }
|
||||||
|
DbSet<OrganizationUser> OrganizationUsers { get; }
|
||||||
|
DbSet<Project> Projects { get; }
|
||||||
|
DbSet<AppEnvironment> Environments { get; }
|
||||||
|
DbSet<Feature> Features { get; }
|
||||||
|
DbSet<FeatureState> FeatureStates { get; }
|
||||||
|
DbSet<FeatureSegment> FeatureSegments { get; }
|
||||||
|
DbSet<Tag> Tags { get; }
|
||||||
|
DbSet<Segment> Segments { get; }
|
||||||
|
DbSet<SegmentRule> SegmentRules { get; }
|
||||||
|
DbSet<SegmentCondition> SegmentConditions { get; }
|
||||||
|
DbSet<Identity> Identities { get; }
|
||||||
|
DbSet<IdentityTrait> IdentityTraits { get; }
|
||||||
|
DbSet<AuditLog> AuditLogs { get; }
|
||||||
|
DbSet<Webhook> Webhooks { get; }
|
||||||
|
DbSet<WebhookDeliveryLog> WebhookDeliveryLogs { get; }
|
||||||
|
DbSet<ApiKey> ApiKeys { get; }
|
||||||
|
DbSet<User> Users { get; }
|
||||||
|
DbSet<RefreshToken> RefreshTokens { get; }
|
||||||
|
DbSet<UserProjectPermission> UserProjectPermissions { get; }
|
||||||
|
DbSet<FeatureUsageDaily> FeatureUsageDaily { get; }
|
||||||
|
|
||||||
|
int SaveChanges();
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public class EnvironmentDocumentService(MicCheckDbContext db)
|
public class EnvironmentDocumentService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
|
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
|
public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
if (environment is null) return NotFound();
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
|
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
|
||||||
return Ok(logs.ToList());
|
return Ok(result.Items.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/identities")]
|
[HttpGet("api/v1/environment/{apiKey}/identities")]
|
||||||
@@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
if (environment is null) return NotFound();
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
|
var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
|
||||||
var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
|
var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
|
||||||
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
|
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/v1/environment/{apiKey}/identities")]
|
[HttpPost("api/v1/environment/{apiKey}/identities")]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureEvaluationService(
|
public class FeatureEvaluationService(
|
||||||
MicCheckDbContext db,
|
IMicCheckDbContext db,
|
||||||
SegmentEvaluator segmentEvaluator,
|
SegmentEvaluator segmentEvaluator,
|
||||||
FlagCache flagCache,
|
FlagCache flagCache,
|
||||||
FeatureUsageMetrics usageMetrics)
|
FeatureUsageMetrics usageMetrics)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureSegmentService(MicCheckDbContext db)
|
public class FeatureSegmentService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
|
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
|
||||||
int featureId, int environmentId, CancellationToken ct = default)
|
int featureId, int environmentId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
|
public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
|
||||||
{
|
{
|
||||||
private const int MaxFeaturesPerProject = 400;
|
private const int MaxFeaturesPerProject = 400;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
|
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureUsageQueryService(MicCheckDbContext db)
|
public class FeatureUsageQueryService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
|
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class TagService(MicCheckDbContext db)
|
public class TagService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
using MicCheck.Api.Common;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MicCheck.Api.Identities;
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
public class AdminIdentityService(MicCheckDbContext db)
|
public class AdminIdentityService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync(
|
public async Task<PagedResult<Identity>> ListAsync(
|
||||||
int environmentId, int page, int pageSize, CancellationToken ct = default)
|
int environmentId, int page, int pageSize, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = db.Identities
|
var query = db.Identities
|
||||||
@@ -20,7 +21,7 @@ public class AdminIdentityService(MicCheckDbContext db)
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return (total, items);
|
return new PagedResult<Identity>(total, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)
|
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Identities;
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
public class IdentityResolutionService(MicCheckDbContext db)
|
public class IdentityResolutionService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<Identity> ResolveAsync(
|
public async Task<Identity> ResolveAsync(
|
||||||
int environmentId,
|
int environmentId,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public class OrganizationService(MicCheckDbContext db, AuditService auditService)
|
public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ try
|
|||||||
|
|
||||||
builder.Services.AddDbContext<MicCheckDbContext>(options =>
|
builder.Services.AddDbContext<MicCheckDbContext>(options =>
|
||||||
options.UseNpgsql(connectionString));
|
options.UseNpgsql(connectionString));
|
||||||
|
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Projects;
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
public class ProjectService(MicCheckDbContext db, AuditService auditService)
|
public class ProjectService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
|
|||||||
|
|
||||||
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
|
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
|
||||||
|
|
||||||
public class SegmentService(MicCheckDbContext db, AuditService auditService)
|
public class SegmentService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
private const int MaxSegmentsPerProject = 100;
|
private const int MaxSegmentsPerProject = 100;
|
||||||
private const int MaxConditionsPerSegment = 100;
|
private const int MaxConditionsPerSegment = 100;
|
||||||
|
|||||||
@@ -4,28 +4,28 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Users;
|
namespace MicCheck.Api.Users;
|
||||||
|
|
||||||
public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
|
public class UserService(IMicCheckDbContext db, IPasswordHasher<User> passwordHasher)
|
||||||
{
|
{
|
||||||
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
|
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
|
||||||
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
|
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
|
||||||
|
|
||||||
public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync(
|
public async Task<ProfileUpdateResult> UpdateProfileAsync(
|
||||||
int userId, string firstName, string lastName, string email, CancellationToken ct = default)
|
int userId, string firstName, string lastName, string email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
|
||||||
if (user is null) return (null, false);
|
if (user is null) return new ProfileUpdateResult(null, EmailConflict: false);
|
||||||
|
|
||||||
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
|
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
|
||||||
if (emailTaken) return (null, true);
|
if (emailTaken) return new ProfileUpdateResult(null, EmailConflict: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.FirstName = firstName;
|
user.FirstName = firstName;
|
||||||
user.LastName = lastName;
|
user.LastName = lastName;
|
||||||
user.Email = email;
|
user.Email = email;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (user, false);
|
return new ProfileUpdateResult(user, EmailConflict: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ChangePasswordAsync(
|
public async Task<bool> ChangePasswordAsync(
|
||||||
@@ -42,3 +42,5 @@ public class UserService(MicCheckDbContext db, IPasswordHasher<User> passwordHas
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ProfileUpdateResult(User? User, bool EmailConflict);
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase
|
|||||||
string.IsNullOrWhiteSpace(request.Email))
|
string.IsNullOrWhiteSpace(request.Email))
|
||||||
return BadRequest("First name, last name, and email are required.");
|
return BadRequest("First name, last name, and email are required.");
|
||||||
|
|
||||||
var (user, emailConflict) = await userService.UpdateProfileAsync(
|
var result = await userService.UpdateProfileAsync(
|
||||||
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
|
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
|
||||||
|
|
||||||
if (emailConflict) return Conflict("Email is already in use.");
|
if (result.EmailConflict) return Conflict("Email is already in use.");
|
||||||
return user is null ? NotFound() : Ok(UserResponse.From(user));
|
return result.User is null ? NotFound() : Ok(UserResponse.From(result.User));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("me/change-password")]
|
[HttpPost("me/change-password")]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Webhooks;
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
public class WebhookDispatcher(MicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger<WebhookDispatcher> logger)
|
public class WebhookDispatcher(IMicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger<WebhookDispatcher> logger)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Webhooks;
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
public class WebhookService(MicCheckDbContext db)
|
public class WebhookService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,268 +1,158 @@
|
|||||||
using System.Net;
|
using MicCheck.Api.Audit;
|
||||||
using System.Net.Http.Json;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Common.Security.ApiKeys;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Features;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Organizations;
|
||||||
using MicCheck.Api.Organizations;
|
using MicCheck.Api.Projects;
|
||||||
using MicCheck.Api.Projects;
|
using MicCheck.Api.Segments;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using NUnit.Framework;
|
using MicCheck.Api.Webhooks;
|
||||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
namespace MicCheck.Api.Tests.Unit.Admin;
|
using NUnit.Framework;
|
||||||
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
[TestFixture]
|
|
||||||
public class AdminApiIntegrationTests
|
namespace MicCheck.Api.Tests.Unit.Admin;
|
||||||
{
|
|
||||||
private MicCheckWebApplicationFactory _factory = null!;
|
[TestFixture]
|
||||||
private string _rawApiKey = null!;
|
public class AdminApiIntegrationTests
|
||||||
|
{
|
||||||
[SetUp]
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
public void SetUp()
|
private List<Project> _projects = null!;
|
||||||
{
|
private List<Feature> _features = null!;
|
||||||
_factory = new MicCheckWebApplicationFactory();
|
private List<FeatureState> _featureStates = null!;
|
||||||
_rawApiKey = SeedOrganizationWithApiKey();
|
private List<AppEnvironment> _environments = null!;
|
||||||
}
|
private List<Segment> _segments = null!;
|
||||||
|
private Mock<AuditService> _auditService = null!;
|
||||||
[TearDown]
|
private int _organizationId;
|
||||||
public void TearDown() => _factory.Dispose();
|
|
||||||
|
[SetUp]
|
||||||
private string SeedOrganizationWithApiKey()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
using var scope = _factory.Services.CreateScope();
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Organizations, [
|
||||||
var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow };
|
new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
db.Organizations.Add(org);
|
]);
|
||||||
db.SaveChanges();
|
_organizationId = 1;
|
||||||
|
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
_projects = [];
|
||||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
_db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects);
|
||||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
_features = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
|
||||||
db.ApiKeys.Add(new ApiKey
|
_featureStates = [];
|
||||||
{
|
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
|
||||||
Key = hashedKey,
|
_environments = [];
|
||||||
Prefix = prefix,
|
_db.SetupDbSetWithGeneratedIds(c => c.Environments, _environments);
|
||||||
Name = "Test Key",
|
_segments = [];
|
||||||
OrganizationId = org.Id,
|
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
|
||||||
IsActive = true,
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, []);
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
|
||||||
});
|
|
||||||
db.SaveChanges();
|
var webhookQueue = new WebhookQueue();
|
||||||
|
_auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
||||||
return rawKey;
|
_auditService.Setup(a => a.LogAsync(
|
||||||
}
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
private HttpClient CreateAuthenticatedClient()
|
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
{
|
.Returns(Task.CompletedTask);
|
||||||
var client = _factory.CreateClient();
|
}
|
||||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
|
|
||||||
return client;
|
private Project AddProject(string name = "My Project")
|
||||||
}
|
{
|
||||||
|
var project = new Project
|
||||||
[Test]
|
{
|
||||||
public async Task WhenCreatingAProject_ThenProjectIsReturned()
|
Name = name,
|
||||||
{
|
OrganizationId = _organizationId,
|
||||||
var client = CreateAuthenticatedClient();
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
using var scope = _factory.Services.CreateScope();
|
_db.Object.Projects.Add(project);
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
return project;
|
||||||
var org = db.Organizations.First();
|
}
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/v1/projects", new
|
[Test]
|
||||||
{
|
public async Task WhenCreatingAProject_ThenProjectIsReturned()
|
||||||
name = "My Project",
|
{
|
||||||
organizationId = org.Id
|
var controller = new ProjectsController(new ProjectService(_db.Object, _auditService.Object));
|
||||||
});
|
|
||||||
|
var result = await controller.Create(new CreateProjectRequest("My Project", _organizationId), CancellationToken.None);
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
|
||||||
}
|
Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
|
||||||
|
}
|
||||||
[Test]
|
|
||||||
public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
|
[Test]
|
||||||
{
|
public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
|
||||||
var client = CreateAuthenticatedClient();
|
{
|
||||||
|
var project = AddProject();
|
||||||
using var scope = _factory.Services.CreateScope();
|
var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue()));
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var org = db.Organizations.First();
|
var result = await controller.Create(project.Id,
|
||||||
|
new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null), CancellationToken.None);
|
||||||
var project = new Project
|
|
||||||
{
|
Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
|
||||||
Name = "My Project",
|
}
|
||||||
OrganizationId = org.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
[Test]
|
||||||
};
|
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
|
||||||
db.Projects.Add(project);
|
{
|
||||||
db.SaveChanges();
|
var project = AddProject();
|
||||||
|
_db.Object.Environments.Add(new AppEnvironment
|
||||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
{
|
||||||
{
|
Name = "Production",
|
||||||
name = "dark_mode",
|
ApiKey = "env-key-prod",
|
||||||
type = "Standard",
|
ProjectId = project.Id,
|
||||||
initialValue = (string?)null,
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
description = (string?)null
|
});
|
||||||
});
|
var controller = new FeaturesController(new FeatureService(_db.Object, _auditService.Object, new WebhookQueue()));
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
await controller.Create(project.Id,
|
||||||
}
|
new CreateFeatureRequest("flag_x", FeatureType.Standard, null, null), CancellationToken.None);
|
||||||
|
|
||||||
[Test]
|
var feature = _features.First(f => f.Name == "flag_x");
|
||||||
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
|
var states = _featureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
|
||||||
{
|
|
||||||
var client = CreateAuthenticatedClient();
|
Assert.That(states, Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
[Test]
|
||||||
var org = db.Organizations.First();
|
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
|
||||||
|
{
|
||||||
var project = new Project
|
var project = AddProject();
|
||||||
{
|
_db.Object.Features.Add(new Feature
|
||||||
Name = "My Project",
|
{
|
||||||
OrganizationId = org.Id,
|
Name = "existing_flag",
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
ProjectId = project.Id,
|
||||||
};
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
db.Projects.Add(project);
|
});
|
||||||
db.SaveChanges();
|
var controller = new EnvironmentsController(
|
||||||
|
new EnvironmentService(_db.Object, _auditService.Object),
|
||||||
db.Environments.Add(new AppEnvironment
|
new WebhookService(_db.Object));
|
||||||
{
|
|
||||||
Name = "Production",
|
var result = await controller.Create(new CreateEnvironmentRequest("Staging", project.Id), CancellationToken.None);
|
||||||
ApiKey = "env-key-prod",
|
|
||||||
ProjectId = project.Id,
|
Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
var feature = _features.First(f => f.Name == "existing_flag");
|
||||||
db.SaveChanges();
|
var env = _environments.First(e => e.ProjectId == project.Id);
|
||||||
|
var states = _featureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
|
||||||
await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
|
||||||
{
|
Assert.That(states, Has.Count.EqualTo(1));
|
||||||
name = "flag_x",
|
}
|
||||||
type = "Standard",
|
|
||||||
initialValue = (string?)null,
|
[Test]
|
||||||
description = (string?)null
|
public async Task WhenCreatingASegment_ThenSegmentIsReturned()
|
||||||
});
|
{
|
||||||
|
var project = AddProject();
|
||||||
using var verifyScope = _factory.Services.CreateScope();
|
var controller = new SegmentsController(new SegmentService(_db.Object, _auditService.Object));
|
||||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var feature = verifyDb.Features.First(f => f.Name == "flag_x");
|
var result = await controller.Create(project.Id, new CreateSegmentRequest(
|
||||||
var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
|
"Premium Users",
|
||||||
|
[
|
||||||
Assert.That(states, Has.Count.EqualTo(1));
|
new CreateSegmentRuleRequest(
|
||||||
}
|
"All",
|
||||||
|
[new CreateSegmentConditionRequest("plan", "Equal", "premium")])
|
||||||
[Test]
|
]), CancellationToken.None);
|
||||||
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
|
|
||||||
{
|
Assert.That(result.Result, Is.TypeOf<CreatedAtActionResult>());
|
||||||
var client = CreateAuthenticatedClient();
|
}
|
||||||
|
}
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var org = db.Organizations.First();
|
|
||||||
|
|
||||||
var project = new Project
|
|
||||||
{
|
|
||||||
Name = "My Project",
|
|
||||||
OrganizationId = org.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
db.Projects.Add(project);
|
|
||||||
db.Features.Add(new Feature
|
|
||||||
{
|
|
||||||
Name = "existing_flag",
|
|
||||||
ProjectId = project.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/v1/environments", new
|
|
||||||
{
|
|
||||||
name = "Staging",
|
|
||||||
projectId = project.Id
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
|
||||||
|
|
||||||
using var verifyScope = _factory.Services.CreateScope();
|
|
||||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var feature = verifyDb.Features.First(f => f.Name == "existing_flag");
|
|
||||||
var env = verifyDb.Environments.First(e => e.ProjectId == project.Id);
|
|
||||||
var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
|
|
||||||
|
|
||||||
Assert.That(states, Has.Count.EqualTo(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/projects?organizationId=1");
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned()
|
|
||||||
{
|
|
||||||
var client = CreateAuthenticatedClient();
|
|
||||||
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var org = db.Organizations.First();
|
|
||||||
|
|
||||||
var project = new Project
|
|
||||||
{
|
|
||||||
Name = "My Project",
|
|
||||||
OrganizationId = org.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
db.Projects.Add(project);
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
|
|
||||||
{
|
|
||||||
name = "invalid name with spaces!",
|
|
||||||
type = "Standard"
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenCreatingASegment_ThenSegmentIsReturned()
|
|
||||||
{
|
|
||||||
var client = CreateAuthenticatedClient();
|
|
||||||
|
|
||||||
using var scope = _factory.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
var org = db.Organizations.First();
|
|
||||||
|
|
||||||
var project = new Project
|
|
||||||
{
|
|
||||||
Name = "My Project",
|
|
||||||
OrganizationId = org.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
db.Projects.Add(project);
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/segments", new
|
|
||||||
{
|
|
||||||
name = "Premium Users",
|
|
||||||
rules = new[]
|
|
||||||
{
|
|
||||||
new
|
|
||||||
{
|
|
||||||
type = "All",
|
|
||||||
conditions = new[]
|
|
||||||
{
|
|
||||||
new { property = "plan", @operator = "Equal", value = "premium" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using MicCheck.Api.Audit;
|
using MicCheck.Api.Audit;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using MicCheck.Api.Webhooks;
|
using MicCheck.Api.Webhooks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
@@ -12,34 +12,31 @@ namespace MicCheck.Api.Tests.Unit.Audit;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class AuditServiceTests
|
public class AuditServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<AuditLog> _auditLogs = null!;
|
||||||
private AuditService _service = null!;
|
private AuditService _service = null!;
|
||||||
private WebhookQueue _queue = null!;
|
private WebhookQueue _queue = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
_auditLogs = [];
|
||||||
.Options;
|
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
|
||||||
_db = new MicCheckDbContext(options);
|
|
||||||
_queue = new WebhookQueue();
|
_queue = new WebhookQueue();
|
||||||
|
|
||||||
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
httpContextAccessor.Setup(x => x.HttpContext).Returns((Microsoft.AspNetCore.Http.HttpContext?)null);
|
httpContextAccessor.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||||
|
|
||||||
_service = new AuditService(_db, httpContextAccessor.Object, _queue);
|
_service = new AuditService(_db.Object, httpContextAccessor.Object, _queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown() => _db.Dispose();
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenRecordingWithNoBefore_ThenChangesIsNull()
|
public async Task WhenRecordingWithNoBefore_ThenChangesIsNull()
|
||||||
{
|
{
|
||||||
await _service.RecordAsync("Feature", "1", "created", organizationId: 1);
|
await _service.RecordAsync("Feature", "1", "created", organizationId: 1);
|
||||||
|
|
||||||
var log = await _db.AuditLogs.FirstAsync();
|
var log = _auditLogs.First();
|
||||||
Assert.That(log.Changes, Is.Null);
|
Assert.That(log.Changes, Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ public class AuditServiceTests
|
|||||||
var after = new { Name = "dark_mode", Enabled = true };
|
var after = new { Name = "dark_mode", Enabled = true };
|
||||||
await _service.RecordAsync("Feature", "1", "created", organizationId: 1, after: after);
|
await _service.RecordAsync("Feature", "1", "created", organizationId: 1, after: after);
|
||||||
|
|
||||||
var log = await _db.AuditLogs.FirstAsync();
|
var log = _auditLogs.First();
|
||||||
Assert.That(log.Changes, Is.Not.Null);
|
Assert.That(log.Changes, Is.Not.Null);
|
||||||
|
|
||||||
var changes = JsonDocument.Parse(log.Changes!).RootElement;
|
var changes = JsonDocument.Parse(log.Changes!).RootElement;
|
||||||
@@ -67,7 +64,7 @@ public class AuditServiceTests
|
|||||||
await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1,
|
await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1,
|
||||||
before: before, after: after);
|
before: before, after: after);
|
||||||
|
|
||||||
var log = await _db.AuditLogs.FirstAsync();
|
var log = _auditLogs.First();
|
||||||
var changes = JsonDocument.Parse(log.Changes!).RootElement;
|
var changes = JsonDocument.Parse(log.Changes!).RootElement;
|
||||||
|
|
||||||
Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False);
|
Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False);
|
||||||
@@ -83,7 +80,7 @@ public class AuditServiceTests
|
|||||||
"Segment", "5", "deleted",
|
"Segment", "5", "deleted",
|
||||||
organizationId: 10, projectId: 20, environmentId: 30);
|
organizationId: 10, projectId: 20, environmentId: 30);
|
||||||
|
|
||||||
var log = await _db.AuditLogs.FirstAsync();
|
var log = _auditLogs.First();
|
||||||
Assert.That(log.ResourceType, Is.EqualTo("Segment"));
|
Assert.That(log.ResourceType, Is.EqualTo("Segment"));
|
||||||
Assert.That(log.ResourceId, Is.EqualTo("5"));
|
Assert.That(log.ResourceId, Is.EqualTo("5"));
|
||||||
Assert.That(log.Action, Is.EqualTo("deleted"));
|
Assert.That(log.Action, Is.EqualTo("deleted"));
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +1,120 @@
|
|||||||
using System.Net;
|
using System.Text.Encodings.Web;
|
||||||
using System.Net.Http.Headers;
|
using MicCheck.Api.Common.Security.ApiKeys;
|
||||||
using MicCheck.Api.Common.Security.ApiKeys;
|
using MicCheck.Api.Common.Security.Authentication;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using NUnit.Framework;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
[TestFixture]
|
using Moq;
|
||||||
public class ApiKeyAuthenticationHandlerTests
|
using NUnit.Framework;
|
||||||
{
|
|
||||||
private MicCheckWebApplicationFactory _factory = null!;
|
namespace MicCheck.Api.Tests.Unit.Common.Security.Authentication;
|
||||||
|
|
||||||
[SetUp]
|
[TestFixture]
|
||||||
public void SetUp()
|
public class ApiKeyAuthenticationHandlerTests
|
||||||
{
|
{
|
||||||
_factory = new MicCheckWebApplicationFactory();
|
private List<ApiKey> _apiKeys = null!;
|
||||||
}
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
|
||||||
[TearDown]
|
[SetUp]
|
||||||
public void TearDown() => _factory.Dispose();
|
public void SetUp()
|
||||||
|
{
|
||||||
private async Task<string> SeedActiveApiKeyAsync(int organizationId = 1)
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
{
|
_apiKeys = [];
|
||||||
using var scope = _factory.Services.CreateScope();
|
_db.SetupDbSet(c => c.ApiKeys, _apiKeys);
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
}
|
||||||
|
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
private async Task<AuthenticateResult> AuthenticateAsync(string? authorizationHeader)
|
||||||
db.ApiKeys.Add(new ApiKey
|
{
|
||||||
{
|
var optionsMonitor = new Mock<IOptionsMonitor<AuthenticationSchemeOptions>>();
|
||||||
Key = ApiKeyHasher.Hash(rawKey),
|
optionsMonitor.Setup(o => o.Get(It.IsAny<string>())).Returns(new AuthenticationSchemeOptions());
|
||||||
Prefix = rawKey[..8],
|
|
||||||
Name = "Test Key",
|
var handler = new ApiKeyAuthenticationHandler(optionsMonitor.Object, NullLoggerFactory.Instance, UrlEncoder.Default, _db.Object);
|
||||||
OrganizationId = organizationId,
|
|
||||||
IsActive = true,
|
var httpContext = new DefaultHttpContext();
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
if (authorizationHeader is not null)
|
||||||
});
|
httpContext.Request.Headers.Authorization = authorizationHeader;
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
var scheme = new AuthenticationScheme(ApiKeyAuthenticationHandler.SchemeName, null, typeof(ApiKeyAuthenticationHandler));
|
||||||
return rawKey;
|
await handler.InitializeAsync(scheme, httpContext);
|
||||||
}
|
|
||||||
|
return await handler.AuthenticateAsync();
|
||||||
[Test]
|
}
|
||||||
public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned()
|
|
||||||
{
|
private ApiKey AddApiKey(string rawKey, int organizationId = 1, bool isActive = true, DateTimeOffset? expiresAt = null)
|
||||||
var client = _factory.CreateClient();
|
{
|
||||||
|
var apiKey = new ApiKey
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
{
|
||||||
|
Key = ApiKeyHasher.Hash(rawKey),
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
Prefix = rawKey[..8],
|
||||||
}
|
Name = "Test Key",
|
||||||
|
OrganizationId = organizationId,
|
||||||
[Test]
|
IsActive = isActive,
|
||||||
public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenUnauthorizedIsReturned()
|
ExpiresAt = expiresAt,
|
||||||
{
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
var client = _factory.CreateClient();
|
};
|
||||||
client.DefaultRequestHeaders.Authorization =
|
_apiKeys.Add(apiKey);
|
||||||
new AuthenticationHeaderValue("Bearer", "not-a-jwt");
|
return apiKey;
|
||||||
|
}
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
|
||||||
|
[Test]
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
public async Task WhenAuthorizationHeaderIsAbsent_ThenNoResultIsReturned()
|
||||||
}
|
{
|
||||||
|
var result = await AuthenticateAsync(null);
|
||||||
[Test]
|
|
||||||
public async Task WhenApiKeyIsInvalid_ThenUnauthorizedIsReturned()
|
Assert.That(result.None, Is.True);
|
||||||
{
|
}
|
||||||
var client = _factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
|
[Test]
|
||||||
|
public async Task WhenAuthorizationHeaderIsNotApiKeyScheme_ThenNoResultIsReturned()
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
{
|
||||||
|
var result = await AuthenticateAsync("Bearer not-a-jwt");
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
||||||
}
|
Assert.That(result.None, Is.True);
|
||||||
|
}
|
||||||
[Test]
|
|
||||||
public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned()
|
[Test]
|
||||||
{
|
public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
|
||||||
using var scope = _factory.Services.CreateScope();
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
var result = await AuthenticateAsync("Api-Key not-a-real-key");
|
||||||
|
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
Assert.That(result.Succeeded, Is.False);
|
||||||
db.ApiKeys.Add(new ApiKey
|
}
|
||||||
{
|
|
||||||
Key = ApiKeyHasher.Hash(rawKey),
|
[Test]
|
||||||
Prefix = rawKey[..8],
|
public async Task WhenApiKeyIsInactive_ThenAuthenticationFails()
|
||||||
Name = "Inactive Key",
|
{
|
||||||
OrganizationId = 1,
|
var rawKey = ApiKeyHasher.GenerateKey();
|
||||||
IsActive = false,
|
AddApiKey(rawKey, isActive: false);
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
var result = await AuthenticateAsync($"Api-Key {rawKey}");
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
Assert.That(result.Succeeded, Is.False);
|
||||||
var client = _factory.CreateClient();
|
}
|
||||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
|
||||||
|
[Test]
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
public async Task WhenApiKeyIsExpired_ThenAuthenticationFails()
|
||||||
|
{
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
var rawKey = ApiKeyHasher.GenerateKey();
|
||||||
}
|
AddApiKey(rawKey, expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
|
||||||
|
|
||||||
[Test]
|
var result = await AuthenticateAsync($"Api-Key {rawKey}");
|
||||||
public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned()
|
|
||||||
{
|
Assert.That(result.Succeeded, Is.False);
|
||||||
using var scope = _factory.Services.CreateScope();
|
}
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
|
||||||
|
[Test]
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationClaims()
|
||||||
db.ApiKeys.Add(new ApiKey
|
{
|
||||||
{
|
var rawKey = ApiKeyHasher.GenerateKey();
|
||||||
Key = ApiKeyHasher.Hash(rawKey),
|
AddApiKey(rawKey, organizationId: 42);
|
||||||
Prefix = rawKey[..8],
|
|
||||||
Name = "Expired Key",
|
var result = await AuthenticateAsync($"Api-Key {rawKey}");
|
||||||
OrganizationId = 1,
|
|
||||||
IsActive = true,
|
Assert.That(result.Succeeded, Is.True);
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1),
|
Assert.That(result.Principal!.FindFirst("OrganizationId")!.Value, Is.EqualTo("42"));
|
||||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10)
|
Assert.That(result.Principal!.FindFirst("OrganizationRole")!.Value, Is.EqualTo("Admin"));
|
||||||
});
|
}
|
||||||
await db.SaveChangesAsync();
|
}
|
||||||
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenApiKeyIsValid_ThenOkIsReturned()
|
|
||||||
{
|
|
||||||
var rawKey = await SeedActiveApiKeyAsync(organizationId: 1);
|
|
||||||
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,180 +1,173 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using MicCheck.Api.Common.Security.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
|
namespace MicCheck.Api.Tests.Unit.Common.Security.Authorization;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class ProjectPermissionRequirementHandlerTests
|
public class ProjectPermissionRequirementHandlerTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private List<UserProjectPermission> _userProjectPermissions = null!;
|
||||||
private Mock<IHttpContextAccessor> _httpContextAccessor = null!;
|
private Mock<IHttpContextAccessor> _httpContextAccessor = null!;
|
||||||
private ProjectPermissionRequirementHandler _handler = null!;
|
private ProjectPermissionRequirementHandler _handler = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
var db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
_userProjectPermissions = [];
|
||||||
.Options;
|
db.SetupDbSet(c => c.UserProjectPermissions, _userProjectPermissions);
|
||||||
_db = new MicCheckDbContext(options);
|
|
||||||
|
_httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
_httpContextAccessor = new Mock<IHttpContextAccessor>();
|
_handler = new ProjectPermissionRequirementHandler(db.Object, _httpContextAccessor.Object);
|
||||||
_handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object);
|
}
|
||||||
}
|
|
||||||
|
private static AuthorizationHandlerContext CreateContext(
|
||||||
[TearDown]
|
ClaimsPrincipal user,
|
||||||
public void TearDown() => _db.Dispose();
|
ProjectPermission permission = ProjectPermission.ViewProject)
|
||||||
|
{
|
||||||
private static AuthorizationHandlerContext CreateContext(
|
var requirement = new ProjectPermissionRequirement(permission);
|
||||||
ClaimsPrincipal user,
|
return new AuthorizationHandlerContext([requirement], user, null);
|
||||||
ProjectPermission permission = ProjectPermission.ViewProject)
|
}
|
||||||
{
|
|
||||||
var requirement = new ProjectPermissionRequirement(permission);
|
private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
|
||||||
return new AuthorizationHandlerContext([requirement], user, null);
|
{
|
||||||
}
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
|
||||||
|
}
|
||||||
private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
|
|
||||||
{
|
private void SetupRouteProjectId(int projectId)
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
|
{
|
||||||
}
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.RouteValues["projectId"] = projectId.ToString();
|
||||||
private void SetupRouteProjectId(int projectId)
|
_httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext);
|
||||||
{
|
}
|
||||||
var httpContext = new DefaultHttpContext();
|
|
||||||
httpContext.Request.RouteValues["projectId"] = projectId.ToString();
|
[Test]
|
||||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext);
|
public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds()
|
||||||
}
|
{
|
||||||
|
var user = CreateUserWithClaims(
|
||||||
[Test]
|
new Claim(JwtRegisteredClaimNames.Sub, "1"),
|
||||||
public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds()
|
new Claim("OrganizationRole", "Admin"));
|
||||||
{
|
|
||||||
var user = CreateUserWithClaims(
|
var context = CreateContext(user);
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "1"),
|
|
||||||
new Claim("OrganizationRole", "Admin"));
|
await _handler.HandleAsync(context);
|
||||||
|
|
||||||
var context = CreateContext(user);
|
Assert.That(context.HasSucceeded, Is.True);
|
||||||
|
}
|
||||||
await _handler.HandleAsync(context);
|
|
||||||
|
[Test]
|
||||||
Assert.That(context.HasSucceeded, Is.True);
|
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
|
||||||
}
|
{
|
||||||
|
_userProjectPermissions.Add(new UserProjectPermission
|
||||||
[Test]
|
{
|
||||||
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
|
UserId = 1,
|
||||||
{
|
ProjectId = 10,
|
||||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
Permissions = [ProjectPermission.ViewProject]
|
||||||
{
|
});
|
||||||
UserId = 1,
|
|
||||||
ProjectId = 10,
|
SetupRouteProjectId(10);
|
||||||
Permissions = [ProjectPermission.ViewProject]
|
|
||||||
});
|
var user = CreateUserWithClaims(
|
||||||
await _db.SaveChangesAsync();
|
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||||
|
|
||||||
SetupRouteProjectId(10);
|
var context = CreateContext(user, ProjectPermission.ViewProject);
|
||||||
|
|
||||||
var user = CreateUserWithClaims(
|
await _handler.HandleAsync(context);
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
|
||||||
|
Assert.That(context.HasSucceeded, Is.True);
|
||||||
var context = CreateContext(user, ProjectPermission.ViewProject);
|
}
|
||||||
|
|
||||||
await _handler.HandleAsync(context);
|
[Test]
|
||||||
|
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
|
||||||
Assert.That(context.HasSucceeded, Is.True);
|
{
|
||||||
}
|
_userProjectPermissions.Add(new UserProjectPermission
|
||||||
|
{
|
||||||
[Test]
|
UserId = 2,
|
||||||
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
|
ProjectId = 20,
|
||||||
{
|
IsAdmin = true,
|
||||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
Permissions = []
|
||||||
{
|
});
|
||||||
UserId = 2,
|
|
||||||
ProjectId = 20,
|
SetupRouteProjectId(20);
|
||||||
IsAdmin = true,
|
|
||||||
Permissions = []
|
var user = CreateUserWithClaims(
|
||||||
});
|
new Claim(JwtRegisteredClaimNames.Sub, "2"));
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||||
SetupRouteProjectId(20);
|
|
||||||
|
await _handler.HandleAsync(context);
|
||||||
var user = CreateUserWithClaims(
|
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "2"));
|
Assert.That(context.HasSucceeded, Is.True);
|
||||||
|
}
|
||||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
|
||||||
|
[Test]
|
||||||
await _handler.HandleAsync(context);
|
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
|
||||||
|
{
|
||||||
Assert.That(context.HasSucceeded, Is.True);
|
_userProjectPermissions.Add(new UserProjectPermission
|
||||||
}
|
{
|
||||||
|
UserId = 3,
|
||||||
[Test]
|
ProjectId = 30,
|
||||||
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
|
Permissions = [ProjectPermission.ViewProject]
|
||||||
{
|
});
|
||||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
|
||||||
{
|
SetupRouteProjectId(30);
|
||||||
UserId = 3,
|
|
||||||
ProjectId = 30,
|
var user = CreateUserWithClaims(
|
||||||
Permissions = [ProjectPermission.ViewProject]
|
new Claim(JwtRegisteredClaimNames.Sub, "3"));
|
||||||
});
|
|
||||||
await _db.SaveChangesAsync();
|
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||||
|
|
||||||
SetupRouteProjectId(30);
|
await _handler.HandleAsync(context);
|
||||||
|
|
||||||
var user = CreateUserWithClaims(
|
Assert.That(context.HasSucceeded, Is.False);
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "3"));
|
}
|
||||||
|
|
||||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
[Test]
|
||||||
|
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails()
|
||||||
await _handler.HandleAsync(context);
|
{
|
||||||
|
SetupRouteProjectId(99);
|
||||||
Assert.That(context.HasSucceeded, Is.False);
|
|
||||||
}
|
var user = CreateUserWithClaims(
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, "5"));
|
||||||
[Test]
|
|
||||||
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails()
|
var context = CreateContext(user);
|
||||||
{
|
|
||||||
SetupRouteProjectId(99);
|
await _handler.HandleAsync(context);
|
||||||
|
|
||||||
var user = CreateUserWithClaims(
|
Assert.That(context.HasSucceeded, Is.False);
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "5"));
|
}
|
||||||
|
|
||||||
var context = CreateContext(user);
|
[Test]
|
||||||
|
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
|
||||||
await _handler.HandleAsync(context);
|
{
|
||||||
|
var user = CreateUserWithClaims();
|
||||||
Assert.That(context.HasSucceeded, Is.False);
|
|
||||||
}
|
var context = CreateContext(user);
|
||||||
|
|
||||||
[Test]
|
await _handler.HandleAsync(context);
|
||||||
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
|
|
||||||
{
|
Assert.That(context.HasSucceeded, Is.False);
|
||||||
var user = CreateUserWithClaims();
|
}
|
||||||
|
|
||||||
var context = CreateContext(user);
|
[Test]
|
||||||
|
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails()
|
||||||
await _handler.HandleAsync(context);
|
{
|
||||||
|
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext());
|
||||||
Assert.That(context.HasSucceeded, Is.False);
|
|
||||||
}
|
var user = CreateUserWithClaims(
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||||
[Test]
|
|
||||||
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails()
|
var context = CreateContext(user);
|
||||||
{
|
|
||||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext());
|
await _handler.HandleAsync(context);
|
||||||
|
|
||||||
var user = CreateUserWithClaims(
|
Assert.That(context.HasSucceeded, Is.False);
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
}
|
||||||
|
}
|
||||||
var context = CreateContext(user);
|
|
||||||
|
|
||||||
await _handler.HandleAsync(context);
|
|
||||||
|
|
||||||
Assert.That(context.HasSucceeded, Is.False);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,10 @@ using MicCheck.Api.Audit;
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Environments;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MicCheck.Api.Identities;
|
||||||
|
using MicCheck.Api.Organizations;
|
||||||
|
using MicCheck.Api.Projects;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
@@ -12,7 +15,11 @@ namespace MicCheck.Api.Tests.Unit.Environments;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class EnvironmentServiceTests
|
public class EnvironmentServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<AppEnvironment> _environments = null!;
|
||||||
|
private List<Feature> _features = null!;
|
||||||
|
private List<FeatureState> _featureStates = null!;
|
||||||
|
private List<Identity> _identities = null!;
|
||||||
private EnvironmentService _service = null!;
|
private EnvironmentService _service = null!;
|
||||||
private const int OrganizationId = 1;
|
private const int OrganizationId = 1;
|
||||||
private const int ProjectId = 1;
|
private const int ProjectId = 1;
|
||||||
@@ -20,60 +27,49 @@ public class EnvironmentServiceTests
|
|||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
_db.SetupDbSet(c => c.Organizations, [
|
||||||
_db = new MicCheckDbContext(options);
|
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_db.SetupDbSet(c => c.Projects, [
|
||||||
|
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_environments = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Environments, _environments);
|
||||||
|
_features = [];
|
||||||
|
_db.SetupDbSet(c => c.Features, _features);
|
||||||
|
_featureStates = [];
|
||||||
|
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
|
||||||
|
_identities = [];
|
||||||
|
_db.SetupDbSet(c => c.Identities, _identities);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db, null!, webhookQueue);
|
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
_service = new EnvironmentService(_db, auditService.Object);
|
_service = new EnvironmentService(_db.Object, auditService.Object);
|
||||||
|
|
||||||
SeedBaseData();
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown() => _db.Dispose();
|
|
||||||
|
|
||||||
private void SeedBaseData()
|
|
||||||
{
|
|
||||||
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
|
|
||||||
{
|
|
||||||
Id = OrganizationId,
|
|
||||||
Name = "Test Org",
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.Projects.Add(new MicCheck.Api.Projects.Project
|
|
||||||
{
|
|
||||||
Id = ProjectId,
|
|
||||||
Name = "Test Project",
|
|
||||||
OrganizationId = OrganizationId,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature()
|
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature()
|
||||||
{
|
{
|
||||||
_db.Features.Add(new Feature
|
_features.Add(new Feature
|
||||||
{
|
{
|
||||||
|
Id = 1,
|
||||||
Name = "feature_a",
|
Name = "feature_a",
|
||||||
ProjectId = ProjectId,
|
ProjectId = ProjectId,
|
||||||
InitialValue = "hello",
|
InitialValue = "hello",
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var environment = await _service.CreateAsync(ProjectId, "Staging");
|
var environment = await _service.CreateAsync(ProjectId, "Staging");
|
||||||
|
|
||||||
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == environment.Id).ToListAsync();
|
var states = _featureStates.Where(fs => fs.EnvironmentId == environment.Id).ToList();
|
||||||
Assert.That(states, Has.Count.EqualTo(1));
|
Assert.That(states, Has.Count.EqualTo(1));
|
||||||
Assert.That(states[0].Value, Is.EqualTo("hello"));
|
Assert.That(states[0].Value, Is.EqualTo("hello"));
|
||||||
}
|
}
|
||||||
@@ -92,11 +88,10 @@ public class EnvironmentServiceTests
|
|||||||
{
|
{
|
||||||
var source = await _service.CreateAsync(ProjectId, "Production");
|
var source = await _service.CreateAsync(ProjectId, "Production");
|
||||||
|
|
||||||
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
_db.Features.Add(feature);
|
_features.Add(feature);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = source.Id,
|
EnvironmentId = source.Id,
|
||||||
@@ -105,13 +100,10 @@ public class EnvironmentServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
|
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
|
||||||
|
|
||||||
var clonedStates = await _db.FeatureStates
|
var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList();
|
||||||
.Where(fs => fs.EnvironmentId == cloned.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
Assert.That(clonedStates, Has.Count.EqualTo(1));
|
Assert.That(clonedStates, Has.Count.EqualTo(1));
|
||||||
Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value"));
|
Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value"));
|
||||||
@@ -123,20 +115,19 @@ public class EnvironmentServiceTests
|
|||||||
{
|
{
|
||||||
var source = await _service.CreateAsync(ProjectId, "Production");
|
var source = await _service.CreateAsync(ProjectId, "Production");
|
||||||
|
|
||||||
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
var feature = new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
_db.Features.Add(feature);
|
_features.Add(feature);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var identity = new MicCheck.Api.Identities.Identity
|
var identity = new Identity
|
||||||
{
|
{
|
||||||
|
Id = 1,
|
||||||
Identifier = "user-1",
|
Identifier = "user-1",
|
||||||
EnvironmentId = source.Id,
|
EnvironmentId = source.Id,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Identities.Add(identity);
|
_identities.Add(identity);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = source.Id,
|
EnvironmentId = source.Id,
|
||||||
@@ -145,13 +136,10 @@ public class EnvironmentServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
|
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
|
||||||
|
|
||||||
var clonedStates = await _db.FeatureStates
|
var clonedStates = _featureStates.Where(fs => fs.EnvironmentId == cloned.Id).ToList();
|
||||||
.Where(fs => fs.EnvironmentId == cloned.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
Assert.That(clonedStates, Is.Empty);
|
Assert.That(clonedStates, Is.Empty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using MicCheck.Api.Data;
|
|||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
@@ -14,7 +14,13 @@ namespace MicCheck.Api.Tests.Unit.Features;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureEvaluationServiceTests
|
public class FeatureEvaluationServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<AppEnvironment> _environments = null!;
|
||||||
|
private List<Feature> _features = null!;
|
||||||
|
private List<FeatureState> _featureStates = null!;
|
||||||
|
private List<Identity> _identities = null!;
|
||||||
|
private List<Segment> _segments = null!;
|
||||||
|
private List<FeatureSegment> _featureSegments = null!;
|
||||||
private FeatureEvaluationService _service = null!;
|
private FeatureEvaluationService _service = null!;
|
||||||
private FeatureUsageMetrics _usageMetrics = null!;
|
private FeatureUsageMetrics _usageMetrics = null!;
|
||||||
private const int EnvironmentId = 1;
|
private const int EnvironmentId = 1;
|
||||||
@@ -23,52 +29,49 @@ public class FeatureEvaluationServiceTests
|
|||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key-test", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
|
||||||
_db = new MicCheckDbContext(options);
|
var environmentsSet = MockDbSetFactory.Create(_environments);
|
||||||
|
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
|
||||||
|
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
|
||||||
|
|
||||||
|
_features = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
|
||||||
|
_featureStates = [];
|
||||||
|
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
|
||||||
|
_identities = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities);
|
||||||
|
_db.SetupDbSet(c => c.IdentityTraits, []);
|
||||||
|
_segments = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
|
||||||
|
_db.SetupDbSet(c => c.SegmentRules, []);
|
||||||
|
_db.SetupDbSet(c => c.SegmentConditions, []);
|
||||||
|
_featureSegments = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments);
|
||||||
|
|
||||||
var meterFactory = new Mock<IMeterFactory>();
|
var meterFactory = new Mock<IMeterFactory>();
|
||||||
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
||||||
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
|
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
|
||||||
|
|
||||||
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
||||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics);
|
_service = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), cache, _usageMetrics);
|
||||||
|
|
||||||
SeedBaseData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
public void TearDown()
|
public void TearDown() => _usageMetrics.Dispose();
|
||||||
{
|
|
||||||
_db.Dispose();
|
|
||||||
_usageMetrics.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SeedBaseData()
|
private Feature AddFeature(string name, bool defaultEnabled = false)
|
||||||
{
|
{
|
||||||
_db.Environments.Add(new AppEnvironment
|
var feature = new Feature
|
||||||
{
|
|
||||||
Id = EnvironmentId,
|
|
||||||
Name = "Production",
|
|
||||||
ApiKey = "env-key-test",
|
|
||||||
ProjectId = ProjectId,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
private MicCheck.Api.Features.Feature AddFeature(string name, bool defaultEnabled = false)
|
|
||||||
{
|
|
||||||
var feature = new MicCheck.Api.Features.Feature
|
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
ProjectId = ProjectId,
|
ProjectId = ProjectId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
DefaultEnabled = defaultEnabled
|
DefaultEnabled = defaultEnabled
|
||||||
};
|
};
|
||||||
_db.Features.Add(feature);
|
_db.Object.Features.Add(feature);
|
||||||
_db.SaveChanges();
|
|
||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +86,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.FeatureStates.Add(fs);
|
_featureStates.Add(fs);
|
||||||
_db.SaveChanges();
|
|
||||||
return fs;
|
return fs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +138,9 @@ public class FeatureEvaluationServiceTests
|
|||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Identities.Add(identity);
|
_db.Object.Identities.Add(identity);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
@@ -149,7 +150,6 @@ public class FeatureEvaluationServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip");
|
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip");
|
||||||
|
|
||||||
@@ -161,11 +161,10 @@ public class FeatureEvaluationServiceTests
|
|||||||
public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed()
|
public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed()
|
||||||
{
|
{
|
||||||
var feature = AddFeature("premium_feature");
|
var feature = AddFeature("premium_feature");
|
||||||
var envDefault = AddEnvironmentDefault(feature.Id, enabled: false);
|
AddEnvironmentDefault(feature.Id, enabled: false);
|
||||||
|
|
||||||
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
_db.Segments.Add(segment);
|
_db.Object.Segments.Add(segment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||||
rule.Conditions.Add(new SegmentCondition
|
rule.Conditions.Add(new SegmentCondition
|
||||||
@@ -175,8 +174,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
Operator = SegmentConditionOperator.Equal,
|
Operator = SegmentConditionOperator.Equal,
|
||||||
Value = "premium"
|
Value = "premium"
|
||||||
});
|
});
|
||||||
_db.SegmentRules.Add(rule);
|
segment.Rules.Add(rule);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var featureSegment = new FeatureSegment
|
var featureSegment = new FeatureSegment
|
||||||
{
|
{
|
||||||
@@ -185,10 +183,9 @@ public class FeatureEvaluationServiceTests
|
|||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
Priority = 1
|
Priority = 1
|
||||||
};
|
};
|
||||||
_db.FeatureSegments.Add(featureSegment);
|
_db.Object.FeatureSegments.Add(featureSegment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
@@ -198,7 +195,6 @@ public class FeatureEvaluationServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var results = await _service.EvaluateForIdentityAsync(
|
var results = await _service.EvaluateForIdentityAsync(
|
||||||
EnvironmentId, "user-123",
|
EnvironmentId, "user-123",
|
||||||
@@ -215,8 +211,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value");
|
AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value");
|
||||||
|
|
||||||
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
_db.Segments.Add(segment);
|
_db.Object.Segments.Add(segment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||||
rule.Conditions.Add(new SegmentCondition
|
rule.Conditions.Add(new SegmentCondition
|
||||||
@@ -226,8 +221,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
Operator = SegmentConditionOperator.Equal,
|
Operator = SegmentConditionOperator.Equal,
|
||||||
Value = "premium"
|
Value = "premium"
|
||||||
});
|
});
|
||||||
_db.SegmentRules.Add(rule);
|
segment.Rules.Add(rule);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var featureSegment = new FeatureSegment
|
var featureSegment = new FeatureSegment
|
||||||
{
|
{
|
||||||
@@ -236,10 +230,9 @@ public class FeatureEvaluationServiceTests
|
|||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
Priority = 1
|
Priority = 1
|
||||||
};
|
};
|
||||||
_db.FeatureSegments.Add(featureSegment);
|
_db.Object.FeatureSegments.Add(featureSegment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
@@ -249,7 +242,6 @@ public class FeatureEvaluationServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var results = await _service.EvaluateForIdentityAsync(
|
var results = await _service.EvaluateForIdentityAsync(
|
||||||
EnvironmentId, "user-123",
|
EnvironmentId, "user-123",
|
||||||
@@ -266,8 +258,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
AddEnvironmentDefault(feature.Id, enabled: false);
|
AddEnvironmentDefault(feature.Id, enabled: false);
|
||||||
|
|
||||||
var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
|
||||||
_db.Segments.Add(segment);
|
_db.Object.Segments.Add(segment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||||
rule.Conditions.Add(new SegmentCondition
|
rule.Conditions.Add(new SegmentCondition
|
||||||
@@ -277,8 +268,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
Operator = SegmentConditionOperator.IsSet,
|
Operator = SegmentConditionOperator.IsSet,
|
||||||
Value = ""
|
Value = ""
|
||||||
});
|
});
|
||||||
_db.SegmentRules.Add(rule);
|
segment.Rules.Add(rule);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var featureSegment = new FeatureSegment
|
var featureSegment = new FeatureSegment
|
||||||
{
|
{
|
||||||
@@ -287,10 +277,9 @@ public class FeatureEvaluationServiceTests
|
|||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
Priority = 1
|
Priority = 1
|
||||||
};
|
};
|
||||||
_db.FeatureSegments.Add(featureSegment);
|
_db.Object.FeatureSegments.Add(featureSegment);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
@@ -307,8 +296,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Identities.Add(identity);
|
_db.Object.Identities.Add(identity);
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
identity.Traits.Add(new IdentityTrait
|
identity.Traits.Add(new IdentityTrait
|
||||||
{
|
{
|
||||||
@@ -317,7 +305,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
Value = "US"
|
Value = "US"
|
||||||
});
|
});
|
||||||
|
|
||||||
_db.FeatureStates.Add(new FeatureState
|
_featureStates.Add(new FeatureState
|
||||||
{
|
{
|
||||||
FeatureId = feature.Id,
|
FeatureId = feature.Id,
|
||||||
EnvironmentId = EnvironmentId,
|
EnvironmentId = EnvironmentId,
|
||||||
@@ -327,7 +315,6 @@ public class FeatureEvaluationServiceTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
var results = await _service.EvaluateForIdentityAsync(
|
var results = await _service.EvaluateForIdentityAsync(
|
||||||
EnvironmentId, "user-special",
|
EnvironmentId, "user-special",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using MicCheck.Api.Audit;
|
using MicCheck.Api.Audit;
|
||||||
|
using MicCheck.Api.Common;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using MicCheck.Api.Common;
|
using MicCheck.Api.Organizations;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MicCheck.Api.Projects;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
@@ -12,7 +14,11 @@ namespace MicCheck.Api.Tests.Unit.Features;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureServiceTests
|
public class FeatureServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<Project> _projects = null!;
|
||||||
|
private List<Feature> _features = null!;
|
||||||
|
private List<FeatureState> _featureStates = null!;
|
||||||
|
private List<Tag> _tags = null!;
|
||||||
private FeatureService _service = null!;
|
private FeatureService _service = null!;
|
||||||
private const int OrganizationId = 1;
|
private const int OrganizationId = 1;
|
||||||
private const int ProjectId = 1;
|
private const int ProjectId = 1;
|
||||||
@@ -21,51 +27,32 @@ public class FeatureServiceTests
|
|||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
_db.SetupDbSet(c => c.Organizations, [
|
||||||
_db = new MicCheckDbContext(options);
|
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_projects = [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }];
|
||||||
|
_db.SetupDbSet(c => c.Projects, _projects);
|
||||||
|
_db.SetupDbSet(c => c.Environments, [
|
||||||
|
new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "test-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_features = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
|
||||||
|
_featureStates = [];
|
||||||
|
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
|
||||||
|
_tags = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db, null!, webhookQueue);
|
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
||||||
auditService.Setup(a => a.RecordAsync(
|
auditService.Setup(a => a.RecordAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
|
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
_service = new FeatureService(_db, auditService.Object, webhookQueue);
|
_service = new FeatureService(_db.Object, auditService.Object, webhookQueue);
|
||||||
|
|
||||||
SeedBaseData();
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown() => _db.Dispose();
|
|
||||||
|
|
||||||
private void SeedBaseData()
|
|
||||||
{
|
|
||||||
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
|
|
||||||
{
|
|
||||||
Id = OrganizationId,
|
|
||||||
Name = "Test Org",
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.Projects.Add(new MicCheck.Api.Projects.Project
|
|
||||||
{
|
|
||||||
Id = ProjectId,
|
|
||||||
Name = "Test Project",
|
|
||||||
OrganizationId = OrganizationId,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.Environments.Add(new AppEnvironment
|
|
||||||
{
|
|
||||||
Id = EnvironmentId,
|
|
||||||
Name = "Production",
|
|
||||||
ApiKey = "test-key",
|
|
||||||
ProjectId = ProjectId,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -73,7 +60,7 @@ public class FeatureServiceTests
|
|||||||
{
|
{
|
||||||
var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null);
|
var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null);
|
||||||
|
|
||||||
var states = await _db.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToListAsync();
|
var states = _featureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
|
||||||
Assert.That(states, Has.Count.EqualTo(1));
|
Assert.That(states, Has.Count.EqualTo(1));
|
||||||
Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId));
|
Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId));
|
||||||
}
|
}
|
||||||
@@ -83,23 +70,23 @@ public class FeatureServiceTests
|
|||||||
{
|
{
|
||||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, "initial-val", null);
|
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, "initial-val", null);
|
||||||
|
|
||||||
var state = await _db.FeatureStates.FirstAsync(fs => fs.FeatureId == feature.Id);
|
var state = _featureStates.First(fs => fs.FeatureId == feature.Id);
|
||||||
Assert.That(state.Value, Is.EqualTo("initial-val"));
|
Assert.That(state.Value, Is.EqualTo("initial-val"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException()
|
public void WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < 400; i++)
|
for (var i = 0; i < 400; i++)
|
||||||
{
|
{
|
||||||
_db.Features.Add(new Feature
|
_features.Add(new Feature
|
||||||
{
|
{
|
||||||
|
Id = i + 1,
|
||||||
Name = $"feature_{i}",
|
Name = $"feature_{i}",
|
||||||
ProjectId = ProjectId,
|
ProjectId = ProjectId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<DomainException>(() =>
|
Assert.ThrowsAsync<DomainException>(() =>
|
||||||
_service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null));
|
_service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null));
|
||||||
@@ -123,7 +110,7 @@ public class FeatureServiceTests
|
|||||||
|
|
||||||
await _service.DeleteAsync(feature.Id);
|
await _service.DeleteAsync(feature.Id);
|
||||||
|
|
||||||
var found = await _db.Features.FirstOrDefaultAsync(f => f.Id == feature.Id);
|
var found = _features.FirstOrDefault(f => f.Id == feature.Id);
|
||||||
Assert.That(found, Is.Null);
|
Assert.That(found, Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +130,7 @@ public class FeatureServiceTests
|
|||||||
{
|
{
|
||||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||||
_db.Tags.Add(tag);
|
_db.Object.Tags.Add(tag);
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||||
|
|
||||||
@@ -156,8 +142,7 @@ public class FeatureServiceTests
|
|||||||
{
|
{
|
||||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||||
_db.Tags.Add(tag);
|
_db.Object.Tags.Add(tag);
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
await _service.AssignTagAsync(feature.Id, tag.Id);
|
await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||||
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||||
@@ -168,7 +153,7 @@ public class FeatureServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
|
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
|
||||||
{
|
{
|
||||||
_db.Projects.Add(new MicCheck.Api.Projects.Project
|
_projects.Add(new Project
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Name = "Other Project",
|
Name = "Other Project",
|
||||||
@@ -177,8 +162,7 @@ public class FeatureServiceTests
|
|||||||
});
|
});
|
||||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||||
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
|
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
|
||||||
_db.Tags.Add(foreignTag);
|
_db.Object.Tags.Add(foreignTag);
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id));
|
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id));
|
||||||
}
|
}
|
||||||
@@ -188,8 +172,7 @@ public class FeatureServiceTests
|
|||||||
{
|
{
|
||||||
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
|
||||||
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
|
||||||
_db.Tags.Add(tag);
|
_db.Object.Tags.Add(tag);
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
await _service.AssignTagAsync(feature.Id, tag.Id);
|
await _service.AssignTagAsync(feature.Id, tag.Id);
|
||||||
|
|
||||||
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);
|
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Features;
|
namespace MicCheck.Api.Tests.Unit.Features;
|
||||||
@@ -8,26 +9,23 @@ namespace MicCheck.Api.Tests.Unit.Features;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureUsageQueryServiceTests
|
public class FeatureUsageQueryServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<FeatureUsageDaily> _usage = null!;
|
||||||
private FeatureUsageQueryService _service = null!;
|
private FeatureUsageQueryService _service = null!;
|
||||||
private const int EnvironmentId = 1;
|
private const int EnvironmentId = 1;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
_usage = [];
|
||||||
.Options;
|
_db.SetupDbSet(c => c.FeatureUsageDaily, _usage);
|
||||||
_db = new MicCheckDbContext(options);
|
_service = new FeatureUsageQueryService(_db.Object);
|
||||||
_service = new FeatureUsageQueryService(_db);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown() => _db.Dispose();
|
|
||||||
|
|
||||||
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
|
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
|
||||||
{
|
{
|
||||||
_db.FeatureUsageDaily.Add(new FeatureUsageDaily
|
_usage.Add(new FeatureUsageDaily
|
||||||
{
|
{
|
||||||
EnvironmentId = environmentId,
|
EnvironmentId = environmentId,
|
||||||
FeatureId = featureId,
|
FeatureId = featureId,
|
||||||
@@ -36,7 +34,6 @@ public class FeatureUsageQueryServiceTests
|
|||||||
Count = count,
|
Count = count,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
using System.Net;
|
using System.Security.Claims;
|
||||||
using System.Net.Http.Json;
|
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using MicCheck.Api.Identities;
|
||||||
|
using MicCheck.Api.Projects;
|
||||||
|
using MicCheck.Api.Segments;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
|
|
||||||
@@ -11,127 +18,110 @@ namespace MicCheck.Api.Tests.Unit.Features;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FlagsApiIntegrationTests
|
public class FlagsApiIntegrationTests
|
||||||
{
|
{
|
||||||
private MicCheckWebApplicationFactory _factory = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
private string _envApiKey = null!;
|
private List<AppEnvironment> _environments = null!;
|
||||||
|
private List<Project> _projects = null!;
|
||||||
|
private List<Feature> _features = null!;
|
||||||
|
private List<FeatureState> _featureStates = null!;
|
||||||
|
private List<Identity> _identities = null!;
|
||||||
|
private AppEnvironment _environment = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
_factory = new MicCheckWebApplicationFactory();
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
_envApiKey = SeedEnvironmentWithFlag();
|
|
||||||
|
_projects = [new Project { Id = 1, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }];
|
||||||
|
_db.SetupDbSet(c => c.Projects, _projects);
|
||||||
|
|
||||||
|
_environment = new AppEnvironment
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Test Env",
|
||||||
|
ApiKey = "test-env-key",
|
||||||
|
ProjectId = 1,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
_environments = [_environment];
|
||||||
|
var environmentsSet = MockDbSetFactory.Create(_environments);
|
||||||
|
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
|
||||||
|
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
|
||||||
|
|
||||||
|
_features = [new Feature { Id = 1, Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
|
||||||
|
|
||||||
|
_featureStates = [
|
||||||
|
new FeatureState { FeatureId = 1, EnvironmentId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }
|
||||||
|
];
|
||||||
|
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
|
||||||
|
|
||||||
|
_identities = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities);
|
||||||
|
_db.SetupDbSet(c => c.IdentityTraits, []);
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Segments, []);
|
||||||
|
_db.SetupDbSet(c => c.SegmentRules, []);
|
||||||
|
_db.SetupDbSet(c => c.SegmentConditions, []);
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
private static ControllerBase WithEnvironmentClaim(ControllerBase controller, int environmentId)
|
||||||
public void TearDown() => _factory.Dispose();
|
|
||||||
|
|
||||||
private string SeedEnvironmentWithFlag()
|
|
||||||
{
|
{
|
||||||
using var scope = _factory.Services.CreateScope();
|
var identity = new ClaimsIdentity([new Claim("EnvironmentId", environmentId.ToString())], "EnvironmentKey");
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
controller.ControllerContext = new ControllerContext
|
||||||
|
|
||||||
var apiKey = $"test-env-key-{Guid.NewGuid():N}";
|
|
||||||
|
|
||||||
var project = new MicCheck.Api.Projects.Project
|
|
||||||
{
|
{
|
||||||
Name = "Test Project",
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
||||||
OrganizationId = 1,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
};
|
||||||
db.Projects.Add(project);
|
return controller;
|
||||||
db.SaveChanges();
|
}
|
||||||
|
|
||||||
var environment = new AppEnvironment
|
private FeatureEvaluationService CreateEvaluationService() =>
|
||||||
{
|
new(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), CreateUsageMetrics());
|
||||||
Name = "Test Env",
|
|
||||||
ApiKey = apiKey,
|
|
||||||
ProjectId = project.Id,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
db.Environments.Add(environment);
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var feature = new Feature
|
private static FeatureUsageMetrics CreateUsageMetrics()
|
||||||
{
|
{
|
||||||
Name = "dark_mode",
|
var meterFactory = new Mock<System.Diagnostics.Metrics.IMeterFactory>();
|
||||||
ProjectId = project.Id,
|
meterFactory.Setup(f => f.Create(It.IsAny<System.Diagnostics.Metrics.MeterOptions>()))
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
.Returns(new System.Diagnostics.Metrics.Meter("test"));
|
||||||
};
|
return new FeatureUsageMetrics(meterFactory.Object);
|
||||||
db.Features.Add(feature);
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
db.FeatureStates.Add(new FeatureState
|
|
||||||
{
|
|
||||||
FeatureId = feature.Id,
|
|
||||||
EnvironmentId = environment.Id,
|
|
||||||
Enabled = true,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
return apiKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned()
|
public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned()
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var controller = (FlagsController)WithEnvironmentClaim(
|
||||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
new FlagsController(CreateEvaluationService()), _environment.Id);
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/flags/");
|
var result = await controller.GetAll(CancellationToken.None);
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
var flags = (result.Result as OkObjectResult)?.Value as IReadOnlyList<FlagResponse>;
|
||||||
|
|
||||||
var flags = await response.Content.ReadFromJsonAsync<List<FlagResponse>>();
|
|
||||||
Assert.That(flags, Has.Count.EqualTo(1));
|
Assert.That(flags, Has.Count.EqualTo(1));
|
||||||
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
|
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
|
||||||
Assert.That(flags[0].Enabled, Is.True);
|
Assert.That(flags[0].Enabled, Is.True);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/flags/");
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key");
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/flags/");
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
|
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var controller = (IdentitiesController)WithEnvironmentClaim(
|
||||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
new IdentitiesController(CreateEvaluationService(), new IdentityResolutionService(_db.Object)),
|
||||||
|
_environment.Id);
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/api/v1/identity/", new
|
var result = await controller.Identify(
|
||||||
{
|
new IdentityRequest("user-123", [new TraitInput("plan", "premium")]), CancellationToken.None);
|
||||||
identifier = "user-123",
|
|
||||||
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned()
|
public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned()
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var controller = (EnvironmentDocumentController)WithEnvironmentClaim(
|
||||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
new EnvironmentDocumentController(new EnvironmentDocumentService(_db.Object)),
|
||||||
|
_environment.Id);
|
||||||
|
|
||||||
var response = await client.GetAsync("/api/v1/environment-document/");
|
var result = await controller.Get(CancellationToken.None);
|
||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="NUnit" Version="4.5.1" />
|
<PackageReference Include="NUnit" Version="4.5.1" />
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using MicCheck.Api.Audit;
|
using MicCheck.Api.Audit;
|
||||||
using MicCheck.Api.Common;
|
using MicCheck.Api.Common;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Organizations;
|
||||||
|
using MicCheck.Api.Projects;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
@@ -11,7 +13,10 @@ namespace MicCheck.Api.Tests.Unit.Segments;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class SegmentServiceTests
|
public class SegmentServiceTests
|
||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private Mock<IMicCheckDbContext> _db = null!;
|
||||||
|
private List<Segment> _segments = null!;
|
||||||
|
private List<SegmentRule> _segmentRules = null!;
|
||||||
|
private List<SegmentCondition> _segmentConditions = null!;
|
||||||
private SegmentService _service = null!;
|
private SegmentService _service = null!;
|
||||||
private const int OrganizationId = 1;
|
private const int OrganizationId = 1;
|
||||||
private const int ProjectId = 1;
|
private const int ProjectId = 1;
|
||||||
@@ -19,43 +24,30 @@ public class SegmentServiceTests
|
|||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
_db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
_db.SetupDbSet(c => c.Organizations, [
|
||||||
_db = new MicCheckDbContext(options);
|
new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_db.SetupDbSet(c => c.Projects, [
|
||||||
|
new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
]);
|
||||||
|
_segments = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
|
||||||
|
_segmentRules = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, _segmentRules);
|
||||||
|
_segmentConditions = [];
|
||||||
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db, null!, webhookQueue);
|
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
_service = new SegmentService(_db, auditService.Object);
|
_service = new SegmentService(_db.Object, auditService.Object);
|
||||||
|
|
||||||
SeedBaseData();
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown() => _db.Dispose();
|
|
||||||
|
|
||||||
private void SeedBaseData()
|
|
||||||
{
|
|
||||||
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
|
|
||||||
{
|
|
||||||
Id = OrganizationId,
|
|
||||||
Name = "Test Org",
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.Projects.Add(new MicCheck.Api.Projects.Project
|
|
||||||
{
|
|
||||||
Id = ProjectId,
|
|
||||||
Name = "Test Project",
|
|
||||||
OrganizationId = OrganizationId,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SegmentRuleDefinition SingleConditionRule(
|
private static SegmentRuleDefinition SingleConditionRule(
|
||||||
@@ -71,36 +63,33 @@ public class SegmentServiceTests
|
|||||||
var rules = new[] { SingleConditionRule() };
|
var rules = new[] { SingleConditionRule() };
|
||||||
var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules);
|
var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules);
|
||||||
|
|
||||||
var loaded = await _db.Segments
|
var persistedRules = _segmentRules.Where(r => r.SegmentId == segment.Id).ToList();
|
||||||
.Include(s => s.Rules)
|
|
||||||
.ThenInclude(r => r.Conditions)
|
|
||||||
.FirstAsync(s => s.Id == segment.Id);
|
|
||||||
|
|
||||||
Assert.That(loaded.Name, Is.EqualTo("Premium Users"));
|
Assert.That(segment.Name, Is.EqualTo("Premium Users"));
|
||||||
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
|
Assert.That(persistedRules, Has.Count.EqualTo(1));
|
||||||
Assert.That(loaded.Rules.First().Conditions, Has.Count.EqualTo(1));
|
Assert.That(persistedRules[0].Conditions, Has.Count.EqualTo(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException()
|
public void WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < 100; i++)
|
for (var i = 0; i < 100; i++)
|
||||||
{
|
{
|
||||||
_db.Segments.Add(new Segment
|
_segments.Add(new Segment
|
||||||
{
|
{
|
||||||
|
Id = i + 1,
|
||||||
Name = $"segment_{i}",
|
Name = $"segment_{i}",
|
||||||
ProjectId = ProjectId,
|
ProjectId = ProjectId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_db.SaveChanges();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<DomainException>(() =>
|
Assert.ThrowsAsync<DomainException>(() =>
|
||||||
_service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()]));
|
_service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException()
|
public void WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException()
|
||||||
{
|
{
|
||||||
var conditions = Enumerable.Range(0, 101)
|
var conditions = Enumerable.Range(0, 101)
|
||||||
.Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val"))
|
.Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val"))
|
||||||
@@ -119,14 +108,11 @@ public class SegmentServiceTests
|
|||||||
var newRules = new[] { SingleConditionRule("country") };
|
var newRules = new[] { SingleConditionRule("country") };
|
||||||
var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules);
|
var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules);
|
||||||
|
|
||||||
var loaded = await _db.Segments
|
var persistedRules = _segmentRules.Where(r => r.SegmentId == updated.Id).ToList();
|
||||||
.Include(s => s.Rules)
|
|
||||||
.ThenInclude(r => r.Conditions)
|
|
||||||
.FirstAsync(s => s.Id == updated.Id);
|
|
||||||
|
|
||||||
Assert.That(loaded.Name, Is.EqualTo("Updated Segment"));
|
Assert.That(updated.Name, Is.EqualTo("Updated Segment"));
|
||||||
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
|
Assert.That(persistedRules, Has.Count.EqualTo(1));
|
||||||
Assert.That(loaded.Rules.First().Conditions.First().Property, Is.EqualTo("country"));
|
Assert.That(persistedRules[0].Conditions.First().Property, Is.EqualTo("country"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -136,7 +122,7 @@ public class SegmentServiceTests
|
|||||||
|
|
||||||
await _service.DeleteAsync(segment.Id);
|
await _service.DeleteAsync(segment.Id);
|
||||||
|
|
||||||
var found = await _db.Segments.FirstOrDefaultAsync(s => s.Id == segment.Id);
|
var found = _segments.FirstOrDefault(s => s.Id == segment.Id);
|
||||||
Assert.That(found, Is.Null);
|
Assert.That(found, Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ using System.Net;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using MicCheck.Api.Webhooks;
|
using MicCheck.Api.Webhooks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Moq.Protected;
|
using Moq.Protected;
|
||||||
@@ -52,28 +52,24 @@ public class WebhookDispatcherTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader()
|
public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader()
|
||||||
{
|
{
|
||||||
var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
|
var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
|
||||||
|
|
||||||
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
|
const int orgId = 1;
|
||||||
db.Organizations.Add(org);
|
webhooks.Add(new Webhook
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
db.Webhooks.Add(new Webhook
|
|
||||||
{
|
{
|
||||||
Url = "https://example.com/hook",
|
Url = "https://example.com/hook",
|
||||||
Secret = "test-secret",
|
Secret = "test-secret",
|
||||||
Scope = WebhookScope.Organization,
|
Scope = WebhookScope.Organization,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
|
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
|
||||||
var webhookEvent = new WebhookEvent
|
var webhookEvent = new WebhookEvent
|
||||||
{
|
{
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Data = new { test = true }
|
Data = new { test = true }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,28 +82,24 @@ public class WebhookDispatcherTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded()
|
public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded()
|
||||||
{
|
{
|
||||||
var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
|
var (db, webhooks, _, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK);
|
||||||
|
|
||||||
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
|
const int orgId = 1;
|
||||||
db.Organizations.Add(org);
|
webhooks.Add(new Webhook
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
db.Webhooks.Add(new Webhook
|
|
||||||
{
|
{
|
||||||
Url = "https://example.com/hook",
|
Url = "https://example.com/hook",
|
||||||
Secret = null,
|
Secret = null,
|
||||||
Scope = WebhookScope.Organization,
|
Scope = WebhookScope.Organization,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
|
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
|
||||||
await dispatcher.DispatchAsync(new WebhookEvent
|
await dispatcher.DispatchAsync(new WebhookEvent
|
||||||
{
|
{
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Data = new { }
|
Data = new { }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,31 +109,27 @@ public class WebhookDispatcherTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess()
|
public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess()
|
||||||
{
|
{
|
||||||
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
|
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
|
||||||
|
|
||||||
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
|
const int orgId = 1;
|
||||||
db.Organizations.Add(org);
|
webhooks.Add(new Webhook
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
db.Webhooks.Add(new Webhook
|
|
||||||
{
|
{
|
||||||
Url = "https://example.com/hook",
|
Url = "https://example.com/hook",
|
||||||
Scope = WebhookScope.Organization,
|
Scope = WebhookScope.Organization,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
|
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
|
||||||
await dispatcher.DispatchAsync(new WebhookEvent
|
await dispatcher.DispatchAsync(new WebhookEvent
|
||||||
{
|
{
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Data = new { }
|
Data = new { }
|
||||||
});
|
});
|
||||||
|
|
||||||
var log = db.WebhookDeliveryLogs.First();
|
var log = deliveryLogs.First();
|
||||||
Assert.That(log.Success, Is.True);
|
Assert.That(log.Success, Is.True);
|
||||||
Assert.That(log.ResponseStatusCode, Is.EqualTo(200));
|
Assert.That(log.ResponseStatusCode, Is.EqualTo(200));
|
||||||
Assert.That(log.AttemptNumber, Is.EqualTo(1));
|
Assert.That(log.AttemptNumber, Is.EqualTo(1));
|
||||||
@@ -150,31 +138,27 @@ public class WebhookDispatcherTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure()
|
public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure()
|
||||||
{
|
{
|
||||||
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError);
|
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError);
|
||||||
|
|
||||||
var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow };
|
const int orgId = 1;
|
||||||
db.Organizations.Add(org);
|
webhooks.Add(new Webhook
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
db.Webhooks.Add(new Webhook
|
|
||||||
{
|
{
|
||||||
Url = "https://example.com/hook",
|
Url = "https://example.com/hook",
|
||||||
Scope = WebhookScope.Organization,
|
Scope = WebhookScope.Organization,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
|
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
|
||||||
await dispatcher.DispatchAsync(new WebhookEvent
|
await dispatcher.DispatchAsync(new WebhookEvent
|
||||||
{
|
{
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
OrganizationId = org.Id,
|
OrganizationId = orgId,
|
||||||
Data = new { }
|
Data = new { }
|
||||||
});
|
});
|
||||||
|
|
||||||
var log = db.WebhookDeliveryLogs.First();
|
var log = deliveryLogs.First();
|
||||||
Assert.That(log.Success, Is.False);
|
Assert.That(log.Success, Is.False);
|
||||||
Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
|
Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
|
||||||
}
|
}
|
||||||
@@ -182,9 +166,9 @@ public class WebhookDispatcherTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
|
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
|
||||||
{
|
{
|
||||||
var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
|
var (db, _, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
|
||||||
|
|
||||||
var dispatcher = new WebhookDispatcher(db, factory, NullLogger<WebhookDispatcher>.Instance);
|
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
|
||||||
await dispatcher.DispatchAsync(new WebhookEvent
|
await dispatcher.DispatchAsync(new WebhookEvent
|
||||||
{
|
{
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
@@ -192,16 +176,17 @@ public class WebhookDispatcherTests
|
|||||||
Data = new { }
|
Data = new { }
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.That(db.WebhookDeliveryLogs.Count(), Is.EqualTo(0));
|
Assert.That(deliveryLogs, Is.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (MicCheckDbContext Db, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests)
|
private static (Mock<IMicCheckDbContext> Db, List<Webhook> Webhooks, List<WebhookDeliveryLog> DeliveryLogs, IHttpClientFactory Factory, List<HttpRequestMessage> CapturedRequests)
|
||||||
SetUpDispatcher(HttpStatusCode responseStatus)
|
SetUpDispatcher(HttpStatusCode responseStatus)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
var db = new Mock<IMicCheckDbContext>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
var webhooks = new List<Webhook>();
|
||||||
.Options;
|
db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks);
|
||||||
var db = new MicCheckDbContext(options);
|
var deliveryLogs = new List<WebhookDeliveryLog>();
|
||||||
|
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
|
||||||
|
|
||||||
var capturedRequests = new List<HttpRequestMessage>();
|
var capturedRequests = new List<HttpRequestMessage>();
|
||||||
var handler = new Mock<HttpMessageHandler>();
|
var handler = new Mock<HttpMessageHandler>();
|
||||||
@@ -222,6 +207,6 @@ public class WebhookDispatcherTests
|
|||||||
var factory = new Mock<IHttpClientFactory>();
|
var factory = new Mock<IHttpClientFactory>();
|
||||||
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
return (db, factory.Object, capturedRequests);
|
return (db, webhooks, deliveryLogs, factory.Object, capturedRequests);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using MicCheck.Api.Webhooks;
|
using MicCheck.Api.Webhooks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Webhooks;
|
namespace MicCheck.Api.Tests.Unit.Webhooks;
|
||||||
@@ -24,28 +26,33 @@ public class WebhookRetryTests
|
|||||||
Assert.That(delay.TotalMinutes, Is.EqualTo(30));
|
Assert.That(delay.TotalMinutes, Is.EqualTo(30));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Mock<IMicCheckDbContext> CreateDb(List<WebhookDeliveryLog> deliveryLogs)
|
||||||
|
{
|
||||||
|
var db = new Mock<IMicCheckDbContext>();
|
||||||
|
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry()
|
public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
var deliveryLogs = new List<WebhookDeliveryLog>
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
using var db = new MicCheckDbContext(options);
|
|
||||||
|
|
||||||
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
|
|
||||||
{
|
{
|
||||||
WebhookId = 1,
|
new()
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
{
|
||||||
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
|
WebhookId = 1,
|
||||||
Success = false,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
AttemptNumber = 1,
|
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
|
||||||
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6),
|
Success = false,
|
||||||
Duration = TimeSpan.FromMilliseconds(100)
|
AttemptNumber = 1,
|
||||||
});
|
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6),
|
||||||
await db.SaveChangesAsync();
|
Duration = TimeSpan.FromMilliseconds(100)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var db = CreateDb(deliveryLogs);
|
||||||
|
|
||||||
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
|
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
|
||||||
var eligible = await db.WebhookDeliveryLogs
|
var eligible = await db.Object.WebhookDeliveryLogs
|
||||||
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
|
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -55,25 +62,23 @@ public class WebhookRetryTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry()
|
public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
var deliveryLogs = new List<WebhookDeliveryLog>
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
using var db = new MicCheckDbContext(options);
|
|
||||||
|
|
||||||
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
|
|
||||||
{
|
{
|
||||||
WebhookId = 1,
|
new()
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
{
|
||||||
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
|
WebhookId = 1,
|
||||||
Success = false,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
AttemptNumber = 1,
|
PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""",
|
||||||
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
Success = false,
|
||||||
Duration = TimeSpan.FromMilliseconds(100)
|
AttemptNumber = 1,
|
||||||
});
|
AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||||
await db.SaveChangesAsync();
|
Duration = TimeSpan.FromMilliseconds(100)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var db = CreateDb(deliveryLogs);
|
||||||
|
|
||||||
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
|
var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5);
|
||||||
var eligible = await db.WebhookDeliveryLogs
|
var eligible = await db.Object.WebhookDeliveryLogs
|
||||||
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
|
.Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -83,37 +88,34 @@ public class WebhookRetryTests
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain()
|
public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
using var db = new MicCheckDbContext(options);
|
|
||||||
|
|
||||||
var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10);
|
var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||||
|
|
||||||
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
|
var deliveryLogs = new List<WebhookDeliveryLog>
|
||||||
{
|
{
|
||||||
WebhookId = 1,
|
new()
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
{
|
||||||
PayloadJson = "{}",
|
WebhookId = 1,
|
||||||
Success = false,
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
AttemptNumber = 1,
|
PayloadJson = "{}",
|
||||||
AttemptedAt = firstAttemptedAt,
|
Success = false,
|
||||||
Duration = TimeSpan.Zero
|
AttemptNumber = 1,
|
||||||
});
|
AttemptedAt = firstAttemptedAt,
|
||||||
|
Duration = TimeSpan.Zero
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WebhookId = 1,
|
||||||
|
EventType = WebhookEventTypes.FlagUpdated,
|
||||||
|
PayloadJson = "{}",
|
||||||
|
Success = false,
|
||||||
|
AttemptNumber = 2,
|
||||||
|
AttemptedAt = firstAttemptedAt.AddMinutes(5),
|
||||||
|
Duration = TimeSpan.Zero
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var db = CreateDb(deliveryLogs);
|
||||||
|
|
||||||
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
|
var alreadyRetried = await db.Object.WebhookDeliveryLogs
|
||||||
{
|
|
||||||
WebhookId = 1,
|
|
||||||
EventType = WebhookEventTypes.FlagUpdated,
|
|
||||||
PayloadJson = "{}",
|
|
||||||
Success = false,
|
|
||||||
AttemptNumber = 2,
|
|
||||||
AttemptedAt = firstAttemptedAt.AddMinutes(5),
|
|
||||||
Duration = TimeSpan.Zero
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var alreadyRetried = await db.WebhookDeliveryLogs
|
|
||||||
.AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt);
|
.AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt);
|
||||||
|
|
||||||
Assert.That(alreadyRetried, Is.True);
|
Assert.That(alreadyRetried, Is.True);
|
||||||
|
|||||||
Reference in New Issue
Block a user