Compare commits

...

8 Commits

Author SHA1 Message Date
James Wampler
8262cd2f61 Drop docker-compose from dev workflow; run full build/test and Aspire locally
All checks were successful
CI / build-and-push (push) Successful in 46s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 26s
dev-build.sh now builds the whole solution and runs .NET + Jest unit
tests instead of restarting docker-compose services. readme documents
the aspire run command for local orchestration; slnx solution-items
reference to the removed docker-compose.yml is gone.
2026-07-06 10:44:11 -07:00
James Wampler
83441b6c69 Removing old docker-compose file. Use Aspire instead
All checks were successful
CI / build-and-push (push) Successful in 51s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 22s
2026-07-06 10:33:02 -07:00
James Wampler
283ca4f148 Move QA Postgres password out of docker-compose into Gitea secret
Follows the same pattern already used for JWT_SECRET_KEY: interpolated
from a required env var, sourced from a Gitea Actions secret in CI.
2026-07-06 10:32:04 -07:00
James Wampler
7a3e2167c2 Remove FluentValidation; validator/DI cleanup (#7)
All checks were successful
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 11s
CI / smoke-qa (push) Successful in 26s
## Summary
- Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter
- Move FeatureUsage code into MicCheck.Api.Features.Usage namespace
- Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily)
- Extract IAuditService interface, drop virtual-method mock seam on AuditService
- Split Program.cs DI registrations into per-namespace DependencyRegistration classes

