Adding Flags API
This commit is contained in:
90
docs/03_database_and_repositories_output.md
Normal file
90
docs/03_database_and_repositories_output.md
Normal file
@@ -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<T>` 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<T>` 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<Program>`. 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
|
||||
```
|
||||
154
docs/04_authentication_and_authorization_output.md
Normal file
154
docs/04_authentication_and_authorization_output.md
Normal file
@@ -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 <TOKEN>` 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<ProjectPermission>` 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<User>`, 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<ProjectPermission>` (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<MicCheckDbContext>` 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
|
||||
```
|
||||
142
docs/05_flags_api_output.md
Normal file
142
docs/05_flags_api_output.md
Normal file
@@ -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<FeatureSegment>(fsg => fsg.Id)` (FK on `FeatureSegment.Id` → `FeatureState.Id`, shared-PK pattern), which is incorrect for the data model. Changed to `HasForeignKey<FeatureState>(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
|
||||
```
|
||||
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Normal file
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Normal file
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Normal file
@@ -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('/', '_');
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Normal file
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Normal file
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Normal file
@@ -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<IReadOnlyList<ApiKey>> 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);
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Normal file
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
||||
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Normal file
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Key,
|
||||
string Prefix,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
145
src/api/MicCheck.Api/Auth/AuthService.cs
Normal file
145
src/api/MicCheck.Api/Auth/AuthService.cs
Normal file
@@ -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<User> passwordHasher)
|
||||
{
|
||||
public async Task<LoginResponse?> 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<LoginResponse?> 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<LoginResponse?> 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using MicCheck.Api.Users;
|
||||
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
TokenResponse GenerateToken(string username);
|
||||
TokenResponse GenerateToken(User user);
|
||||
}
|
||||
|
||||
3
src/api/MicCheck.Api/Auth/LoginRequest.cs
Normal file
3
src/api/MicCheck.Api/Auth/LoginRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
3
src/api/MicCheck.Api/Auth/LoginResponse.cs
Normal file
3
src/api/MicCheck.Api/Auth/LoginResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
||||
3
src/api/MicCheck.Api/Auth/LogoutRequest.cs
Normal file
3
src/api/MicCheck.Api/Auth/LogoutRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record LogoutRequest(string Token);
|
||||
3
src/api/MicCheck.Api/Auth/RefreshRequest.cs
Normal file
3
src/api/MicCheck.Api/Auth/RefreshRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record RefreshRequest(string Token);
|
||||
8
src/api/MicCheck.Api/Auth/RegisterRequest.cs
Normal file
8
src/api/MicCheck.Api/Auth/RegisterRequest.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
string Password,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string OrganizationName);
|
||||
@@ -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<Claim>
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -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<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "ApiKey";
|
||||
private const string ApiKeyPrefix = "Api-Key ";
|
||||
|
||||
protected override async Task<AuthenticateResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "EnvironmentKey";
|
||||
|
||||
protected override async Task<AuthenticateResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
17
src/api/MicCheck.Api/Authorization/ProjectPermission.cs
Normal file
17
src/api/MicCheck.Api/Authorization/ProjectPermission.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature,
|
||||
EditFeature,
|
||||
DeleteFeature,
|
||||
CreateEnvironment,
|
||||
EditEnvironment,
|
||||
DeleteEnvironment,
|
||||
CreateSegment,
|
||||
EditSegment,
|
||||
DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ProjectPermissionRequirement>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public List<ProjectPermission> Permissions { get; set; } = [];
|
||||
public bool IsAdmin { get; set; }
|
||||
}
|
||||
@@ -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<ApiKey>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ApiKey> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<AuditLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLog> 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<AppEnvironment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AppEnvironment> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Feature>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Feature> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<FeatureSegment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FeatureSegment> builder)
|
||||
{
|
||||
builder.HasKey(fs => fs.Id);
|
||||
|
||||
builder.HasOne(fsg => fsg.FeatureState)
|
||||
.WithOne()
|
||||
.HasForeignKey<MicCheck.Api.Features.FeatureState>(fs => fs.FeatureSegmentId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
@@ -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<FeatureState>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FeatureState> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Identity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Identity> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<IdentityTrait>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<IdentityTrait> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Organization>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Organization> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<OrganizationUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OrganizationUser> builder)
|
||||
{
|
||||
builder.HasKey(ou => new { ou.OrganizationId, ou.UserId });
|
||||
builder.Property(ou => ou.Role).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -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<Project>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Project> builder)
|
||||
{
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -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<RefreshToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RefreshToken> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<SegmentCondition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SegmentCondition> builder)
|
||||
{
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Property).HasMaxLength(200).IsRequired();
|
||||
builder.Property(c => c.Value).HasMaxLength(1_000).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -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<Segment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Segment> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<SegmentRule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SegmentRule> 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);
|
||||
}
|
||||
}
|
||||
15
src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
Normal file
15
src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
Normal file
@@ -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<Tag>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Tag> builder)
|
||||
{
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Label).HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Color).HasMaxLength(20).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -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<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<UserProjectPermission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserProjectPermission> 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<ProjectPermission>()
|
||||
: v.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Enum.Parse<ProjectPermission>)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -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<Webhook>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Webhook> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<WebhookDeliveryLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WebhookDeliveryLog> 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);
|
||||
}
|
||||
}
|
||||
53
src/api/MicCheck.Api/Data/DatabaseSeeder.cs
Normal file
53
src/api/MicCheck.Api/Data/DatabaseSeeder.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
21
src/api/MicCheck.Api/Data/DatabaseUrlParser.cs
Normal file
21
src/api/MicCheck.Api/Data/DatabaseUrlParser.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace MicCheck.Api.Data;
|
||||
|
||||
public static class DatabaseUrlParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a DATABASE_URL in postgresql://user:password@host:port/dbname format
|
||||
/// to an Npgsql connection string.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
43
src/api/MicCheck.Api/Data/MicCheckDbContext.cs
Normal file
43
src/api/MicCheck.Api/Data/MicCheckDbContext.cs
Normal file
@@ -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<MicCheckDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<OrganizationUser> OrganizationUsers => Set<OrganizationUser>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<AppEnvironment> Environments => Set<AppEnvironment>();
|
||||
public DbSet<Feature> Features => Set<Feature>();
|
||||
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Segment> Segments => Set<Segment>();
|
||||
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
|
||||
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||
public DbSet<Identity> Identities => Set<Identity>();
|
||||
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<Webhook> Webhooks => Set<Webhook>();
|
||||
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||
}
|
||||
@@ -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<ActionResult<EnvironmentDocumentResponse>> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Segments;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public record EnvironmentDocumentResponse(
|
||||
int Id,
|
||||
string ApiKey,
|
||||
IReadOnlyList<FlagResponse> FeatureStates,
|
||||
EnvironmentProjectResponse Project);
|
||||
|
||||
public record EnvironmentProjectResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
IReadOnlyList<SegmentDocumentResponse> Segments);
|
||||
|
||||
public record SegmentDocumentResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
IReadOnlyList<SegmentRuleDocumentResponse> 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<SegmentConditionDocumentResponse> Conditions,
|
||||
IReadOnlyList<SegmentRuleDocumentResponse> 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);
|
||||
}
|
||||
@@ -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<EnvironmentDocumentResponse?> 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()));
|
||||
}
|
||||
}
|
||||
139
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file
139
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file
@@ -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<IReadOnlyList<FeatureStateResult>> 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<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? 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<FeatureStateResult>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file
7
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file
@@ -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());
|
||||
}
|
||||
22
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file
22
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file
@@ -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<FeatureStateResult>? Get(int environmentId)
|
||||
{
|
||||
cache.TryGetValue(CacheKey(environmentId), out IReadOnlyList<FeatureStateResult>? flags);
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
||||
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
|
||||
public void Invalidate(int environmentId)
|
||||
=> cache.Remove(CacheKey(environmentId));
|
||||
|
||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||
}
|
||||
14
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file
14
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file
@@ -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);
|
||||
}
|
||||
19
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file
19
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file
@@ -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<ActionResult<IReadOnlyList<FlagResponse>>> 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());
|
||||
}
|
||||
}
|
||||
47
src/api/MicCheck.Api/Identities/IdentitiesController.cs
Normal file
47
src/api/MicCheck.Api/Identities/IdentitiesController.cs
Normal file
@@ -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<ActionResult<IdentityResponse>> 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<ActionResult<IdentityResponse>> 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()));
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/Identities/IdentityRequest.cs
Normal file
3
src/api/MicCheck.Api/Identities/IdentityRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Identities;
|
||||
|
||||
public record IdentityRequest(string Identifier, IReadOnlyList<TraitInput>? Traits);
|
||||
58
src/api/MicCheck.Api/Identities/IdentityResolutionService.cs
Normal file
58
src/api/MicCheck.Api/Identities/IdentityResolutionService.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Identities;
|
||||
|
||||
public class IdentityResolutionService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<Identity> ResolveAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? 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;
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Identities/IdentityResponse.cs
Normal file
7
src/api/MicCheck.Api/Identities/IdentityResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using MicCheck.Api.Features;
|
||||
|
||||
namespace MicCheck.Api.Identities;
|
||||
|
||||
public record IdentityResponse(
|
||||
IReadOnlyList<TraitResponse> Traits,
|
||||
IReadOnlyList<FlagResponse> Flags);
|
||||
31
src/api/MicCheck.Api/Identities/TraitInput.cs
Normal file
31
src/api/MicCheck.Api/Identities/TraitInput.cs
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
7
src/api/MicCheck.Api/Identities/TraitResponse.cs
Normal file
7
src/api/MicCheck.Api/Identities/TraitResponse.cs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
|
||||
@@ -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<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
|
||||
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
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<Program>();
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<ApiKeyService>();
|
||||
builder.Services.AddScoped<DatabaseSeeder>();
|
||||
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
|
||||
builder.Services.AddScoped<FeatureEvaluationService>();
|
||||
builder.Services.AddScoped<IdentityResolutionService>();
|
||||
builder.Services.AddScoped<EnvironmentDocumentService>();
|
||||
builder.Services.AddSingleton<SegmentEvaluator>();
|
||||
builder.Services.AddSingleton<FlagCache>();
|
||||
|
||||
var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
|
||||
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
|
||||
: builder.Configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
builder.Services.AddDbContext<MicCheckDbContext>(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<DatabaseSeeder>();
|
||||
await seeder.SeedAsync();
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -71,6 +126,7 @@ try
|
||||
|
||||
app.MapControllers();
|
||||
app.MapAuthEndpoints();
|
||||
app.MapApiKeyEndpoints();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
|
||||
113
src/api/MicCheck.Api/Segments/SegmentEvaluator.cs
Normal file
113
src/api/MicCheck.Api/Segments/SegmentEvaluator.cs
Normal file
@@ -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<IdentityTrait> 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<IdentityTrait> 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<IdentityTrait> 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;
|
||||
}
|
||||
}
|
||||
12
src/api/MicCheck.Api/Users/RefreshToken.cs
Normal file
12
src/api/MicCheck.Api/Users/RefreshToken.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Program> _factory = null!;
|
||||
private Mock<ITokenService> _tokenService = null!;
|
||||
private MicCheckWebApplicationFactory _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_tokenService = new Mock<ITokenService>();
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.AddScoped<ITokenService>(_ => _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<TokenResponse>();
|
||||
Assert.That(body?.Token, Is.EqualTo(expectedResponse.Token));
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRegisteringWithDuplicateEmail_ThenConflictIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"alice@example.com", "SecurePass1!", "Alice", "Smith", "Acme Corp"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"alice@example.com", "AnotherPass1!", "Alice", "Jones", "Other Corp"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithValidCredentials_ThenOkIsReturnedWithTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"bob@example.com", "SecurePass1!", "Bob", "Jones", "BobCo"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("bob@example.com", "SecurePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithWrongPassword_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"carol@example.com", "CorrectPass1!", "Carol", "White", "CarolCo"));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("carol@example.com", "WrongPassword"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithUnknownEmail_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("nobody@example.com", "SomePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoginWithEmptyEmail_ThenBadRequestIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("", "SomePass1!"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithValidToken_ThenOkIsReturnedWithNewTokens()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"dave@example.com", "SecurePass1!", "Dave", "Brown", "DaveCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody!.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
Assert.That(body?.AccessToken, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(body?.RefreshToken, Is.Not.EqualTo(registerBody.RefreshToken));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithInvalidToken_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest("not-a-valid-refresh-token"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenRefreshingWithRevokedToken_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"eve@example.com", "SecurePass1!", "Eve", "Davis", "EveCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody!.RefreshToken));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingOut_ThenNoContentIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"frank@example.com", "SecurePass1!", "Frank", "Green", "FrankCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/logout",
|
||||
new LogoutRequest(registerBody!.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenLoggingOutAndRefreshing_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync("/api/v1/auth/register", new RegisterRequest(
|
||||
"grace@example.com", "SecurePass1!", "Grace", "Black", "GraceCo"));
|
||||
var registerBody = await registerResponse.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
|
||||
await client.PostAsJsonAsync("/api/v1/auth/logout",
|
||||
new LogoutRequest(registerBody!.RefreshToken));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||
new RefreshRequest(registerBody.RefreshToken));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> SeedActiveApiKeyAsync(int organizationId = 1)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Test Key",
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAuthorizationHeaderIsAbsent_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/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<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Inactive Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/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<MicCheckDbContext>();
|
||||
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = ApiKeyHasher.Hash(rawKey),
|
||||
Prefix = rawKey[..8],
|
||||
Name = "Expired Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = true,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/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));
|
||||
}
|
||||
}
|
||||
@@ -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<IHttpContextAccessor> _httpContextAccessor = null!;
|
||||
private ProjectPermissionRequirementHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
|
||||
_httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||
_handler = new ProjectPermissionRequirementHandler(_db, _httpContextAccessor.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
private static AuthorizationHandlerContext CreateContext(
|
||||
ClaimsPrincipal user,
|
||||
ProjectPermission permission = ProjectPermission.ViewProject)
|
||||
{
|
||||
var requirement = new ProjectPermissionRequirement(permission);
|
||||
return new AuthorizationHandlerContext([requirement], user, null);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithClaims(params Claim[] claims)
|
||||
{
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
|
||||
}
|
||||
|
||||
private void SetupRouteProjectId(int projectId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.RouteValues["projectId"] = projectId.ToString();
|
||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIsOrganizationAdmin_ThenRequirementSucceeds()
|
||||
{
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"),
|
||||
new Claim("OrganizationRole", "Admin"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserHasRequiredPermission_ThenRequirementSucceeds()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 1,
|
||||
ProjectId = 10,
|
||||
Permissions = [ProjectPermission.ViewProject]
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(10);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.ViewProject);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIsProjectAdmin_ThenAllPermissionsAreGranted()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 2,
|
||||
ProjectId = 20,
|
||||
IsAdmin = true,
|
||||
Permissions = []
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(20);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "2"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserDoesNotHaveRequiredPermission_ThenRequirementFails()
|
||||
{
|
||||
_db.UserProjectPermissions.Add(new UserProjectPermission
|
||||
{
|
||||
UserId = 3,
|
||||
ProjectId = 30,
|
||||
Permissions = [ProjectPermission.ViewProject]
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
SetupRouteProjectId(30);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "3"));
|
||||
|
||||
var context = CreateContext(user, ProjectPermission.DeleteFeature);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserHasNoProjectPermissionRecord_ThenRequirementFails()
|
||||
{
|
||||
SetupRouteProjectId(99);
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "5"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUserIdClaimIsMissing_ThenRequirementFails()
|
||||
{
|
||||
var user = CreateUserWithClaims();
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenProjectIdIsNotInRoute_ThenRequirementFails()
|
||||
{
|
||||
_httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext());
|
||||
|
||||
var user = CreateUserWithClaims(
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "1"));
|
||||
|
||||
var context = CreateContext(user);
|
||||
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
Assert.That(context.HasSucceeded, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
224
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file
224
tests/api/MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs
Normal file
@@ -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<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnOrganizationIsSaved_ThenItCanBeRetrievedById()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Organizations.FindAsync(organization.Id);
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Acme"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAProjectIsSaved_ThenItCanBeRetrievedByOrganization()
|
||||
{
|
||||
var organization = new Organization { Name = "Acme", CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Organizations.Add(organization);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var project = new Project { Name = "My Project", OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var projects = await _db.Projects.Where(p => p.OrganizationId == organization.Id).ToListAsync();
|
||||
|
||||
Assert.That(projects, Has.Count.EqualTo(1));
|
||||
Assert.That(projects[0].Name, Is.EqualTo("My Project"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnEnvironmentIsSaved_ThenItCanBeRetrievedByApiKey()
|
||||
{
|
||||
var project = new Project { Name = "Test", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Projects.Add(project);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var env = new AppEnvironment { Name = "Production", ApiKey = "env-key-abc", ProjectId = project.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Environments.Add(env);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var retrieved = await _db.Environments.FirstOrDefaultAsync(e => e.ApiKey == "env-key-abc");
|
||||
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Name, Is.EqualTo("Production"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureIsSaved_ThenItCanBeRetrievedByProject()
|
||||
{
|
||||
var feature = new Feature { Name = "dark_mode", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Features.Add(feature);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var features = await _db.Features.Where(f => f.ProjectId == 1).ToListAsync();
|
||||
|
||||
Assert.That(features, Has.Count.EqualTo(1));
|
||||
Assert.That(features[0].Name, Is.EqualTo("dark_mode"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAFeatureStateIsSaved_ThenItCanBeQueriedByEnvironment()
|
||||
{
|
||||
var state = new FeatureState
|
||||
{
|
||||
FeatureId = 1,
|
||||
EnvironmentId = 1,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.FeatureStates.Add(state);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == 1).ToListAsync();
|
||||
|
||||
Assert.That(states, Has.Count.EqualTo(1));
|
||||
Assert.That(states[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenASegmentIsSaved_ThenItsRulesCanBeLoaded()
|
||||
{
|
||||
var segment = new Segment { Name = "Power Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Segments.Add(segment);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
|
||||
_db.SegmentRules.Add(rule);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.FirstAsync(s => s.Id == segment.Id);
|
||||
|
||||
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnIdentityIsSaved_ThenItsTraitsCanBeLoaded()
|
||||
{
|
||||
var identity = new Identity { Identifier = "user-123", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_db.Identities.Add(identity);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.IdentityTraits.Add(new IdentityTrait { IdentityId = identity.Id, Key = "plan", Value = "premium" });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var loaded = await _db.Identities
|
||||
.Include(i => i.Traits)
|
||||
.FirstAsync(i => i.Id == identity.Id);
|
||||
|
||||
Assert.That(loaded.Traits, Has.Count.EqualTo(1));
|
||||
Assert.That(loaded.Traits.First().Key, Is.EqualTo("plan"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnAuditLogIsSaved_ThenItCanBeFilteredByOrganization()
|
||||
{
|
||||
_db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ResourceType = "Feature",
|
||||
ResourceId = "1",
|
||||
Action = "Created",
|
||||
OrganizationId = 42,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var logs = await _db.AuditLogs.Where(a => a.OrganizationId == 42).ToListAsync();
|
||||
|
||||
Assert.That(logs, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAWebhookIsSaved_ThenItCanBeFilteredByEnvironment()
|
||||
{
|
||||
_db.Webhooks.Add(new Webhook
|
||||
{
|
||||
Url = "https://example.com/hook",
|
||||
Scope = WebhookScope.Environment,
|
||||
EnvironmentId = 5,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var webhooks = await _db.Webhooks.Where(w => w.EnvironmentId == 5).ToListAsync();
|
||||
|
||||
Assert.That(webhooks, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAnApiKeyIsSaved_ThenItCanBeRetrievedByHashedKey()
|
||||
{
|
||||
_db.ApiKeys.Add(new ApiKey
|
||||
{
|
||||
Key = "hashed-value-xyz",
|
||||
Prefix = "hashed-va",
|
||||
Name = "CI Key",
|
||||
OrganizationId = 1,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var key = await _db.ApiKeys.FirstOrDefaultAsync(k => k.Key == "hashed-value-xyz");
|
||||
|
||||
Assert.That(key, Is.Not.Null);
|
||||
Assert.That(key!.IsActive, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenAUserIsSaved_ThenItCanBeRetrievedByEmail()
|
||||
{
|
||||
_db.Users.Add(new User
|
||||
{
|
||||
Email = "alice@example.com",
|
||||
PasswordHash = "hashed",
|
||||
FirstName = "Alice",
|
||||
LastName = "Smith",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com");
|
||||
|
||||
Assert.That(user, Is.Not.Null);
|
||||
Assert.That(user!.FirstName, Is.EqualTo("Alice"));
|
||||
}
|
||||
}
|
||||
@@ -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<MicCheckDbContext>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
@@ -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<MicCheckDbContext>();
|
||||
|
||||
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<List<FlagResponse>>();
|
||||
Assert.That(flags, Has.Count.EqualTo(1));
|
||||
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
|
||||
Assert.That(flags[0].Enabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
|
||||
|
||||
var 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));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.5.1" />
|
||||
|
||||
@@ -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<Program>
|
||||
{
|
||||
private static readonly IServiceProvider InMemoryEfServiceProvider =
|
||||
new ServiceCollection()
|
||||
.AddEntityFrameworkInMemoryDatabase()
|
||||
.BuildServiceProvider();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(MicCheckDbContext));
|
||||
if (contextDescriptor is not null)
|
||||
services.Remove(contextDescriptor);
|
||||
|
||||
var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<MicCheckDbContext>));
|
||||
if (optionsDescriptor is not null)
|
||||
services.Remove(optionsDescriptor);
|
||||
|
||||
services.AddDbContext<MicCheckDbContext>(options =>
|
||||
options
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.UseInternalServiceProvider(InMemoryEfServiceProvider));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user