Initial commit, project structure and domain models

This commit is contained in:
2026-04-07 09:14:06 -07:00
commit 7b15086fe5
111 changed files with 19818 additions and 0 deletions

104
docs/01_project_setup.md Normal file
View File

@@ -0,0 +1,104 @@
# Step 1: Project Setup & Solution Structure
## Goal
Establish the solution structure, project references, NuGet packages, and foundational infrastructure for the MicCheck C# port of Flagsmith.
## Solution Layout
```
mic-check.sln
├── src/
│ ├── MicCheck.Api/ # ASP.NET Core Web API — domain, services, and controllers
│ │ ├── Features/ # Feature flag domain, services, controllers, DTOs
│ │ ├── Environments/ # Environment domain, services, controllers, DTOs
│ │ ├── Projects/ # Project domain, services, controllers, DTOs
│ │ ├── Organizations/ # Organization domain, services, controllers, DTOs
│ │ ├── Segments/ # Segment domain, services, controllers, DTOs
│ │ ├── Identities/ # Identity domain, services, controllers, DTOs
│ │ ├── Audit/ # Audit log domain, service, controller, DTOs
│ │ ├── Webhooks/ # Webhook domain, service, controller, DTOs
│ │ ├── ApiKeys/ # API key domain, service, DTOs
│ │ └── Authentication/ # Auth handlers and policies
│ └── MicCheck.Infrastructure/ # EF Core DbContext, entity configurations, migrations
└── tests/
├── MicCheck.Api.Tests/
└── MicCheck.Infrastructure.Tests/
```
Each feature folder in `MicCheck.Api` contains everything for that area:
- Domain model(s) — e.g. `Feature.cs`, `FeatureState.cs`
- Service(s) — e.g. `FeatureService.cs`
- Controller(s) — e.g. `FeaturesController.cs`
- Request/Response DTOs — e.g. `CreateFeatureRequest.cs`, `FeatureResponse.cs`
## Project Configurations
### All `.csproj` files must include:
```xml
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
```
## MicCheck.Api
**Purpose:** The entire application — domain models, business logic services, and HTTP controllers all live here, organized by feature area.
**NuGet packages:**
- `Microsoft.AspNetCore.Authentication.JwtBearer` (latest)
- `Swashbuckle.AspNetCore` (latest)
- `Serilog.AspNetCore` (latest)
- `FluentValidation.AspNetCore` (latest)
**Project reference:**
- References `MicCheck.Infrastructure`
## MicCheck.Infrastructure
**Purpose:** EF Core DbContext, entity configurations, and PostgreSQL migrations only. No business logic.
**NuGet packages:**
- `Microsoft.EntityFrameworkCore` (latest for net10.0)
- `Npgsql.EntityFrameworkCore.PostgreSQL` (latest)
- `Microsoft.EntityFrameworkCore.Design` (latest)
**Key namespaces:**
- `MicCheck.Infrastructure.Data` — DbContext, entity configurations, migrations
## Test Projects
**NuGet packages (all test projects):**
- `NUnit` (latest)
- `NUnit3TestAdapter` (latest)
- `Moq` (latest)
- `Microsoft.NET.Test.Sdk` (latest)
- `coverlet.collector` (latest)
**Project references:**
- `MicCheck.Api.Tests` references `MicCheck.Api` and `MicCheck.Infrastructure`
- `MicCheck.Infrastructure.Tests` references `MicCheck.Infrastructure`
## Docker Compose (development)
Services required:
- `postgres:15-alpine` — primary database, port 5432
- `mic-check-api` — the ASP.NET Core API, port 8080
## Environment Variables
```
DATABASE_URL=postgresql://miccheck:password@localhost:5432/miccheck
JWT_SECRET=<secret>
ADMIN_API_KEY=<key>
```
## Acceptance Criteria
- [ ] Solution builds with `dotnet build` with zero warnings
- [ ] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
- [ ] Docker Compose brings up API and database cleanly
- [ ] `dotnet test` runs successfully (no tests yet, but harness works)
- [ ] Swagger UI is accessible at `/swagger`

View File