## Test plan
- [x] dotnet build (API + tests) clean
- [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing
- [x] pre-push hook (full solution build/test + admin build) passed

Co-authored-by: miccheck-ci <ci@miccheck.local>
Reviewed-on: #7
2026-07-05 22:54:30 -07:00
James Wampler
127aefc020 Fix npm audit vulnerabilities in admin (form-data high, js-yaml moderate)
All checks were successful
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 15s
CI / smoke-qa (push) Successful in 30s
Both are transitive (axios->form-data, babel-jest->js-yaml); npm audit
fix bumped them non-breaking, package.json unchanged. Full jest suite
still green (306/306).
2026-07-05 16:14:33 -07:00
miccheck-ci
9d445aca67 chore: refresh coverage badge [skip ci] 2026-07-05 22:08:11 +00:00
James Wampler
87113ccdcd Add unit tests for Environments, Audit, and Webhooks namespaces (#6)
All checks were successful
CI / build-and-push (push) Successful in 54s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 24s
## Summary
- Add missing unit test coverage for the Environments, Audit, and Webhooks namespaces (raises them from ~0-62% to 91-100%)
- Exclude WebhookBackgroundService/WebhookRetryBackgroundService from coverage (require live DI/DB, disallowed by CLAUDE.md's no-InMemory/WebApplicationFactory rule)

## Test plan
- [x] `dotnet test` full suite passes (646/646)
- [x] Coverage report confirms Environments ~99.5%, Audit 100%, Webhooks ~91%

Reviewed-on: #6
2026-07-05 15:07:26 -07:00
James Wampler
cae55e5737 Add coverage reporting/badges and split CI between Gitea and GitHub (#5)
All checks were successful
CI / build-and-push (push) Successful in 46s
CI / deploy-qa (push) Successful in 13s
CI / smoke-qa (push) Successful in 35s
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.

Reviewed-on: #5
2026-07-05 10:14:30 -07:00
143 changed files with 7410 additions and 466 deletions

View File

@@ -2,15 +2,25 @@
# (both look under .github/workflows/). Every non-checkout step just invokes a # (both look under .github/workflows/). Every non-checkout step just invokes a
# bash script under scripts/ci/, so the entire pipeline is reproducible by # bash script under scripts/ci/, so the entire pipeline is reproducible by
# running the same scripts locally - no marketplace build/test/push actions. # running the same scripts locally - no marketplace build/test/push actions.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI name: CI
on: on:
push: push:
branches: [main, build-runner-fix] paths-ignore: [badges/**]
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }} REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
@@ -25,14 +35,25 @@ jobs:
- name: Test - name: Test
run: ./scripts/ci/test.sh run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images - name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh run: ./scripts/ci/docker-build.sh
- name: Push Docker images - name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh run: ./scripts/ci/docker-push.sh
deploy-qa: deploy-qa:
needs: build-and-push needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
@@ -40,6 +61,7 @@ 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
@@ -56,9 +78,11 @@ jobs:
smoke-qa: smoke-qa:
needs: deploy-qa needs: deploy-qa
if: github.server_url != 'https://github.com'
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.

3
.gitignore vendored
View File

@@ -476,3 +476,6 @@ ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK) # Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/ /.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/

View File

@@ -1,10 +1,10 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo. Guidance for Claude Code (claude.ai/code) in this repo.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
## Planned Structure ## Planned Structure
@@ -18,27 +18,30 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- 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 as much logic as possible (both nunit and jest) - New features need unit tests covering logic as much as possible (both nunit and jest)
- Any modified file: evaluate for missing test coverage and that all tests pass - Modified file: check missing test coverage, all tests pass
# Coding # Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper - Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer - Wrap lines at 220 chars, 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. - Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations separate file.
- Do not use tuples for return types. Prefer records or classes for multiple values - No tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed` - No `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 with Moq - Mock external deps w/ Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider. - Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
- New features need unit tests covering as much logic as possible - New features need unit tests covering logic as much as possible
- Any modified file: evaluate for missing test coverage- - Modified file: check 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 should pass before commit - All tests 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/`
@@ -47,4 +50,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- 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` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. `.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.

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

@@ -1,44 +1,53 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project ## Project
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure ## Planned Structure
- `src/admin/`administrative components in VueJS - `src/admin/`VueJS admin components
- `src/api/` - api and restful endpoints in .NET - `src/api/` - .NET API + REST endpoints
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## Best Practices
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version - Use latest LTS .NET + latest supported nuget packages for that version
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled - Set `langVersion` to latest in all csproj files; enable nullable
- 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) - Organize code by feature/area, not type (e.g. `features` namespace)
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible. - New features need unit tests covering as much logic as possible (both nunit and jest)
- Any file modified should be evaluated for potential test cases and missing coverage areas. - Any modified file: evaluate for missing test coverage and that all tests pass
# Coding # Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Coding should match formating and style rules in the .editorconfig file - Match formatting/style from `.editorconfig`
- 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
- 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) - Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
- Mock any external dependencies using Moq. - 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.
- Do not name any mocked objects with the word Mock in them - Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
- Do not include any Arrange / Act / Assert comments in the code - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- 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
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/` - Plans = `.md` files in `docs/plans/`. Admin `docs/plans/admin/`, API `docs/plans/api/`
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently. - Split large plans into discrete chunks — each buildable + committable independently
- 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 generated from `docs/plans/<name>.md` save as `docs/plans/<name>_plan.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 - Plan implemented from `docs/plans/<name>.md` save summary as `docs/plans/<name>_output.md`
## 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,7 +3,6 @@
<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/">

138
badges/coverage.svg Normal file
View File

@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.10.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">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>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -9,4 +9,5 @@ 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: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
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=password" ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_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,27 +3,19 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
build_api() { echo "Building solution..."
echo "Building API..." dotnet build "$SCRIPT_DIR/MicCheck.slnx"
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."
}
build_admin() { echo "Installing admin dependencies..."
echo "Building admin..." npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
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."
}
case "${1:-all}" in echo "Building admin..."
api) build_api ;; npm --prefix "$SCRIPT_DIR/src/admin" run build
admin) build_admin ;;
all) build_api && build_admin ;; echo "Running .NET unit tests..."
*) dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
echo "Usage: $0 [api|admin|all]"
exit 1 echo "Running Jest tests..."
;; npm --prefix "$SCRIPT_DIR/src/admin" test
esac
echo "Build and tests complete."

View File

@@ -1,60 +0,0 @@
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

@@ -1,5 +1,9 @@
# MicCheck # MicCheck
[![GitHub CI](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps. Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps.
Built with .NET (API) and Vue.js + Vuetify (admin UI). Built with .NET (API) and Vue.js + Vuetify (admin UI).
@@ -25,10 +29,11 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
## Running locally ## Running locally
`docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development. `MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
```sh ```sh
./dev-build.sh ./dev-build.sh
aspire run --project src/MicCheck.AppHost
``` ```
## Testing ## Testing

32
scripts/ci/coverage.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Merges the .NET (coverlet/Cobertura) and admin (Jest/lcov) coverage output
# produced by test.sh into one report via reportgenerator, prints a summary,
# appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that
# badge via a relative path, which resolves on both GitHub and Gitea since
# the same repo content is pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Merging coverage reports with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/**/coverage.cobertura.xml;src/admin/coverage/lcov.info" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"

View File

@@ -11,6 +11,7 @@ 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

@@ -125,6 +125,22 @@ ensure_node() {
export PATH="$install_dir/bin:$PATH" export PATH="$install_dir/bin:$PATH"
} }
# Installs the dotnet-reportgenerator-globaltool CLI (merges coverlet/Jest
# coverage output into badges + build-summary markdown) into $CI_ROOT/.dotnet-tools
# if it isn't already on PATH. Mirrors ensure_dotnet()/ensure_node() above.
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# The IP address on which a container published on 0.0.0.0/<gateway-ip> is # The IP address on which a container published on 0.0.0.0/<gateway-ip> is
# reachable from a sibling container on docker's default bridge network (i.e. # reachable from a sibling container on docker's default bridge network (i.e.
# the docker host's bridge-side address, not its public interface). Used to # the docker host's bridge-side address, not its public interface). Used to

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "miccheck-ci"
git config user.email "ci@miccheck.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"

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=password}" 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}}"
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

@@ -8,10 +8,19 @@ cd "$CI_ROOT"
ensure_dotnet ensure_dotnet
# --results-directory doesn't clear prior runs - it adds a new GUID folder
# alongside old ones every time. On a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
# runs would otherwise get merged in by coverage.sh and silently skew the
# combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running MicCheck.Api.Tests.Unit" log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \
--collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \
--settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
log "Running admin Jest tests" log "Running admin Jest tests"
npm --prefix src/admin test npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
log "test.sh complete" log "test.sh complete"

View File

@@ -6012,16 +6012,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"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.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8430,11 +8429,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.2", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"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 class AuditLog public record 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,29 +6,25 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync( public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync( public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync( public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync( private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
return new PagedResult<AuditLogResponse>(total, items); return new PagedResult<AuditLogResponse>(total, items);
} }
} }

