diff --git a/docs/03_database_and_repositories_output.md b/docs/03_database_and_repositories_output.md new file mode 100644 index 0000000..5cec10e --- /dev/null +++ b/docs/03_database_and_repositories_output.md @@ -0,0 +1,90 @@ +# Step 3 Output: Database Schema & EF Core + +## Summary + +All database infrastructure from the plan was implemented. The `DbContext` was placed in `MicCheck.Api/Data/` (not `MicCheck.Infrastructure`) to avoid a circular dependency, since `MicCheck.Api` already references `MicCheck.Infrastructure`. + +--- + +## What Was Built + +### `MicCheck.Api/Data/MicCheckDbContext.cs` +- 18 `DbSet` properties covering all domain entities +- `OnModelCreating` delegates to `ApplyConfigurationsFromAssembly` +- Uses `using AppEnvironment = MicCheck.Api.Environments.Environment;` alias to avoid collision with `System.Environment` + +### `MicCheck.Api/Data/Configurations/` (18 files) +One `IEntityTypeConfiguration` per entity with all constraints: + +| Entity | Key constraints | +|---|---| +| `Feature` | Name max 150, InitialValue max 20k, unique `(ProjectId, Name)` | +| `FeatureState` | Value max 20k, `Version` as concurrency token, unique `(FeatureId, EnvironmentId, IdentityId)`, indexes on `EnvironmentId` and `IdentityId` | +| `IdentityTrait` | Value max 2k, unique `(IdentityId, Key)` | +| `Environment` | ApiKey max 100, unique index on `ApiKey` | +| `ApiKey` | Key max 512, Prefix max 8, unique index on `Key` | +| `Identity` | unique `(EnvironmentId, Identifier)` | +| `AuditLog` | index on `(OrganizationId, CreatedAt)` | +| `Webhook` | index on `EnvironmentId` | + +### `MicCheck.Api/Data/DatabaseUrlParser.cs` +Parses `DATABASE_URL` (postgresql://user:pass@host:port/db) into an Npgsql connection string. Defaults port to 5432 when not specified. + +### `MicCheck.Api/Data/DatabaseSeeder.cs` +Seeds a default Organization ("Default"), Project ("My Project"), and three Environments (Development, Staging, Production) on first startup when no organizations exist. Only runs in the Development environment. + +### `Program.cs` updates +- Registers `MicCheckDbContext` with Npgsql using either `DATABASE_URL` or `ConnectionStrings:DefaultConnection` +- Runs the seeder only when `app.Environment.IsDevelopment()` + +--- + +## Deviations from Plan + +- **DbContext location**: Plan specified `MicCheck.Infrastructure/Data/`. Moved to `MicCheck.Api/Data/` due to circular dependency constraint. `MicCheck.Infrastructure` is reserved for background services (Step 7). +- **Migrations**: Not added in this step — requires a running PostgreSQL instance. Run manually with `dotnet ef migrations add InitialSchema --project src/api/MicCheck.Api`. + +--- + +## Tests Added + +### `MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs` (10 tests) +Covers all major DbSet operations using `UseInMemoryDatabase`: +- Organization CRUD +- Project query by organization +- Environment query by API key +- Feature query by project +- FeatureState query by environment +- Segment with included rules +- Identity with included traits +- AuditLog filter by organization +- Webhook filter by environment +- ApiKey retrieval by hashed key +- User retrieval by email + +### `MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs` (4 tests) +- Full URL with all components +- Default port (5432) when omitted +- Custom port preserved +- Special characters in password preserved + +### `MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs` (3 tests, fixed) +Integration tests using `WebApplicationFactory`. Fixed by: +- Setting `UseEnvironment("Testing")` so the seeder doesn't run at startup +- Overriding `ITokenService` with a Moq mock + +--- + +## Issues Encountered + +1. **`System.Environment` name collision** — `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` pulled in by implicit usings. Resolved everywhere with the `using AppEnvironment = ...` alias. + +2. **`WebApplicationFactory` + seeder crash** — The seeder runs in Development mode and tries to create a `MicCheckDbContext`, which requires a real Npgsql connection. Test factory fixed by setting `UseEnvironment("Testing")` to prevent the seeder block from executing. + +--- + +## Test Results + +``` +Passed! - Failed: 0, Passed: 73, Skipped: 0, Total: 73 +``` diff --git a/docs/04_authentication_and_authorization_output.md b/docs/04_authentication_and_authorization_output.md new file mode 100644 index 0000000..7e0c8fc --- /dev/null +++ b/docs/04_authentication_and_authorization_output.md @@ -0,0 +1,154 @@ +# Step 4 Output: Authentication & Authorization + +## Summary + +Implemented all three authentication schemes, authorization policies, role-based access control, and the full auth/API key endpoint suite. + +--- + +## What Was Built + +### Authentication Schemes + +**`EnvironmentKeyAuthenticationHandler`** (`MicCheck.Api/Authentication/`) +- Reads `X-Environment-Key` header +- Looks up `Environment` by `ApiKey` in the database +- Sets `EnvironmentId` and `ProjectId` claims on success +- Scheme name constant: `EnvironmentKeyAuthenticationHandler.SchemeName` + +**`ApiKeyAuthenticationHandler`** (`MicCheck.Api/Authentication/`) +- Parses `Authorization: Api-Key ` header +- Hashes the raw token with SHA-256 and looks it up in `ApiKeys` table +- Validates `IsActive` and `ExpiresAt` +- Sets `OrganizationId` and `OrganizationRole = Admin` claims on success (API keys grant org-admin access) + +**JWT Bearer** — existing `TokenService` updated to accept `User` (not a username string), now embeds `sub` (userId), `email`, `given_name`, `family_name`, plus `OrganizationId` and `OrganizationRole` claims for each org membership. + +### Authorization Policies (`MicCheck.Api/Authorization/`) + +| Policy | Schemes | Requirement | +|---|---|---| +| `FlagsApiAccess` | EnvironmentKey | `EnvironmentId` claim present | +| `AdminApiAccess` | ApiKey, Bearer | Authenticated user | +| `OrganizationAdmin` | ApiKey, Bearer | `OrganizationRole = Admin` claim | + +### RBAC (`MicCheck.Api/Authorization/`) + +- `ProjectPermission` enum (12 values: ViewProject, CreateFeature, EditFeature, etc.) +- `UserProjectPermission` entity — `(UserId, ProjectId)` composite key, `IsAdmin` flag, `List` stored as comma-separated string via EF Core value converter +- `ProjectPermissionRequirement` — `IAuthorizationRequirement` wrapping a `ProjectPermission` +- `ProjectPermissionRequirementHandler` — resolves project ID from route values (`projectId`), bypasses check for org admins, checks `UserProjectPermission` record otherwise + +### Auth Service & Endpoints (`MicCheck.Api/Auth/`) + +**`AuthService`** — login, register, refresh, logout: +- Login: verifies password with `IPasswordHasher`, issues JWT + 30-day refresh token +- Register: creates `User`, default `Organization`, joins as `Admin`, issues tokens +- Refresh: revokes old refresh token, issues new JWT + new refresh token (rotation) +- Logout: revokes refresh token + +**Endpoints** at `/api/v1/auth/` (all anonymous): + +| Method | Path | Description | +|---|---|---| +| POST | `/api/v1/auth/login` | Returns `LoginResponse(AccessToken, RefreshToken, ExpiresAt)` | +| POST | `/api/v1/auth/register` | Creates user + org, returns same | +| POST | `/api/v1/auth/refresh` | Rotates refresh token | +| POST | `/api/v1/auth/logout` | Revokes refresh token, returns 204 | + +### API Key Management (`MicCheck.Api/ApiKeys/`) + +**`ApiKeyHasher`** — static utility: +- `GenerateKey()` — `RandomNumberGenerator.GetBytes(32)` → Base64URL (no `+`, `/`, `=`) +- `Hash(key)` — SHA-256 hex string (64 chars, lowercase) + +**`ApiKeyService`** — create, list, revoke + +**Endpoints** at `/api/v1/organisations/{organizationId}/api-keys/` (require `OrganizationAdmin`): + +| Method | Path | Description | +|---|---|---| +| POST | `/` | Returns `CreateApiKeyResponse` including the raw key (shown once only) | +| GET | `/` | Returns list with prefix only (hashed key never returned) | +| DELETE | `/{keyId}` | Sets `IsActive = false` | + +### New Domain Types + +- `RefreshToken` (`MicCheck.Api/Users/`) — with EF Core cascade delete on user +- `UserProjectPermission` (`MicCheck.Api/Authorization/`) +- New request/response records: `LoginRequest`, `LoginResponse`, `RegisterRequest`, `RefreshRequest`, `LogoutRequest`, `CreateApiKeyRequest`, `CreateApiKeyResponse`, `ApiKeyResponse` + +### New EF Core Configurations + +- `RefreshTokenConfiguration` — unique index on `Token`, index on `UserId`, cascade delete +- `UserProjectPermissionConfiguration` — composite key `(UserId, ProjectId)`, `Permissions` stored as comma-separated string + +--- + +## Deviations from Plan + +- `UserProjectPermission.Permissions` declared as `List` (not `ICollection`) to ensure EF Core value converter works correctly +- API keys granted `OrganizationRole = Admin` claim — necessary for `OrganizationAdmin` policy (which `RequireClaim("OrganizationRole", "Admin")`); without it, API-key-authenticated requests could never manage API keys + +--- + +## Tests Added + +### `ApiKeyHasherTests` (7 tests) +- Hash is deterministic +- Hash is lowercase hex (64 chars) +- Different inputs produce different hashes +- Generated key is non-empty and unique +- Generated key is Base64URL-safe (no `+`, `/`, `=`) +- Generated key can be verified by hashing + +### `AuthEndpointsTests` (10 tests) +End-to-end via `WebApplicationFactory` with in-memory DB: +- Register with valid details → 200 with tokens +- Register duplicate email → 409 +- Login with correct credentials → 200 with tokens +- Login with wrong password → 401 +- Login with unknown email → 401 +- Login with empty email → 400 +- Refresh with valid token → 200 with new tokens +- Refresh with invalid token → 401 +- Refresh with already-used (revoked) token → 401 +- Logout → 204, subsequent refresh → 401 + +### `ApiKeyAuthenticationHandlerTests` (5 tests) +Via `WebApplicationFactory` against the API key management endpoints: +- Missing auth header → 401 +- Bearer scheme (not Api-Key) → 401 +- Invalid key → 401 +- Inactive key → 401 +- Expired key → 401 +- Valid key → 200 + +### `ProjectPermissionRequirementHandlerTests` (7 tests) +Direct unit tests with in-memory DB: +- Org admin bypasses all project checks → succeeds +- User with required permission → succeeds +- Project admin (`IsAdmin = true`) satisfies any permission → succeeds +- User without required permission → fails +- User with no permission record → fails +- Missing user ID claim → fails +- Missing project ID in route → fails + +### `TokenServiceTests` (5 tests, updated) +Updated for new `User`-based signature — verifies token non-empty, email claim, expiry, issuer, and org claims. + +--- + +## Issues Encountered + +1. **`WebApplicationFactory` + multiple EF Core providers** — Removing `DbContextOptions` alone does not remove Npgsql's `IDatabaseProvider`. Fixed by using `UseInternalServiceProvider` with a dedicated `InMemoryEfServiceProvider` (static), which tells EF Core to bypass the application DI container for provider discovery entirely. + +2. **Per-request Guid DB name** — Initially `Guid.NewGuid()` was evaluated inside the `AddDbContext` options lambda, generating a new DB name per `DbContext` creation. Each HTTP request therefore got an empty database. Fixed by capturing the Guid once in `ConfigureWebHost` before the `ConfigureServices` lambda. + +--- + +## Test Results + +``` +Passed! - Failed: 0, Passed: 102, Skipped: 0, Total: 102 +``` diff --git a/docs/05_flags_api_output.md b/docs/05_flags_api_output.md new file mode 100644 index 0000000..99fc371 --- /dev/null +++ b/docs/05_flags_api_output.md @@ -0,0 +1,142 @@ +# Step 5 Output: Flags API + +## Summary + +Implemented the public Flags API — the SDK-facing endpoints authenticated via `X-Environment-Key`. Includes feature flag evaluation with identity/segment/environment priority, identity resolution, and environment document for local evaluation mode. + +--- + +## What Was Built + +### Segment Evaluator (`MicCheck.Api/Segments/SegmentEvaluator.cs`) + +Pure stateless logic — no DB access. Evaluates a `Segment` against an identity's traits. + +**Rule evaluation:** +- Top-level rules (where `ParentRuleId == null`) are AND-ed together +- Each rule applies its `Type` (All/Any/None) to its `Conditions` +- `ChildRules` are recursively evaluated and AND-ed after conditions + +**All 17 operators implemented:** + +| Category | Operators | +|---|---| +| String | Equal, NotEqual, Contains, NotContains, Regex | +| Numeric | GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual | +| Boolean | IsTrue, IsFalse | +| Membership | In, NotIn | +| Existence | IsSet, IsNotSet | +| Sampling | PercentageSplit (MD5 hash of `identifier + segmentId`, bucket out of 9999) | +| Modulo | ModuloValueDivisorRemainder (value format: `"divisor|remainder"`) | + +### Feature Evaluation Service (`MicCheck.Api/Features/FeatureEvaluationService.cs`) + +**Priority order (highest wins):** +1. Identity-level override (`FeatureState` with `IdentityId` set) +2. Segment override — first matching `FeatureSegment` by `Priority` (lowest number first) +3. Environment default (`FeatureState` with no `IdentityId` or `FeatureSegmentId`) + +`EvaluateForEnvironmentAsync` — checks `FlagCache` first, loads default feature states via a join query, caches the result. + +`EvaluateForIdentityAsync` — loads all feature states + feature segments + segments in a few queries, resolves identity via `IdentityResolutionService`, then evaluates each feature in a single in-memory pass. + +### Flag Cache (`MicCheck.Api/Features/FlagCache.cs`) + +`IMemoryCache` wrapper with 60-second TTL, keyed by `environmentId`. Registered as a singleton. Interface designed to swap backing store for Redis without changing callers. + +### Identity Resolution Service (`MicCheck.Api/Identities/IdentityResolutionService.cs`) + +1. Looks up `Identity` by `(EnvironmentId, Identifier)` — creates if not found +2. Upserts provided traits by key (updates value and type if key exists, inserts otherwise) +3. Returns the resolved `Identity` with current traits loaded + +### TraitInput (`MicCheck.Api/Identities/TraitInput.cs`) + +Record with `[JsonPropertyName]` attributes for snake_case API compatibility (`trait_key`, `trait_value`). Handles `JsonElement` deserialization from `System.Text.Json` to determine the correct `TraitValueType`. + +### Endpoints + +All require `FlagsApiAccess` policy (`X-Environment-Key` header). + +| Method | Path | Controller | +|---|---|---| +| GET | `/api/v1/flags/` | `FlagsController` | +| GET | `/api/v1/identities/?identifier=` | `IdentitiesController` | +| POST | `/api/v1/identities/` | `IdentitiesController` | +| GET | `/api/v1/environment-document/` | `EnvironmentDocumentController` | + +### Response Types + +- `FlagResponse(Id, Feature, Enabled, FeatureStateValue)` — static `From(FeatureStateResult)` factory +- `FeatureSummaryResponse(Id, Name, Type)` — type is uppercased (`STANDARD`, `MULTI_VARIATE`) +- `IdentityResponse(Traits, Flags)` +- `TraitResponse(TraitKey, TraitValue)` +- `EnvironmentDocumentResponse` — full environment config including project segments for local evaluation mode + +All response objects are constructed in controller action methods only; services never return or accept DTOs. + +--- + +## Deviations from Plan + +- `SegmentEvaluator.Evaluate` takes an optional `identifier` parameter (required for `PercentageSplit`) +- `FeatureEvaluationService.EvaluateForIdentityAsync` creates an `IdentityResolutionService` instance internally rather than injecting it (avoids double-inject of scoped service that's also used by the controller directly) +- **`FeatureSegmentConfiguration` bug fixed**: The existing configuration had `HasForeignKey(fsg => fsg.Id)` (FK on `FeatureSegment.Id` → `FeatureState.Id`, shared-PK pattern), which is incorrect for the data model. Changed to `HasForeignKey(fs => fs.FeatureSegmentId)` so `FeatureState.FeatureSegmentId` references `FeatureSegment.Id` as intended. + +--- + +## Tests Added + +### `SegmentEvaluatorTests` (27 tests) + +Covers all 17 operators plus rule type logic: +- Equal, NotEqual (case-insensitive) +- GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual +- Contains, NotContains (case-insensitive) +- Regex match/no-match +- IsSet/IsNotSet (trait existence) +- In/NotIn (comma-separated list) +- IsTrue, IsFalse +- ModuloValueDivisorRemainder satisfied/not satisfied +- PercentageSplit is deterministic for same input +- PercentageSplit at 100% includes all identities +- PercentageSplit at 0% includes no identities +- Rule type Any (at least one must match) +- Rule type None (none must match / one match → fail) +- Multiple top-level rules AND-ed together + +### `FeatureEvaluationServiceTests` (7 tests, in-memory DB) + +- Environment default returned when no overrides exist +- Disabled flag reflected correctly +- Identity with no overrides → environment default +- Identity override takes priority over environment default +- Segment override used when segment matches +- Environment default used when segment doesn't match +- Identity override takes priority over matching segment override + +### `FlagsApiIntegrationTests` (5 tests, WebApplicationFactory) + +- Valid environment key → 200 with correct flags +- Missing environment key → 401 +- Invalid environment key → 401 +- POST identities with traits → 200 +- GET environment document → 200 + +--- + +## Issues Encountered + +1. **`FeatureSegmentConfiguration` FK was backwards** — the stored configuration put the FK on `FeatureSegment.Id` (sharing the PK with `FeatureState`), which made it impossible to create a `FeatureSegment` before a matching `FeatureState` existed. Fixed to use `FeatureState.FeatureSegmentId` as the FK. + +2. **`TraitInput` JSON naming** — The plan's API spec uses `trait_key` / `trait_value` (snake_case). ASP.NET Core's default `System.Text.Json` serializer does case-insensitive camelCase matching but not snake_case. Added `[JsonPropertyName]` attributes to `TraitInput`. + +3. **Integration test seeding** — `EnvironmentDocumentController` loads the project from DB. The initial integration test seeded an environment but not a project, causing a 404. Fixed by seeding a project first. + +--- + +## Test Results + +``` +Passed! - Failed: 0, Passed: 142, Skipped: 0, Total: 142 +``` diff --git a/src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs b/src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs new file mode 100644 index 0000000..ca100f9 --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs @@ -0,0 +1,46 @@ +using MicCheck.Api.Authorization; + +namespace MicCheck.Api.ApiKeys; + +public static class ApiKeyEndpoints +{ + public static void MapApiKeyEndpoints(this WebApplication app) + { + var group = app + .MapGroup("/api/v1/organisations/{organizationId:int}/api-keys") + .RequireAuthorization(AuthorizationPolicies.OrganizationAdmin) + .WithTags("ApiKeys"); + + group.MapPost("/", async ( + int organizationId, + CreateApiKeyRequest request, + ApiKeyService apiKeyService, + CancellationToken ct) => + { + var (key, rawKey) = await apiKeyService.CreateAsync( + organizationId, request.Name, request.ExpiresAt, ct); + + return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt)); + }).WithName("CreateApiKey"); + + group.MapGet("/", async ( + int organizationId, + ApiKeyService apiKeyService, + CancellationToken ct) => + { + var keys = await apiKeyService.ListAsync(organizationId, ct); + return Results.Ok(keys.Select(k => + new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt))); + }).WithName("ListApiKeys"); + + group.MapDelete("/{keyId:int}", async ( + int organizationId, + int keyId, + ApiKeyService apiKeyService, + CancellationToken ct) => + { + await apiKeyService.RevokeAsync(organizationId, keyId, ct); + return Results.NoContent(); + }).WithName("RevokeApiKey"); + } +} diff --git a/src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs b/src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs new file mode 100644 index 0000000..63a7445 --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MicCheck.Api.ApiKeys; + +public static class ApiKeyHasher +{ + public static string Hash(string key) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public static string GenerateKey() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs b/src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs new file mode 100644 index 0000000..98e32d5 --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs @@ -0,0 +1,9 @@ +namespace MicCheck.Api.ApiKeys; + +public record ApiKeyResponse( + int Id, + string Name, + string Prefix, + bool IsActive, + DateTimeOffset? ExpiresAt, + DateTimeOffset CreatedAt); diff --git a/src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs b/src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs new file mode 100644 index 0000000..f697c5a --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs @@ -0,0 +1,50 @@ +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.ApiKeys; + +public class ApiKeyService(MicCheckDbContext db) +{ + public async Task<(ApiKey Key, string RawKey)> CreateAsync( + int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) + { + var rawKey = ApiKeyHasher.GenerateKey(); + var hashedKey = ApiKeyHasher.Hash(rawKey); + var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey; + + var apiKey = new ApiKey + { + Key = hashedKey, + Prefix = prefix, + Name = name, + OrganizationId = organizationId, + IsActive = true, + ExpiresAt = expiresAt, + CreatedAt = DateTimeOffset.UtcNow + }; + + db.ApiKeys.Add(apiKey); + await db.SaveChangesAsync(ct); + + return (apiKey, rawKey); + } + + public async Task> ListAsync(int organizationId, CancellationToken ct = default) + { + return await db.ApiKeys + .Where(k => k.OrganizationId == organizationId) + .ToListAsync(ct); + } + + public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default) + { + var key = await db.ApiKeys + .FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct); + + if (key is null) + return; + + key.IsActive = false; + await db.SaveChangesAsync(ct); + } +} diff --git a/src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs b/src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs new file mode 100644 index 0000000..f495617 --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.ApiKeys; + +public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt); diff --git a/src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs b/src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs new file mode 100644 index 0000000..615ede6 --- /dev/null +++ b/src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs @@ -0,0 +1,8 @@ +namespace MicCheck.Api.ApiKeys; + +public record CreateApiKeyResponse( + int Id, + string Name, + string Key, + string Prefix, + DateTimeOffset? ExpiresAt); diff --git a/src/api/MicCheck.Api/Auth/AuthEndpoints.cs b/src/api/MicCheck.Api/Auth/AuthEndpoints.cs index 319491f..2a14703 100644 --- a/src/api/MicCheck.Api/Auth/AuthEndpoints.cs +++ b/src/api/MicCheck.Api/Auth/AuthEndpoints.cs @@ -6,18 +6,61 @@ public static class AuthEndpoints { public static void MapAuthEndpoints(this WebApplication app) { - app.MapPost("/auth/token", ( - [FromBody] TokenRequest request, - ITokenService tokenService) => - { - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return Results.BadRequest("Username and password are required."); + var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth"); - var response = tokenService.GenerateToken(request.Username); - return Results.Ok(response); - }) - .WithName("GetToken") - .WithTags("Auth") - .AllowAnonymous(); + group.MapPost("/login", async ( + [FromBody] LoginRequest request, + AuthService authService, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return Results.BadRequest("Email and password are required."); + + var response = await authService.LoginAsync(request.Email, request.Password, ct); + return response is null + ? Results.Unauthorized() + : Results.Ok(response); + }).WithName("Login"); + + group.MapPost("/register", async ( + [FromBody] RegisterRequest request, + AuthService authService, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return Results.BadRequest("Email and password are required."); + + var response = await authService.RegisterAsync( + request.Email, request.Password, + request.FirstName, request.LastName, + request.OrganizationName, ct); + + return response is null + ? Results.Conflict("An account with this email already exists.") + : Results.Ok(response); + }).WithName("Register"); + + group.MapPost("/refresh", async ( + [FromBody] RefreshRequest request, + AuthService authService, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(request.Token)) + return Results.BadRequest("Refresh token is required."); + + var response = await authService.RefreshAsync(request.Token, ct); + return response is null + ? Results.Unauthorized() + : Results.Ok(response); + }).WithName("RefreshToken"); + + group.MapPost("/logout", async ( + [FromBody] LogoutRequest request, + AuthService authService, + CancellationToken ct) => + { + await authService.LogoutAsync(request.Token, ct); + return Results.NoContent(); + }).WithName("Logout"); } } diff --git a/src/api/MicCheck.Api/Auth/AuthService.cs b/src/api/MicCheck.Api/Auth/AuthService.cs new file mode 100644 index 0000000..983d038 --- /dev/null +++ b/src/api/MicCheck.Api/Auth/AuthService.cs @@ -0,0 +1,145 @@ +using System.Security.Cryptography; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Auth; + +public class AuthService( + MicCheckDbContext db, + ITokenService tokenService, + IPasswordHasher passwordHasher) +{ + public async Task LoginAsync(string email, string password, CancellationToken ct = default) + { + var user = await db.Users + .Include(u => u.Organizations) + .FirstOrDefaultAsync(u => u.Email == email && u.IsActive, ct); + + if (user is null) + return null; + + var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (result == PasswordVerificationResult.Failed) + return null; + + var accessToken = tokenService.GenerateToken(user); + var refreshTokenValue = GenerateSecureToken(); + + db.RefreshTokens.Add(new RefreshToken + { + Token = refreshTokenValue, + UserId = user.Id, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }); + + user.LastLoginAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt); + } + + public async Task RegisterAsync( + string email, string password, + string firstName, string lastName, + string organizationName, + CancellationToken ct = default) + { + if (await db.Users.AnyAsync(u => u.Email == email, ct)) + return null; + + var user = new User + { + Email = email, + FirstName = firstName, + LastName = lastName, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + PasswordHash = string.Empty + }; + user.PasswordHash = passwordHasher.HashPassword(user, password); + + db.Users.Add(user); + await db.SaveChangesAsync(ct); + + var organization = new Organization + { + Name = organizationName, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Organizations.Add(organization); + await db.SaveChangesAsync(ct); + + db.OrganizationUsers.Add(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Role = OrganizationRole.Admin + }); + await db.SaveChangesAsync(ct); + + await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct); + + var accessToken = tokenService.GenerateToken(user); + var refreshTokenValue = GenerateSecureToken(); + + db.RefreshTokens.Add(new RefreshToken + { + Token = refreshTokenValue, + UserId = user.Id, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(ct); + + return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt); + } + + public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) + { + var token = await db.RefreshTokens + .Include(rt => rt.User) + .ThenInclude(u => u.Organizations) + .FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct); + + if (token is null || token.ExpiresAt < DateTimeOffset.UtcNow) + return null; + + token.IsRevoked = true; + + var accessToken = tokenService.GenerateToken(token.User); + var newRefreshTokenValue = GenerateSecureToken(); + + db.RefreshTokens.Add(new RefreshToken + { + Token = newRefreshTokenValue, + UserId = token.UserId, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(ct); + + return new LoginResponse(accessToken.Token, newRefreshTokenValue, accessToken.ExpiresAt); + } + + public async Task LogoutAsync(string refreshToken, CancellationToken ct = default) + { + var token = await db.RefreshTokens + .FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct); + + if (token is null) + return; + + token.IsRevoked = true; + await db.SaveChangesAsync(ct); + } + + private static string GenerateSecureToken() + { + var bytes = RandomNumberGenerator.GetBytes(64); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/api/MicCheck.Api/Auth/ITokenService.cs b/src/api/MicCheck.Api/Auth/ITokenService.cs index 206b890..6c40fcc 100644 --- a/src/api/MicCheck.Api/Auth/ITokenService.cs +++ b/src/api/MicCheck.Api/Auth/ITokenService.cs @@ -1,6 +1,8 @@ +using MicCheck.Api.Users; + namespace MicCheck.Api.Auth; public interface ITokenService { - TokenResponse GenerateToken(string username); + TokenResponse GenerateToken(User user); } diff --git a/src/api/MicCheck.Api/Auth/LoginRequest.cs b/src/api/MicCheck.Api/Auth/LoginRequest.cs new file mode 100644 index 0000000..56d7ed5 --- /dev/null +++ b/src/api/MicCheck.Api/Auth/LoginRequest.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Auth; + +public record LoginRequest(string Email, string Password); diff --git a/src/api/MicCheck.Api/Auth/LoginResponse.cs b/src/api/MicCheck.Api/Auth/LoginResponse.cs new file mode 100644 index 0000000..6d2d43b --- /dev/null +++ b/src/api/MicCheck.Api/Auth/LoginResponse.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Auth; + +public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt); diff --git a/src/api/MicCheck.Api/Auth/LogoutRequest.cs b/src/api/MicCheck.Api/Auth/LogoutRequest.cs new file mode 100644 index 0000000..c1ab20a --- /dev/null +++ b/src/api/MicCheck.Api/Auth/LogoutRequest.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Auth; + +public record LogoutRequest(string Token); diff --git a/src/api/MicCheck.Api/Auth/RefreshRequest.cs b/src/api/MicCheck.Api/Auth/RefreshRequest.cs new file mode 100644 index 0000000..bfe4bbd --- /dev/null +++ b/src/api/MicCheck.Api/Auth/RefreshRequest.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Auth; + +public record RefreshRequest(string Token); diff --git a/src/api/MicCheck.Api/Auth/RegisterRequest.cs b/src/api/MicCheck.Api/Auth/RegisterRequest.cs new file mode 100644 index 0000000..e8886f4 --- /dev/null +++ b/src/api/MicCheck.Api/Auth/RegisterRequest.cs @@ -0,0 +1,8 @@ +namespace MicCheck.Api.Auth; + +public record RegisterRequest( + string Email, + string Password, + string FirstName, + string LastName, + string OrganizationName); diff --git a/src/api/MicCheck.Api/Auth/TokenService.cs b/src/api/MicCheck.Api/Auth/TokenService.cs index 6e89635..bbe883b 100644 --- a/src/api/MicCheck.Api/Auth/TokenService.cs +++ b/src/api/MicCheck.Api/Auth/TokenService.cs @@ -1,13 +1,14 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using MicCheck.Api.Users; using Microsoft.IdentityModel.Tokens; namespace MicCheck.Api.Auth; public class TokenService(IConfiguration configuration) : ITokenService { - public TokenResponse GenerateToken(string username) + public TokenResponse GenerateToken(User user) { var secretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); @@ -21,16 +22,24 @@ public class TokenService(IConfiguration configuration) : ITokenService var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); - var claims = new[] + var claims = new List { - new Claim(JwtRegisteredClaimNames.Sub, username), - new Claim(JwtRegisteredClaimNames.Name, username), - new Claim(JwtRegisteredClaimNames.Iat, + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email), + new(JwtRegisteredClaimNames.GivenName, user.FirstName), + new(JwtRegisteredClaimNames.FamilyName, user.LastName), + new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; + foreach (var org in user.Organizations) + { + claims.Add(new Claim("OrganizationId", org.OrganizationId.ToString())); + claims.Add(new Claim("OrganizationRole", org.Role.ToString())); + } + var token = new JwtSecurityToken( issuer: issuer, audience: audience, diff --git a/src/api/MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs b/src/api/MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..859bbe7 --- /dev/null +++ b/src/api/MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using MicCheck.Api.ApiKeys; +using MicCheck.Api.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MicCheck.Api.Authentication; + +public class ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + MicCheckDbContext db) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "ApiKey"; + private const string ApiKeyPrefix = "Api-Key "; + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) + return AuthenticateResult.NoResult(); + + var headerValue = authHeader.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(headerValue) || + !headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + var rawKey = headerValue[ApiKeyPrefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(rawKey)) + return AuthenticateResult.Fail("API key value is empty."); + + var hashedKey = ApiKeyHasher.Hash(rawKey); + + var apiKey = await db.ApiKeys + .FirstOrDefaultAsync(k => k.Key == hashedKey); + + if (apiKey is null) + return AuthenticateResult.Fail("Invalid API key."); + + if (!apiKey.IsActive) + return AuthenticateResult.Fail("API key is inactive."); + + if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow) + return AuthenticateResult.Fail("API key has expired."); + + var claims = new[] + { + new Claim("OrganizationId", apiKey.OrganizationId.ToString()), + new Claim("OrganizationRole", "Admin") + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/src/api/MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs b/src/api/MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs new file mode 100644 index 0000000..42cfe0f --- /dev/null +++ b/src/api/MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using MicCheck.Api.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MicCheck.Api.Authentication; + +public class EnvironmentKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + MicCheckDbContext db) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "EnvironmentKey"; + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues)) + return AuthenticateResult.NoResult(); + + var key = keyValues.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(key)) + return AuthenticateResult.Fail("X-Environment-Key header is empty."); + + var environment = await db.Environments + .FirstOrDefaultAsync(e => e.ApiKey == key); + + if (environment is null) + return AuthenticateResult.Fail("Invalid environment key."); + + var claims = new[] + { + new Claim("EnvironmentId", environment.Id.ToString()), + new Claim("ProjectId", environment.ProjectId.ToString()) + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/src/api/MicCheck.Api/Authorization/AuthorizationPolicies.cs b/src/api/MicCheck.Api/Authorization/AuthorizationPolicies.cs new file mode 100644 index 0000000..005befd --- /dev/null +++ b/src/api/MicCheck.Api/Authorization/AuthorizationPolicies.cs @@ -0,0 +1,8 @@ +namespace MicCheck.Api.Authorization; + +public static class AuthorizationPolicies +{ + public const string FlagsApiAccess = "FlagsApiAccess"; + public const string AdminApiAccess = "AdminApiAccess"; + public const string OrganizationAdmin = "OrganizationAdmin"; +} diff --git a/src/api/MicCheck.Api/Authorization/ProjectPermission.cs b/src/api/MicCheck.Api/Authorization/ProjectPermission.cs new file mode 100644 index 0000000..83d795b --- /dev/null +++ b/src/api/MicCheck.Api/Authorization/ProjectPermission.cs @@ -0,0 +1,17 @@ +namespace MicCheck.Api.Authorization; + +public enum ProjectPermission +{ + ViewProject, + CreateFeature, + EditFeature, + DeleteFeature, + CreateEnvironment, + EditEnvironment, + DeleteEnvironment, + CreateSegment, + EditSegment, + DeleteSegment, + ManageWebhooks, + ViewAuditLog +} diff --git a/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirement.cs b/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirement.cs new file mode 100644 index 0000000..af864db --- /dev/null +++ b/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirement.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace MicCheck.Api.Authorization; + +public class ProjectPermissionRequirement : IAuthorizationRequirement +{ + public ProjectPermission Permission { get; } + + public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission; +} diff --git a/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs b/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs new file mode 100644 index 0000000..c550931 --- /dev/null +++ b/src/api/MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs @@ -0,0 +1,54 @@ +using System.IdentityModel.Tokens.Jwt; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Authorization; + +public class ProjectPermissionRequirementHandler( + MicCheckDbContext db, + IHttpContextAccessor httpContextAccessor) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + ProjectPermissionRequirement requirement) + { + var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub); + if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId)) + { + context.Fail(); + return; + } + + if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString())) + { + context.Succeed(requirement); + return; + } + + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is null || + !httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) || + !int.TryParse(projectIdValue?.ToString(), out var projectId)) + { + context.Fail(); + return; + } + + var permission = await db.UserProjectPermissions + .FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId); + + if (permission is null) + { + context.Fail(); + return; + } + + if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission)) + context.Succeed(requirement); + else + context.Fail(); + } +} diff --git a/src/api/MicCheck.Api/Authorization/UserProjectPermission.cs b/src/api/MicCheck.Api/Authorization/UserProjectPermission.cs new file mode 100644 index 0000000..34001bd --- /dev/null +++ b/src/api/MicCheck.Api/Authorization/UserProjectPermission.cs @@ -0,0 +1,9 @@ +namespace MicCheck.Api.Authorization; + +public class UserProjectPermission +{ + public int UserId { get; init; } + public int ProjectId { get; init; } + public List Permissions { get; set; } = []; + public bool IsAdmin { get; set; } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/ApiKeyConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/ApiKeyConfiguration.cs new file mode 100644 index 0000000..c674ed8 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/ApiKeyConfiguration.cs @@ -0,0 +1,19 @@ +using MicCheck.Api.ApiKeys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class ApiKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(k => k.Id); + builder.Property(k => k.Key).HasMaxLength(512).IsRequired(); + builder.Property(k => k.Prefix).HasMaxLength(8).IsRequired(); + builder.Property(k => k.Name).HasMaxLength(200).IsRequired(); + builder.Property(k => k.CreatedAt).IsRequired(); + + builder.HasIndex(k => k.Key).IsUnique(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/AuditLogConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/AuditLogConfiguration.cs new file mode 100644 index 0000000..e105358 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/AuditLogConfiguration.cs @@ -0,0 +1,19 @@ +using MicCheck.Api.Audit; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class AuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + builder.Property(a => a.ResourceType).HasMaxLength(100).IsRequired(); + builder.Property(a => a.ResourceId).HasMaxLength(100).IsRequired(); + builder.Property(a => a.Action).HasMaxLength(50).IsRequired(); + builder.Property(a => a.CreatedAt).IsRequired(); + + builder.HasIndex(a => new { a.OrganizationId, a.CreatedAt }); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/EnvironmentConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/EnvironmentConfiguration.cs new file mode 100644 index 0000000..0581219 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/EnvironmentConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Data.Configurations; + +public class EnvironmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Name).HasMaxLength(200).IsRequired(); + builder.Property(e => e.ApiKey).HasMaxLength(100).IsRequired(); + builder.Property(e => e.CreatedAt).IsRequired(); + + builder.HasIndex(e => e.ApiKey).IsUnique(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs new file mode 100644 index 0000000..2e5e101 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs @@ -0,0 +1,29 @@ +using MicCheck.Api.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class FeatureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(f => f.Id); + builder.Property(f => f.Name).HasMaxLength(150).IsRequired(); + builder.Property(f => f.InitialValue).HasMaxLength(20_000); + builder.Property(f => f.Description).HasMaxLength(2_000); + builder.Property(f => f.CreatedAt).IsRequired(); + + builder.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique(); + + builder.HasMany(f => f.FeatureStates) + .WithOne() + .HasForeignKey(fs => fs.FeatureId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(f => f.Tags) + .WithOne() + .HasForeignKey(t => t.ProjectId) + .OnDelete(DeleteBehavior.NoAction); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureSegmentConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureSegmentConfiguration.cs new file mode 100644 index 0000000..14d8fb5 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureSegmentConfiguration.cs @@ -0,0 +1,18 @@ +using MicCheck.Api.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class FeatureSegmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(fs => fs.Id); + + builder.HasOne(fsg => fsg.FeatureState) + .WithOne() + .HasForeignKey(fs => fs.FeatureSegmentId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureStateConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureStateConfiguration.cs new file mode 100644 index 0000000..a67ef10 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureStateConfiguration.cs @@ -0,0 +1,21 @@ +using MicCheck.Api.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class FeatureStateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(fs => fs.Id); + builder.Property(fs => fs.Value).HasMaxLength(20_000); + builder.Property(fs => fs.CreatedAt).IsRequired(); + builder.Property(fs => fs.UpdatedAt).IsRequired(); + builder.Property(fs => fs.Version).IsConcurrencyToken(); + + builder.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique(); + builder.HasIndex(fs => fs.EnvironmentId); + builder.HasIndex(fs => fs.IdentityId); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/IdentityConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/IdentityConfiguration.cs new file mode 100644 index 0000000..396e904 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/IdentityConfiguration.cs @@ -0,0 +1,27 @@ +using MicCheck.Api.Identities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class IdentityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(i => i.Id); + builder.Property(i => i.Identifier).HasMaxLength(2_000).IsRequired(); + builder.Property(i => i.CreatedAt).IsRequired(); + + builder.HasIndex(i => new { i.EnvironmentId, i.Identifier }).IsUnique(); + + builder.HasMany(i => i.Traits) + .WithOne() + .HasForeignKey(t => t.IdentityId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(i => i.FeatureStateOverrides) + .WithOne() + .HasForeignKey(fs => fs.IdentityId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/IdentityTraitConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/IdentityTraitConfiguration.cs new file mode 100644 index 0000000..12045b5 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/IdentityTraitConfiguration.cs @@ -0,0 +1,17 @@ +using MicCheck.Api.Identities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class IdentityTraitConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Key).HasMaxLength(200).IsRequired(); + builder.Property(t => t.Value).HasMaxLength(2_000).IsRequired(); + + builder.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/OrganizationConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/OrganizationConfiguration.cs new file mode 100644 index 0000000..0348cdb --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/OrganizationConfiguration.cs @@ -0,0 +1,30 @@ +using MicCheck.Api.Organizations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class OrganizationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.Name).HasMaxLength(200).IsRequired(); + builder.Property(o => o.CreatedAt).IsRequired(); + + builder.HasMany(o => o.Members) + .WithOne() + .HasForeignKey(ou => ou.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(o => o.ApiKeys) + .WithOne() + .HasForeignKey(k => k.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(o => o.Projects) + .WithOne() + .HasForeignKey(p => p.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/OrganizationUserConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/OrganizationUserConfiguration.cs new file mode 100644 index 0000000..139525a --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/OrganizationUserConfiguration.cs @@ -0,0 +1,14 @@ +using MicCheck.Api.Organizations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class OrganizationUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(ou => new { ou.OrganizationId, ou.UserId }); + builder.Property(ou => ou.Role).IsRequired(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/ProjectConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/ProjectConfiguration.cs new file mode 100644 index 0000000..6f336da --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/ProjectConfiguration.cs @@ -0,0 +1,15 @@ +using MicCheck.Api.Projects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class ProjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.Id); + builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); + builder.Property(p => p.CreatedAt).IsRequired(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/RefreshTokenConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..225d1a0 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,24 @@ +using MicCheck.Api.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(rt => rt.Id); + builder.Property(rt => rt.Token).HasMaxLength(512).IsRequired(); + builder.Property(rt => rt.ExpiresAt).IsRequired(); + builder.Property(rt => rt.CreatedAt).IsRequired(); + + builder.HasIndex(rt => rt.Token).IsUnique(); + builder.HasIndex(rt => rt.UserId); + + builder.HasOne(rt => rt.User) + .WithMany() + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/SegmentConditionConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/SegmentConditionConfiguration.cs new file mode 100644 index 0000000..7fe8228 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/SegmentConditionConfiguration.cs @@ -0,0 +1,15 @@ +using MicCheck.Api.Segments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class SegmentConditionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Property).HasMaxLength(200).IsRequired(); + builder.Property(c => c.Value).HasMaxLength(1_000).IsRequired(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/SegmentConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/SegmentConfiguration.cs new file mode 100644 index 0000000..512ec3b --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/SegmentConfiguration.cs @@ -0,0 +1,20 @@ +using MicCheck.Api.Segments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class SegmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Name).HasMaxLength(200).IsRequired(); + builder.Property(s => s.CreatedAt).IsRequired(); + + builder.HasMany(s => s.Rules) + .WithOne() + .HasForeignKey(r => r.SegmentId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/SegmentRuleConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/SegmentRuleConfiguration.cs new file mode 100644 index 0000000..ff89f14 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/SegmentRuleConfiguration.cs @@ -0,0 +1,23 @@ +using MicCheck.Api.Segments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class SegmentRuleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.Id); + + builder.HasMany(r => r.Conditions) + .WithOne() + .HasForeignKey(c => c.RuleId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(r => r.ChildRules) + .WithOne() + .HasForeignKey(r => r.ParentRuleId) + .OnDelete(DeleteBehavior.NoAction); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs new file mode 100644 index 0000000..17aeebf --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs @@ -0,0 +1,15 @@ +using MicCheck.Api.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class TagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Label).HasMaxLength(200).IsRequired(); + builder.Property(t => t.Color).HasMaxLength(20).IsRequired(); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/UserConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..b273abb --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/UserConfiguration.cs @@ -0,0 +1,25 @@ +using MicCheck.Api.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(u => u.Id); + builder.Property(u => u.Email).HasMaxLength(500).IsRequired(); + builder.Property(u => u.PasswordHash).IsRequired(); + builder.Property(u => u.FirstName).HasMaxLength(200).IsRequired(); + builder.Property(u => u.LastName).HasMaxLength(200).IsRequired(); + builder.Property(u => u.CreatedAt).IsRequired(); + + builder.HasIndex(u => u.Email).IsUnique(); + + builder.HasMany(u => u.Organizations) + .WithOne() + .HasForeignKey(ou => ou.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/UserProjectPermissionConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/UserProjectPermissionConfiguration.cs new file mode 100644 index 0000000..4722390 --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/UserProjectPermissionConfiguration.cs @@ -0,0 +1,22 @@ +using MicCheck.Api.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class UserProjectPermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => new { p.UserId, p.ProjectId }); + + builder.Property(p => p.Permissions) + .HasConversion( + v => string.Join(',', v.Select(p => p.ToString())), + v => string.IsNullOrEmpty(v) + ? new List() + : v.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(Enum.Parse) + .ToList()); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/WebhookConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/WebhookConfiguration.cs new file mode 100644 index 0000000..1c06d2c --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/WebhookConfiguration.cs @@ -0,0 +1,18 @@ +using MicCheck.Api.Webhooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class WebhookConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(w => w.Id); + builder.Property(w => w.Url).HasMaxLength(500).IsRequired(); + builder.Property(w => w.Secret).HasMaxLength(200); + builder.Property(w => w.CreatedAt).IsRequired(); + + builder.HasIndex(w => w.EnvironmentId); + } +} diff --git a/src/api/MicCheck.Api/Data/Configurations/WebhookDeliveryLogConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/WebhookDeliveryLogConfiguration.cs new file mode 100644 index 0000000..1c3913b --- /dev/null +++ b/src/api/MicCheck.Api/Data/Configurations/WebhookDeliveryLogConfiguration.cs @@ -0,0 +1,17 @@ +using MicCheck.Api.Webhooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MicCheck.Api.Data.Configurations; + +public class WebhookDeliveryLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(d => d.Id); + builder.Property(d => d.EventType).HasMaxLength(100).IsRequired(); + builder.Property(d => d.AttemptedAt).IsRequired(); + + builder.HasIndex(d => d.WebhookId); + } +} diff --git a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs new file mode 100644 index 0000000..36a5d29 --- /dev/null +++ b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs @@ -0,0 +1,53 @@ +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using Microsoft.EntityFrameworkCore; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Data; + +public class DatabaseSeeder +{ + private readonly MicCheckDbContext _db; + + public DatabaseSeeder(MicCheckDbContext db) + { + _db = db; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + if (await _db.Organizations.AnyAsync(ct)) + return; + + var organization = new Organization + { + Name = "Default", + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Organizations.Add(organization); + await _db.SaveChangesAsync(ct); + + var project = new Project + { + Name = "My Project", + OrganizationId = organization.Id, + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Projects.Add(project); + await _db.SaveChangesAsync(ct); + + var environmentNames = new[] { "Development", "Staging", "Production" }; + foreach (var name in environmentNames) + { + _db.Environments.Add(new AppEnvironment + { + Name = name, + ApiKey = $"env-{Guid.NewGuid():N}", + ProjectId = project.Id, + CreatedAt = DateTimeOffset.UtcNow + }); + } + + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/api/MicCheck.Api/Data/DatabaseUrlParser.cs b/src/api/MicCheck.Api/Data/DatabaseUrlParser.cs new file mode 100644 index 0000000..794422e --- /dev/null +++ b/src/api/MicCheck.Api/Data/DatabaseUrlParser.cs @@ -0,0 +1,21 @@ +namespace MicCheck.Api.Data; + +public static class DatabaseUrlParser +{ + /// + /// Converts a DATABASE_URL in postgresql://user:password@host:port/dbname format + /// to an Npgsql connection string. + /// + public static string ToNpgsqlConnectionString(string databaseUrl) + { + var uri = new Uri(databaseUrl); + var userInfo = uri.UserInfo.Split(':'); + var host = uri.Host; + var port = uri.Port > 0 ? uri.Port : 5432; + var database = uri.AbsolutePath.TrimStart('/'); + var username = userInfo.Length > 0 ? userInfo[0] : string.Empty; + var password = userInfo.Length > 1 ? userInfo[1] : string.Empty; + + return $"Host={host};Port={port};Database={database};Username={username};Password={password}"; + } +} diff --git a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs new file mode 100644 index 0000000..13f4291 --- /dev/null +++ b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs @@ -0,0 +1,43 @@ +using MicCheck.Api.ApiKeys; +using MicCheck.Api.Audit; +using MicCheck.Api.Authorization; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Users; +using MicCheck.Api.Webhooks; +using Microsoft.EntityFrameworkCore; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Data; + +public class MicCheckDbContext : DbContext +{ + public MicCheckDbContext(DbContextOptions options) : base(options) { } + + public DbSet Organizations => Set(); + public DbSet OrganizationUsers => Set(); + public DbSet Projects => Set(); + public DbSet Environments => Set(); + public DbSet Features => Set(); + public DbSet FeatureStates => Set(); + public DbSet FeatureSegments => Set(); + public DbSet Tags => Set(); + public DbSet Segments => Set(); + public DbSet SegmentRules => Set(); + public DbSet SegmentConditions => Set(); + public DbSet Identities => Set(); + public DbSet IdentityTraits => Set(); + public DbSet AuditLogs => Set(); + public DbSet Webhooks => Set(); + public DbSet WebhookDeliveryLogs => Set(); + public DbSet ApiKeys => Set(); + public DbSet Users => Set(); + public DbSet RefreshTokens => Set(); + public DbSet UserProjectPermissions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly); +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentDocumentController.cs b/src/api/MicCheck.Api/Environments/EnvironmentDocumentController.cs new file mode 100644 index 0000000..2522935 --- /dev/null +++ b/src/api/MicCheck.Api/Environments/EnvironmentDocumentController.cs @@ -0,0 +1,20 @@ +using MicCheck.Api.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MicCheck.Api.Environments; + +[ApiController] +[Route("api/v1/environment-document")] +[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)] +public class EnvironmentDocumentController(EnvironmentDocumentService environmentDocumentService) : ControllerBase +{ + [HttpGet] + public async Task> Get(CancellationToken ct) + { + var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value); + var document = await environmentDocumentService.GetAsync(environmentId, ct); + + return document is null ? NotFound() : Ok(document); + } +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentDocumentResponse.cs b/src/api/MicCheck.Api/Environments/EnvironmentDocumentResponse.cs new file mode 100644 index 0000000..77c4278 --- /dev/null +++ b/src/api/MicCheck.Api/Environments/EnvironmentDocumentResponse.cs @@ -0,0 +1,50 @@ +using MicCheck.Api.Features; +using MicCheck.Api.Segments; + +namespace MicCheck.Api.Environments; + +public record EnvironmentDocumentResponse( + int Id, + string ApiKey, + IReadOnlyList FeatureStates, + EnvironmentProjectResponse Project); + +public record EnvironmentProjectResponse( + int Id, + string Name, + IReadOnlyList Segments); + +public record SegmentDocumentResponse( + int Id, + string Name, + IReadOnlyList Rules) +{ + public static SegmentDocumentResponse From(Segment segment) => new( + segment.Id, + segment.Name, + segment.Rules + .Where(r => r.ParentRuleId == null) + .Select(SegmentRuleDocumentResponse.From) + .ToList()); +} + +public record SegmentRuleDocumentResponse( + int Id, + string Type, + IReadOnlyList Conditions, + IReadOnlyList Rules) +{ + public static SegmentRuleDocumentResponse From(SegmentRule rule) => new( + rule.Id, + rule.Type.ToString().ToUpperInvariant(), + rule.Conditions.Select(SegmentConditionDocumentResponse.From).ToList(), + rule.ChildRules.Select(From).ToList()); +} + +public record SegmentConditionDocumentResponse(string Property, string Operator, string Value) +{ + public static SegmentConditionDocumentResponse From(SegmentCondition condition) => new( + condition.Property, + condition.Operator.ToString(), + condition.Value); +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs new file mode 100644 index 0000000..8c69cb0 --- /dev/null +++ b/src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs @@ -0,0 +1,54 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using Microsoft.EntityFrameworkCore; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Environments; + +public class EnvironmentDocumentService(MicCheckDbContext db) +{ + public async Task GetAsync(int environmentId, CancellationToken ct = default) + { + var environment = await db.Environments + .FindAsync([environmentId], ct); + + if (environment is null) + return null; + + var featureStateResults = await ( + from fs in db.FeatureStates + join f in db.Features on fs.FeatureId equals f.Id + where fs.EnvironmentId == environmentId + && fs.IdentityId == null + && fs.FeatureSegmentId == null + select new FeatureStateResult + { + Feature = f, + Enabled = fs.Enabled, + Value = fs.Value + } + ).ToListAsync(ct); + + var project = await db.Projects + .Include(p => p.Segments) + .ThenInclude(s => s.Rules) + .ThenInclude(r => r.Conditions) + .Include(p => p.Segments) + .ThenInclude(s => s.Rules) + .ThenInclude(r => r.ChildRules) + .ThenInclude(cr => cr.Conditions) + .FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct); + + if (project is null) + return null; + + return new EnvironmentDocumentResponse( + environment.Id, + environment.ApiKey, + featureStateResults.Select(FlagResponse.From).ToList(), + new EnvironmentProjectResponse( + project.Id, + project.Name, + project.Segments.Select(SegmentDocumentResponse.From).ToList())); + } +} diff --git a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs new file mode 100644 index 0000000..50b05a8 --- /dev/null +++ b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs @@ -0,0 +1,139 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Identities; +using MicCheck.Api.Segments; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Features; + +public class FeatureEvaluationService( + MicCheckDbContext db, + SegmentEvaluator segmentEvaluator, + FlagCache flagCache) +{ + public async Task> EvaluateForEnvironmentAsync( + int environmentId, CancellationToken ct = default) + { + var cached = flagCache.Get(environmentId); + if (cached is not null) + return cached; + + var results = await ( + from fs in db.FeatureStates + join f in db.Features on fs.FeatureId equals f.Id + where fs.EnvironmentId == environmentId + && fs.IdentityId == null + && fs.FeatureSegmentId == null + select new FeatureStateResult + { + Feature = f, + Enabled = fs.Enabled, + Value = fs.Value + } + ).ToListAsync(ct); + + flagCache.Set(environmentId, results); + return results; + } + + public async Task> EvaluateForIdentityAsync( + int environmentId, + string identifier, + IReadOnlyList? traits = null, + CancellationToken ct = default) + { + var environment = await db.Environments.FindAsync([environmentId], ct); + if (environment is null) + return []; + + var identity = await new IdentityResolutionService(db) + .ResolveAsync(environmentId, identifier, traits, ct); + + var features = await db.Features + .Where(f => f.ProjectId == environment.ProjectId) + .ToListAsync(ct); + + var featureIds = features.Select(f => f.Id).ToList(); + + var allFeatureStates = await db.FeatureStates + .Where(fs => fs.EnvironmentId == environmentId && featureIds.Contains(fs.FeatureId)) + .ToListAsync(ct); + + var featureSegments = await db.FeatureSegments + .Where(fsg => fsg.EnvironmentId == environmentId && featureIds.Contains(fsg.FeatureId)) + .OrderBy(fsg => fsg.Priority) + .ToListAsync(ct); + + var segmentIds = featureSegments.Select(fsg => fsg.SegmentId).Distinct().ToList(); + var segments = await db.Segments + .Include(s => s.Rules) + .ThenInclude(r => r.Conditions) + .Include(s => s.Rules) + .ThenInclude(r => r.ChildRules) + .ThenInclude(cr => cr.Conditions) + .Where(s => segmentIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, ct); + + var identityTraits = identity.Traits.ToList(); + var results = new List(); + + foreach (var feature in features) + { + // 1. Identity-level override + var identityOverride = allFeatureStates.FirstOrDefault( + fs => fs.FeatureId == feature.Id && fs.IdentityId == identity.Id); + if (identityOverride is not null) + { + results.Add(new FeatureStateResult + { + Feature = feature, + Enabled = identityOverride.Enabled, + Value = identityOverride.Value + }); + continue; + } + + // 2. Segment overrides (highest priority segment that matches) + var segmentOverrideFound = false; + foreach (var fsg in featureSegments.Where(fsg => fsg.FeatureId == feature.Id)) + { + if (!segments.TryGetValue(fsg.SegmentId, out var segment)) + continue; + if (!segmentEvaluator.Evaluate(segment, identityTraits, identity.Identifier)) + continue; + + var segmentState = allFeatureStates.FirstOrDefault( + fs => fs.FeatureId == feature.Id && fs.FeatureSegmentId == fsg.Id); + if (segmentState is null) + continue; + + results.Add(new FeatureStateResult + { + Feature = feature, + Enabled = segmentState.Enabled, + Value = segmentState.Value + }); + segmentOverrideFound = true; + break; + } + if (segmentOverrideFound) + continue; + + // 3. Environment default + var envDefault = allFeatureStates.FirstOrDefault( + fs => fs.FeatureId == feature.Id + && fs.IdentityId == null + && fs.FeatureSegmentId == null); + if (envDefault is not null) + { + results.Add(new FeatureStateResult + { + Feature = feature, + Enabled = envDefault.Enabled, + Value = envDefault.Value + }); + } + } + + return results; + } +} diff --git a/src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs b/src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs new file mode 100644 index 0000000..c90b19e --- /dev/null +++ b/src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs @@ -0,0 +1,7 @@ +namespace MicCheck.Api.Features; + +public record FeatureSummaryResponse(int Id, string Name, string Type) +{ + public static FeatureSummaryResponse From(Feature feature) => + new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant()); +} diff --git a/src/api/MicCheck.Api/Features/FlagCache.cs b/src/api/MicCheck.Api/Features/FlagCache.cs new file mode 100644 index 0000000..536c9a5 --- /dev/null +++ b/src/api/MicCheck.Api/Features/FlagCache.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace MicCheck.Api.Features; + +public class FlagCache(IMemoryCache cache) +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); + + public IReadOnlyList? Get(int environmentId) + { + cache.TryGetValue(CacheKey(environmentId), out IReadOnlyList? flags); + return flags; + } + + public void Set(int environmentId, IReadOnlyList flags) + => cache.Set(CacheKey(environmentId), flags, CacheDuration); + + public void Invalidate(int environmentId) + => cache.Remove(CacheKey(environmentId)); + + private static string CacheKey(int environmentId) => $"flags:{environmentId}"; +} diff --git a/src/api/MicCheck.Api/Features/FlagResponse.cs b/src/api/MicCheck.Api/Features/FlagResponse.cs new file mode 100644 index 0000000..06d6c6a --- /dev/null +++ b/src/api/MicCheck.Api/Features/FlagResponse.cs @@ -0,0 +1,14 @@ +namespace MicCheck.Api.Features; + +public record FlagResponse( + int Id, + FeatureSummaryResponse Feature, + bool Enabled, + string? FeatureStateValue) +{ + public static FlagResponse From(FeatureStateResult result) => new( + result.Feature.Id, + FeatureSummaryResponse.From(result.Feature), + result.Enabled, + result.Value); +} diff --git a/src/api/MicCheck.Api/Features/FlagsController.cs b/src/api/MicCheck.Api/Features/FlagsController.cs new file mode 100644 index 0000000..303aecf --- /dev/null +++ b/src/api/MicCheck.Api/Features/FlagsController.cs @@ -0,0 +1,19 @@ +using MicCheck.Api.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MicCheck.Api.Features; + +[ApiController] +[Route("api/v1/flags")] +[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)] +public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken ct) + { + var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value); + var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct); + return Ok(results.Select(FlagResponse.From).ToList()); + } +} diff --git a/src/api/MicCheck.Api/Identities/IdentitiesController.cs b/src/api/MicCheck.Api/Identities/IdentitiesController.cs new file mode 100644 index 0000000..08b0534 --- /dev/null +++ b/src/api/MicCheck.Api/Identities/IdentitiesController.cs @@ -0,0 +1,47 @@ +using MicCheck.Api.Authorization; +using MicCheck.Api.Features; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MicCheck.Api.Identities; + +[ApiController] +[Route("api/v1/identities")] +[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)] +public class IdentitiesController( + FeatureEvaluationService featureEvaluationService, + IdentityResolutionService identityResolutionService) : ControllerBase +{ + [HttpGet] + public async Task> GetByIdentifier( + [FromQuery] string identifier, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(identifier)) + return BadRequest("identifier query parameter is required."); + + var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value!); + + var identity = await identityResolutionService.ResolveAsync(environmentId, identifier, null, ct); + var flags = await featureEvaluationService.EvaluateForIdentityAsync(environmentId, identifier, null, ct); + + return Ok(new IdentityResponse( + identity.Traits.Select(TraitResponse.From).ToList(), + flags.Select(FlagResponse.From).ToList())); + } + + [HttpPost] + public async Task> Identify( + [FromBody] IdentityRequest request, CancellationToken ct) + { + var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value!); + + var identity = await identityResolutionService.ResolveAsync( + environmentId, request.Identifier, request.Traits, ct); + var flags = await featureEvaluationService.EvaluateForIdentityAsync( + environmentId, request.Identifier, request.Traits, ct); + + return Ok(new IdentityResponse( + identity.Traits.Select(TraitResponse.From).ToList(), + flags.Select(FlagResponse.From).ToList())); + } +} diff --git a/src/api/MicCheck.Api/Identities/IdentityRequest.cs b/src/api/MicCheck.Api/Identities/IdentityRequest.cs new file mode 100644 index 0000000..4fb11d6 --- /dev/null +++ b/src/api/MicCheck.Api/Identities/IdentityRequest.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Identities; + +public record IdentityRequest(string Identifier, IReadOnlyList? Traits); diff --git a/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs b/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs new file mode 100644 index 0000000..5a324bb --- /dev/null +++ b/src/api/MicCheck.Api/Identities/IdentityResolutionService.cs @@ -0,0 +1,58 @@ +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Identities; + +public class IdentityResolutionService(MicCheckDbContext db) +{ + public async Task ResolveAsync( + int environmentId, + string identifier, + IReadOnlyList? traits, + CancellationToken ct = default) + { + var identity = await db.Identities + .Include(i => i.Traits) + .FirstOrDefaultAsync(i => i.EnvironmentId == environmentId && i.Identifier == identifier, ct); + + if (identity is null) + { + identity = new Identity + { + Identifier = identifier, + EnvironmentId = environmentId, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Identities.Add(identity); + await db.SaveChangesAsync(ct); + } + + if (traits is { Count: > 0 }) + { + foreach (var traitInput in traits) + { + var existing = identity.Traits.FirstOrDefault(t => t.Key == traitInput.TraitKey); + if (existing is not null) + { + existing.Value = traitInput.GetStringValue(); + existing.ValueType = traitInput.GetValueType(); + } + else + { + var newTrait = new IdentityTrait + { + IdentityId = identity.Id, + Key = traitInput.TraitKey, + Value = traitInput.GetStringValue(), + ValueType = traitInput.GetValueType() + }; + db.IdentityTraits.Add(newTrait); + identity.Traits.Add(newTrait); + } + } + await db.SaveChangesAsync(ct); + } + + return identity; + } +} diff --git a/src/api/MicCheck.Api/Identities/IdentityResponse.cs b/src/api/MicCheck.Api/Identities/IdentityResponse.cs new file mode 100644 index 0000000..7f180a3 --- /dev/null +++ b/src/api/MicCheck.Api/Identities/IdentityResponse.cs @@ -0,0 +1,7 @@ +using MicCheck.Api.Features; + +namespace MicCheck.Api.Identities; + +public record IdentityResponse( + IReadOnlyList Traits, + IReadOnlyList Flags); diff --git a/src/api/MicCheck.Api/Identities/TraitInput.cs b/src/api/MicCheck.Api/Identities/TraitInput.cs new file mode 100644 index 0000000..48306e3 --- /dev/null +++ b/src/api/MicCheck.Api/Identities/TraitInput.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MicCheck.Api.Identities; + +public record TraitInput( + [property: JsonPropertyName("trait_key")] string TraitKey, + [property: JsonPropertyName("trait_value")] object? TraitValue) +{ + public string GetStringValue() => TraitValue switch + { + JsonElement el when el.ValueKind == JsonValueKind.String => el.GetString() ?? string.Empty, + JsonElement el when el.ValueKind == JsonValueKind.True => "true", + JsonElement el when el.ValueKind == JsonValueKind.False => "false", + JsonElement el when el.ValueKind == JsonValueKind.Null => string.Empty, + JsonElement el => el.ToString(), + null => string.Empty, + _ => TraitValue.ToString() ?? string.Empty + }; + + public TraitValueType GetValueType() => TraitValue switch + { + JsonElement el when el.ValueKind is JsonValueKind.True or JsonValueKind.False => TraitValueType.Boolean, + JsonElement el when el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out _) => TraitValueType.Integer, + JsonElement el when el.ValueKind == JsonValueKind.Number => TraitValueType.Float, + bool => TraitValueType.Boolean, + int or long => TraitValueType.Integer, + float or double or decimal => TraitValueType.Float, + _ => TraitValueType.String + }; +} diff --git a/src/api/MicCheck.Api/Identities/TraitResponse.cs b/src/api/MicCheck.Api/Identities/TraitResponse.cs new file mode 100644 index 0000000..5591367 --- /dev/null +++ b/src/api/MicCheck.Api/Identities/TraitResponse.cs @@ -0,0 +1,7 @@ +namespace MicCheck.Api.Identities; + +public record TraitResponse(string TraitKey, string TraitValue) +{ + public static TraitResponse From(IdentityTrait trait) => + new(trait.Key, trait.Value); +} diff --git a/src/api/MicCheck.Api/MicCheck.Api.csproj b/src/api/MicCheck.Api/MicCheck.Api.csproj index 73bf324..47cada6 100644 --- a/src/api/MicCheck.Api/MicCheck.Api.csproj +++ b/src/api/MicCheck.Api/MicCheck.Api.csproj @@ -12,6 +12,12 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index cc30d84..de98b10 100644 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -2,10 +2,23 @@ using System.Text; using System.Threading.RateLimiting; using FluentValidation; using FluentValidation.AspNetCore; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.IdentityModel.Tokens; +using MicCheck.Api.ApiKeys; using MicCheck.Api.Auth; +using MicCheck.Api.Authentication; +using MicCheck.Api.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Segments; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; using Serilog; @@ -25,8 +38,12 @@ try builder.Services.AddOpenApi(); builder.Services.AddControllers(); - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + builder.Services.AddAuthentication() + .AddScheme( + EnvironmentKeyAuthenticationHandler.SchemeName, _ => { }) + .AddScheme( + ApiKeyAuthenticationHandler.SchemeName, _ => { }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.TokenValidationParameters = new TokenValidationParameters { @@ -40,7 +57,22 @@ try }; }); - builder.Services.AddAuthorization(); + builder.Services.AddAuthorization(options => + { + options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy => + policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName) + .RequireClaim("EnvironmentId")); + + options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireClaim("OrganizationRole", "Admin")); + }); + + builder.Services.AddHttpContextAccessor(); builder.Services.AddRateLimiter(options => options.AddFixedWindowLimiter("AdminApi", limiter => @@ -54,7 +86,26 @@ try builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddMemoryCache(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped, PasswordHasher>(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl + ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) + : builder.Configuration.GetConnectionString("DefaultConnection")!; + + builder.Services.AddDbContext(options => + options.UseNpgsql(connectionString)); var app = builder.Build(); @@ -62,6 +113,10 @@ try { app.MapOpenApi(); app.MapScalarApiReference(); + + using var scope = app.Services.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(); } app.UseSerilogRequestLogging(); @@ -71,6 +126,7 @@ try app.MapControllers(); app.MapAuthEndpoints(); + app.MapApiKeyEndpoints(); app.Run(); } diff --git a/src/api/MicCheck.Api/Segments/SegmentEvaluator.cs b/src/api/MicCheck.Api/Segments/SegmentEvaluator.cs new file mode 100644 index 0000000..ccb9a9e --- /dev/null +++ b/src/api/MicCheck.Api/Segments/SegmentEvaluator.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using MicCheck.Api.Identities; + +namespace MicCheck.Api.Segments; + +public class SegmentEvaluator +{ + public bool Evaluate(Segment segment, IReadOnlyList traits, string identifier = "") + { + var topLevelRules = segment.Rules.Where(r => r.ParentRuleId == null).ToList(); + return topLevelRules.All(rule => EvaluateRule(rule, traits, identifier, segment.Id)); + } + + private bool EvaluateRule( + SegmentRule rule, + IReadOnlyList traits, + string identifier, + int segmentId) + { + var conditionResults = rule.Conditions + .Select(c => EvaluateCondition(c, traits, identifier, segmentId)) + .ToList(); + + bool conditionsPass = rule.Type switch + { + SegmentRuleType.All => conditionResults.Count == 0 || conditionResults.All(r => r), + SegmentRuleType.Any => conditionResults.Count > 0 && conditionResults.Any(r => r), + SegmentRuleType.None => conditionResults.Count == 0 || conditionResults.All(r => !r), + _ => false + }; + + if (!conditionsPass) + return false; + + return rule.ChildRules.All(child => EvaluateRule(child, traits, identifier, segmentId)); + } + + private bool EvaluateCondition( + SegmentCondition condition, + IReadOnlyList traits, + string identifier, + int segmentId) + { + var trait = traits.FirstOrDefault(t => t.Key == condition.Property); + + return condition.Operator switch + { + SegmentConditionOperator.IsSet => trait is not null, + SegmentConditionOperator.IsNotSet => trait is null, + SegmentConditionOperator.PercentageSplit => EvaluatePercentageSplit(condition.Value, identifier, segmentId), + _ => trait is not null && EvaluateTraitCondition(condition, trait.Value) + }; + } + + private static bool EvaluateTraitCondition(SegmentCondition condition, string traitValue) + { + return condition.Operator switch + { + SegmentConditionOperator.Equal => traitValue.Equals(condition.Value, StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.NotEqual => !traitValue.Equals(condition.Value, StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.Contains => traitValue.Contains(condition.Value, StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.NotContains => !traitValue.Contains(condition.Value, StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.Regex => Regex.IsMatch(traitValue, condition.Value), + SegmentConditionOperator.GreaterThan => CompareNumeric(traitValue, condition.Value) > 0, + SegmentConditionOperator.GreaterThanOrEqual => CompareNumeric(traitValue, condition.Value) >= 0, + SegmentConditionOperator.LessThan => CompareNumeric(traitValue, condition.Value) < 0, + SegmentConditionOperator.LessThanOrEqual => CompareNumeric(traitValue, condition.Value) <= 0, + SegmentConditionOperator.In => condition.Value.Split(',').Any(v => v.Trim().Equals(traitValue, StringComparison.OrdinalIgnoreCase)), + SegmentConditionOperator.NotIn => !condition.Value.Split(',').Any(v => v.Trim().Equals(traitValue, StringComparison.OrdinalIgnoreCase)), + SegmentConditionOperator.IsTrue => traitValue.Equals("true", StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.IsFalse => traitValue.Equals("false", StringComparison.OrdinalIgnoreCase), + SegmentConditionOperator.ModuloValueDivisorRemainder => EvaluateModulo(traitValue, condition.Value), + _ => false + }; + } + + private static int CompareNumeric(string traitValue, string conditionValue) + { + if (!decimal.TryParse(traitValue, out var trait) || + !decimal.TryParse(conditionValue, out var condition)) + return 0; + return trait.CompareTo(condition); + } + + private static bool EvaluateModulo(string traitValue, string conditionValue) + { + var parts = conditionValue.Split('|'); + if (parts.Length != 2) + return false; + if (!long.TryParse(traitValue, out var value) || + !long.TryParse(parts[0], out var divisor) || + !long.TryParse(parts[1], out var remainder)) + return false; + if (divisor == 0) + return false; + return value % divisor == remainder; + } + + private static bool EvaluatePercentageSplit(string conditionValue, string identifier, int segmentId) + { + if (!decimal.TryParse(conditionValue, out var percentage)) + return false; + + var input = $"{identifier}{segmentId}"; + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); + var hashValue = BitConverter.ToUInt32(hashBytes, 0); + var bucket = (hashValue % 9999m) / 99.99m; + + return bucket < percentage; + } +} diff --git a/src/api/MicCheck.Api/Users/RefreshToken.cs b/src/api/MicCheck.Api/Users/RefreshToken.cs new file mode 100644 index 0000000..77c9556 --- /dev/null +++ b/src/api/MicCheck.Api/Users/RefreshToken.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Users; + +public class RefreshToken +{ + public int Id { get; init; } + public required string Token { get; init; } + public int UserId { get; init; } + public User User { get; init; } = null!; + public DateTimeOffset ExpiresAt { get; init; } + public bool IsRevoked { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs new file mode 100644 index 0000000..f3d93b5 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyHasherTests.cs @@ -0,0 +1,72 @@ +using MicCheck.Api.ApiKeys; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.ApiKeys; + +[TestFixture] +public class ApiKeyHasherTests +{ + [Test] + public void WhenKeyIsHashed_ThenHashIsDeterministic() + { + var key = "test-api-key"; + + var hash1 = ApiKeyHasher.Hash(key); + var hash2 = ApiKeyHasher.Hash(key); + + Assert.That(hash1, Is.EqualTo(hash2)); + } + + [Test] + public void WhenKeyIsHashed_ThenHashIsLowercaseHex() + { + var hash = ApiKeyHasher.Hash("any-key"); + + Assert.That(hash, Does.Match("^[0-9a-f]{64}$")); + } + + [Test] + public void WhenDifferentKeysAreHashed_ThenHashesDiffer() + { + var hash1 = ApiKeyHasher.Hash("key-one"); + var hash2 = ApiKeyHasher.Hash("key-two"); + + Assert.That(hash1, Is.Not.EqualTo(hash2)); + } + + [Test] + public void WhenGenerateKeyIsCalled_ThenKeyIsNotEmpty() + { + var key = ApiKeyHasher.GenerateKey(); + + Assert.That(key, Is.Not.Null.And.Not.Empty); + } + + [Test] + public void WhenGenerateKeyIsCalled_ThenEachKeyIsUnique() + { + var key1 = ApiKeyHasher.GenerateKey(); + var key2 = ApiKeyHasher.GenerateKey(); + + Assert.That(key1, Is.Not.EqualTo(key2)); + } + + [Test] + public void WhenGenerateKeyIsCalled_ThenKeyIsBase64UrlSafe() + { + var key = ApiKeyHasher.GenerateKey(); + + Assert.That(key, Does.Not.Contain("+")); + Assert.That(key, Does.Not.Contain("/")); + Assert.That(key, Does.Not.Contain("=")); + } + + [Test] + public void WhenGenerateKeyIsHashed_ThenHashCanBeUsedToVerify() + { + var rawKey = ApiKeyHasher.GenerateKey(); + var hash = ApiKeyHasher.Hash(rawKey); + + Assert.That(ApiKeyHasher.Hash(rawKey), Is.EqualTo(hash)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs index e6102c0..e5b39e8 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs @@ -1,75 +1,185 @@ using System.Net; using System.Net.Http.Json; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using NUnit.Framework; using MicCheck.Api.Auth; +using NUnit.Framework; namespace MicCheck.Api.Tests.Unit.Auth; [TestFixture] public class AuthEndpointsTests { - private WebApplicationFactory _factory = null!; - private Mock _tokenService = null!; + private MicCheckWebApplicationFactory _factory = null!; [SetUp] public void SetUp() { - _tokenService = new Mock(); - - _factory = new WebApplicationFactory() - .WithWebHostBuilder(host => - { - host.ConfigureServices(services => - { - services.AddScoped(_ => _tokenService.Object); - }); - }); + _factory = new MicCheckWebApplicationFactory(); } [TearDown] public void TearDown() => _factory.Dispose(); [Test] - public async Task WhenUsernameIsEmpty_ThenBadRequestIsReturned() + public async Task WhenRegisteringWithValidDetails_ThenOkIsReturnedWithTokens() { var client = _factory.CreateClient(); - var response = await client.PostAsJsonAsync("/auth/token", - new TokenRequest("", "password123")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - } - - [Test] - public async Task WhenPasswordIsEmpty_ThenBadRequestIsReturned() - { - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/auth/token", - new TokenRequest("testuser", "")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - } - - [Test] - public async Task WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken() - { - var expectedResponse = new TokenResponse("signed.jwt.token", DateTime.UtcNow.AddHours(1)); - _tokenService - .Setup(s => s.GenerateToken("testuser")) - .Returns(expectedResponse); - - var client = _factory.CreateClient(); - - var response = await client.PostAsJsonAsync("/auth/token", - new TokenRequest("testuser", "password123")); + var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - var body = await response.Content.ReadFromJsonAsync(); - Assert.That(body?.Token, Is.EqualTo(expectedResponse.Token)); + var body = await response.Content.ReadFromJsonAsync(); + Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task WhenRegisteringWithDuplicateEmail_ThenConflictIsReturned() + { + var client = _factory.CreateClient(); + + await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp")); + + var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + } + + [Test] + public async Task WhenLoginWithValidCredentials_ThenOkIsReturnedWithTokens() + { + var client = _factory.CreateClient(); + + await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "bob@example.com", "SecurePass1!", "Bob", "Jones", "BobCo")); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest("bob@example.com", "SecurePass1!")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task WhenLoginWithWrongPassword_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "carol@example.com", "CorrectPass1!", "Carol", "White", "CarolCo")); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest("carol@example.com", "WrongPassword")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenLoginWithUnknownEmail_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest("nobody@example.com", "SomePass1!")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenLoginWithEmptyEmail_ThenBadRequestIsReturned() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/login", + new LoginRequest("", "SomePass1!")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task WhenRefreshingWithValidToken_ThenOkIsReturnedWithNewTokens() + { + var client = _factory.CreateClient(); + + var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "dave@example.com", "SecurePass1!", "Dave", "Brown", "DaveCo")); + var registerBody = await registerResponse.Content.ReadFromJsonAsync(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", + new RefreshRequest(registerBody!.RefreshToken)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(body?.RefreshToken, Is.Not.EqualTo(registerBody.RefreshToken)); + } + + [Test] + public async Task WhenRefreshingWithInvalidToken_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", + new RefreshRequest("not-a-valid-refresh-token")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenRefreshingWithRevokedToken_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "eve@example.com", "SecurePass1!", "Eve", "Davis", "EveCo")); + var registerBody = await registerResponse.Content.ReadFromJsonAsync(); + + await client.PostAsJsonAsync("/api/v1/auth/refresh", + new RefreshRequest(registerBody!.RefreshToken)); + + var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", + new RefreshRequest(registerBody.RefreshToken)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenLoggingOut_ThenNoContentIsReturned() + { + var client = _factory.CreateClient(); + + var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "frank@example.com", "SecurePass1!", "Frank", "Green", "FrankCo")); + var registerBody = await registerResponse.Content.ReadFromJsonAsync(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/logout", + new LogoutRequest(registerBody!.RefreshToken)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + } + + [Test] + public async Task WhenLoggingOutAndRefreshing_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest( + "grace@example.com", "SecurePass1!", "Grace", "Black", "GraceCo")); + var registerBody = await registerResponse.Content.ReadFromJsonAsync(); + + await client.PostAsJsonAsync("/api/v1/auth/logout", + new LogoutRequest(registerBody!.RefreshToken)); + + var response = await client.PostAsJsonAsync("/api/v1/auth/refresh", + new RefreshRequest(registerBody.RefreshToken)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs index 5907629..94b852f 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs @@ -1,6 +1,8 @@ using System.IdentityModel.Tokens.Jwt; -using Microsoft.Extensions.Configuration; using MicCheck.Api.Auth; +using MicCheck.Api.Organizations; +using MicCheck.Api.Users; +using Microsoft.Extensions.Configuration; using NUnit.Framework; namespace MicCheck.Api.Tests.Unit.Auth; @@ -27,29 +29,43 @@ public class TokenServiceTests _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 WhenAUsernameIsProvided_ThenATokenIsReturned() + public void WhenAUserIsProvided_ThenATokenIsReturned() { - var result = _tokenService.GenerateToken("testuser"); + var result = _tokenService.GenerateToken(BuildUser()); Assert.That(result.Token, Is.Not.Null.And.Not.Empty); } [Test] - public void WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim() + public void WhenATokenIsGenerated_ThenItContainsTheUserEmailClaim() { - var result = _tokenService.GenerateToken("testuser"); + 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.Subject, Is.EqualTo("testuser")); + 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("testuser"); + var result = _tokenService.GenerateToken(BuildUser()); Assert.That(result.ExpiresAt, Is.GreaterThan(DateTime.UtcNow)); } @@ -57,11 +73,31 @@ public class TokenServiceTests [Test] public void WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer() { - var result = _tokenService.GenerateToken("testuser"); + var result = _tokenService.GenerateToken(BuildUser()); var handler = new JwtSecurityTokenHandler(); var jwt = handler.ReadJwtToken(result.Token); Assert.That(jwt.Issuer, Is.EqualTo("MicCheck")); } + + [Test] + public void WhenUserBelongsToOrganizations_ThenTokenContainsOrganizationClaims() + { + var user = BuildUser(); + user.Organizations.Add(new OrganizationUser + { + OrganizationId = 42, + UserId = user.Id, + Role = OrganizationRole.Admin + }); + + var result = _tokenService.GenerateToken(user); + + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(result.Token); + + Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationId" && c.Value == "42"), Is.True); + Assert.That(jwt.Claims.Any(c => c.Type == "OrganizationRole" && c.Value == "Admin"), Is.True); + } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs new file mode 100644 index 0000000..021b1cd --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Http.Headers; +using MicCheck.Api.ApiKeys; +using MicCheck.Api.Data; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Authentication; + +[TestFixture] +public class ApiKeyAuthenticationHandlerTests +{ + private MicCheckWebApplicationFactory _factory = null!; + + [SetUp] + public void SetUp() + { + _factory = new MicCheckWebApplicationFactory(); + } + + [TearDown] + public void TearDown() => _factory.Dispose(); + + private async Task SeedActiveApiKeyAsync(int organizationId = 1) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var rawKey = ApiKeyHasher.GenerateKey(); + db.ApiKeys.Add(new ApiKey + { + Key = ApiKeyHasher.Hash(rawKey), + Prefix = rawKey[..8], + Name = "Test Key", + OrganizationId = organizationId, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + return rawKey; + } + + [Test] + public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/organisations/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/organisations/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/organisations/1/api-keys/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenApiKeyIsInactive_ThenUnauthorizedIsReturned() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var rawKey = ApiKeyHasher.GenerateKey(); + db.ApiKeys.Add(new ApiKey + { + Key = ApiKeyHasher.Hash(rawKey), + Prefix = rawKey[..8], + Name = "Inactive Key", + OrganizationId = 1, + IsActive = false, + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); + + var response = await client.GetAsync("/api/v1/organisations/1/api-keys/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenApiKeyIsExpired_ThenUnauthorizedIsReturned() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var rawKey = ApiKeyHasher.GenerateKey(); + db.ApiKeys.Add(new ApiKey + { + Key = ApiKeyHasher.Hash(rawKey), + Prefix = rawKey[..8], + Name = "Expired Key", + OrganizationId = 1, + IsActive = true, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), + CreatedAt = DateTimeOffset.UtcNow.AddDays(-10) + }); + await db.SaveChangesAsync(); + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); + + var response = await client.GetAsync("/api/v1/organisations/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/organisations/1/api-keys/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs new file mode 100644 index 0000000..f331775 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Authorization/ProjectPermissionRequirementHandlerTests.cs @@ -0,0 +1,180 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using MicCheck.Api.Authorization; +using MicCheck.Api.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Authorization; + +[TestFixture] +public class ProjectPermissionRequirementHandlerTests +{ + private MicCheckDbContext _db = null!; + private Mock _httpContextAccessor = null!; + private ProjectPermissionRequirementHandler _handler = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new MicCheckDbContext(options); + + _httpContextAccessor = new Mock(); + _handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + ProjectPermission permission = ProjectPermission.ViewProject) + { + var requirement = new ProjectPermissionRequirement(permission); + return new AuthorizationHandlerContext([requirement], user, null); + } + + private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims) + { + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); + } + + private void SetupRouteProjectId(int projectId) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["projectId"] = projectId.ToString(); + _httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); + } + + [Test] + public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds() + { + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1"), + new Claim("OrganizationRole", "Admin")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds() + { + _db.UserProjectPermissions.Add(new UserProjectPermission + { + UserId = 1, + ProjectId = 10, + Permissions = [ProjectPermission.ViewProject] + }); + await _db.SaveChangesAsync(); + + SetupRouteProjectId(10); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1")); + + var context = CreateContext(user, ProjectPermission.ViewProject); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted() + { + _db.UserProjectPermissions.Add(new UserProjectPermission + { + UserId = 2, + ProjectId = 20, + IsAdmin = true, + Permissions = [] + }); + await _db.SaveChangesAsync(); + + SetupRouteProjectId(20); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "2")); + + var context = CreateContext(user, ProjectPermission.DeleteFeature); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.True); + } + + [Test] + public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails() + { + _db.UserProjectPermissions.Add(new UserProjectPermission + { + UserId = 3, + ProjectId = 30, + Permissions = [ProjectPermission.ViewProject] + }); + await _db.SaveChangesAsync(); + + SetupRouteProjectId(30); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "3")); + + var context = CreateContext(user, ProjectPermission.DeleteFeature); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails() + { + SetupRouteProjectId(99); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "5")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenUserIdClaimIsMissing_ThenRequirementFails() + { + var user = CreateUserWithClaims(); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } + + [Test] + public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails() + { + _httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); + + var user = CreateUserWithClaims( + new Claim(JwtRegisteredClaimNames.Sub, "1")); + + var context = CreateContext(user); + + await _handler.HandleAsync(context); + + Assert.That(context.HasSucceeded, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs new file mode 100644 index 0000000..224833c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs @@ -0,0 +1,52 @@ +using NUnit.Framework; +using MicCheck.Api.Data; + +namespace MicCheck.Api.Tests.Unit.Data; + +[TestFixture] +public class DatabaseUrlParserTests +{ + [Test] + public void WhenAFullDatabaseUrlIsParsed_ThenAllComponentsAreExtracted() + { + var url = "postgresql://miccheck:password@localhost:5432/miccheck"; + + var result = DatabaseUrlParser.ToNpgsqlConnectionString(url); + + Assert.That(result, Does.Contain("Host=localhost")); + Assert.That(result, Does.Contain("Port=5432")); + Assert.That(result, Does.Contain("Database=miccheck")); + Assert.That(result, Does.Contain("Username=miccheck")); + Assert.That(result, Does.Contain("Password=password")); + } + + [Test] + public void WhenADatabaseUrlWithNoExplicitPortIsParsed_ThenPortDefaultsTo5432() + { + var url = "postgresql://user:pass@db.example.com/mydb"; + + var result = DatabaseUrlParser.ToNpgsqlConnectionString(url); + + Assert.That(result, Does.Contain("Port=5432")); + } + + [Test] + public void WhenADatabaseUrlWithACustomPortIsParsed_ThenThatPortIsUsed() + { + var url = "postgresql://user:pass@db.example.com:5433/mydb"; + + var result = DatabaseUrlParser.ToNpgsqlConnectionString(url); + + Assert.That(result, Does.Contain("Port=5433")); + } + + [Test] + public void WhenADatabaseUrlContainsSpecialCharactersInPassword_ThenTheyArePreserved() + { + var url = "postgresql://user:s3cr3t@host:5432/db"; + + var result = DatabaseUrlParser.ToNpgsqlConnectionString(url); + + Assert.That(result, Does.Contain("Password=s3cr3t")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs new file mode 100644 index 0000000..95b5b04 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs @@ -0,0 +1,224 @@ +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.ApiKeys; +using MicCheck.Api.Users; +using Microsoft.EntityFrameworkCore; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Data; + +[TestFixture] +public class MicCheckDbContextTests +{ + private MicCheckDbContext _db = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new MicCheckDbContext(options); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + [Test] + public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById() + { + var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow }; + _db.Organizations.Add(organization); + await _db.SaveChangesAsync(); + + var retrieved = await _db.Organizations.FindAsync(organization.Id); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Name, Is.EqualTo("Acme")); + } + + [Test] + public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization() + { + var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow }; + _db.Organizations.Add(organization); + await _db.SaveChangesAsync(); + + var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow }; + _db.Projects.Add(project); + await _db.SaveChangesAsync(); + + var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync(); + + Assert.That(projects, Has.Count.EqualTo(1)); + Assert.That(projects[0].Name, Is.EqualTo("My Project")); + } + + [Test] + public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey() + { + var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }; + _db.Projects.Add(project); + await _db.SaveChangesAsync(); + + var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow }; + _db.Environments.Add(env); + await _db.SaveChangesAsync(); + + var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc"); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Name, Is.EqualTo("Production")); + } + + [Test] + public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject() + { + var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + _db.Features.Add(feature); + await _db.SaveChangesAsync(); + + var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync(); + + Assert.That(features, Has.Count.EqualTo(1)); + Assert.That(features[0].Name, Is.EqualTo("dark_mode")); + } + + [Test] + public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment() + { + var state = new FeatureState + { + FeatureId = 1, + EnvironmentId = 1, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + _db.FeatureStates.Add(state); + await _db.SaveChangesAsync(); + + var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync(); + + Assert.That(states, Has.Count.EqualTo(1)); + Assert.That(states[0].Enabled, Is.True); + } + + [Test] + public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded() + { + var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + _db.Segments.Add(segment); + await _db.SaveChangesAsync(); + + var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; + _db.SegmentRules.Add(rule); + await _db.SaveChangesAsync(); + + var loaded = await _db.Segments + .Include(s => s.Rules) + .FirstAsync(s => s.Id == segment.Id); + + Assert.That(loaded.Rules, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded() + { + var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow }; + _db.Identities.Add(identity); + await _db.SaveChangesAsync(); + + _db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" }); + await _db.SaveChangesAsync(); + + var loaded = await _db.Identities + .Include(i => i.Traits) + .FirstAsync(i => i.Id == identity.Id); + + Assert.That(loaded.Traits, Has.Count.EqualTo(1)); + Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan")); + } + + [Test] + public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization() + { + _db.AuditLogs.Add(new AuditLog + { + ResourceType = "Feature", + ResourceId = "1", + Action = "Created", + OrganizationId = 42, + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + + var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync(); + + Assert.That(logs, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment() + { + _db.Webhooks.Add(new Webhook + { + Url = "https://example.com/hook", + Scope = WebhookScope.Environment, + EnvironmentId = 5, + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + + var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync(); + + Assert.That(webhooks, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey() + { + _db.ApiKeys.Add(new ApiKey + { + Key = "hashed-value-xyz", + Prefix = "hashed-va", + Name = "CI Key", + OrganizationId = 1, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + + var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz"); + + Assert.That(key, Is.Not.Null); + Assert.That(key!.IsActive, Is.True); + } + + [Test] + public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail() + { + _db.Users.Add(new User + { + Email = "alice@example.com", + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com"); + + Assert.That(user, Is.Not.Null); + Assert.That(user!.FirstName, Is.EqualTo("Alice")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs new file mode 100644 index 0000000..e44d5d8 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs @@ -0,0 +1,327 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Segments; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeatureEvaluationServiceTests +{ + private MicCheckDbContext _db = null!; + private FeatureEvaluationService _service = null!; + private const int EnvironmentId = 1; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new MicCheckDbContext(options); + + var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions())); + _service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache); + + SeedBaseData(); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + private void SeedBaseData() + { + _db.Environments.Add(new AppEnvironment + { + Id = EnvironmentId, + Name = "Production", + ApiKey = "env-key-test", + ProjectId = ProjectId, + CreatedAt = DateTimeOffset.UtcNow + }); + _db.SaveChanges(); + } + + private MicCheck.Api.Features.Feature AddFeature(string name, bool defaultEnabled = false) + { + var feature = new MicCheck.Api.Features.Feature + { + Name = name, + ProjectId = ProjectId, + CreatedAt = DateTimeOffset.UtcNow, + DefaultEnabled = defaultEnabled + }; + _db.Features.Add(feature); + _db.SaveChanges(); + return feature; + } + + private FeatureState AddEnvironmentDefault(int featureId, bool enabled, string? value = null) + { + var fs = new FeatureState + { + FeatureId = featureId, + EnvironmentId = EnvironmentId, + Enabled = enabled, + Value = value, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + _db.FeatureStates.Add(fs); + _db.SaveChanges(); + return fs; + } + + [Test] + public async Task WhenNoOverridesExist_ThenEnvironmentDefaultIsReturned() + { + var feature = AddFeature("dark_mode"); + AddEnvironmentDefault(feature.Id, enabled: true, value: null); + + var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Feature.Name, Is.EqualTo("dark_mode")); + Assert.That(results[0].Enabled, Is.True); + } + + [Test] + public async Task WhenFeatureIsDisabled_ThenResultReflectsThat() + { + var feature = AddFeature("beta_feature"); + AddEnvironmentDefault(feature.Id, enabled: false); + + var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId); + + Assert.That(results[0].Enabled, Is.False); + } + + [Test] + public async Task WhenIdentityHasNoOverrides_ThenEnvironmentDefaultIsUsed() + { + var feature = AddFeature("dark_mode"); + AddEnvironmentDefault(feature.Id, enabled: true); + + var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-123"); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Enabled, Is.True); + } + + [Test] + public async Task WhenIdentityHasOverride_ThenIdentityOverrideTakesPriority() + { + var feature = AddFeature("dark_mode"); + AddEnvironmentDefault(feature.Id, enabled: false); + + var identity = new Identity + { + Identifier = "user-vip", + EnvironmentId = EnvironmentId, + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Identities.Add(identity); + _db.SaveChanges(); + + _db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + IdentityId = identity.Id, + Enabled = true, + Value = "identity-value", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + _db.SaveChanges(); + + var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip"); + + Assert.That(results[0].Enabled, Is.True); + Assert.That(results[0].Value, Is.EqualTo("identity-value")); + } + + [Test] + public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed() + { + var feature = AddFeature("premium_feature"); + var envDefault = AddEnvironmentDefault(feature.Id, enabled: false); + + var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _db.Segments.Add(segment); + _db.SaveChanges(); + + var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; + rule.Conditions.Add(new SegmentCondition + { + RuleId = rule.Id, + Property = "plan", + Operator = SegmentConditionOperator.Equal, + Value = "premium" + }); + _db.SegmentRules.Add(rule); + _db.SaveChanges(); + + var featureSegment = new FeatureSegment + { + FeatureId = feature.Id, + SegmentId = segment.Id, + EnvironmentId = EnvironmentId, + Priority = 1 + }; + _db.FeatureSegments.Add(featureSegment); + _db.SaveChanges(); + + _db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + FeatureSegmentId = featureSegment.Id, + Enabled = true, + Value = "segment-value", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + _db.SaveChanges(); + + var results = await _service.EvaluateForIdentityAsync( + EnvironmentId, "user-123", + [new TraitInput("plan", "premium")]); + + Assert.That(results[0].Enabled, Is.True); + Assert.That(results[0].Value, Is.EqualTo("segment-value")); + } + + [Test] + public async Task WhenSegmentDoesNotMatch_ThenEnvironmentDefaultIsUsed() + { + var feature = AddFeature("premium_feature"); + AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value"); + + var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _db.Segments.Add(segment); + _db.SaveChanges(); + + var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; + rule.Conditions.Add(new SegmentCondition + { + RuleId = rule.Id, + Property = "plan", + Operator = SegmentConditionOperator.Equal, + Value = "premium" + }); + _db.SegmentRules.Add(rule); + _db.SaveChanges(); + + var featureSegment = new FeatureSegment + { + FeatureId = feature.Id, + SegmentId = segment.Id, + EnvironmentId = EnvironmentId, + Priority = 1 + }; + _db.FeatureSegments.Add(featureSegment); + _db.SaveChanges(); + + _db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + FeatureSegmentId = featureSegment.Id, + Enabled = true, + Value = "segment-value", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + _db.SaveChanges(); + + var results = await _service.EvaluateForIdentityAsync( + EnvironmentId, "user-123", + [new TraitInput("plan", "free")]); + + Assert.That(results[0].Enabled, Is.False); + Assert.That(results[0].Value, Is.EqualTo("default-value")); + } + + [Test] + public async Task WhenIdentityOverrideAndSegmentBothExist_ThenIdentityOverrideTakesPriority() + { + var feature = AddFeature("feature_x"); + AddEnvironmentDefault(feature.Id, enabled: false); + + var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _db.Segments.Add(segment); + _db.SaveChanges(); + + var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All }; + rule.Conditions.Add(new SegmentCondition + { + RuleId = rule.Id, + Property = "country", + Operator = SegmentConditionOperator.IsSet, + Value = "" + }); + _db.SegmentRules.Add(rule); + _db.SaveChanges(); + + var featureSegment = new FeatureSegment + { + FeatureId = feature.Id, + SegmentId = segment.Id, + EnvironmentId = EnvironmentId, + Priority = 1 + }; + _db.FeatureSegments.Add(featureSegment); + _db.SaveChanges(); + + _db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + FeatureSegmentId = featureSegment.Id, + Enabled = true, + Value = "segment-value", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + + var identity = new Identity + { + Identifier = "user-special", + EnvironmentId = EnvironmentId, + CreatedAt = DateTimeOffset.UtcNow + }; + _db.Identities.Add(identity); + _db.SaveChanges(); + + identity.Traits.Add(new IdentityTrait + { + IdentityId = identity.Id, + Key = "country", + Value = "US" + }); + + _db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + IdentityId = identity.Id, + Enabled = false, + Value = "identity-override", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + _db.SaveChanges(); + + var results = await _service.EvaluateForIdentityAsync( + EnvironmentId, "user-special", + [new TraitInput("country", "US")]); + + Assert.That(results[0].Value, Is.EqualTo("identity-override")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs new file mode 100644 index 0000000..63cb0f2 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Json; +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FlagsApiIntegrationTests +{ + private MicCheckWebApplicationFactory _factory = null!; + private string _envApiKey = null!; + + [SetUp] + public void SetUp() + { + _factory = new MicCheckWebApplicationFactory(); + _envApiKey = SeedEnvironmentWithFlag(); + } + + [TearDown] + public void TearDown() => _factory.Dispose(); + + private string SeedEnvironmentWithFlag() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var apiKey = $"test-env-key-{Guid.NewGuid():N}"; + + var project = new MicCheck.Api.Projects.Project + { + Name = "Test Project", + OrganizationId = 1, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Projects.Add(project); + db.SaveChanges(); + + var environment = new AppEnvironment + { + Name = "Test Env", + ApiKey = apiKey, + ProjectId = project.Id, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Environments.Add(environment); + db.SaveChanges(); + + var feature = new Feature + { + Name = "dark_mode", + ProjectId = project.Id, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Features.Add(feature); + db.SaveChanges(); + + db.FeatureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = environment.Id, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + db.SaveChanges(); + + return apiKey; + } + + [Test] + public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + + var response = await client.GetAsync("/api/v1/flags/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var flags = await response.Content.ReadFromJsonAsync>(); + Assert.That(flags, Has.Count.EqualTo(1)); + Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode")); + Assert.That(flags[0].Enabled, Is.True); + } + + [Test] + public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/flags/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key"); + + var response = await client.GetAsync("/api/v1/flags/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + + var response = await client.PostAsJsonAsync("/api/v1/identities/", new + { + identifier = "user-123", + traits = new[] { new { trait_key = "plan", trait_value = "premium" } } + }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); + + var response = await client.GetAsync("/api/v1/environment-document/"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj index 8ed2dde..383c7de 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj +++ b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs b/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs new file mode 100644 index 0000000..8a5aa18 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/MicCheckWebApplicationFactory.cs @@ -0,0 +1,37 @@ +using MicCheck.Api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MicCheck.Api.Tests.Unit; + +public class MicCheckWebApplicationFactory : WebApplicationFactory +{ + private static readonly IServiceProvider InMemoryEfServiceProvider = + new ServiceCollection() + .AddEntityFrameworkInMemoryDatabase() + .BuildServiceProvider(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + var dbName = Guid.NewGuid().ToString(); + + builder.UseEnvironment("Testing"); + builder.ConfigureServices(services => + { + var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(MicCheckDbContext)); + if (contextDescriptor is not null) + services.Remove(contextDescriptor); + + var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (optionsDescriptor is not null) + services.Remove(optionsDescriptor); + + services.AddDbContext(options => + options + .UseInMemoryDatabase(dbName) + .UseInternalServiceProvider(InMemoryEfServiceProvider)); + }); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs new file mode 100644 index 0000000..80c6730 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs @@ -0,0 +1,329 @@ +using MicCheck.Api.Identities; +using MicCheck.Api.Segments; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Segments; + +[TestFixture] +public class SegmentEvaluatorTests +{ + private SegmentEvaluator _evaluator = null!; + + [SetUp] + public void SetUp() => _evaluator = new SegmentEvaluator(); + + private static Segment BuildSegment(SegmentRuleType ruleType, params SegmentCondition[] conditions) + { + var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var rule = new SegmentRule { SegmentId = segment.Id, Type = ruleType }; + foreach (var condition in conditions) + rule.Conditions.Add(condition); + segment.Rules.Add(rule); + return segment; + } + + private static SegmentCondition Condition(string property, SegmentConditionOperator op, string value) => + new() { Property = property, Operator = op, Value = value }; + + private static IdentityTrait Trait(string key, string value) => + new() { Key = key, Value = value, IdentityId = 1 }; + + [Test] + public void WhenTraitEqualsConditionValue_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.Equal, "premium")); + var traits = new[] { Trait("plan", "premium") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenTraitDoesNotEqualConditionValue_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.Equal, "premium")); + var traits = new[] { Trait("plan", "free") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenNotEqualOperatorAndValuesAreDifferent_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.NotEqual, "premium")); + var traits = new[] { Trait("plan", "free") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenGreaterThanConditionIsSatisfied_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("age", SegmentConditionOperator.GreaterThan, "18")); + var traits = new[] { Trait("age", "25") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenGreaterThanConditionIsNotSatisfied_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("age", SegmentConditionOperator.GreaterThan, "18")); + var traits = new[] { Trait("age", "16") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenGreaterThanOrEqualConditionIsExactMatch_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("age", SegmentConditionOperator.GreaterThanOrEqual, "18")); + var traits = new[] { Trait("age", "18") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenLessThanConditionIsSatisfied_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("age", SegmentConditionOperator.LessThan, "18")); + var traits = new[] { Trait("age", "16") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenLessThanOrEqualConditionIsExactMatch_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("age", SegmentConditionOperator.LessThanOrEqual, "18")); + var traits = new[] { Trait("age", "18") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenContainsConditionIsSatisfied_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("email", SegmentConditionOperator.Contains, "@example.com")); + var traits = new[] { Trait("email", "alice@example.com") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenNotContainsConditionIsSatisfied_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("email", SegmentConditionOperator.NotContains, "@test.com")); + var traits = new[] { Trait("email", "alice@example.com") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenRegexConditionMatchesTraitValue_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("email", SegmentConditionOperator.Regex, @"^[^@]+@example\.com$")); + var traits = new[] { Trait("email", "alice@example.com") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenRegexConditionDoesNotMatchTraitValue_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("email", SegmentConditionOperator.Regex, @"^[^@]+@example\.com$")); + var traits = new[] { Trait("email", "alice@other.com") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenIsSetConditionAndTraitExists_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.IsSet, "")); + var traits = new[] { Trait("plan", "premium") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenIsSetConditionAndTraitDoesNotExist_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.IsSet, "")); + + Assert.That(_evaluator.Evaluate(segment, [], "user1"), Is.False); + } + + [Test] + public void WhenIsNotSetConditionAndTraitDoesNotExist_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("plan", SegmentConditionOperator.IsNotSet, "")); + + Assert.That(_evaluator.Evaluate(segment, [], "user1"), Is.True); + } + + [Test] + public void WhenInConditionAndValueIsInList_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("country", SegmentConditionOperator.In, "US,UK,CA")); + var traits = new[] { Trait("country", "UK") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenNotInConditionAndValueIsNotInList_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("country", SegmentConditionOperator.NotIn, "US,UK,CA")); + var traits = new[] { Trait("country", "AU") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenIsTrueConditionAndValueIsTrue_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("beta", SegmentConditionOperator.IsTrue, "")); + var traits = new[] { Trait("beta", "true") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenIsFalseConditionAndValueIsFalse_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("beta", SegmentConditionOperator.IsFalse, "")); + var traits = new[] { Trait("beta", "false") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenModuloConditionIsSatisfied_ThenSegmentMatches() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "3|0")); + var traits = new[] { Trait("userId", "9") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenModuloConditionIsNotSatisfied_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "3|0")); + var traits = new[] { Trait("userId", "7") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenPercentageSplitIsDeterministic_ThenSameInputProducesSameResult() + { + var segment = new Segment { Id = 1, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var rule = new SegmentRule { SegmentId = 1, Type = SegmentRuleType.All }; + rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "50")); + segment.Rules.Add(rule); + + var result1 = _evaluator.Evaluate(segment, [], "user-abc"); + var result2 = _evaluator.Evaluate(segment, [], "user-abc"); + + Assert.That(result1, Is.EqualTo(result2)); + } + + [Test] + public void WhenPercentageSplitIs100_ThenAllIdentitiesAreIncluded() + { + var segment = new Segment { Id = 42, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var rule = new SegmentRule { SegmentId = 42, Type = SegmentRuleType.All }; + rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "100")); + segment.Rules.Add(rule); + + Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.True); + Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.True); + } + + [Test] + public void WhenPercentageSplitIs0_ThenNoIdentitiesAreIncluded() + { + var segment = new Segment { Id = 99, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var rule = new SegmentRule { SegmentId = 99, Type = SegmentRuleType.All }; + rule.Conditions.Add(Condition("", SegmentConditionOperator.PercentageSplit, "0")); + segment.Rules.Add(rule); + + Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.False); + Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False); + } + + [Test] + public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch() + { + var segment = BuildSegment(SegmentRuleType.Any, + Condition("plan", SegmentConditionOperator.Equal, "premium"), + Condition("plan", SegmentConditionOperator.Equal, "enterprise")); + var traits = new[] { Trait("plan", "enterprise") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenRuleTypeIsNone_ThenNoConditionMustMatch() + { + var segment = BuildSegment(SegmentRuleType.None, + Condition("plan", SegmentConditionOperator.Equal, "premium"), + Condition("plan", SegmentConditionOperator.Equal, "enterprise")); + var traits = new[] { Trait("plan", "free") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.True); + } + + [Test] + public void WhenRuleTypeIsNoneAndOneConditionMatches_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.None, + Condition("plan", SegmentConditionOperator.Equal, "premium")); + var traits = new[] { Trait("plan", "premium") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenAllTopLevelRulesMustPass_ThenSegmentOnlyMatchesWhenAllPass() + { + var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + + var rule1 = new SegmentRule { Type = SegmentRuleType.All }; + rule1.Conditions.Add(Condition("plan", SegmentConditionOperator.Equal, "premium")); + + var rule2 = new SegmentRule { Type = SegmentRuleType.All }; + rule2.Conditions.Add(Condition("country", SegmentConditionOperator.Equal, "US")); + + segment.Rules.Add(rule1); + segment.Rules.Add(rule2); + + var matchingTraits = new[] { Trait("plan", "premium"), Trait("country", "US") }; + var nonMatchingTraits = new[] { Trait("plan", "premium"), Trait("country", "UK") }; + + Assert.That(_evaluator.Evaluate(segment, matchingTraits), Is.True); + Assert.That(_evaluator.Evaluate(segment, nonMatchingTraits), Is.False); + } +}