Compare commits

..

3 Commits

Author SHA1 Message Date
James Wampler
7fc021500c Add unit tests for MicCheck.Api.Users
Covers UserService (profile update, email conflict, password change)
and UsersController (auth/validation/not-found/conflict branches),
raising the namespace from 3% coverage.
2026-07-05 13:11:29 -07:00
James Wampler
3888168571 Exclude Migrations namespace from dotnet code coverage
EF migrations are generated scaffolding with no meaningful logic to
test; counting them against the coverage target only dilutes the
signal.
2026-07-05 13:11:25 -07:00
James Wampler
fe22ba01b7 Add coverage reporting/badges and split CI between Gitea and GitHub
All checks were successful
CI / build-and-push (push) Successful in 38s
CI / deploy-qa (push) Has been skipped
CI / smoke-qa (push) Has been skipped
Merge dotnet+jest coverage via reportgenerator, publish a self-hosted
coverage badge and build-status badges on the readme. Gitea remains the
full pipeline (build/test/docker push/deploy-qa/smoke); GitHub only
builds and tests since it has no registry secrets or qa runner.
2026-07-05 10:12:12 -07:00
136 changed files with 464 additions and 6764 deletions

View File

@@ -61,7 +61,6 @@ jobs:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
@@ -82,7 +81,6 @@ jobs:
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action. # in PATH, so checkout plain git instead of via marketplace action.

View File