@@ -0,0 +1,67 @@
# Step 1 Implementation Output
## What Was Implemented
### Solution Structure
The solution (`MicCheck.slnx`) now contains four projects:
| Project | Path |
|---|---|
| `MicCheck.Api` | `src/api/MicCheck.Api/` |
| `MicCheck.Infrastructure` | `src/infrastructure/MicCheck.Infrastructure/` |
| `MicCheck.Api.Tests.Unit` | `tests/api/MicCheck.Api.Tests.Unit/` |
| `MicCheck.Infrastructure.Tests` | `tests/infrastructure/MicCheck.Infrastructure.Tests/` |
### Projects Created
**`MicCheck.Infrastructure`** — new class library targeting net10.0 with:
- `Microsoft.EntityFrameworkCore` 10.0.5
- `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1
- `Microsoft.EntityFrameworkCore.Design` 10.0.5
- Folder structure: `Data/Configurations/`, `Data/Migrations/`
**`MicCheck.Infrastructure.Tests`** — new NUnit test project targeting net10.0 with:
- `NUnit` 4.3.2, `NUnit3TestAdapter` 5.0.0, `NUnit.Analyzers` 4.7.0
- `Moq` 4.20.72, `coverlet.collector` 6.0.4
- Project reference to `MicCheck.Infrastructure`
### Projects Updated
**`MicCheck.Api`** — added:
- `TreatWarningsAsErrors`
- `Serilog.AspNetCore` 9.0.0
- `FluentValidation.AspNetCore` 11.3.0
- Project reference to `MicCheck.Infrastructure`
- `Program.cs` updated: Serilog bootstrap logger, `AddControllers()`, `AddFluentValidationAutoValidation()`, `AddRateLimiter("AdminApi")`, `MapControllers()`
- `appsettings.json` updated: Serilog config section, `ConnectionStrings:DefaultConnection`
**`MicCheck.Api.Tests.Unit`** — added:
- `TreatWarningsAsErrors`
- `coverlet.collector` 6.0.4
- Project reference to `MicCheck.Infrastructure`
### Feature Folders Created in MicCheck.Api
`Features/`, `Environments/`, `Projects/`, `Organizations/`, `Segments/`, `Identities/`, `Audit/`, `Webhooks/`, `ApiKeys/`, `Users/`
Existing `Auth/` folder retained (equivalent to `Authentication/` in the plan).
### Files Added
- `docker-compose.yml` — PostgreSQL 15-alpine + API service with health check
- `src/api/MicCheck.Api/Dockerfile` — multi-stage build (SDK → publish → runtime)
### Deviations from Plan
- **Scalar instead of Swashbuckle**: The project already used `Scalar.AspNetCore` + `Microsoft.AspNetCore.OpenApi`. This is the recommended approach for .NET 10 and was retained. The API reference UI is at `/scalar` rather than `/swagger`.
- **`Auth/` folder**: The existing auth code lives in `Auth/` rather than `Authentication/` as named in the plan. The existing code was not renamed.
- **NUnit template version**: `MicCheck.Infrastructure.Tests` was created with `dotnet new nunit` which brought in NUnit 4.3.2 / NUnit3TestAdapter 5.0.0 (slightly different patch versions from the Api test project). Both are current stable releases.
## Acceptance Criteria
- [x] Solution builds with `dotnet build` with zero warnings
- [x] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
- [x] `dotnet test` runs successfully — 8 tests pass across 2 test projects
- [x] API reference UI accessible at `/scalar` (Scalar, not Swagger)
- [ ] Docker Compose brings up API and database cleanly — requires Docker; not verified in this step

View File

@@ -0,0 +1,332 @@
# Step 2: Domain Models
## Goal
Define all domain entities in `MicCheck.Api`, organized by feature area. Each entity lives in the folder for the feature it belongs to.
## Constraints (from Flagsmith spec)
- Max 400 features per project
- Max 100 segments per project
- Max 100 segment overrides per environment
- Max 100 segment rule conditions
- Feature flag values: max 20,000 bytes
- Identity trait values: max 2,000 bytes
---
## MicCheck.Api/Organizations/
```csharp
// Organizations/Organization.cs
public class Organization
{
public int Id { get; init; }
public required string Name { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<Project> Projects { get; init; } = [];
public ICollection<OrganizationUser> Members { get; init; } = [];
public ICollection<ApiKey> ApiKeys { get; init; } = [];
}
// Organizations/OrganizationUser.cs
public class OrganizationUser
{
public int OrganizationId { get; init; }
public int UserId { get; init; }
public OrganizationRole Role { get; set; }
}
public enum OrganizationRole { Admin, User }
```
---
## MicCheck.Api/Projects/
```csharp
// Projects/Project.cs
public class Project
{
public int Id { get; init; }
public required string Name { get; set; }
public int OrganizationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public bool HideDisabledFlags { get; set; }
public ICollection<Environment> Environments { get; init; } = [];
public ICollection<Feature> Features { get; init; } = [];
public ICollection<Segment> Segments { get; init; } = [];
}
```
---
## MicCheck.Api/Environments/
```csharp
// Environments/Environment.cs
public class Environment
{
public int Id { get; init; }
public required string Name { get; set; }
public required string ApiKey { get; set; } // X-Environment-Key header value
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<FeatureState> FeatureStates { get; init; } = [];
}
```
---
## MicCheck.Api/Features/
```csharp
// Features/Feature.cs
public class Feature
{
public int Id { get; init; }
public required string Name { get; set; }
public FeatureType Type { get; set; }
public string? InitialValue { get; set; } // max 20,000 bytes
public string? Description { get; set; }
public bool DefaultEnabled { get; set; }
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<FeatureState> FeatureStates { get; init; } = [];
public ICollection<Tag> Tags { get; init; } = [];
}
public enum FeatureType { Standard, MultiVariate }
// Features/FeatureState.cs
// Represents the value of a feature within a specific environment
public class FeatureState
{
public int Id { get; init; }
public int FeatureId { get; init; }
public int EnvironmentId { get; init; }
public int? IdentityId { get; set; } // null = environment-level
public bool Enabled { get; set; }
public string? Value { get; set; } // max 20,000 bytes
public int? FeatureSegmentId { get; set; } // null = not a segment override
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public int Version { get; set; } // optimistic concurrency
}
// Features/FeatureSegment.cs
// Links a feature to a segment with an override priority
public class FeatureSegment
{
public int Id { get; init; }
public int FeatureId { get; init; }
public int SegmentId { get; init; }
public int EnvironmentId { get; init; }
public int Priority { get; set; }
public FeatureState? FeatureState { get; set; }
}
// Features/Tag.cs
public class Tag
{
public int Id { get; init; }
public required string Label { get; set; }
public required string Color { get; set; }
public int ProjectId { get; init; }
}
// Features/FeatureStateResult.cs
// Value object returned by FeatureEvaluationService — never exposed directly via API
public class FeatureStateResult
{
public required Feature Feature { get; init; }
public bool Enabled { get; init; }
public string? Value { get; init; }
}
```
---
## MicCheck.Api/Segments/
```csharp
// Segments/Segment.cs
public class Segment
{
public int Id { get; init; }
public required string Name { get; set; }
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<SegmentRule> Rules { get; init; } = [];
}
// Segments/SegmentRule.cs
public class SegmentRule
{
public int Id { get; init; }
public int SegmentId { get; init; }
public int? ParentRuleId { get; set; } // allows nested AND/OR
public SegmentRuleType Type { get; set; }
public ICollection<SegmentCondition> Conditions { get; init; } = [];
public ICollection<SegmentRule> ChildRules { get; init; } = [];
}
public enum SegmentRuleType { All, Any, None }
// Segments/SegmentCondition.cs
public class SegmentCondition
{
public int Id { get; init; }
public int RuleId { get; init; }
public required string Property { get; set; } // trait key
public SegmentConditionOperator Operator { get; set; }
public required string Value { get; set; }
}
public enum SegmentConditionOperator
{
Equal, NotEqual,
GreaterThan, GreaterThanOrEqual,
LessThan, LessThanOrEqual,
Contains, NotContains,
Regex,
IsSet, IsNotSet,
In, NotIn,
PercentageSplit,
ModuloValueDivisorRemainder,
IsTrue, IsFalse
}
```
---
## MicCheck.Api/Identities/
```csharp
// Identities/Identity.cs
// Represents an end-user or SDK client
public class Identity
{
public int Id { get; init; }
public required string Identifier { get; set; }
public int EnvironmentId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<IdentityTrait> Traits { get; init; } = [];
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
}
// Identities/IdentityTrait.cs
public class IdentityTrait
{
public int Id { get; init; }
public int IdentityId { get; init; }
public required string Key { get; set; }
public required string Value { get; set; } // max 2,000 bytes
public TraitValueType ValueType { get; set; }
}
public enum TraitValueType { String, Integer, Float, Boolean }
```
---
## MicCheck.Api/Audit/
```csharp
// Audit/AuditLog.cs
public class AuditLog
{
public int Id { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required string Action { get; init; } // Created, Updated, Deleted
public string? Changes { get; init; } // JSON diff
public int OrganizationId { get; init; }
public int? ProjectId { get; init; }
public int? EnvironmentId { get; init; }
public int? ActorUserId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
```
---
## MicCheck.Api/Webhooks/
```csharp
// Webhooks/Webhook.cs
public class Webhook
{
public int Id { get; init; }
public required string Url { get; set; }
public string? Secret { get; set; }
public WebhookScope Scope { get; set; }
public int? EnvironmentId { get; set; }
public int? OrganizationId { get; set; }
public bool Enabled { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}
public enum WebhookScope { Environment, Organization }
// Webhooks/WebhookDeliveryLog.cs
public class WebhookDeliveryLog
{
public int Id { get; init; }
public int WebhookId { get; init; }
public required string EventType { get; init; }
public required string PayloadJson { get; init; }
public int? ResponseStatusCode { get; set; }
public string? ResponseBody { get; set; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public DateTimeOffset AttemptedAt { get; init; }
public TimeSpan Duration { get; set; }
}
```
---
## MicCheck.Api/ApiKeys/
```csharp
// ApiKeys/ApiKey.cs
public class ApiKey
{
public int Id { get; init; }
public required string Key { get; set; } // hashed
public required string Prefix { get; set; } // first 8 chars for display
public required string Name { get; set; }
public int OrganizationId { get; init; }
public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}
```
---
## MicCheck.Api/Users/ (referenced by auth)
```csharp
// Users/User.cs
public class User
{
public int Id { get; init; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string FirstName { get; set; }
public required string LastName { get; set; }
public bool IsActive { get; set; }
public bool IsTwoFactorEnabled { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; set; }
public ICollection<OrganizationUser> Organizations { get; init; } = [];
}
```
---
## Acceptance Criteria
- [ ] All domain models compile within `MicCheck.Api` with no cross-project domain dependencies
- [ ] Nullable reference types enabled — all required properties explicitly marked
- [ ] No domain logic in DTOs — domain models and request/response types are distinct classes
- [ ] Unit tests validate domain constraints (e.g., feature count limits enforced in service logic)

View File

@@ -0,0 +1,72 @@
# Step 2 Implementation Output
## What Was Implemented
### Domain Models Created
19 files added to `MicCheck.Api`, each in its feature area folder:
| File | Namespace |
|---|---|
| `Organizations/Organization.cs` | `MicCheck.Api.Organizations` |
| `Organizations/OrganizationUser.cs` | `MicCheck.Api.Organizations` |
| `Projects/Project.cs` | `MicCheck.Api.Projects` |
| `Environments/Environment.cs` | `MicCheck.Api.Environments` |
| `Features/Feature.cs` | `MicCheck.Api.Features` |
| `Features/FeatureState.cs` | `MicCheck.Api.Features` |
| `Features/FeatureSegment.cs` | `MicCheck.Api.Features` |
| `Features/Tag.cs` | `MicCheck.Api.Features` |
| `Features/FeatureStateResult.cs` | `MicCheck.Api.Features` |
| `Segments/Segment.cs` | `MicCheck.Api.Segments` |
| `Segments/SegmentRule.cs` | `MicCheck.Api.Segments` |
| `Segments/SegmentCondition.cs` | `MicCheck.Api.Segments` |
| `Identities/Identity.cs` | `MicCheck.Api.Identities` |
| `Identities/IdentityTrait.cs` | `MicCheck.Api.Identities` |
| `Audit/AuditLog.cs` | `MicCheck.Api.Audit` |
| `Webhooks/Webhook.cs` | `MicCheck.Api.Webhooks` |
| `Webhooks/WebhookDeliveryLog.cs` | `MicCheck.Api.Webhooks` |
| `ApiKeys/ApiKey.cs` | `MicCheck.Api.ApiKeys` |
| `Users/User.cs` | `MicCheck.Api.Users` |
### Enums Defined
| Enum | Values | File |
|---|---|---|
| `OrganizationRole` | `User`, `Admin` | `OrganizationUser.cs` |
| `FeatureType` | `Standard`, `MultiVariate` | `Feature.cs` |
| `SegmentRuleType` | `All`, `Any`, `None` | `SegmentRule.cs` |
| `SegmentConditionOperator` | 17 values | `SegmentCondition.cs` |
| `TraitValueType` | `String`, `Integer`, `Float`, `Boolean` | `IdentityTrait.cs` |
| `WebhookScope` | `Environment`, `Organization` | `Webhook.cs` |
### Tests Created
10 test files covering all domain areas, 51 new tests (58 total in the suite):
| File | Tests |
|---|---|
| `Organizations/OrganizationTests.cs` | 6 |
| `Projects/ProjectTests.cs` | 3 |
| `Environments/EnvironmentTests.cs` | 2 |
| `Features/FeatureTests.cs` | 12 |
| `Segments/SegmentTests.cs` | 7 |
| `Identities/IdentityTests.cs` | 5 |
| `Audit/AuditLogTests.cs` | 2 |
| `Webhooks/WebhookTests.cs` | 6 |
| `ApiKeys/ApiKeyTests.cs` | 3 |
| `Users/UserTests.cs` | 4 |
## Deviations from Plan
- **`OrganizationRole` enum ordering**: The plan declared `{ Admin, User }` making `Admin` the zero-value default. Reordered to `{ User, Admin }` so that an `OrganizationUser` created without an explicit role safely defaults to `User` rather than `Admin`.
- **`System.Environment` collision**: `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` (pulled in via implicit usings). Resolved with `using AppEnvironment = MicCheck.Api.Environments.Environment;` in `Projects/Project.cs` and the corresponding test file. The class name `Environment` was kept as specified in the plan.
- **`using NUnit.Framework;`**: The existing `MicCheck.Api.Tests.Unit` project does not configure `NUnit.Framework` as a global using (unlike the `MicCheck.Infrastructure.Tests` template). All new test files include the using directive explicitly.
## Acceptance Criteria
- [x] All domain models compile with zero warnings
- [x] Nullable reference types enabled — all required properties explicitly marked with `required`
- [x] No domain logic in DTOs — models are plain C# classes with no service dependencies
- [x] `dotnet test` — 59 tests pass, 0 failures

View File

@@ -0,0 +1,195 @@
# Step 3: Database Schema & EF Core
## Goal
Implement the EF Core `DbContext` and entity configurations in `MicCheck.Infrastructure`, and wire up services in `MicCheck.Api` to use it directly. There is no repository abstraction layer — services inject `MicCheckDbContext` and query it with LINQ.
---
## DbContext
```csharp
// MicCheck.Infrastructure/Data/MicCheckDbContext.cs
public class MicCheckDbContext : DbContext
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<Environment> Environments => Set<Environment>();
public DbSet<Feature> Features => Set<Feature>();
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
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<Tag> Tags => Set<Tag>();
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
}
```
---
## Entity Configurations (EF Core Fluent API)
Each domain entity gets its own `IEntityTypeConfiguration<T>` in `MicCheck.Infrastructure/Data/Configurations/`:
### Key configurations:
**Feature:**
```csharp
entity.Property(f => f.Name).HasMaxLength(150).IsRequired();
entity.Property(f => f.InitialValue).HasMaxLength(20_000);
entity.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
```
**FeatureState:**
```csharp
entity.Property(fs => fs.Value).HasMaxLength(20_000);
entity.Property(fs => fs.Version).IsConcurrencyToken();
entity.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
```
**IdentityTrait:**
```csharp
entity.Property(t => t.Value).HasMaxLength(2_000);
entity.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
```
**Environment:**
```csharp
entity.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
entity.HasIndex(e => e.ApiKey).IsUnique();
```
**ApiKey:**
```csharp
entity.Property(k => k.Key).HasMaxLength(512).IsRequired(); // stores hash
entity.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
entity.HasIndex(k => k.Key).IsUnique();
```
---
## Service Usage Pattern
Services receive `MicCheckDbContext` via constructor injection and query it directly:
```csharp
// MicCheck.Api/Features/FeatureService.cs
public class FeatureService
{
private readonly MicCheckDbContext _db;
private readonly AuditService _auditService;
public FeatureService(MicCheckDbContext db, AuditService auditService)
{
_db = db;
_auditService = auditService;
}
public async Task<Feature> CreateAsync(
int projectId, string name, FeatureType type, string? initialValue,
CancellationToken ct = default)
{
var count = await _db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= 400)
throw new DomainException("Projects may not have more than 400 features.");
var feature = new Feature { ProjectId = projectId, Name = name, Type = type, InitialValue = initialValue };
_db.Features.Add(feature);
// Auto-create FeatureState for every existing environment
var environments = await _db.Environments
.Where(e => e.ProjectId == projectId)
.ToListAsync(ct);
foreach (var env in environments)
_db.FeatureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = env.Id });
await _db.SaveChangesAsync(ct);
await _auditService.RecordAsync("Feature", feature.Id.ToString(), "Created", ...);
return feature;
}
}
```
Controllers call the service and map the returned domain object to a response DTO:
```csharp
// MicCheck.Api/Features/FeaturesController.cs
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
var feature = await _featureService.CreateAsync(
projectId, request.Name, request.Type, request.InitialValue, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
FeatureResponse.From(feature));
}
```
The static `From` method (or a mapping method on the controller) is the only place a domain object becomes a DTO.
---
## Migrations
- Use EF Core code-first migrations
- Migration naming: `{Timestamp}_{DescriptiveName}` (e.g., `20260101_InitialSchema`)
- All migrations in `MicCheck.Infrastructure/Data/Migrations/`
- Run via `dotnet ef migrations add` and `dotnet ef database update`
- The `--project MicCheck.Infrastructure --startup-project MicCheck.Api` flags are required since the DbContext is in Infrastructure
---
## Connection String Configuration
```json
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
}
}
```
Support `DATABASE_URL` environment variable override (Heroku/Railway style parsing).
---
## Seed Data
Create an initial migration seeder for local development:
- Default organization: "Default"
- Default project: "My Project"
- Default environments: "Development", "Staging", "Production"
---
## Indexes for Performance
Critical indexes beyond unique constraints:
- `FeatureState (EnvironmentId)` — for flag serving
- `FeatureState (IdentityId)` — for identity lookups
- `Identity (EnvironmentId, Identifier)` — unique, for identity resolution
- `AuditLog (OrganizationId, CreatedAt DESC)` — for audit queries
- `Webhook (EnvironmentId)` — for event dispatch
---
## Acceptance Criteria
- [ ] `dotnet ef migrations add InitialSchema` generates a valid migration
- [ ] `dotnet ef database update` applies cleanly against a fresh PostgreSQL instance
- [ ] All unique constraints are enforced at the database level
- [ ] Services use `MicCheckDbContext` directly — no repository interfaces
- [ ] No raw SQL — all queries use EF Core LINQ
- [ ] Integration tests against a real test database verify EF configurations are correct

View File

@@ -0,0 +1,214 @@
# Step 4: Authentication & Authorization
## Goal
Implement the two authentication schemes Flagsmith uses — Environment Key (public, for Flags API) and API Key (secret, for Admin API) — plus JWT for dashboard users, and role-based authorization.
---
## Authentication Schemes
### 1. Environment Key (Flags API)
- Delivered via `X-Environment-Key` header
- Public — safe for client-side exposure
- Identifies which environment to serve flags for
- No user identity implied
**Implementation:**
```csharp
// MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
public class EnvironmentKeyAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
// Reads X-Environment-Key header
// Looks up Environment from DB by ApiKey value
// Sets ClaimsPrincipal with EnvironmentId claim
// Scheme name: "EnvironmentKey"
}
```
### 2. Organisation API Key (Admin API)
- Delivered via `Authorization: Api-Key <TOKEN>` header
- Secret — never expose client-side
- Identifies the organization and grants admin access
- Stored hashed in DB; prefix shown for identification
**Implementation:**
```csharp
// MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
public class ApiKeyAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
// Parses "Api-Key <TOKEN>" from Authorization header
// Hashes the provided token
// Looks up ApiKey record by hash
// Validates IsActive and ExpiresAt
// Sets ClaimsPrincipal with OrganizationId and UserId claims
// Scheme name: "ApiKey"
}
```
**API key hashing:**
- Use `SHA256` for hashing stored keys
- Store prefix (first 8 chars) for display/identification only
- Generate new keys with `RandomNumberGenerator.GetBytes(32)` → Base64URL encoded
### 3. JWT (Dashboard Users)
- Standard bearer token authentication
- Used for the admin web UI
- Short-lived access token + refresh token pattern
**Implementation:**
```csharp
builder.Services.AddAuthentication()
.AddJwtBearer("Bearer", options => {
options.TokenValidationParameters = new() {
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!))
};
});
```
---
## Authorization Policies
Define named policies in `MicCheck.Api/Authorization/`:
```csharp
// Policies
public static class AuthorizationPolicies
{
public const string FlagsApiAccess = "FlagsApiAccess";
public const string AdminApiAccess = "AdminApiAccess";
public const string OrganizationAdmin = "OrganizationAdmin";
}
```
**Policy registrations:**
```csharp
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes("EnvironmentKey")
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
.RequireClaim("OrganizationRole", "Admin"));
});
```
---
## Role-Based Access Control (RBAC)
### Organization Roles
- `Admin` — full access to all projects and settings
- `User` — access to assigned projects only
### Project-Level Permissions
```csharp
// MicCheck.Api/Authorization/ProjectPermission.cs
public enum ProjectPermission
{
ViewProject,
CreateFeature, EditFeature, DeleteFeature,
CreateEnvironment, EditEnvironment, DeleteEnvironment,
CreateSegment, EditSegment, DeleteSegment,
ManageWebhooks,
ViewAuditLog
}
// MicCheck.Api/Authorization/UserProjectPermission.cs
public class UserProjectPermission
{
public int UserId { get; init; }
public int ProjectId { get; init; }
public ICollection<ProjectPermission> Permissions { get; init; } = [];
public bool IsAdmin { get; set; } // grants all permissions
}
```
### Authorization requirement handler:
```csharp
// MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
// Checks UserProjectPermission for the current user and requested project
// Organization admins bypass all project-level checks
```
---
## User Model
```csharp
// MicCheck.Api/Users/User.cs
public class User
{
public int Id { get; init; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string FirstName { get; set; }
public required string LastName { get; set; }
public bool IsActive { get; set; }
public bool IsTwoFactorEnabled { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; set; }
public ICollection<OrganizationUser> Organizations { get; init; } = [];
}
```
**Password hashing:** Use ASP.NET Core's `IPasswordHasher<User>`.
---
## Auth Endpoints
```
POST /api/v1/auth/login → Returns JWT access + refresh tokens
POST /api/v1/auth/refresh → Exchanges refresh token for new access token
POST /api/v1/auth/logout → Revokes refresh token
POST /api/v1/auth/register → Creates new user + organization
POST /api/v1/organisations/{id}/api-keys/ → Create new API key
GET /api/v1/organisations/{id}/api-keys/ → List API keys (prefix only)
DELETE /api/v1/organisations/{id}/api-keys/{keyId}/ → Revoke key
```
---
## Rate Limiting
Apply to Admin API:
- 500 requests per minute per API key
- Use ASP.NET Core's built-in rate limiting middleware (`Microsoft.AspNetCore.RateLimiting`)
```csharp
builder.Services.AddRateLimiter(options =>
options.AddFixedWindowLimiter("AdminApi", limiter => {
limiter.PermitLimit = 500;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
}));
```
---
## Acceptance Criteria
- [ ] `X-Environment-Key` header resolves to correct environment; invalid key returns 401
- [ ] `Authorization: Api-Key <TOKEN>` validates against hashed DB record; invalid key returns 401
- [ ] JWT tokens are issued and validated correctly
- [ ] Organization admins have access to all project operations
- [ ] Project-level permission checks block unauthorized users with 403
- [ ] Rate limit returns 429 after 500 Admin API requests within a minute
- [ ] Unit tests cover: key hashing, auth handler success/failure paths, permission checks

264
docs/05_flags_api.md Normal file
View File

@@ -0,0 +1,264 @@
# Step 5: Flags API (Public SDK Endpoint)
## Goal
Implement the public Flags API — the endpoints consumed by SDK clients to retrieve feature flags and identify users. This is the high-traffic, read-heavy API authenticated via `X-Environment-Key`.
---
## Endpoints
All routes prefixed with `/api/v1/` and authenticated with the `EnvironmentKey` scheme.
### GET /api/v1/flags/
Returns all enabled feature flags for the resolved environment.
**Response:**
```json
[
{
"id": 1,
"feature": {
"id": 1,
"name": "dark_mode",
"type": "STANDARD"
},
"enabled": true,
"feature_state_value": null
}
]
```
### GET /api/v1/identities/?identifier=user123
Returns flags for a specific user identity, including segment overrides and identity-level overrides.
### POST /api/v1/identities/
Identifies a user, optionally sets traits, and returns flags.
**Request:**
```json
{
"identifier": "user123",
"traits": [
{ "trait_key": "plan", "trait_value": "premium" }
]
}
```
**Response:**
```json
{
"traits": [...],
"flags": [...]
}
```
### GET /api/v1/environment-document/
Returns the full environment configuration for **local evaluation mode**.
Used by server-side SDKs to pull all flags, segments, and targeting rules locally.
**Response:**
```json
{
"id": 1,
"api_key": "env-key-xxx",
"feature_states": [...],
"project": {
"id": 1,
"name": "My Project",
"segments": [...]
}
}
```
---
## Feature Evaluation Service
Lives in `MicCheck.Api/Features/`. Injects `MicCheckDbContext` and returns domain objects — no DTOs.
```csharp
// MicCheck.Api/Features/FeatureEvaluationService.cs
public class FeatureEvaluationService
{
private readonly MicCheckDbContext _db;
private readonly SegmentEvaluator _segmentEvaluator;
private readonly FlagCache _flagCache;
// Resolves the effective FeatureState for a given environment
// Priority order (highest wins):
// 1. Identity-level override
// 2. Segment override (highest priority segment that matches)
// 3. Environment default
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
int environmentId, CancellationToken ct = default);
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
int environmentId,
string identifier,
IReadOnlyList<TraitInput>? traits = null,
CancellationToken ct = default);
}
```
---
## Identity Resolution Service
Lives in `MicCheck.Api/Identities/`. Injects `MicCheckDbContext` and returns domain objects.
```csharp
// MicCheck.Api/Identities/IdentityResolutionService.cs
public class IdentityResolutionService
{
private readonly MicCheckDbContext _db;
// 1. Look up Identity by (EnvironmentId, Identifier) — create if not found
// 2. Upsert provided traits by key
// 3. Return the resolved Identity with current traits loaded
public Task<Identity> ResolveAsync(
int environmentId,
string identifier,
IReadOnlyList<TraitInput>? traits,
CancellationToken ct = default);
}
```
---
## Segment Evaluator
Lives in `MicCheck.Api/Segments/`. Pure logic — no DB access (data is preloaded by callers).
```csharp
// MicCheck.Api/Segments/SegmentEvaluator.cs
public class SegmentEvaluator
{
public bool Evaluate(Segment segment, IReadOnlyList<IdentityTrait> traits);
private bool EvaluateRule(SegmentRule rule, IReadOnlyList<IdentityTrait> traits);
private bool EvaluateCondition(SegmentCondition condition, IReadOnlyList<IdentityTrait> traits);
}
```
**Operator implementations** (pure functions):
- String: Equal, NotEqual, Contains, NotContains, Regex
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
- Boolean: IsTrue, IsFalse
- Membership: In, NotIn
- Existence: IsSet, IsNotSet
- Sampling: PercentageSplit (deterministic by identity+segment hash)
- Modulo: ModuloValueDivisorRemainder
---
## Request/Response DTOs
Live alongside their controllers in the relevant feature folder. DTOs are only constructed in controller action methods — services never return or accept DTOs.
```csharp
// Features/FlagResponse.cs
public record FlagResponse(
int Id,
FeatureSummaryResponse Feature,
bool Enabled,
string? FeatureStateValue
)
{
public static FlagResponse From(FeatureStateResult result) => new(
result.Feature.Id, // mapping happens here, in the response type, called from the controller
FeatureSummaryResponse.From(result.Feature),
result.Enabled,
result.Value);
}
// Features/FeatureSummaryResponse.cs
public record FeatureSummaryResponse(int Id, string Name, string Type)
{
public static FeatureSummaryResponse From(Feature feature) =>
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
}
// Identities/IdentityRequest.cs
public record IdentityRequest(
string Identifier,
IReadOnlyList<TraitInput>? Traits
);
// Identities/TraitInput.cs
public record TraitInput(
string TraitKey,
object? TraitValue // string | int | float | bool
);
```
---
## Controllers
Controllers receive a request DTO, call a service with domain-level parameters, then map the domain result to a response DTO before returning.
```csharp
// MicCheck.Api/Features/FlagsController.cs
[ApiController]
[Route("api/v1/flags")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class FlagsController : ControllerBase
{
private readonly FeatureEvaluationService _featureEvaluationService;
[HttpGet]
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
{
var environmentId = int.Parse(User.FindFirstValue("EnvironmentId")!);
var results = await _featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
return Ok(results.Select(FlagResponse.From).ToList());
}
}
// MicCheck.Api/Identities/IdentitiesController.cs
[ApiController]
[Route("api/v1/identities")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class IdentitiesController : ControllerBase { ... }
// MicCheck.Api/Environments/EnvironmentDocumentController.cs
[ApiController]
[Route("api/v1/environment-document")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class EnvironmentDocumentController : ControllerBase { ... }
```
---
## Caching
Lives in `MicCheck.Api/Features/`. Cache stores domain results, not serialized responses.
```csharp
// MicCheck.Api/Features/FlagCache.cs
public class FlagCache
{
private readonly IMemoryCache _cache;
public IReadOnlyList<FeatureStateResult>? Get(int environmentId);
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags);
public void Invalidate(int environmentId);
}
```
Cache is invalidated inside `FeatureEvaluationService` whenever a `FeatureState` is modified. Designed to swap the backing store for Redis without changing the `FlagCache` interface.
---
## Acceptance Criteria
- [ ] `GET /api/v1/flags/` returns correct flags for a valid environment key
- [ ] `GET /api/v1/flags/` returns 401 for missing or invalid environment key
- [ ] `POST /api/v1/identities/` creates identity + upserts traits + returns correct flags
- [ ] Segment override takes priority over environment default
- [ ] Identity override takes priority over segment override
- [ ] `GET /api/v1/environment-document/` returns full environment config
- [ ] Controllers never pass DTOs into services — only primitive/domain values
- [ ] `SegmentEvaluator` unit tests cover all operator types
- [ ] `FeatureEvaluationService` unit tests cover all three priority tiers
- [ ] `PercentageSplit` distributes identities deterministically and uniformly

325
docs/06_admin_api.md Normal file
View File

@@ -0,0 +1,325 @@
# Step 6: Admin API (Management Endpoints)
## Goal
Implement the Admin API — the authenticated management API for creating and managing projects, environments, features, segments, users, and API keys. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
---
## API Versioning
```csharp
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
});
```
Routes:
- Current: `/api/v1/`
- Future: `/api/v2/`
All Admin API controllers:
```csharp
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
```
---
## Endpoint Groups
### Organizations
```
GET /api/v1/organisations/
POST /api/v1/organisations/
GET /api/v1/organisations/{id}/
PUT /api/v1/organisations/{id}/
DELETE /api/v1/organisations/{id}/
GET /api/v1/organisations/{id}/users/
POST /api/v1/organisations/{id}/users/invite/
DELETE /api/v1/organisations/{id}/users/{userId}/
```
### Projects
```
GET /api/v1/projects/
POST /api/v1/projects/
GET /api/v1/projects/{id}/
PUT /api/v1/projects/{id}/
DELETE /api/v1/projects/{id}/
GET /api/v1/projects/{id}/user-permissions/
POST /api/v1/projects/{id}/user-permissions/
PUT /api/v1/projects/{id}/user-permissions/{userId}/
DELETE /api/v1/projects/{id}/user-permissions/{userId}/
```
### Environments
```
GET /api/v1/environments/
POST /api/v1/environments/
GET /api/v1/environments/{apiKey}/
PUT /api/v1/environments/{apiKey}/
DELETE /api/v1/environments/{apiKey}/
POST /api/v1/environments/{apiKey}/clone/
GET /api/v1/environments/{apiKey}/webhooks/
POST /api/v1/environments/{apiKey}/webhooks/
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
```
### Features
```
GET /api/v1/projects/{projectId}/features/
POST /api/v1/projects/{projectId}/features/
GET /api/v1/projects/{projectId}/features/{id}/
PUT /api/v1/projects/{projectId}/features/{id}/
PATCH /api/v1/projects/{projectId}/features/{id}/
DELETE /api/v1/projects/{projectId}/features/{id}/
```
### Feature States (Environment-level)
```
GET /api/v1/environments/{apiKey}/featurestates/
GET /api/v1/environments/{apiKey}/featurestates/{id}/
PUT /api/v1/environments/{apiKey}/featurestates/{id}/
PATCH /api/v1/environments/{apiKey}/featurestates/{id}/
```
### Segments
```
GET /api/v1/projects/{projectId}/segments/
POST /api/v1/projects/{projectId}/segments/
GET /api/v1/projects/{projectId}/segments/{id}/
PUT /api/v1/projects/{projectId}/segments/{id}/
DELETE /api/v1/projects/{projectId}/segments/{id}/
```
### Identities (Admin view)
```
GET /api/v1/environments/{apiKey}/identities/
GET /api/v1/environments/{apiKey}/identities/{id}/
DELETE /api/v1/environments/{apiKey}/identities/{id}/
GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/
PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
```
### Audit Logs
```
GET /api/v1/organisations/{id}/audit-logs/
GET /api/v1/projects/{projectId}/audit-logs/
GET /api/v1/environments/{apiKey}/audit-logs/
```
### Tags
```
GET /api/v1/projects/{projectId}/tags/
POST /api/v1/projects/{projectId}/tags/
DELETE /api/v1/projects/{projectId}/tags/{id}/
```
---
## Service Layer
Each feature area has a service class in its folder within `MicCheck.Api`. Services accept and return domain objects — they have no knowledge of request/response DTOs. Services inject `MicCheckDbContext` directly.
```csharp
// MicCheck.Api/Features/FeatureService.cs
public class FeatureService
{
private readonly MicCheckDbContext _db;
private readonly AuditService _auditService;
// Enforces: max 400 features per project
// On create: auto-creates FeatureState records for all existing environments
public Task<Feature> CreateAsync(int projectId, string name, FeatureType type,
string? initialValue, string? description, CancellationToken ct = default);
public Task<Feature> UpdateAsync(int id, string name, string? description,
CancellationToken ct = default);
public Task DeleteAsync(int id, CancellationToken ct = default);
public Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default);
public Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId,
CancellationToken ct = default);
}
// MicCheck.Api/Environments/EnvironmentService.cs
public class EnvironmentService
{
private readonly MicCheckDbContext _db;
private readonly AuditService _auditService;
// On create: auto-creates FeatureState records for all existing features
public Task<Environment> CreateAsync(int projectId, string name, CancellationToken ct = default);
// Clone: duplicates all environment-level FeatureStates; not identity/segment overrides
public Task<Environment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default);
}
// MicCheck.Api/Segments/SegmentService.cs
public class SegmentService
{
private readonly MicCheckDbContext _db;
private readonly AuditService _auditService;
// Enforces: max 100 segments per project
// Enforces: max 100 segment rule conditions
public Task<Segment> CreateAsync(int projectId, string name,
IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default);
}
```
---
## Controller Pattern
Controllers are responsible for:
1. Receiving the request DTO
2. Validating it (FluentValidation runs automatically via middleware)
3. Calling the appropriate service with extracted domain-level values
4. Mapping the returned domain object(s) to a response DTO
5. Returning the HTTP response
```csharp
// MicCheck.Api/Features/FeaturesController.cs
[ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController : ControllerBase
{
private readonly FeatureService _featureService;
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
var feature = await _featureService.CreateAsync(
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
FeatureResponse.From(feature));
}
[HttpGet("{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var feature = await _featureService.FindByIdAsync(id, ct);
if (feature is null) return NotFound();
return Ok(FeatureResponse.From(feature));
}
}
```
---
## Request Validation
Use FluentValidation for all incoming request DTOs. Validators live in the same folder as the DTO they validate.
```csharp
// MicCheck.Api/Features/CreateFeatureRequest.cs
public record CreateFeatureRequest(
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
// MicCheck.Api/Features/CreateFeatureRequestValidator.cs
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
{
public CreateFeatureRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.InitialValue)
.MaximumLength(20_000)
.When(x => x.InitialValue is not null);
}
}
```
---
## Response DTOs
Live in the same folder as their domain entity. The static `From` factory is the only mapping point.
```csharp
// MicCheck.Api/Features/FeatureResponse.cs
public record FeatureResponse(
int Id,
string Name,
string Type,
string? InitialValue,
string? Description,
bool DefaultEnabled,
DateTimeOffset CreatedAt
)
{
public static FeatureResponse From(Feature feature) => new(
feature.Id, feature.Name,
feature.Type.ToString().ToUpperInvariant(),
feature.InitialValue, feature.Description,
feature.DefaultEnabled, feature.CreatedAt);
}
```
Pagination wrapper for list endpoints:
```csharp
// MicCheck.Api/PaginatedResponse.cs
public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);
```
---
## Environment Cloning
When cloning an environment:
1. Create new `Environment` record with a fresh `ApiKey`
2. Copy all environment-level `FeatureState` records from the source environment
3. Do NOT copy identity-level overrides
4. Do NOT copy segment overrides (those follow segment definitions)
5. Emit an audit log entry
---
## Acceptance Criteria
- [ ] Full CRUD for organizations, projects, environments, features, segments
- [ ] Creating a feature auto-creates `FeatureState` for all project environments
- [ ] Creating an environment auto-creates `FeatureState` for all project features
- [ ] 400 feature limit enforced (returns 400 with descriptive error)
- [ ] 100 segment limit enforced
- [ ] 100 segment condition limit enforced
- [ ] FluentValidation errors return 422 with field-level detail
- [ ] Environment clone creates independent copy of feature states
- [ ] All endpoints return 401 for missing credentials, 403 for insufficient permissions
- [ ] Paginated list endpoints default to page size 20, max 100
- [ ] Controllers never call `_db` directly — all data access goes through a service
- [ ] Services never reference request/response DTO types
- [ ] Unit tests for all service methods covering success paths and constraint violations

View File

@@ -0,0 +1,249 @@
# Step 7: Audit Logging & Webhooks
## Goal
Implement the audit trail system (recording all changes to flags, environments, segments, etc.) and the webhook system (notifying external systems of flag state changes).
---
## Audit Logging
### What Gets Audited
Every create, update, and delete operation on:
- Features and FeatureStates
- Environments
- Segments
- Identities (admin operations)
- API Keys (create/revoke)
- User permissions
- Organization settings
- Project settings
### AuditLog Domain Model
```csharp
// MicCheck.Api/Audit/AuditLog.cs (already defined in Step 2)
// Key fields:
// ResourceType: "Feature", "FeatureState", "Environment", etc.
// ResourceId: string representation of the entity's PK
// Action: "Created", "Updated", "Deleted"
// Changes: JSON diff of before/after values (for Updates)
// ActorUserId: null for API key operations without user context
```
### Audit Service
Lives in `MicCheck.Api/Audit/`. Injects `MicCheckDbContext` directly.
```csharp
// MicCheck.Api/Audit/AuditService.cs
public class AuditService
{
private readonly MicCheckDbContext _db;
public Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
int? actorUserId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
}
```
**Change diffing:**
- Serialize `before` and `after` objects to JSON
- Store both in `Changes` as `{ "before": {...}, "after": {...} }`
- Use `System.Text.Json` for serialization
### Integration with Services
Audit logging is called directly from other service methods:
```csharp
// In FeatureService.CreateAsync:
await _auditService.RecordAsync(
resourceType: "Feature",
resourceId: feature.Id.ToString(),
action: "Created",
organizationId: project.OrganizationId,
projectId: feature.ProjectId,
after: feature);
```
### Audit Log Queries
`AuditService` exposes a query method returning domain objects; the controller maps to response DTOs.
Support filtering by:
- Date range (`from`, `to`)
- Resource type
- Action type
- Project
- Environment
- Actor user
Always ordered by `CreatedAt DESC`. Paginated (default 20, max 100).
```csharp
// MicCheck.Api/Audit/AuditController.cs
[HttpGet]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> List(
int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
{
var (logs, total) = await _auditService.ListAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(
total, null, null,
logs.Select(AuditLogResponse.From).ToList()));
}
```
---
## Webhooks
### Webhook Triggers
Webhooks fire on:
- `FLAG_UPDATED` — any `FeatureState` change
- `FLAG_DELETED` — feature deleted
- `AUDIT_LOG_CREATED` — any audit log entry (organization webhooks)
### Webhook Payload
```json
{
"data": {
"changed_by": "user@example.com",
"timestamp": "2026-04-06T12:00:00Z",
"new_state": {
"id": 42,
"enabled": true,
"feature_state_value": null,
"feature": { "id": 1, "name": "dark_mode" },
"environment": { "id": 5, "name": "Production" }
},
"previous_state": { ... }
},
"event_type": "FLAG_UPDATED"
}
```
### Webhook Event & Dispatcher
Live in `MicCheck.Api/Webhooks/`. Dispatcher injects `MicCheckDbContext` directly.
```csharp
// MicCheck.Api/Webhooks/WebhookEvent.cs
public class WebhookEvent
{
public required string EventType { get; init; }
public required object Data { get; init; }
public int? EnvironmentId { get; init; }
public int OrganizationId { get; init; }
}
// MicCheck.Api/Webhooks/WebhookDispatcher.cs
public class WebhookDispatcher
{
private readonly MicCheckDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
// Finds all active webhooks for the environment (and org-level)
// Serializes payload to JSON
// Signs payload with HMAC-SHA256 using webhook secret
// POSTs to webhook URL with timeout of 10 seconds
// Stores delivery result in WebhookDeliveryLog
public Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken ct = default);
}
```
### Webhook Signing
```csharp
// HMAC-SHA256 signature in header:
// X-Flagsmith-Signature: sha256=<hex_digest>
private static string ComputeSignature(string secret, string payload)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes);
return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant();
}
```
### Webhook Queue (Async Dispatch)
Lives in `MicCheck.Api/Webhooks/`. Uses `System.Threading.Channels` so dispatch does not block the API response.
```csharp
// MicCheck.Api/Webhooks/WebhookQueue.cs
public class WebhookQueue
{
private readonly Channel<WebhookEvent> _channel = Channel.CreateUnbounded<WebhookEvent>();
public ValueTask EnqueueAsync(WebhookEvent webhookEvent) =>
_channel.Writer.WriteAsync(webhookEvent);
public IAsyncEnumerable<WebhookEvent> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
```
### Retry Background Service
Lives in `MicCheck.Infrastructure/Webhooks/`. Polls `WebhookDeliveryLog` and retries failures.
```csharp
// MicCheck.Infrastructure/Webhooks/WebhookRetryBackgroundService.cs
public class WebhookRetryBackgroundService : BackgroundService
{
// Polls WebhookDeliveryLog for failed deliveries within retry window
// Up to 3 attempts: immediate, +5 min, +30 min
// Reattempts via WebhookDispatcher
}
```
### Retry Strategy
- Up to 3 delivery attempts (immediate, +5 min, +30 min)
- Failed deliveries are logged but do not block the API response
---
## Webhook Admin Endpoints
Controllers map domain objects to/from DTOs in the action method, calling `WebhookService` for all data access.
```
GET /api/v1/environments/{apiKey}/webhooks/
POST /api/v1/environments/{apiKey}/webhooks/
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries/
GET /api/v1/organisations/{id}/webhooks/
POST /api/v1/organisations/{id}/webhooks/
PUT /api/v1/organisations/{id}/webhooks/{id}/
DELETE /api/v1/organisations/{id}/webhooks/{id}/
```
---
## Acceptance Criteria
- [ ] Every create/update/delete in the Admin API produces an `AuditLog` record
- [ ] Audit logs correctly capture before/after state for updates
- [ ] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
- [ ] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs in the action
- [ ] Webhooks fire within 1 second of a flag state change
- [ ] HMAC-SHA256 signature is correct and verifiable by receivers
- [ ] Failed webhook deliveries are logged with status code and response body
- [ ] Retry logic attempts delivery 3 times with backoff
- [ ] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
- [ ] Unit tests cover: signature computation, payload serialization, retry scheduling
- [ ] Unit tests cover: audit diff generation for create/update/delete scenarios

277
docs/08_testing_strategy.md Normal file
View File

@@ -0,0 +1,277 @@
# Step 8: Testing Strategy
## Goal
Establish a comprehensive testing strategy that achieves high coverage of business logic, enforces BDD-style test naming, and validates the full request pipeline without touching external resources (database, file system, network).
---
## Test Project Structure
```
tests/
├── MicCheck.Core.Tests/
│ ├── Features/
│ │ ├── FeatureEvaluationServiceTests.cs
│ │ └── FeatureServiceTests.cs
│ ├── Segments/
│ │ └── SegmentEvaluatorTests.cs
│ ├── Environments/
│ │ └── EnvironmentServiceTests.cs
│ ├── Identities/
│ │ └── IdentityResolutionServiceTests.cs
│ ├── Audit/
│ │ └── AuditServiceTests.cs
│ └── Webhooks/
│ ├── WebhookDispatcherTests.cs
│ └── WebhookSignatureTests.cs
├── MicCheck.Api.Tests/
│ ├── Flags/
│ │ ├── FlagsControllerTests.cs
│ │ └── IdentitiesControllerTests.cs
│ ├── Admin/
│ │ ├── FeaturesControllerTests.cs
│ │ ├── EnvironmentsControllerTests.cs
│ │ ├── SegmentsControllerTests.cs
│ │ └── OrganizationsControllerTests.cs
│ └── Authentication/
│ ├── EnvironmentKeyAuthenticationHandlerTests.cs
│ └── ApiKeyAuthenticationHandlerTests.cs
└── MicCheck.Infrastructure.Tests/
└── (integration tests — marked [Category("Integration")])
```
---
## Naming Convention (BDD Style)
```
WhenA{Subject}_{Condition}_Then{ExpectedOutcome}
Examples:
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
WhenAnIdentityIsResolved_WithMatchingSegment_ThenSegmentFlagOverrideIsReturned
WhenAWebhookIsDispatched_WithASecret_ThenPayloadIsSignedWithHmacSha256
WhenAnEnvironmentKeyIsInvalid_ThenAuthenticationFails
WhenAFlagIsUpdated_ThenAnAuditLogEntryIsCreated
```
---
## Test Patterns
### Unit Tests (Api — Services)
Services depend on `MicCheckDbContext`. Use an in-memory SQLite EF Core provider (or a real test-scoped PostgreSQL instance for integration tests) rather than mocking the DbContext. Mock only non-EF dependencies such as `AuditService` or `WebhookQueue`.
```csharp
[TestFixture]
public class FeatureServiceTests
{
private MicCheckDbContext _db = null!;
private Mock<AuditService> _auditService = null!;
private FeatureService _sut = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
_auditService = new Mock<AuditService>();
_sut = new FeatureService(_db, _auditService.Object);
}
[TearDown]
public void TearDown() => _db.Dispose();
[Test]
public void WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown()
{
var project = new Project { Name = "Test", OrganizationId = 1 };
_db.Projects.Add(project);
for (var i = 0; i < 400; i++)
_db.Features.Add(new Feature { Name = $"flag_{i}", ProjectId = project.Id });
_db.SaveChanges();
Assert.That(
async () => await _sut.CreateAsync(project.Id, "new_flag", FeatureType.Standard, null, null),
Throws.TypeOf<DomainException>());
}
}
```
### API Controller Tests
Use `WebApplicationFactory<Program>` with mocked services for HTTP-level tests:
```csharp
[TestFixture]
public class FlagsControllerTests
{
private WebApplicationFactory<Program> _factory = null!;
[SetUp]
public void SetUp()
{
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.AddSingleton<IFeatureEvaluationService>(
Mock.Of<IFeatureEvaluationService>());
}));
}
[TearDown]
public void TearDown() => _factory.Dispose();
[Test]
public async Task WhenGetFlagsIsCalledWithAValidEnvironmentKey_ThenFlagsAreReturned()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", "valid-env-key");
var response = await client.GetAsync("/api/v1/flags/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}
```
---
## SegmentEvaluator Test Coverage
The `SegmentEvaluator` must have exhaustive unit tests for every operator:
The `SegmentEvaluator` takes no dependencies, so it is constructed directly with no mocking needed.
```csharp
[TestFixture]
public class SegmentEvaluatorTests
{
private readonly SegmentEvaluator _sut = new();
[TestCase("premium", "premium", true)]
[TestCase("premium", "free", false)]
public void WhenEvaluatingEqualOperator_ThenResultMatchesExpectation(
string traitValue, string conditionValue, bool expected) { ... }
[Test]
public void WhenEvaluatingPercentageSplit_WithSameIdentifier_ThenResultIsDeterministic() { ... }
[Test]
public void WhenEvaluatingPercentageSplit_WithZeroPercent_ThenNobodyMatches() { ... }
[Test]
public void WhenEvaluatingPercentageSplit_WithHundredPercent_ThenEveryoneMatches() { ... }
[Test]
public void WhenASegmentHasNestedRules_ThenAllRulesAreEvaluated() { ... }
}
```
---
## Feature Evaluation Priority Tests
```csharp
[TestFixture]
public class FeatureEvaluationServiceTests
{
[Test]
public async Task WhenIdentityHasAnOverride_ThenIdentityOverrideTakesPriorityOverSegment() { ... }
[Test]
public async Task WhenIdentityMatchesSegment_ThenSegmentOverrideTakesPriorityOverEnvironmentDefault() { ... }
[Test]
public async Task WhenIdentityMatchesMultipleSegments_ThenHighestPrioritySegmentWins() { ... }
[Test]
public async Task WhenIdentityMatchesNoSegment_ThenEnvironmentDefaultIsReturned() { ... }
}
```
---
## Authentication Tests
```csharp
[TestFixture]
public class EnvironmentKeyAuthenticationHandlerTests
{
[Test]
public async Task WhenEnvironmentKeyHeaderIsMissing_ThenAuthenticationFails() { ... }
[Test]
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { ... }
[Test]
public async Task WhenEnvironmentKeyIsValid_ThenAuthenticationSucceedsWithEnvironmentIdClaim() { ... }
}
[TestFixture]
public class ApiKeyAuthenticationHandlerTests
{
[Test]
public async Task WhenApiKeyIsRevoked_ThenAuthenticationFails() { ... }
[Test]
public async Task WhenApiKeyIsExpired_ThenAuthenticationFails() { ... }
[Test]
public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationIdClaim() { ... }
}
```
---
## Domain Constraint Tests
```csharp
// For each enforced constraint, there must be a test:
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
WhenASegmentIsCreated_AndProjectHasReachedMaxSegments_ThenAnExceptionIsThrown
WhenASegmentConditionIsAdded_AndRuleHasReachedMaxConditions_ThenAnExceptionIsThrown
WhenAFeatureStateValueExceedsMaxBytes_ThenValidationFails
WhenATraitValueExceedsMaxBytes_ThenValidationFails
```
---
## Code Coverage Target
- `MicCheck.Api` services and domain logic: 90%+ line coverage
- `MicCheck.Api` controllers and auth handlers: 80%+ line coverage
- All new code must include corresponding tests per CLAUDE.md requirements
Run coverage via:
```bash
dotnet test --collect:"XPlat Code Coverage"
```
---
## CI Test Execution
Tests are separated by NUnit category:
```bash
# Run all unit tests (fast, no external dependencies):
dotnet test --filter "Category!=Integration"
# Run integration tests (requires database):
dotnet test --filter "Category=Integration"
```
---
## Acceptance Criteria
- [ ] All tests use BDD-style names (`WhenA{Subject}_{Condition}_Then{Outcome}`)
- [ ] No mocked objects named with the word "Mock" (use descriptive names or `_sut`)
- [ ] No Arrange/Act/Assert comments in test code
- [ ] `SegmentEvaluator` has tests for every `SegmentConditionOperator` value
- [ ] Feature evaluation priority (identity > segment > environment) is tested explicitly
- [ ] All domain constraint limits have corresponding tests
- [ ] `dotnet test` passes cleanly with zero failures
- [ ] Core project achieves 90%+ line coverage

View File

@@ -0,0 +1,71 @@
# MicCheck Admin SPA — Implementation Output
All source files have been created under `src/admin/`. Node.js/npm is not installed on this machine, so the build and tests could not be run. See setup instructions below.
## Getting Started
```bash
# Install Node.js (via nvm is recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
# Then from the admin directory:
cd /home/james/src/mic-check/src/admin
npm install
npm run build # produces dist/
npm test # runs 6 Jest tests
```
Or install via your package manager:
```bash
sudo apt install nodejs npm # Ubuntu/Debian
```
---
## Files Created
### Config files (`src/admin/`)
- `package.json` — scripts: `build`, `serve`, `test`; all deps pinned
- `webpack.config.js` — vue-loader, ts-loader, sass-loader, VuetifyPlugin, HtmlWebpackPlugin
- `tsconfig.json`, `jest.config.js`, `babel.config.js`
### Application (`src/admin/src/`)
- `main.ts` / `App.vue` — entry point
- `plugins/vuetify.ts` — Vuetify 3 with MDI icons + light theme
- `router/index.ts` — routes for `/`, `/features`, `/environments`, `/projects`, `/profile`, `/settings`
- `assets/logo.svg` — vector microphone on stand with sound-wave arcs
- `components/AppHeader.vue` — logo + nav links (center) + John Doe profile + gear (right)
- `components/AppLayout.vue` — collapsible side drawer + `v-main` content area
- `views/` — 6 stub pages (Dashboard, Features, Environments, Projects, Profile, Settings)
### Tests (`src/admin/tests/`)
- `setup.ts` — Vuetify global registration + ResizeObserver stub for jsdom
- `__mocks__/styleMock.js` — stubs CSS/SCSS imports in Jest
- `__mocks__/fileMock.js` — stubs SVG/image imports in Jest
- `unit/AppHeader.spec.ts` — 6 tests covering all acceptance criteria
---
## Stack
| Concern | Technology |
|---|---|
| Framework | Vue 3 + TypeScript |
| UI components | Vuetify 3 |
| Bundler | webpack 5 |
| Tests | Jest 29 + @vue/test-utils 2 |
| Routing | vue-router 4 |
| Icons | @mdi/font (Material Design Icons) |
---
## npm Commands
| Command | Purpose |
|---|---|
| `npm install` | Install all dependencies |
| `npm run build` | Production webpack bundle → `dist/` |
| `npm run serve` | Dev server on :8080 with HMR |
| `npm test` | Run all Jest unit tests |

View File

@@ -0,0 +1,18 @@
The admin website for MicCheck will be a single page application written in type script and vuejs. We need to create a basic dashboard style admin website to manage feature flags, projects and environments
## Requirements
1. Find a simple admin theme for use with Vuetify and create a basic vuejs spa using typescript for the components.
2. Utilize npm for build, webpack for bundling, and jest for frontend unit tests
3. Generate a vector-based logo for MicCheck that uses a Microphone on a stand and add that logo to the header of the site
4. For main navigation add the following: Features, Environments, Projects
5. In the upper right hand of the header, include a link to a user profile with the name John Doe and a profile icon that points to /profile, and a gear icon that points to /settings
6. Ensure the project with build and tests will pass using npm commands
## Acceptance Criteria
- A new website has been created with a default theme and key elements created for header, navigation, project and settings
- A vector image of a microphone has been created and added to the header
- A main navigation area is displayed with placeholder links for the expected sections
- The site will build successfully and tests run without error

View File

@@ -0,0 +1,17 @@
The api project for MicCheck will contain the admin endpoints needed to manage features, environments, projects, settings and users. It will also contain the endpoints for checking if a flag is enabled and returning current state of a flag.
## Requirements
1. Create a new asp.net project in /src/api/ called MicCheck.Api using .net 10 and asp.net webapi.
2. Ensure the project is configured to use OpenAPI and Swagger to document all endpoints
3. Create a GetToken endpoint that returns a JWT user token when requested
4. Create a corresponding unit test project in /tests/api called MicCheck.Api.Tests.Unit and use the latest version of NUnit and Moq as the testing frameworks
5. Create a .slnx solution file at the root of the project called MicCheck.slnx and create src and tests solution folders within it
6. Add the MicCheck API and Unit tests projects to the MicCheck.slnx
7. Ensure the solution builds and all tests pass.
## Acceptance Criteria
- A new asp.net project exists at /src/api
- A corresponding unit test project exists with any code covered by unit test cases
- A new solution file called MicCheck.slnx was create and builds successfully
- Unit tests run without error

77
docs/api/00-output.md Normal file
View File

@@ -0,0 +1,77 @@
# MicCheck.Api ASP.NET Project — Implementation Output
All source files have been created. The `dotnet` CLI is not on the PATH in the current shell session, so the build and tests could not be run. See setup instructions below.
## Getting Started
```bash
# If dotnet is not on your PATH, install the SDK:
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
source ~/.bashrc
# Then from the repo root:
cd /home/james/src/mic-check
dotnet build MicCheck.slnx # 0 errors expected
dotnet test MicCheck.slnx # 7 tests expected to pass
```
---
## Files Created
### Solution (`/`)
- `MicCheck.slnx` — XML solution file with `/src/` and `/tests/` solution folders
### API Project (`src/api/MicCheck.Api/`)
- `MicCheck.Api.csproj``net10.0`, nullable enabled, LangVersion latest
- `Program.cs` — OpenAPI + Scalar UI + JWT bearer auth + `ITokenService` DI registration
- `appsettings.json` — JWT config (`SecretKey`, `Issuer`, `Audience`, `ExpiryMinutes`)
- `appsettings.Development.json` — debug-level logging for development
- `Auth/TokenRequest.cs``record TokenRequest(string Username, string Password)`
- `Auth/TokenResponse.cs``record TokenResponse(string Token, DateTime ExpiresAt)`
- `Auth/ITokenService.cs` — service interface
- `Auth/TokenService.cs` — JWT generation using `System.IdentityModel.Tokens.Jwt`
- `Auth/AuthEndpoints.cs``POST /auth/token`, 400 on empty credentials, 200 + token on success
### Test Project (`tests/api/MicCheck.Api.Tests.Unit/`)
- `MicCheck.Api.Tests.Unit.csproj` — NUnit 4.5.1 + Moq 4.20.72 + `Microsoft.AspNetCore.Mvc.Testing`
- `Auth/TokenServiceTests.cs` — 4 BDD tests directly against `TokenService`
- `Auth/AuthEndpointsTests.cs` — 3 BDD tests via `WebApplicationFactory<Program>` with mocked `ITokenService`
---
## Stack
| Concern | Technology |
|---|---|
| Framework | .NET 10 (`net10.0`) |
| API style | ASP.NET Minimal API |
| OpenAPI document | `Microsoft.AspNetCore.OpenApi` 10.0.5 |
| OpenAPI UI | `Scalar.AspNetCore` 2.5.14 (available at `/scalar/v1` in Development) |
| JWT | `System.IdentityModel.Tokens.Jwt` 8.16.0 + `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5 |
| Test framework | NUnit 4.5.1 + NUnit3TestAdapter 6.2.0 + Microsoft.NET.Test.Sdk 18.3.0 |
| Mocking | Moq 4.20.72 |
---
## Endpoints
| Method | Path | Description |
|---|---|---|
| `POST` | `/auth/token` | Returns a JWT for the provided username/password |
| `GET` | `/openapi/v1.json` | OpenAPI document (Development only) |
| `GET` | `/scalar/v1` | Scalar API UI (Development only) |
---
## Test Cases
| Class | Test | Coverage |
|---|---|---|
| `TokenServiceTests` | `WhenAUsernameIsProvided_ThenATokenIsReturned` | Token is non-empty |
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim` | JWT `sub` claim |
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasAFutureExpiry` | Expiry is in the future |
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer` | JWT `iss` claim |
| `AuthEndpointsTests` | `WhenUsernameIsEmpty_ThenBadRequestIsReturned` | 400 on empty username |
| `AuthEndpointsTests` | `WhenPasswordIsEmpty_ThenBadRequestIsReturned` | 400 on empty password |
| `AuthEndpointsTests` | `WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken` | 200 + token body |