View File

@@ -12,17 +12,15 @@ 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("api/v1/organisation/{id}/audit-logs")] [HttpGet("organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
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();
@@ -31,11 +29,8 @@ 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("api/v1/project/{projectId}/audit-logs")] [HttpGet("project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
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();
@@ -44,11 +39,8 @@ 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("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
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) public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService
{ {
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 virtual async Task RecordAsync( public 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 = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
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 virtual async Task LogAsync( public 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 = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -112,9 +112,33 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}); });
} }
private int? ResolveActorUserId() private int? GetCurrentUserId()
{ {
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,40 @@
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,7 +1,9 @@
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,9 +1,3 @@
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public record ApiKeyResponse( public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View File

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

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,14 @@
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; using MicCheck.Api.Features.Usage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;

View File

@@ -0,0 +1,21 @@
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,6 +2,7 @@ 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,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record CloneEnvironmentRequest(string Name); public record CloneEnvironmentRequest(string Name);
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest> public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest>
{ {
public CloneEnvironmentRequestValidator() public ValidationResult Validate(CloneEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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,14 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
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 : AbstractValidator<CreateEnvironmentRequest> public class CreateEnvironmentRequestValidator : IModelValidator<CreateEnvironmentRequest>
{ {
public CreateEnvironmentRequestValidator() public ValidationResult Validate(CreateEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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

@@ -0,0 +1,12 @@
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, AuditService auditService) public class EnvironmentService(IMicCheckDbContext db, IAuditService 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,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record UpdateEnvironmentRequest(string Name); public record UpdateEnvironmentRequest(string Name);
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest> public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest>
{ {
public UpdateEnvironmentRequestValidator() public ValidationResult Validate(UpdateEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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,26 +1,28 @@
using FluentValidation; using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateFeatureRequest( public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest> public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
{ {
public CreateFeatureRequestValidator() private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
RuleFor(x => x.InitialValue) public ValidationResult Validate(CreateFeatureRequest model)
.MaximumLength(20_000) {
.When(x => x.InitialValue is not null); var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 150)
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,16 +1,30 @@
using FluentValidation; using System.Text.RegularExpressions;
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 : AbstractValidator<CreateTagRequest> public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
{ {
public CreateTagRequestValidator() private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$");
public ValidationResult Validate(CreateTagRequest model)
{ {
RuleFor(x => x.Label).NotEmpty().MaximumLength(100); var result = new ValidationResult();
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
.Matches("^#[0-9A-Fa-f]{3,6}$") if (string.IsNullOrEmpty(model.Label))
.WithMessage("Color must be a valid hex color (e.g. #FF0000)."); result.AddError(nameof(model.Label), "'Label' must not be empty.");
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

@@ -0,0 +1,20 @@
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,4 +1,5 @@
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, AuditService auditService, WebhookQueue webhookQueue) public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue)
{ {
private const int MaxFeaturesPerProject = 400; private const int MaxFeaturesPerProject = 400;
@@ -23,13 +23,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
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( public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
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)
@@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
return feature; return feature;
} }
public async Task<Feature> UpdateAsync( public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
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.");
@@ -162,11 +155,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
EventType = WebhookEventTypes.FlagDeleted, EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id, EnvironmentId = env.Id,
OrganizationId = project.OrganizationId, OrganizationId = project.OrganizationId,
Data = new FlagDeletedData( Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
} }
} }
} }

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureStateResult public record 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, AuditService auditService) public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService 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,12 +0,0 @@
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

@@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache)
return flags; return flags;
} }
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Invalidate(int environmentId) public void Invalidate(int environmentId) => cache.Remove(CacheKey(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 class Tag public record Tag
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Label { get; set; } public required string Label { get; init; }
public required string Color { get; set; } public required string Color { get; init; }
public int ProjectId { get; init; } public int ProjectId { get; init; }
} }

View File

@@ -8,23 +8,24 @@ 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("api/v1/project/{projectId}/tags")] [HttpGet("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("api/v1/project/{projectId}/tags")] [HttpPost("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("api/v1/project/{projectId}/tag/{id}")] [HttpDelete("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,20 +1,25 @@
using FluentValidation; using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record UpdateFeatureRequest( public record UpdateFeatureRequest(string Name, string? Description);
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest> public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
{ {
public UpdateFeatureRequestValidator() private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
public ValidationResult Validate(UpdateFeatureRequest model)
{ {
RuleFor(x => x.Name) var result = new ValidationResult();
.NotEmpty()
.MaximumLength(150) if (string.IsNullOrEmpty(model.Name))
.Matches("^[a-zA-Z0-9_-]+$") result.AddError(nameof(model.Name), "'Name' must not be empty.");
.WithMessage("Name may only contain letters, digits, underscores, and hyphens."); else if (model.Name.Length > 150)
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

@@ -0,0 +1,13 @@
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,3 +1,3 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
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; namespace MicCheck.Api.Features.Usage;
[ApiController] [ApiController]
[Route("api/v1/environment/{environmentId}/usage")] [Route("api/v1/environment/{environmentId}/usage")]
@@ -13,10 +13,7 @@ namespace MicCheck.Api.Features;
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage( public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
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.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,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
[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; namespace MicCheck.Api.Features.Usage;
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; namespace MicCheck.Api.Features.Usage;
public class FeatureUsageQueryService(IMicCheckDbContext db) public class FeatureUsageQueryService(IMicCheckDbContext db)
{ {

View File

@@ -1,9 +1,7 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
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( public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -0,0 +1,12 @@
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,7 +9,6 @@
</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.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.Usage.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.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View File

@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record CreateOrganizationRequest(string Name); public record CreateOrganizationRequest(string Name);
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest> public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest>
{ {
public CreateOrganizationRequestValidator() public ValidationResult Validate(CreateOrganizationRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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

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

View File

@@ -1,15 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
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 : AbstractValidator<InviteUserRequest> public class InviteUserRequestValidator : IModelValidator<InviteUserRequest>
{ {
public InviteUserRequestValidator() public ValidationResult Validate(InviteUserRequest model)
{ {
RuleFor(x => x.UserId).GreaterThan(0); var result = new ValidationResult();
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
.WithMessage("Role must be 'User' or 'Admin'."); if (model.UserId <= 0)
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,10 +1,11 @@
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, AuditService auditService) public class OrganizationService(IMicCheckDbContext db, IAuditService 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)
{ {
@@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic
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,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record UpdateOrganizationRequest(string Name); public record UpdateOrganizationRequest(string Name);
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest> public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest>
{ {
public UpdateOrganizationRequestValidator() public ValidationResult Validate(UpdateOrganizationRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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 +1,9 @@
using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authentication; using MicCheck.Api.Common;
using MicCheck.Api.Common.Security.ApiKeys;
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;
@@ -15,14 +13,7 @@ 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;
@@ -42,44 +33,12 @@ try
.WriteTo.Console()); .WriteTo.Console());
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddControllers() builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
.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.AddAuthentication() builder.Services.AddCommonServices(builder.Configuration);
.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();
@@ -92,67 +51,20 @@ 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.AddScoped<ITokenService, TokenService>(); builder.Services.AddAuditServices();
builder.Services.AddScoped<AuthService>(); builder.Services.AddIdentitiesServices();
builder.Services.AddScoped<ApiKeyService>(); builder.Services.AddEnvironmentsServices();
builder.Services.AddScoped<DatabaseSeeder>(); builder.Services.AddSegmentsServices();
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>(); builder.Services.AddOrganizationsServices();
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>(); builder.Services.AddProjectsServices();
builder.Services.AddScoped<FeatureEvaluationService>(); builder.Services.AddFeaturesServices();
builder.Services.AddScoped<IdentityResolutionService>(); builder.Services.AddUsersServices();
builder.Services.AddScoped<EnvironmentDocumentService>(); builder.Services.AddWebhooksServices();
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"));
var connectionString = builder.Configuration.GetConnectionString("miccheck") builder.Services.AddDataServices(builder.Configuration);
?? (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,14 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
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 : AbstractValidator<CreateProjectRequest> public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
{ {
public CreateProjectRequestValidator() public ValidationResult Validate(CreateProjectRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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

@@ -0,0 +1,11 @@
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, AuditService auditService) public class ProjectService(IMicCheckDbContext db, IAuditService 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,17 +1,25 @@
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 : AbstractValidator<SetUserPermissionsRequest> public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest>
{ {
public SetUserPermissionsRequestValidator() public ValidationResult Validate(SetUserPermissionsRequest model)
{ {
RuleFor(x => x.UserId).GreaterThan(0); var result = new ValidationResult();
RuleForEach(x => x.Permissions)
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _)) if (model.UserId <= 0)
.WithMessage("Invalid permission value."); result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
}
} 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,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
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 : AbstractValidator<UpdateProjectRequest> public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
{ {
public UpdateProjectRequestValidator() public ValidationResult Validate(UpdateProjectRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
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 FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Segments; namespace MicCheck.Api.Segments;
@@ -16,24 +16,38 @@ public record CreateSegmentRequest(
string Name, string Name,
IReadOnlyList<CreateSegmentRuleRequest> Rules); IReadOnlyList<CreateSegmentRuleRequest> Rules);
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest> public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest>
{ {
public CreateSegmentRequestValidator() public ValidationResult Validate(CreateSegmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
RuleFor(x => x.Rules).NotNull();
RuleForEach(x => x.Rules).ChildRules(rule => 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.Rules is null)
{ {
rule.RuleFor(r => r.Type) result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _)) return result;
.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 _))
result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'.");
foreach (var condition in rule.Conditions)
{ {
cond.RuleFor(c => c.Property).NotEmpty(); if (string.IsNullOrEmpty(condition.Property))
cond.RuleFor(c => c.Operator) result.AddError(nameof(model.Rules), "'Property' must not be empty.");
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
.WithMessage("Invalid operator."); if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
}); result.AddError(nameof(model.Rules), "Invalid operator.");
}); }
}
return result;
} }
} }

View File

@@ -0,0 +1,12 @@
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, AuditService auditService) public class SegmentService(IMicCheckDbContext db, IAuditService auditService)
{ {
private const int MaxSegmentsPerProject = 100; private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100; private const int MaxConditionsPerSegment = 100;

View File

@@ -0,0 +1,14 @@
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,14 +1,22 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
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 : AbstractValidator<CreateWebhookRequest> public class CreateWebhookRequestValidator : IModelValidator<CreateWebhookRequest>
{ {
public CreateWebhookRequestValidator() public ValidationResult Validate(CreateWebhookRequest model)
{ {
RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _)) var result = new ValidationResult();
.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

@@ -0,0 +1,17 @@
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,8 +1,10 @@
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 class WebhookEvent public record 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,8 +1,10 @@
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<AuditService> _auditService = null!; private Mock<IAuditService> _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<AuditService>(_db.Object, null!, webhookQueue); _auditService = new Mock<IAuditService>();
_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

@@ -0,0 +1,219 @@
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

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,118 @@
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,6 +90,85 @@ 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

@@ -0,0 +1,97 @@
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,6 +75,14 @@ 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,6 +51,14 @@ 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,4 +216,10 @@ 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

@@ -0,0 +1,95 @@
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

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,120 @@
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

@@ -0,0 +1,30 @@
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));
}
}

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