@@ -1,10 +1,10 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) in this repo. Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure ## Planned Structure
@@ -18,30 +18,27 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
- Use latest LTS .NET + latest supported nuget packages for that version - Use latest LTS .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable - Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not type (e.g. `features` namespace) - Organize code by feature/area, not type (e.g. `features` namespace)
- New features need unit tests covering logic as much as possible (both nunit and jest) - New features need unit tests covering as much logic as possible (both nunit and jest)
- Modified file: check missing test coverage, all tests pass - Any modified file: evaluate for missing test coverage and that all tests pass
# Coding # Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 chars, single line if fewer - Wrap lines at 220 characters, leave single line if fewer
- Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations → separate file. - Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- No tuples for return types. Prefer records or classes for multiple values - Do not use tuples for return types. Prefer records or classes for multiple values
- No `sealed` - Do not use `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps w/ Moq - Mock external deps with Moq
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider. - Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
- New features need unit tests covering logic as much as possible - New features need unit tests covering as much logic as possible
- Modified file: check missing test coverage - Any modified file: evaluate for missing test coverage-
- No "Mock" in mocked object names - No "Mock" in mocked object names
- No Arrange/Act/Assert comments - No Arrange/Act/Assert comments
- All tests pass before commit - All tests should pass before commit
## Claude ## Claude
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` - Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
@@ -50,4 +47,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
## Stack ## Stack
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change. `.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.

55
CLAUDE.original.md Normal file → Executable file
View File

@@ -1,53 +1,44 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
## Planned Structure ## Planned Structure
- `src/admin/`VueJS admin components - `src/admin/`administrative components in VueJS
- `src/api/` - .NET API + REST endpoints - `src/api/` - api and restful endpoints in .NET
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## Best Practices
- Use latest LTS .NET + latest supported nuget packages for that version - Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
- Set `langVersion` to latest in all csproj files; enable nullable - Ensure that langVersion is set to latest in all csproj files and nullable is enabled
- Organize code by feature/area, not type (e.g. `features` namespace) - Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
- New features need unit tests covering as much logic as possible (both nunit and jest) - All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
- Any modified file: evaluate for missing test coverage and that all tests pass - Any file modified should be evaluated for potential test cases and missing coverage areas.
# Coding # Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper - Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Coding should match formating and style rules in the .editorconfig file
- Wrap lines at 220 characters, leave single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- Do not use tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first. - Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
- Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user. - Mock any external dependencies using Moq.
- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage. - Do not name any mocked objects with the word Mock in them
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - Do not include any Arrange / Act / Assert comments in the code
- Mock external deps with Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage-
- No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests should pass before commit
## Claude ## Claude
- Plans = `.md` files in `docs/plans/`. Admin `docs/plans/admin/`, API `docs/plans/api/` - Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
- Split large plans into discrete chunks — each buildable + committable independently - Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
- Plan generated from `docs/plans/<name>.md` save as `docs/plans/<name>_plan.md` - When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.

View File

@@ -3,6 +3,7 @@
<File Path=".editorconfig" /> <File Path=".editorconfig" />
<File Path=".gitignore" /> <File Path=".gitignore" />
<File Path="CLAUDE.md" /> <File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" /> <File Path="readme.md" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">

View File

@@ -123,7 +123,7 @@
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text> <text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text> <text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">68.9%</text><text class="" x="132.5" y="14">68.9%</text> <text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">29.8%</text><text class="" x="132.5" y="14">29.8%</text>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -9,5 +9,4 @@ REGISTRY_TOKEN=changeme
# QA environment # QA environment
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001 QA_ADMIN_PORT=3001

View File

@@ -13,7 +13,7 @@ services:
environment: environment:
POSTGRES_DB: miccheck POSTGRES_DB: miccheck
POSTGRES_USER: miccheck POSTGRES_USER: miccheck
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_PASSWORD: password
ports: ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432" - "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes: volumes:
@@ -31,7 +31,7 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}" ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: ${JWT_SECRET_KEY} Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck Jwt__Audience: MicCheck

View File

@@ -3,19 +3,27 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Building solution..." build_api() {
dotnet build "$SCRIPT_DIR/MicCheck.slnx" echo "Building API..."
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
echo "API done."
}
echo "Installing admin dependencies..." build_admin() {
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
npm --prefix "$SCRIPT_DIR/src/admin" run build
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
echo "Admin done."
}
echo "Building admin..." case "${1:-all}" in
npm --prefix "$SCRIPT_DIR/src/admin" run build api) build_api ;;
admin) build_admin ;;
echo "Running .NET unit tests..." all) build_api && build_admin ;;
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" *)
echo "Usage: $0 [api|admin|all]"
echo "Running Jest tests..." exit 1
npm --prefix "$SCRIPT_DIR/src/admin" test ;;
esac
echo "Build and tests complete."

60
docker-compose.yml Executable file
View File

@@ -0,0 +1,60 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api:
build:
context: .
dockerfile: src/api/MicCheck.Api/Dockerfile
ports:
- "8080:8080"
volumes:
- ./src/api/MicCheck.Api/publish:/app
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 0"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── Vue Admin Site (nginx) ───────────────────────────────────────────────────
admin:
build:
context: src/admin
dockerfile: Dockerfile
ports:
- "3000:80"
volumes:
- ./src/admin/dist:/usr/share/nginx/html
depends_on:
api:
condition: service_started
volumes:
postgres_data:

View File

@@ -29,11 +29,10 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
## Running locally ## Running locally
`MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development. `docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development.
```sh ```sh
./dev-build.sh ./dev-build.sh
aspire run --project src/MicCheck.AppHost
``` ```
## Testing ## Testing

View File

@@ -11,7 +11,6 @@ registry_login
export API_IMAGE ADMIN_IMAGE export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}" export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}" export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable # Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable

View File

@@ -22,7 +22,7 @@ BASE_URL="http://${CI_HOST}:${QA_ADMIN_PORT}"
log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports" log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports"
export MICCHECK_API_BASE_URL="$BASE_URL" export MICCHECK_API_BASE_URL="$BASE_URL"
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}}" export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=password}"
log "Running API integration suite against $BASE_URL" log "Running API integration suite against $BASE_URL"
dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx

View File

@@ -6012,15 +6012,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4", "hasown": "^2.0.2",
"mime-types": "^2.1.35" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8429,10 +8430,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.15.0", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public record AuditLog public class AuditLog
{ {
public int Id { get; init; } public int Id { get; init; }
public required string ResourceType { get; init; } public required string ResourceType { get; init; }

View File

@@ -6,25 +6,29 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default) public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default) public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default) public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct) private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);

View File

@@ -12,15 +12,17 @@ namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("organisation/{id}/audit-logs")] [HttpGet("api/v1/organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
@@ -29,8 +31,11 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("project/{projectId}/audit-logs")] [HttpGet("api/v1/project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
@@ -39,8 +44,11 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("environment/{apiKey}/audit-logs")] [HttpGet("api/v1/environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();

View File

@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
public async Task RecordAsync( public virtual async Task RecordAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions); }, JsonOptions);
} }
var actorUserId = GetCurrentUserId(); var actorUserId = ResolveActorUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
} }
// Backward-compatible overload used by existing callers // Backward-compatible overload used by existing callers
public async Task LogAsync( public virtual async Task LogAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
string? changes = null, string? changes = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var actorUserId = GetCurrentUserId(); var actorUserId = ResolveActorUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -112,33 +112,9 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}); });
} }
private int? GetCurrentUserId() private int? ResolveActorUserId()
{ {
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null; return int.TryParse(claim, out var id) ? id : null;
} }
} }
public interface IAuditService
{
Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default);
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Audit;
public static class DependencyRegistration
{
public static IServiceCollection AddAuditServices(this IServiceCollection services)
{
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<AuditLogQueryService>();
return services;
}
}

View File

@@ -1,65 +0,0 @@
using System.Text;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Common;
public static class DependencyRegistration
{
public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!))
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthService>();
services.AddScoped<ApiKeyService>();
services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState);
});
return services;
}
}

View File

@@ -1,40 +0,0 @@
namespace MicCheck.Api.Common;
public static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}

View File

@@ -1,9 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints public static class ApiKeyEndpoints
{ {
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)

View File

@@ -1,3 +1,9 @@
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt); public record ApiKeyResponse(
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View File

@@ -1,9 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")]
public static class AuthEndpoints public static class AuthEndpoints
{ {
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)

View File

@@ -2,4 +2,9 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement; public class ProjectPermissionRequirement : IAuthorizationRequirement
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -1,13 +0,0 @@
namespace MicCheck.Api.Common.Validation;
public interface IModelValidator
{
ValidationResult Validate(object model);
}
public interface IModelValidator<in T> : IModelValidator
{
ValidationResult Validate(T model);
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
}

View File

@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace MicCheck.Api.Common.Validation;
public class ModelValidationActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null) continue;
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
var result = validator.Validate(argument);
foreach (var error in result.Errors)
context.ModelState.AddModelError(error.PropertyName, error.Message);
}
if (!context.ModelState.IsValid)
context.Result = ValidationProblemResponseFactory.Create(context.ModelState);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}

View File

@@ -1,20 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Common.Validation;
public static class ModelValidatorServiceCollectionExtensions
{
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
foreach (var (@interface, implementation) in registrations)
services.AddScoped(@interface, implementation);
return services;
}
}

View File

@@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace MicCheck.Api.Common.Validation;
public static class ValidationProblemResponseFactory
{
public static IActionResult Create(ModelStateDictionary modelState)
{
var errors = modelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new UnprocessableEntityObjectResult(new { errors });
}
}

View File

@@ -1,14 +0,0 @@
namespace MicCheck.Api.Common.Validation;
public record ValidationError(string PropertyName, string Message);
public class ValidationResult
{
private readonly List<ValidationError> _errors = [];
public IReadOnlyList<ValidationError> Errors => _errors;
public bool IsValid => _errors.Count == 0;
public bool IsInvalid => _errors.Count > 0;
public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message));
}

View File

@@ -1,4 +1,4 @@
using MicCheck.Api.Features.Usage; using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;

View File

@@ -1,21 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Data;
public static class DependencyRegistration
{
public static IServiceCollection AddDataServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<DatabaseSeeder>();
var connectionString = configuration.GetConnectionString("miccheck")
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: configuration.GetConnectionString("DefaultConnection")!);
services.AddDbContext<MicCheckDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
return services;
}
}

View File

@@ -2,7 +2,6 @@ using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record CloneEnvironmentRequest(string Name); public record CloneEnvironmentRequest(string Name);
public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest> public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
{ {
public ValidationResult Validate(CloneEnvironmentRequest model) public CloneEnvironmentRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }

View File

@@ -1,23 +1,14 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record CreateEnvironmentRequest(string Name, int ProjectId); public record CreateEnvironmentRequest(string Name, int ProjectId);
public class CreateEnvironmentRequestValidator : IModelValidator<CreateEnvironmentRequest> public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
{ {
public ValidationResult Validate(CreateEnvironmentRequest model) public CreateEnvironmentRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.ProjectId).GreaterThan(0);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.ProjectId <= 0)
result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0.");
return result;
} }
} }

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Environments;
public static class DependencyRegistration
{
public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services)
{
services.AddScoped<EnvironmentService>();
services.AddScoped<EnvironmentDocumentService>();
return services;
}
}

View File

@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService) public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record UpdateEnvironmentRequest(string Name); public record UpdateEnvironmentRequest(string Name);
public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest> public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
{ {
public ValidationResult Validate(UpdateEnvironmentRequest model) public UpdateEnvironmentRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }

View File

@@ -1,28 +1,26 @@
using System.Text.RegularExpressions; using FluentValidation;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description); public record CreateFeatureRequest(
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest> public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
{ {
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); public CreateFeatureRequestValidator()
public ValidationResult Validate(CreateFeatureRequest model)
{ {
var result = new ValidationResult(); RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
if (string.IsNullOrEmpty(model.Name)) RuleFor(x => x.InitialValue)
result.AddError(nameof(model.Name), "'Name' must not be empty."); .MaximumLength(20_000)
else if (model.Name.Length > 150) .When(x => x.InitialValue is not null);
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
if (model.InitialValue is not null && model.InitialValue.Length > 20_000)
result.AddError(nameof(model.InitialValue), "'Initial Value' must be 20000 characters or fewer.");
return result;
} }
} }

View File

@@ -1,30 +1,16 @@
using System.Text.RegularExpressions; using FluentValidation;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateTagRequest(string Label, string Color); public record CreateTagRequest(string Label, string Color);
public class CreateTagRequestValidator : IModelValidator<CreateTagRequest> public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
{ {
private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$"); public CreateTagRequestValidator()
public ValidationResult Validate(CreateTagRequest model)
{ {
var result = new ValidationResult(); RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
if (string.IsNullOrEmpty(model.Label)) .Matches("^#[0-9A-Fa-f]{3,6}$")
result.AddError(nameof(model.Label), "'Label' must not be empty."); .WithMessage("Color must be a valid hex color (e.g. #FF0000).");
else if (model.Label.Length > 100)
result.AddError(nameof(model.Label), "'Label' must be 100 characters or fewer.");
if (string.IsNullOrEmpty(model.Color))
result.AddError(nameof(model.Color), "'Color' must not be empty.");
else if (model.Color.Length > 20)
result.AddError(nameof(model.Color), "'Color' must be 20 characters or fewer.");
else if (!ColorPattern.IsMatch(model.Color))
result.AddError(nameof(model.Color), "Color must be a valid hex color (e.g. #FF0000).");
return result;
} }
} }

View File

@@ -1,20 +0,0 @@
using MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public static class DependencyRegistration
{
public static IServiceCollection AddFeaturesServices(this IServiceCollection services)
{
services.AddScoped<FeatureEvaluationService>();
services.AddSingleton<FlagCache>();
services.AddScoped<FeatureService>();
services.AddScoped<FeatureStateService>();
services.AddScoped<FeatureSegmentService>();
services.AddScoped<TagService>();
services.AddFeatureUsageServices();
return services;
}
}

View File

@@ -1,5 +1,4 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue) public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
{ {
private const int MaxFeaturesPerProject = 400; private const int MaxFeaturesPerProject = 400;
@@ -23,7 +23,13 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct); return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
} }
public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default) public async Task<Feature> CreateAsync(
int projectId,
string name,
FeatureType type,
string? initialValue,
string? description,
CancellationToken ct = default)
{ {
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct); var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= MaxFeaturesPerProject) if (count >= MaxFeaturesPerProject)
@@ -72,7 +78,8 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
return feature; return feature;
} }
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default) public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{ {
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct) var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found."); ?? throw new KeyNotFoundException($"Feature {id} not found.");
@@ -155,7 +162,11 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
EventType = WebhookEventTypes.FlagDeleted, EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id, EnvironmentId = env.Id,
OrganizationId = project.OrganizationId, OrganizationId = project.OrganizationId,
Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) }); Data = new FlagDeletedData(
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
} }
} }
} }

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record FeatureStateResult public class FeatureStateResult
{ {
public required Feature Feature { get; init; } public required Feature Feature { get; init; }
public bool Enabled { get; init; } public bool Enabled { get; init; }

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService auditService) public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
{ {
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{ {

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate); public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Route("api/v1/environment/{environmentId}/usage")] [Route("api/v1/environment/{environmentId}/usage")]
@@ -13,7 +13,10 @@ namespace MicCheck.Api.Features.Usage;
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default) public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{ {
days = Math.Clamp(days, 1, 90); days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}"; var cacheKey = $"usage-dashboard:{environmentId}:{days}";

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -1,10 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")]
public class FeatureUsageFlushBackgroundService( public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics, FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
public class FeatureUsageMetrics : IDisposable public class FeatureUsageMetrics : IDisposable
{ {

View File

@@ -1,7 +1,7 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(IMicCheckDbContext db) public class FeatureUsageQueryService(IMicCheckDbContext db)
{ {

View File

@@ -1,7 +1,9 @@
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count); public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features); public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage); public record DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -12,9 +12,11 @@ public class FlagCache(IMemoryCache cache)
return flags; return flags;
} }
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration); public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId)); public void Invalidate(int environmentId)
=> cache.Remove(CacheKey(environmentId));
private static string CacheKey(int environmentId) => $"flags:{environmentId}"; private static string CacheKey(int environmentId) => $"flags:{environmentId}";
} }

View File

@@ -1,9 +1,9 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record Tag public class Tag
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Label { get; init; } public required string Label { get; set; }
public required string Color { get; init; } public required string Color { get; set; }
public int ProjectId { get; init; } public int ProjectId { get; init; }
} }

View File

@@ -8,24 +8,23 @@ namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1/project/{projectId}")]
public class TagsController(TagService tagService) : ControllerBase public class TagsController(TagService tagService) : ControllerBase
{ {
[HttpGet("tags")] [HttpGet("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{ {
var tags = await tagService.ListByProjectAsync(projectId, ct); var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList()); return Ok(tags.Select(TagResponse.From).ToList());
} }
[HttpPost("tags")] [HttpPost("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct) public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{ {
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct); var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag)); return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
} }
[HttpDelete("tag/{id}")] [HttpDelete("api/v1/project/{projectId}/tag/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var tag = await tagService.FindByIdAsync(id, ct); var tag = await tagService.FindByIdAsync(id, ct);

View File

@@ -1,25 +1,20 @@
using System.Text.RegularExpressions; using FluentValidation;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record UpdateFeatureRequest(string Name, string? Description); public record UpdateFeatureRequest(
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest> public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
{ {
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); public UpdateFeatureRequestValidator()
public ValidationResult Validate(UpdateFeatureRequest model)
{ {
var result = new ValidationResult(); RuleFor(x => x.Name)
.NotEmpty()
if (string.IsNullOrEmpty(model.Name)) .MaximumLength(150)
result.AddError(nameof(model.Name), "'Name' must not be empty."); .Matches("^[a-zA-Z0-9_-]+$")
else if (model.Name.Length > 150) .WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
return result;
} }
} }

View File

@@ -1,13 +0,0 @@
namespace MicCheck.Api.Features.Usage;
public static class DependencyRegistration
{
public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services)
{
services.AddSingleton<FeatureUsageMetrics>();
services.AddScoped<FeatureUsageQueryService>();
services.AddHostedService<FeatureUsageFlushBackgroundService>();
return services;
}
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Features.Usage;
public record FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; init; }
public DateOnly UsageDate { get; init; }
public long Count { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Identities;
public static class DependencyRegistration
{
public static IServiceCollection AddIdentitiesServices(this IServiceCollection services)
{
services.AddScoped<IdentityResolutionService>();
services.AddScoped<AdminIdentityService>();
return services;
}
}

View File

@@ -9,6 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" /> <PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />

View File

@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates"); b.ToTable("FeatureStates");
}); });
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View File

@@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates"); b.ToTable("FeatureStates");
}); });
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record CreateOrganizationRequest(string Name); public record CreateOrganizationRequest(string Name);
public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest> public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
{ {
public ValidationResult Validate(CreateOrganizationRequest model) public CreateOrganizationRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }

View File

@@ -1,11 +0,0 @@
namespace MicCheck.Api.Organizations;
public static class DependencyRegistration
{
public static IServiceCollection AddOrganizationsServices(this IServiceCollection services)
{
services.AddScoped<OrganizationService>();
return services;
}
}

View File

@@ -1,23 +1,15 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record InviteUserRequest(int UserId, string Role); public record InviteUserRequest(int UserId, string Role);
public class InviteUserRequestValidator : IModelValidator<InviteUserRequest> public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
{ {
public ValidationResult Validate(InviteUserRequest model) public InviteUserRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
if (model.UserId <= 0) .WithMessage("Role must be 'User' or 'Admin'.");
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
if (string.IsNullOrEmpty(model.Role))
result.AddError(nameof(model.Role), "'Role' must not be empty.");
else if (!Enum.TryParse<OrganizationRole>(model.Role, true, out _))
result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'.");
return result;
} }
} }

View File

@@ -1,11 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public class OrganizationService(IMicCheckDbContext db, IAuditService auditService) public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default) public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{ {
@@ -47,7 +46,6 @@ public class OrganizationService(IMicCheckDbContext db, IAuditService auditServi
return org; return org;
} }
[ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock<DbSet<T>> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")]
public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default)
{ {
var member = await db.OrganizationUsers var member = await db.OrganizationUsers

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record UpdateOrganizationRequest(string Name); public record UpdateOrganizationRequest(string Name);
public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest> public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
{ {
public ValidationResult Validate(UpdateOrganizationRequest model) public UpdateOrganizationRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }

View File

@@ -1,9 +1,11 @@
using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using MicCheck.Api.Audit; using FluentValidation;
using MicCheck.Api.Common; using FluentValidation.AspNetCore;
using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Environments; using MicCheck.Api.Environments;
using MicCheck.Api.Features; using MicCheck.Api.Features;
@@ -13,7 +15,14 @@ using MicCheck.Api.Projects;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using MicCheck.Api.Users; using MicCheck.Api.Users;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
@@ -33,12 +42,44 @@ try
.WriteTo.Console()); .WriteTo.Console());
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>()) builder.Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add( options.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter())); new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddCommonServices(builder.Configuration); builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -51,20 +92,67 @@ try
limiter.QueueLimit = 0; limiter.QueueLimit = 0;
})); }));
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new UnprocessableEntityObjectResult(new { errors });
};
});
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddMetrics(); builder.Services.AddMetrics();
builder.Services.AddAuditServices(); builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddIdentitiesServices(); builder.Services.AddScoped<AuthService>();
builder.Services.AddEnvironmentsServices(); builder.Services.AddScoped<ApiKeyService>();
builder.Services.AddSegmentsServices(); builder.Services.AddScoped<DatabaseSeeder>();
builder.Services.AddOrganizationsServices(); builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
builder.Services.AddProjectsServices(); builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
builder.Services.AddFeaturesServices(); builder.Services.AddScoped<FeatureEvaluationService>();
builder.Services.AddUsersServices(); builder.Services.AddScoped<IdentityResolutionService>();
builder.Services.AddWebhooksServices(); builder.Services.AddScoped<EnvironmentDocumentService>();
builder.Services.AddSingleton<SegmentEvaluator>();
builder.Services.AddSingleton<FlagCache>();
builder.Services.AddScoped<AuditService>();
builder.Services.AddScoped<AuditLogQueryService>();
builder.Services.AddScoped<OrganizationService>();
builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<EnvironmentService>();
builder.Services.AddScoped<FeatureService>();
builder.Services.AddScoped<FeatureStateService>();
builder.Services.AddScoped<FeatureSegmentService>();
builder.Services.AddScoped<SegmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<WebhookService>();
builder.Services.AddScoped<WebhookDispatcher>();
builder.Services.AddScoped<AdminIdentityService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddSingleton<WebhookQueue>();
builder.Services.AddHostedService<WebhookBackgroundService>();
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
builder.Services.AddSingleton<FeatureUsageMetrics>();
builder.Services.AddScoped<FeatureUsageQueryService>();
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
builder.Services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
builder.Services.AddDataServices(builder.Configuration); var connectionString = builder.Configuration.GetConnectionString("miccheck")
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: builder.Configuration.GetConnectionString("DefaultConnection")!);
builder.Services.AddDbContext<MicCheckDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
var app = builder.Build(); var app = builder.Build();

View File

@@ -1,23 +1,14 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record CreateProjectRequest(string Name, int OrganizationId); public record CreateProjectRequest(string Name, int OrganizationId);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest> public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
{ {
public ValidationResult Validate(CreateProjectRequest model) public CreateProjectRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.OrganizationId).GreaterThan(0);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.OrganizationId <= 0)
result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0.");
return result;
} }
} }

View File

@@ -1,11 +0,0 @@
namespace MicCheck.Api.Projects;
public static class DependencyRegistration
{
public static IServiceCollection AddProjectsServices(this IServiceCollection services)
{
services.AddScoped<ProjectService>();
return services;
}
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public class ProjectService(IMicCheckDbContext db, IAuditService auditService) public class ProjectService(IMicCheckDbContext db, AuditService auditService)
{ {
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{ {

View File

@@ -1,25 +1,17 @@
using FluentValidation;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions); public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest> public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
{ {
public ValidationResult Validate(SetUserPermissionsRequest model) public SetUserPermissionsRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.UserId).GreaterThan(0);
RuleForEach(x => x.Permissions)
if (model.UserId <= 0) .Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0."); .WithMessage("Invalid permission value.");
foreach (var permission in model.Permissions)
{
if (!Enum.TryParse<ProjectPermission>(permission, true, out _))
result.AddError(nameof(model.Permissions), "Invalid permission value.");
}
return result;
} }
} }

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record UpdateProjectRequest(string Name, bool HideDisabledFlags); public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest> public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
{ {
public ValidationResult Validate(UpdateProjectRequest model) public UpdateProjectRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }

View File

@@ -1,4 +1,4 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Segments; namespace MicCheck.Api.Segments;
@@ -16,38 +16,24 @@ public record CreateSegmentRequest(
string Name, string Name,
IReadOnlyList<CreateSegmentRuleRequest> Rules); IReadOnlyList<CreateSegmentRuleRequest> Rules);
public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest> public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
{ {
public ValidationResult Validate(CreateSegmentRequest model) public CreateSegmentRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Rules).NotNull();
if (string.IsNullOrEmpty(model.Name)) RuleForEach(x => x.Rules).ChildRules(rule =>
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.Rules is null)
{ {
result.AddError(nameof(model.Rules), "'Rules' must not be empty."); rule.RuleFor(r => r.Type)
return result; .Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
} .WithMessage("Rule type must be 'All', 'Any', or 'None'.");
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
foreach (var rule in model.Rules)
{ {
if (!Enum.TryParse<SegmentRuleType>(rule.Type, true, out _)) cond.RuleFor(c => c.Property).NotEmpty();
result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'."); cond.RuleFor(c => c.Operator)
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
foreach (var condition in rule.Conditions) .WithMessage("Invalid operator.");
{ });
if (string.IsNullOrEmpty(condition.Property)) });
result.AddError(nameof(model.Rules), "'Property' must not be empty.");
if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
result.AddError(nameof(model.Rules), "Invalid operator.");
}
}
return result;
} }
} }

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Segments;
public static class DependencyRegistration
{
public static IServiceCollection AddSegmentsServices(this IServiceCollection services)
{
services.AddSingleton<SegmentEvaluator>();
services.AddScoped<SegmentService>();
return services;
}
}

View File

@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null); public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public class SegmentService(IMicCheckDbContext db, IAuditService auditService) public class SegmentService(IMicCheckDbContext db, AuditService auditService)
{ {
private const int MaxSegmentsPerProject = 100; private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100; private const int MaxConditionsPerSegment = 100;

View File

@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Identity;
namespace MicCheck.Api.Users;
public static class DependencyRegistration
{
public static IServiceCollection AddUsersServices(this IServiceCollection services)
{
services.AddScoped<UserService>();
services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
return services;
}
}

View File

@@ -1,22 +1,14 @@
using MicCheck.Api.Common.Validation; using FluentValidation;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled); public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
public class CreateWebhookRequestValidator : IModelValidator<CreateWebhookRequest> public class CreateWebhookRequestValidator : AbstractValidator<CreateWebhookRequest>
{ {
public ValidationResult Validate(CreateWebhookRequest model) public CreateWebhookRequestValidator()
{ {
var result = new ValidationResult(); RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
.WithMessage("Url must be a valid absolute URL.");
if (string.IsNullOrEmpty(model.Url))
result.AddError(nameof(model.Url), "'Url' must not be empty.");
else if (model.Url.Length > 500)
result.AddError(nameof(model.Url), "'Url' must be 500 characters or fewer.");
else if (!Uri.TryCreate(model.Url, UriKind.Absolute, out _))
result.AddError(nameof(model.Url), "Url must be a valid absolute URL.");
return result;
} }
} }

View File

@@ -1,17 +0,0 @@
namespace MicCheck.Api.Webhooks;
public static class DependencyRegistration
{
public static IServiceCollection AddWebhooksServices(this IServiceCollection services)
{
services.AddScoped<WebhookService>();
services.AddScoped<WebhookDispatcher>();
services.AddSingleton<WebhookQueue>();
services.AddHostedService<WebhookBackgroundService>();
services.AddHostedService<WebhookRetryBackgroundService>();
services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
return services;
}
}

View File

@@ -1,10 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
[ExcludeFromCodeCoverage(Justification = "Long-running BackgroundService reading from a Channel and issuing DI-scoped dispatches; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Dispatch logic is covered by WebhookDispatcherTests.")]
public class WebhookBackgroundService( public class WebhookBackgroundService(
WebhookQueue queue, WebhookQueue queue,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
public record WebhookEvent public class WebhookEvent
{ {
public required string EventType { get; init; } public required string EventType { get; init; }
public int? EnvironmentId { get; init; } public int? EnvironmentId { get; init; }

View File

@@ -1,10 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that resolves a DI-scoped concrete MicCheckDbContext and WebhookDispatcher; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). The retry-eligibility query logic is covered by WebhookRetryTests.")]
public class WebhookRetryBackgroundService( public class WebhookRetryBackgroundService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<WebhookRetryBackgroundService> logger) : BackgroundService ILogger<WebhookRetryBackgroundService> logger) : BackgroundService

View File

@@ -23,7 +23,7 @@ public class AdminApiIntegrationTests
private List<FeatureState> _featureStates = null!; private List<FeatureState> _featureStates = null!;
private List<AppEnvironment> _environments = null!; private List<AppEnvironment> _environments = null!;
private List<Segment> _segments = null!; private List<Segment> _segments = null!;
private Mock<IAuditService> _auditService = null!; private Mock<AuditService> _auditService = null!;
private int _organizationId; private int _organizationId;
[SetUp] [SetUp]
@@ -50,7 +50,7 @@ public class AdminApiIntegrationTests
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []); _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
var webhookQueue = new WebhookQueue(); var webhookQueue = new WebhookQueue();
_auditService = new Mock<IAuditService>(); _auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
_auditService.Setup(a => a.LogAsync( _auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -1,219 +0,0 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogQueryServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AuditLog> _auditLogs = null!;
private List<User> _users = null!;
private AuditLogQueryService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_auditLogs = [];
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_users = [];
_db.SetupDbSet(c => c.Users, _users);
_service = new AuditLogQueryService(_db.Object);
}
private AuditLog AddLog(string resourceType = "Feature", string action = "created", int? projectId = ProjectId,
int? environmentId = EnvironmentId, int? actorUserId = null, DateTimeOffset? createdAt = null)
{
var log = new AuditLog
{
Id = _auditLogs.Count + 1,
ResourceType = resourceType,
ResourceId = "1",
Action = action,
OrganizationId = OrganizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow
};
_auditLogs.Add(log);
return log;
}
[Test]
public async Task WhenListingByOrganization_ThenOnlyLogsForThatOrganizationAreReturned()
{
AddLog();
_auditLogs.Add(new AuditLog { Id = 2, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = 999, CreatedAt = DateTimeOffset.UtcNow });
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenListingByProject_ThenOnlyLogsForThatProjectAreReturned()
{
AddLog(projectId: ProjectId);
AddLog(projectId: 999);
var result = await _service.ListByProjectAsync(ProjectId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByEnvironment_ThenOnlyLogsForThatEnvironmentAreReturned()
{
AddLog(environmentId: EnvironmentId);
AddLog(environmentId: 999);
var result = await _service.ListByEnvironmentAsync(EnvironmentId, new AuditLogFilter());
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByFromDate_ThenOnlyLogsOnOrAfterAreReturned()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10));
AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(From: DateTimeOffset.UtcNow.AddDays(-1)));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByToDate_ThenOnlyLogsOnOrBeforeAreReturned()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10));
AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(To: DateTimeOffset.UtcNow.AddDays(-1)));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByResourceType_ThenOnlyMatchingLogsAreReturned()
{
AddLog(resourceType: "Feature");
AddLog(resourceType: "Segment");
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ResourceType: "Segment"));
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items[0].ResourceType, Is.EqualTo("Segment"));
}
[Test]
public async Task WhenFilteringByAction_ThenOnlyMatchingLogsAreReturned()
{
AddLog(action: "created");
AddLog(action: "deleted");
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(Action: "deleted"));
Assert.That(result.Total, Is.EqualTo(1));
Assert.That(result.Items[0].Action, Is.EqualTo("deleted"));
}
[Test]
public async Task WhenFilteringByProjectIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned()
{
AddLog(projectId: ProjectId);
AddLog(projectId: 999);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ProjectId: ProjectId));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByEnvironmentIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned()
{
AddLog(environmentId: EnvironmentId);
AddLog(environmentId: 999);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(EnvironmentId: EnvironmentId));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenFilteringByActorUserId_ThenOnlyMatchingLogsAreReturned()
{
AddLog(actorUserId: 1);
AddLog(actorUserId: 2);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ActorUserId: 1));
Assert.That(result.Total, Is.EqualTo(1));
}
[Test]
public async Task WhenPageSizeExceedsMaximum_ThenItIsClampedTo100()
{
for (var i = 0; i < 150; i++)
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 1000));
Assert.That(result.Items, Has.Count.EqualTo(100));
}
[Test]
public async Task WhenPageSizeIsBelowMinimum_ThenItIsClampedTo1()
{
AddLog();
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 0));
Assert.That(result.Items, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenResultsAreOrderedByCreationDate_ThenNewestAppearsFirst()
{
AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-1));
var newest = AddLog(createdAt: DateTimeOffset.UtcNow);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].Id, Is.EqualTo(newest.Id));
}
[Test]
public async Task WhenALogHasAnActorUser_ThenTheActorUserNameIsPopulated()
{
_users.Add(new User { Id = 7, Email = "alice@example.com", PasswordHash = "hash", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow });
AddLog(actorUserId: 7);
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].ActorUserName, Is.EqualTo("Alice Smith"));
}
[Test]
public async Task WhenALogHasNoActorUser_ThenTheActorUserNameIsNull()
{
AddLog();
var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter());
Assert.That(result.Items[0].ActorUserName, Is.Null);
}
}

View File

@@ -1,76 +0,0 @@
using MicCheck.Api.Audit;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogResponseTests
{
[Test]
public void WhenMappingWithoutAnActorUserName_ThenItDefaultsToNull()
{
var log = new AuditLog
{
Id = 1,
ResourceType = "Feature",
ResourceId = "42",
Action = "created",
OrganizationId = 1,
CreatedAt = DateTimeOffset.UtcNow
};
var response = AuditLogResponse.From(log);
Assert.That(response.ActorUserName, Is.Null);
}
[Test]
public void WhenMappingWithAnActorUserName_ThenItIsIncluded()
{
var log = new AuditLog
{
Id = 1,
ResourceType = "Feature",
ResourceId = "42",
Action = "created",
OrganizationId = 1,
ActorUserId = 7,
CreatedAt = DateTimeOffset.UtcNow
};
var response = AuditLogResponse.From(log, "Alice Smith");
Assert.That(response.ActorUserId, Is.EqualTo(7));
Assert.That(response.ActorUserName, Is.EqualTo("Alice Smith"));
}
[Test]
public void WhenMapping_ThenAllFieldsAreCopiedFromTheLog()
{
var createdAt = DateTimeOffset.UtcNow;
var log = new AuditLog
{
Id = 1,
ResourceType = "FeatureState",
ResourceId = "5",
Action = "updated",
Changes = """{"before":{},"after":{}}""",
OrganizationId = 10,
ProjectId = 20,
EnvironmentId = 30,
CreatedAt = createdAt
};
var response = AuditLogResponse.From(log);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.ResourceType, Is.EqualTo("FeatureState"));
Assert.That(response.ResourceId, Is.EqualTo("5"));
Assert.That(response.Action, Is.EqualTo("updated"));
Assert.That(response.Changes, Is.EqualTo("""{"before":{},"after":{}}"""));
Assert.That(response.OrganizationId, Is.EqualTo(10));
Assert.That(response.ProjectId, Is.EqualTo(20));
Assert.That(response.EnvironmentId, Is.EqualTo(30));
Assert.That(response.CreatedAt, Is.EqualTo(createdAt));
}
}

View File

@@ -1,118 +0,0 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Audit;
[TestFixture]
public class AuditLogsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<Organization> _organizations = null!;
private List<Project> _projects = null!;
private List<AppEnvironment> _environments = null!;
private List<AuditLog> _auditLogs = null!;
private AuditLogsController _controller = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
private const string ApiKey = "env-key";
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_organizations = [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Organizations, _organizations);
_projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = ApiKey, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Environments, _environments);
_auditLogs = [];
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_db.SetupDbSet(c => c.Users, []);
var auditServiceMock = new Mock<IAuditService>();
var auditService = auditServiceMock.Object;
_controller = new AuditLogsController(
new AuditLogQueryService(_db.Object),
new OrganizationService(_db.Object, auditService),
new ProjectService(_db.Object, auditService),
new EnvironmentService(_db.Object, auditService));
}
[Test]
public async Task WhenListingByOrganizationThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByOrganization(999, new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByOrganizationThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByOrganization(OrganizationId, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByProject(999, new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByProjectThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByProject(ProjectId, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenListingByEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListByEnvironment("missing-key", new AuditLogFilter(), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingByEnvironmentThatExists_ThenMatchingLogsAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListByEnvironment(ApiKey, new AuditLogFilter(), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AuditLogResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
}

View File

@@ -90,85 +90,6 @@ public class AuditServiceTests
Assert.That(log.ActorUserId, Is.Null); Assert.That(log.ActorUserId, Is.Null);
} }
[Test]
public async Task WhenLoggingWithNoChanges_ThenChangesRemainsNull()
{
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
var log = _auditLogs.First();
Assert.That(log.Changes, Is.Null);
}
[Test]
public async Task WhenLoggingWithChanges_ThenChangesAndMetadataArePersisted()
{
await _service.LogAsync("Feature", "2", "updated", organizationId: 5, projectId: 6, environmentId: 7, changes: "raw-json");
var log = _auditLogs.First();
Assert.That(log.ResourceType, Is.EqualTo("Feature"));
Assert.That(log.ResourceId, Is.EqualTo("2"));
Assert.That(log.Action, Is.EqualTo("updated"));
Assert.That(log.Changes, Is.EqualTo("raw-json"));
Assert.That(log.OrganizationId, Is.EqualTo(5));
Assert.That(log.ProjectId, Is.EqualTo(6));
Assert.That(log.EnvironmentId, Is.EqualTo(7));
}
[Test]
public async Task WhenLogging_ThenAuditLogCreatedEventIsEnqueued()
{
await _service.LogAsync("Feature", "1", "created", organizationId: 1);
var events = new List<WebhookEvent>();
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
try
{
await foreach (var e in _queue.ReadAllAsync(cts.Token))
{
events.Add(e);
break;
}
}
catch (OperationCanceledException) { }
Assert.That(events, Has.Count.EqualTo(1));
Assert.That(events[0].EventType, Is.EqualTo(WebhookEventTypes.AuditLogCreated));
}
[Test]
public async Task WhenTheHttpContextHasAnAuthenticatedUser_ThenTheActorUserIdIsResolvedFromTheClaim()
{
var identity = new System.Security.Claims.ClaimsIdentity(
[new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "42")], "TestAuth");
var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(identity)
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext);
var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue);
await service.RecordAsync("Feature", "1", "created", organizationId: 1);
Assert.That(_auditLogs.First().ActorUserId, Is.EqualTo(42));
}
[Test]
public async Task WhenTheHttpContextHasNoNameIdentifierClaim_ThenTheActorUserIdIsNull()
{
var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity())
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext);
var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue);
await service.RecordAsync("Feature", "1", "created", organizationId: 1);
Assert.That(_auditLogs.First().ActorUserId, Is.Null);
}
[Test] [Test]
public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued() public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued()
{ {

View File

@@ -1,97 +0,0 @@
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Data;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys;
[TestFixture]
public class ApiKeyServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<ApiKey> _apiKeys = null!;
private ApiKeyService _service = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_apiKeys = [];
_db.SetupDbSetWithGeneratedIds(c => c.ApiKeys, _apiKeys);
_service = new ApiKeyService(_db.Object);
}
[Test]
public async Task WhenCreatingAnApiKey_ThenTheStoredKeyIsHashedAndTheRawKeyIsReturnedOnce()
{
var result = await _service.CreateAsync(OrganizationId, "CI Key", null);
Assert.That(result.RawKey, Is.Not.Empty);
Assert.That(result.Key.Key, Is.EqualTo(ApiKeyHasher.Hash(result.RawKey)));
Assert.That(result.Key.Key, Is.Not.EqualTo(result.RawKey));
}
[Test]
public async Task WhenCreatingAnApiKey_ThenThePrefixIsTheFirstEightCharactersOfTheRawKey()
{
var result = await _service.CreateAsync(OrganizationId, "CI Key", null);
Assert.That(result.Key.Prefix, Is.EqualTo(result.RawKey[..8]));
}
[Test]
public async Task WhenCreatingAnApiKey_ThenItIsPersistedAsActiveForTheGivenOrganization()
{
var expiresAt = DateTimeOffset.UtcNow.AddDays(30);
var result = await _service.CreateAsync(OrganizationId, "CI Key", expiresAt);
var persisted = _apiKeys.Single();
Assert.That(persisted.Id, Is.EqualTo(result.Key.Id));
Assert.That(persisted.Name, Is.EqualTo("CI Key"));
Assert.That(persisted.OrganizationId, Is.EqualTo(OrganizationId));
Assert.That(persisted.IsActive, Is.True);
Assert.That(persisted.ExpiresAt, Is.EqualTo(expiresAt));
}
[Test]
public async Task WhenListingApiKeysForAnOrganization_ThenOnlyThatOrganizationsKeysAreReturned()
{
await _service.CreateAsync(OrganizationId, "Org 1 Key", null);
await _service.CreateAsync(999, "Org 2 Key", null);
var keys = await _service.ListAsync(OrganizationId);
Assert.That(keys, Has.Count.EqualTo(1));
Assert.That(keys[0].Name, Is.EqualTo("Org 1 Key"));
}
[Test]
public async Task WhenRevokingAnExistingApiKey_ThenItIsMarkedInactive()
{
var created = await _service.CreateAsync(OrganizationId, "CI Key", null);
await _service.RevokeAsync(OrganizationId, created.Key.Id);
Assert.That(_apiKeys.Single().IsActive, Is.False);
}
[Test]
public async Task WhenRevokingAnApiKeyThatBelongsToADifferentOrganization_ThenItIsNotRevoked()
{
var created = await _service.CreateAsync(OrganizationId, "CI Key", null);
await _service.RevokeAsync(999, created.Key.Id);
Assert.That(_apiKeys.Single().IsActive, Is.True);
}
[Test]
public void WhenRevokingAnApiKeyThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.RevokeAsync(OrganizationId, 999));
}
}

View File

@@ -75,14 +75,6 @@ public class ApiKeyAuthenticationHandlerTests
Assert.That(result.None, Is.True); Assert.That(result.None, Is.True);
} }
[Test]
public async Task WhenApiKeyValueIsEmpty_ThenAuthenticationFails()
{
var result = await AuthenticateAsync("Api-Key ");
Assert.That(result.Succeeded, Is.False);
}
[Test] [Test]
public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails() public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
{ {

View File

@@ -51,14 +51,6 @@ public class EnvironmentKeyAuthenticationHandlerTests
Assert.That(result.None, Is.True); Assert.That(result.None, Is.True);
} }
[Test]
public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails()
{
var result = await AuthenticateAsync("");
Assert.That(result.Succeeded, Is.False);
}
[Test] [Test]
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails()
{ {

View File

@@ -216,10 +216,4 @@ public class AuthServiceTests
Assert.That(response, Is.Null); Assert.That(response, Is.Null);
} }
[Test]
public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown()
{
Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token"));
}
} }

View File

@@ -1,95 +0,0 @@
using MicCheck.Api.Environments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class CreateEnvironmentRequestValidatorTests
{
private CreateEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 1));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest("", 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest(new string('a', 201), 1));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenProjectIdIsZeroOrLess_ThenValidationFails()
{
var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 0));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class UpdateEnvironmentRequestValidatorTests
{
private UpdateEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new UpdateEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new UpdateEnvironmentRequest("Renamed"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new UpdateEnvironmentRequest(""));
Assert.That(result.IsValid, Is.False);
}
}
[TestFixture]
public class CloneEnvironmentRequestValidatorTests
{
private CloneEnvironmentRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CloneEnvironmentRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CloneEnvironmentRequest("Staging Copy"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CloneEnvironmentRequest(""));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -1,69 +0,0 @@
using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentDocumentControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private EnvironmentDocumentController _controller = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_db.SetupDbSet(c => c.Projects, [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }]);
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_db.SetupDbSet(c => c.Features, []);
_db.SetupDbSet(c => c.FeatureStates, []);
_controller = new EnvironmentDocumentController(new EnvironmentDocumentService(_db.Object));
var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "TestAuth");
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
[Test]
public async Task WhenTheEnvironmentDocumentExists_ThenItIsReturned()
{
var result = await _controller.Get(CancellationToken.None);
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
var document = ok!.Value as EnvironmentDocumentResponse;
Assert.That(document!.Id, Is.EqualTo(EnvironmentId));
}
[Test]
public async Task WhenTheEnvironmentDocumentDoesNotExist_ThenNotFoundIsReturned()
{
_environments.Clear();
var result = await _controller.Get(CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
}

View File

@@ -1,63 +0,0 @@
using MicCheck.Api.Environments;
using MicCheck.Api.Segments;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class SegmentDocumentResponseTests
{
[Test]
public void WhenMappingASegment_ThenOnlyTopLevelRulesAreIncluded()
{
var segment = new Segment { Id = 1, Name = "Beta Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow };
var topLevelRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
var nestedRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any };
segment.Rules.Add(topLevelRule);
segment.Rules.Add(nestedRule);
var response = SegmentDocumentResponse.From(segment);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("Beta Users"));
Assert.That(response.Rules, Has.Count.EqualTo(1));
Assert.That(response.Rules[0].Id, Is.EqualTo(1));
}
}
[TestFixture]
public class SegmentRuleDocumentResponseTests
{
[Test]
public void WhenMappingARule_ThenTypeIsUpperInvariantAndChildRulesAreIncluded()
{
var rule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.Any };
var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" };
var childRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.All };
rule.Conditions.Add(condition);
rule.ChildRules.Add(childRule);
var response = SegmentRuleDocumentResponse.From(rule);
Assert.That(response.Type, Is.EqualTo("ANY"));
Assert.That(response.Conditions, Has.Count.EqualTo(1));
Assert.That(response.Rules, Has.Count.EqualTo(1));
Assert.That(response.Rules[0].Id, Is.EqualTo(2));
}
}
[TestFixture]
public class SegmentConditionDocumentResponseTests
{
[Test]
public void WhenMappingACondition_ThenPropertyOperatorAndValueAreCopied()
{
var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Contains, Value = "pro" };
var response = SegmentConditionDocumentResponse.From(condition);
Assert.That(response.Property, Is.EqualTo("plan"));
Assert.That(response.Operator, Is.EqualTo("Contains"));
Assert.That(response.Value, Is.EqualTo("pro"));
}
}

View File

@@ -1,120 +0,0 @@
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentDocumentServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private EnvironmentDocumentService _service = null!;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_projects = [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_features = [];
_db.SetupDbSet(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
_service = new EnvironmentDocumentService(_db.Object);
}
[Test]
public async Task WhenTheEnvironmentDoesNotExist_ThenNullIsReturned()
{
var result = await _service.GetAsync(999);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenTheEnvironmentsProjectDoesNotExist_ThenNullIsReturned()
{
_projects.Clear();
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenTheEnvironmentExists_ThenEnvironmentLevelFeatureStatesAreIncluded()
{
var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_features.Add(feature);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
Enabled = true,
Value = "on",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result, Is.Not.Null);
Assert.That(result!.Id, Is.EqualTo(EnvironmentId));
Assert.That(result.ApiKey, Is.EqualTo("env-key"));
Assert.That(result.FeatureStates, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenTheEnvironmentExists_ThenIdentityAndSegmentScopedFeatureStatesAreExcluded()
{
var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_features.Add(feature);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = 5,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result!.FeatureStates, Is.Empty);
}
[Test]
public async Task WhenTheProjectHasSegments_ThenTheyAreIncludedInTheProjectResponse()
{
_projects[0].Segments.Add(new Segment { Id = 1, Name = "Beta Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _service.GetAsync(EnvironmentId);
Assert.That(result!.Project.Segments, Has.Count.EqualTo(1));
Assert.That(result.Project.Segments[0].Name, Is.EqualTo("Beta Users"));
}
}

View File

@@ -1,30 +0,0 @@
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentResponseTests
{
[Test]
public void WhenMappingAnEnvironment_ThenAllFieldsAreCopied()
{
var createdAt = DateTimeOffset.UtcNow;
var environment = new AppEnvironment
{
Id = 1,
Name = "Production",
ApiKey = "env-key-abc",
ProjectId = 2,
CreatedAt = createdAt
};
var response = MicCheck.Api.Environments.EnvironmentResponse.From(environment);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Name, Is.EqualTo("Production"));
Assert.That(response.ApiKey, Is.EqualTo("env-key-abc"));
Assert.That(response.ProjectId, Is.EqualTo(2));
Assert.That(response.CreatedAt, Is.EqualTo(createdAt));
}
}

View File

@@ -45,7 +45,7 @@ public class EnvironmentServiceTests
_db.SetupDbSet(c => c.Identities, _identities); _db.SetupDbSet(c => c.Identities, _identities);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>(); var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync( auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
@@ -153,73 +153,4 @@ public class EnvironmentServiceTests
Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey)); Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey));
} }
[Test]
public void WhenCloningFromANonExistentApiKey_ThenKeyNotFoundExceptionIsThrown()
{
Assert.That(async () => await _service.CloneAsync("missing-key", "Staging"), Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public async Task WhenListingByProject_ThenOnlyEnvironmentsForThatProjectAreReturned()
{
await _service.CreateAsync(ProjectId, "Env1");
await _service.CreateAsync(999, "OtherProjectEnv");
var result = await _service.ListByProjectAsync(ProjectId);
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].Name, Is.EqualTo("Env1"));
}
[Test]
public async Task WhenFindingByApiKeyThatDoesNotExist_ThenNullIsReturned()
{
var result = await _service.FindByApiKeyAsync("missing-key");
Assert.That(result, Is.Null);
}
[Test]
public async Task WhenFindingByApiKeyThatExists_ThenTheEnvironmentIsReturned()
{
var created = await _service.CreateAsync(ProjectId, "Production");
var result = await _service.FindByApiKeyAsync(created.ApiKey);
Assert.That(result, Is.Not.Null);
Assert.That(result!.Id, Is.EqualTo(created.Id));
}
[Test]
public async Task WhenUpdatingAnEnvironment_ThenNameIsChanged()
{
var created = await _service.CreateAsync(ProjectId, "Original");
var updated = await _service.UpdateAsync(created.ApiKey, "Renamed");
Assert.That(updated.Name, Is.EqualTo("Renamed"));
}
[Test]
public void WhenUpdatingANonExistentEnvironment_ThenKeyNotFoundExceptionIsThrown()
{
Assert.That(async () => await _service.UpdateAsync("missing-key", "Renamed"), Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public async Task WhenDeletingAnExistingEnvironment_ThenItIsRemoved()
{
var created = await _service.CreateAsync(ProjectId, "ToDelete");
await _service.DeleteAsync(created.ApiKey);
Assert.That(_environments, Is.Empty);
}
[Test]
public void WhenDeletingANonExistentEnvironment_ThenNoExceptionIsThrown()
{
Assert.That(async () => await _service.DeleteAsync("missing-key"), Throws.Nothing);
}
} }

View File

@@ -1,905 +0,0 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentsControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Project> _projects = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private List<IdentityTrait> _identityTraits = null!;
private List<Segment> _segments = null!;
private List<FeatureSegment> _featureSegments = null!;
private List<Webhook> _webhooks = null!;
private List<WebhookDeliveryLog> _webhookDeliveryLogs = null!;
private List<AuditLog> _auditLogs = null!;
private EnvironmentsController _controller = null!;
private AuditLogQueryService _auditLogQueryService = null!;
private AdminIdentityService _adminIdentityService = null!;
private FeatureStateService _featureStateService = null!;
private FeatureSegmentService _featureSegmentService = null!;
private SegmentService _segmentService = null!;
private SegmentEvaluator _segmentEvaluator = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
private AppEnvironment _environment = null!;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_db.SetupDbSet(c => c.Organizations, [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]);
_projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }];
_db.SetupDbSet(c => c.Projects, _projects);
_environment = new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_environments = [_environment];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.Add(It.IsAny<AppEnvironment>())).Callback<AppEnvironment>(env =>
{
typeof(AppEnvironment).GetProperty(nameof(AppEnvironment.Id))!.SetValue(env, _environments.Count + 1);
_environments.Add(env);
});
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_features = [];
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
_identities = [];
var identitiesSet = MockDbSetFactory.Create(_identities);
identitiesSet.Setup(m => m.Add(It.IsAny<Identity>())).Callback<Identity>(i =>
{
typeof(Identity).GetProperty(nameof(Identity.Id))!.SetValue(i, _identities.Count + 1);
_identities.Add(i);
});
_db.Setup(c => c.Identities).Returns(identitiesSet.Object);
_identityTraits = [];
var traitsSet = MockDbSetFactory.Create(_identityTraits);
traitsSet.Setup(m => m.Add(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
typeof(IdentityTrait).GetProperty(nameof(IdentityTrait.Id))!.SetValue(trait, _identityTraits.Count + 1);
_identityTraits.Add(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait);
});
traitsSet.Setup(m => m.Remove(It.IsAny<IdentityTrait>())).Callback<IdentityTrait>(trait =>
{
_identityTraits.Remove(trait);
_identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait);
});
_db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object);
_segments = [];
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
_featureSegments = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments);
_webhooks = [];
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
_webhookDeliveryLogs = [];
_db.SetupDbSet(c => c.WebhookDeliveryLogs, _webhookDeliveryLogs);
_auditLogs = [];
_db.SetupDbSetWithGeneratedIds(c => c.AuditLogs, _auditLogs);
_db.SetupDbSet(c => c.Users, []);
var webhookQueue = new WebhookQueue();
var auditServiceMock = new Mock<IAuditService>();
auditServiceMock.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
auditServiceMock.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<object?>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var auditService = auditServiceMock.Object;
_auditLogQueryService = new AuditLogQueryService(_db.Object);
_adminIdentityService = new AdminIdentityService(_db.Object);
_featureStateService = new FeatureStateService(_db.Object, webhookQueue, auditService);
_featureSegmentService = new FeatureSegmentService(_db.Object);
_segmentService = new SegmentService(_db.Object, auditService);
_segmentEvaluator = new SegmentEvaluator();
_controller = new EnvironmentsController(
new EnvironmentService(_db.Object, auditService),
new WebhookService(_db.Object));
}
private const string MissingApiKey = "missing-key";
// ─── Core CRUD ──────────────────────────────────────────────────────────
[Test]
public async Task WhenListingEnvironments_ThenPageSizeIsClampedAndResultsAreMapped()
{
var result = await _controller.List(ProjectId, page: 1, pageSize: 1000);
var ok = result.Result as OkObjectResult;
var paged = ok!.Value as PaginatedResponse<EnvironmentResponse>;
Assert.That(paged!.Count, Is.EqualTo(1));
Assert.That(paged.Results, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnEnvironment_ThenItIsPersistedAndReturned()
{
var result = await _controller.Create(new CreateEnvironmentRequest("Staging", ProjectId), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
Assert.That(created, Is.Not.Null);
var body = created!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Staging"));
}
[Test]
public async Task WhenGettingByApiKeyThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetByApiKey(MissingApiKey, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingByApiKeyThatExists_ThenTheEnvironmentIsReturned()
{
var result = await _controller.GetByApiKey(_environment.ApiKey, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as EnvironmentResponse;
Assert.That(body!.Id, Is.EqualTo(EnvironmentId));
}
[Test]
public async Task WhenUpdatingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(MissingApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAnEnvironmentThatExists_ThenTheNameIsChanged()
{
var result = await _controller.Update(_environment.ApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Renamed"));
}
[Test]
public async Task WhenDeletingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Delete(MissingApiKey, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnEnvironmentThatExists_ThenNoContentIsReturned()
{
var result = await _controller.Delete(_environment.ApiKey, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_environments, Is.Empty);
}
[Test]
public async Task WhenCloningAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Clone(MissingApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCloningAnEnvironmentThatExists_ThenTheCloneIsReturned()
{
var result = await _controller.Clone(_environment.ApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as EnvironmentResponse;
Assert.That(body!.Name, Is.EqualTo("Clone"));
Assert.That(body.ApiKey, Is.Not.EqualTo(_environment.ApiKey));
}
// ─── Webhooks ───────────────────────────────────────────────────────────
[Test]
public async Task WhenListingWebhooksForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListWebhooks(MissingApiKey, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhooksForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListWebhooks(_environment.ApiKey, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<WebhookResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateWebhook(MissingApiKey, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAWebhookForAnEnvironmentThatExists_ThenItIsPersisted()
{
var result = await _controller.CreateWebhook(_environment.ApiKey, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as WebhookResponse;
Assert.That(body!.Url, Is.EqualTo("https://example.com"));
Assert.That(_webhooks, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateWebhook(MissingApiKey, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, true), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAWebhookThatBelongsToTheEnvironment_ThenItIsUpdated()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as WebhookResponse;
Assert.That(body!.Url, Is.EqualTo("https://updated.com"));
}
[Test]
public async Task WhenListingWebhookDeliveriesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListWebhookDeliveries(MissingApiKey, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToTheEnvironment_ThenTheyAreReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
_webhookDeliveryLogs.Add(new WebhookDeliveryLog
{
Id = 1, WebhookId = 1, EventType = "flag.updated", PayloadJson = "{}", Success = true, AttemptNumber = 1, AttemptedAt = DateTimeOffset.UtcNow
});
var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<WebhookDeliveryLogResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenDeletingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteWebhook(MissingApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAWebhookThatBelongsToTheEnvironment_ThenNoContentIsReturned()
{
_webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_webhooks, Is.Empty);
}
// ─── Audit logs ─────────────────────────────────────────────────────────
[Test]
public async Task WhenListingAuditLogsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListAuditLogs(MissingApiKey, _auditLogQueryService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingAuditLogsForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Environment", ResourceId = "1", Action = "updated", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListAuditLogs(_environment.ApiKey, _auditLogQueryService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as List<AuditLogResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
// ─── Identities ─────────────────────────────────────────────────────────
[Test]
public async Task WhenListingIdentitiesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListIdentities(MissingApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingIdentitiesForAnEnvironmentThatExists_ThenPageSizeIsClampedAndResultsMapped()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListIdentities(_environment.ApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as PaginatedResponse<AdminIdentityResponse>;
Assert.That(body!.Count, Is.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateIdentity(MissingApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAnIdentityThatAlreadyExists_ThenConflictIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<ConflictObjectResult>());
}
[Test]
public async Task WhenCreatingANewIdentity_ThenItIsPersisted()
{
var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None);
var created = result.Result as CreatedAtActionResult;
var body = created!.Value as AdminIdentityResponse;
Assert.That(body!.Identifier, Is.EqualTo("user-1"));
}
[Test]
public async Task WhenGettingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAnIdentityThatExists_ThenItIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as AdminIdentityResponse;
Assert.That(body!.Identifier, Is.EqualTo("user-1"));
}
[Test]
public async Task WhenDeletingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAnIdentityThatExists_ThenNoContentIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_identities, Is.Empty);
}
[Test]
public async Task WhenUpsertingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpsertIdentityTrait(MissingApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 999, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpsertingATraitForAnIdentityThatExists_ThenTheTraitIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as TraitResponse;
Assert.That(body!.Value, Is.EqualTo("pro"));
}
[Test]
public async Task WhenDeletingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityTrait(MissingApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATraitThatDoesNotExist_ThenNotFoundIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingATraitThatExists_ThenNoContentIsReturned()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow };
_identities.Add(identity);
await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro");
var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentityFeatureStates(MissingApiKey, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingFeatureStatesForAnIdentityThatExists_ThenTheyAreReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureStateResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.SetIdentityFeatureState(MissingApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 999, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenSettingAFeatureStateForAnIdentityThatExists_ThenTheStateIsPersisted()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
Assert.That(body.Value, Is.EqualTo("on"));
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityFeatureState(MissingApiKey, 1, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 999, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureStateForAnIdentityThatExists_ThenNoContentIsReturned()
{
_identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 1, 1, _adminIdentityService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_featureStates, Is.Empty);
}
// ─── Environment-level feature states ──────────────────────────────────
[Test]
public async Task WhenListingFeatureStatesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListFeatureStates(MissingApiKey, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingFeatureStatesForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.ListFeatureStates(_environment.ApiKey, _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureStateResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenGettingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetFeatureState(MissingApiKey, 1, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetFeatureState(_environment.ApiKey, 999, _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingAFeatureStateThatExists_ThenItIsReturned()
{
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.GetFeatureState(_environment.ApiKey, 1, _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Id, Is.EqualTo(1));
}
[Test]
public async Task WhenUpdatingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureState(MissingApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureState(_environment.ApiKey, 999, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureStateThatExists_ThenItIsUpdated()
{
_features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.UpdateFeatureState(_environment.ApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
}
[Test]
public async Task WhenPatchingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.PatchFeatureState(MissingApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenPatchingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.PatchFeatureState(_environment.ApiKey, 999, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenPatchingAFeatureStateThatExists_ThenOnlyProvidedFieldsAreChanged()
{
_features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, Value = "original", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow });
var result = await _controller.PatchFeatureState(_environment.ApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureStateResponse;
Assert.That(body!.Enabled, Is.True);
Assert.That(body.Value, Is.EqualTo("original"));
}
// ─── Feature segments ───────────────────────────────────────────────────
[Test]
public async Task WhenListingFeatureSegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.ListFeatureSegments(MissingApiKey, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenListingFeatureSegmentsForAnEnvironmentThatExists_ThenTheyAreReturned()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.ListFeatureSegments(_environment.ApiKey, 1, _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<FeatureSegmentResponse>;
Assert.That(body, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateFeatureSegment(MissingApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenCreatingAFeatureSegmentForASegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(999, 1, true, null), _featureSegmentService, CancellationToken.None);
var notFound = result.Result as NotFoundObjectResult;
Assert.That(notFound, Is.Not.Null);
}
[Test]
public async Task WhenCreatingAFeatureSegmentForASegmentThatExists_ThenItIsPersisted()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, "on"), _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureSegmentResponse;
Assert.That(body!.SegmentName, Is.EqualTo("Beta"));
}
[Test]
public async Task WhenUpdatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureSegment(MissingApiKey, 1, 1, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 999, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAFeatureSegmentThatExists_ThenItIsUpdated()
{
_segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow });
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 1, new UpdateFeatureSegmentRequest(5, true, "on"), _featureSegmentService, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as FeatureSegmentResponse;
Assert.That(body!.Priority, Is.EqualTo(5));
}
[Test]
public async Task WhenDeletingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteFeatureSegment(MissingApiKey, 1, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 999, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenDeletingAFeatureSegmentThatExists_ThenNoContentIsReturned()
{
_featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 });
var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 1, _featureSegmentService, CancellationToken.None);
Assert.That(result, Is.InstanceOf<NoContentResult>());
Assert.That(_featureSegments, Is.Empty);
}
// ─── Identity segments ──────────────────────────────────────────────────
[Test]
public async Task WhenGettingIdentitySegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentitySegments(MissingApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingIdentitySegmentsForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.GetIdentitySegments(_environment.ApiKey, 999, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[Test]
public async Task WhenGettingIdentitySegments_ThenOnlyMatchingSegmentsAreReturned()
{
var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow };
_identities.Add(identity);
await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro");
var matchingSegment = new Segment { Id = 1, Name = "Pro Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
var matchingRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All };
matchingRule.Conditions.Add(new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" });
matchingSegment.Rules.Add(matchingRule);
_segments.Add(matchingSegment);
var nonMatchingSegment = new Segment { Id = 2, Name = "Free Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
var nonMatchingRule = new SegmentRule { Id = 2, SegmentId = 2, Type = SegmentRuleType.All };
nonMatchingRule.Conditions.Add(new SegmentCondition { Id = 2, RuleId = 2, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "free" });
nonMatchingSegment.Rules.Add(nonMatchingRule);
_segments.Add(nonMatchingSegment);
var result = await _controller.GetIdentitySegments(_environment.ApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None);
var ok = result.Result as OkObjectResult;
var body = ok!.Value as IReadOnlyList<SegmentSummaryResponse>;
Assert.That(body, Has.Count.EqualTo(1));
Assert.That(body![0].Name, Is.EqualTo("Pro Users"));
}
}

View File

@@ -1,61 +0,0 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class CreateFeatureRequestValidatorTests
{
private CreateFeatureRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateFeatureRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenNameIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest(new string('a', 151), FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenNameContainsInvalidCharacters_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("dark mode!", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenInitialValueExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, new string('a', 20_001), null));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenInitialValueIsNull_ThenTheLengthRuleIsSkipped()
{
var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, null, null));
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -1,61 +0,0 @@
using MicCheck.Api.Features;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class CreateTagRequestValidatorTests
{
private CreateTagRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateTagRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "#FF0000"));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenLabelIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("", "#FF0000"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenLabelExceedsMaximumLength_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest(new string('a', 101), "#FF0000"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("Beta", ""));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsNotAValidHexCode_ThenValidationFails()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "red"));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenColorIsAShortHandHexCode_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateTagRequest("Beta", "#F00"));
Assert.That(result.IsValid, Is.True);
}
}

View File

@@ -1,7 +1,6 @@
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport; using MicCheck.Api.Tests.Unit.TestSupport;

Some files were not shown because too many files have changed in this diff Show More