Compare commits
8 Commits
5959c1b2e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8262cd2f61 | ||
|
|
83441b6c69 | ||
|
|
283ca4f148 | ||
|
|
7a3e2167c2 | ||
|
|
127aefc020 | ||
|
|
9d445aca67 | ||
|
|
87113ccdcd | ||
|
|
cae55e5737 |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -2,15 +2,25 @@
|
||||
# (both look under .github/workflows/). Every non-checkout step just invokes a
|
||||
# bash script under scripts/ci/, so the entire pipeline is reproducible by
|
||||
# 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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, build-runner-fix]
|
||||
paths-ignore: [badges/**]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||
@@ -25,14 +35,25 @@ jobs:
|
||||
- name: Test
|
||||
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
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
run: ./scripts/ci/docker-build.sh
|
||||
|
||||
- name: Push Docker images
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
run: ./scripts/ci/docker-push.sh
|
||||
|
||||
deploy-qa:
|
||||
needs: build-and-push
|
||||
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
|
||||
runs-on: [self-hosted, qa]
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
@@ -40,6 +61,7 @@ jobs:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
|
||||
steps:
|
||||
# actions/checkout@v4 is a Node-based action; this runner has no node
|
||||
@@ -56,9 +78,11 @@ jobs:
|
||||
|
||||
smoke-qa:
|
||||
needs: deploy-qa
|
||||
if: github.server_url != 'https://github.com'
|
||||
runs-on: [self-hosted, qa]
|
||||
env:
|
||||
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
steps:
|
||||
# actions/checkout@v4 is a Node-based action; this runner has no node
|
||||
# in PATH, so checkout plain git instead of via marketplace action.
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -476,3 +476,6 @@ ehthumbs.db
|
||||
|
||||
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
|
||||
/.dotnet/
|
||||
|
||||
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
|
||||
/.dotnet-tools/
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -1,10 +1,10 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- 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)
|
||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||
- New features need unit tests covering logic as much as possible (both nunit and jest)
|
||||
- Modified file: check missing test coverage, all tests pass
|
||||
|
||||
# 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`
|
||||
- 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`
|
||||
- Wrap lines at 220 chars, single line if fewer
|
||||
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
|
||||
- No tuples for return types. Prefer records or classes for multiple values
|
||||
- No `sealed`
|
||||
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
|
||||
|
||||
## 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`
|
||||
- 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-
|
||||
- Mock external deps w/ Moq
|
||||
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
|
||||
- New features need unit tests covering logic as much as possible
|
||||
- Modified file: check missing test coverage
|
||||
- No "Mock" in mocked object names
|
||||
- No Arrange/Act/Assert comments
|
||||
- All tests should pass before commit
|
||||
- All tests pass before commit
|
||||
|
||||
## Claude
|
||||
- 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`
|
||||
|
||||
## 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
55
CLAUDE.original.md
Executable file → Normal file
@@ -1,44 +1,53 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
- `src/admin/` — administrative components in VueJS
|
||||
- `src/api/` - api and restful endpoints in .NET
|
||||
- `src/admin/` — VueJS admin components
|
||||
- `src/api/` - .NET API + REST endpoints
|
||||
- `tests/` — test suite
|
||||
- `docs/` — documentation
|
||||
|
||||
## 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
|
||||
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||
- 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)
|
||||
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- 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)
|
||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||
|
||||
# Coding
|
||||
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||
- Coding should match formating and style rules in the .editorconfig file
|
||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||
- 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
|
||||
- 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)
|
||||
- Mock any external dependencies using Moq.
|
||||
- Do not name any mocked objects with the word Mock in them
|
||||
- Do not include any Arrange / Act / Assert comments in the code
|
||||
|
||||
- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
|
||||
- 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.
|
||||
- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
|
||||
- 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
|
||||
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
|
||||
- Divide up large plans into discrete chucks of functionality so each can be built and committed 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
|
||||
- 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
|
||||
|
||||
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
|
||||
- Split large plans into discrete chunks — each buildable + committable independently
|
||||
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
|
||||
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
|
||||
|
||||
## Stack
|
||||
|
||||
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
|
||||
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitignore" />
|
||||
<File Path="CLAUDE.md" />
|
||||
<File Path="docker-compose.yml" />
|
||||
<File Path="readme.md" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
|
||||
138
badges/coverage.svg
Normal file
138
badges/coverage.svg
Normal 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 |
@@ -9,4 +9,5 @@ REGISTRY_TOKEN=changeme
|
||||
|
||||
# QA environment
|
||||
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
|
||||
POSTGRES_PASSWORD=change-this-to-a-random-password
|
||||
QA_ADMIN_PORT=3001
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: miccheck
|
||||
POSTGRES_USER: miccheck
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
ports:
|
||||
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
|
||||
volumes:
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
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__Issuer: MicCheck
|
||||
Jwt__Audience: MicCheck
|
||||
|
||||
36
dev-build.sh
36
dev-build.sh
@@ -3,27 +3,19 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
build_api() {
|
||||
echo "Building API..."
|
||||
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
|
||||
echo "API done."
|
||||
}
|
||||
echo "Building solution..."
|
||||
dotnet build "$SCRIPT_DIR/MicCheck.slnx"
|
||||
|
||||
build_admin() {
|
||||
echo "Building admin..."
|
||||
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
|
||||
npm --prefix "$SCRIPT_DIR/src/admin" run build
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
|
||||
echo "Admin done."
|
||||
}
|
||||
echo "Installing admin dependencies..."
|
||||
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
|
||||
|
||||
case "${1:-all}" in
|
||||
api) build_api ;;
|
||||
admin) build_admin ;;
|
||||
all) build_api && build_admin ;;
|
||||
*)
|
||||
echo "Usage: $0 [api|admin|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Building admin..."
|
||||
npm --prefix "$SCRIPT_DIR/src/admin" run build
|
||||
|
||||
echo "Running .NET unit tests..."
|
||||
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
|
||||
|
||||
echo "Running Jest tests..."
|
||||
npm --prefix "$SCRIPT_DIR/src/admin" test
|
||||
|
||||
echo "Build and tests complete."
|
||||
|
||||
@@ -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:
|
||||
@@ -1,5 +1,9 @@
|
||||
# MicCheck
|
||||
|
||||
[](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
|
||||
[](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
|
||||

|
||||
|
||||
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).
|
||||
@@ -25,10 +29,11 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
|
||||
|
||||
## 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
|
||||
./dev-build.sh
|
||||
aspire run --project src/MicCheck.AppHost
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
32
scripts/ci/coverage.sh
Executable file
32
scripts/ci/coverage.sh
Executable 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"
|
||||
@@ -11,6 +11,7 @@ registry_login
|
||||
|
||||
export API_IMAGE ADMIN_IMAGE
|
||||
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}"
|
||||
|
||||
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
|
||||
|
||||
@@ -125,6 +125,22 @@ ensure_node() {
|
||||
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
|
||||
# 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
|
||||
|
||||
30
scripts/ci/publish-coverage-badge.sh
Executable file
30
scripts/ci/publish-coverage-badge.sh
Executable 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"
|
||||
@@ -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"
|
||||
|
||||
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"
|
||||
dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx
|
||||
|
||||
@@ -8,10 +8,19 @@ cd "$CI_ROOT"
|
||||
|
||||
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"
|
||||
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"
|
||||
npm --prefix src/admin test
|
||||
npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
|
||||
|
||||
log "test.sh complete"
|
||||
|
||||
18
src/admin/package-lock.json
generated
18
src/admin/package-lock.json
generated
@@ -6012,16 +6012,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -8430,11 +8429,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
public class AuditLog
|
||||
public record AuditLog
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
|
||||
@@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit;
|
||||
|
||||
public class AuditLogQueryService(IMicCheckDbContext db)
|
||||
{
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
|
||||
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
|
||||
int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
|
||||
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||
}
|
||||
|
||||
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
|
||||
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
||||
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
if (filter.From.HasValue)
|
||||
query = query.Where(l => l.CreatedAt >= filter.From.Value);
|
||||
@@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
|
||||
|
||||
return new PagedResult<AuditLogResponse>(total, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,15 @@ namespace MicCheck.Api.Audit;
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
[Route("api/v1")]
|
||||
public class AuditLogsController(
|
||||
AuditLogQueryService auditLogQueryService,
|
||||
OrganizationService organizationService,
|
||||
ProjectService projectService,
|
||||
EnvironmentService environmentService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/organisation/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||
int id,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("organisation/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
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()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/project/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||
int projectId,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("project/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||
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()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||
string apiKey,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
[HttpGet("environment/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public virtual async Task RecordAsync(
|
||||
public async Task RecordAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
string action,
|
||||
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
||||
}, JsonOptions);
|
||||
}
|
||||
|
||||
var actorUserId = ResolveActorUserId();
|
||||
var actorUserId = GetCurrentUserId();
|
||||
|
||||
var log = new AuditLog
|
||||
{
|
||||
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
||||
}
|
||||
|
||||
// Backward-compatible overload used by existing callers
|
||||
public virtual async Task LogAsync(
|
||||
public async Task LogAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
string action,
|
||||
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
||||
string? changes = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var actorUserId = ResolveActorUserId();
|
||||
var actorUserId = GetCurrentUserId();
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
12
src/api/MicCheck.Api/Audit/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Audit/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/api/MicCheck.Api/Common/DependencyRegistration.cs
Normal file
65
src/api/MicCheck.Api/Common/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/api/MicCheck.Api/Common/Guard.cs
Normal file
40
src/api/MicCheck.Api/Common/Guard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
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 void MapApiKeyEndpoints(this WebApplication app)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
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 void MapAuthEndpoints(this WebApplication app)
|
||||
|
||||
@@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public ProjectPermission Permission { get; }
|
||||
|
||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
||||
}
|
||||
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
|
||||
|
||||
13
src/api/MicCheck.Api/Common/Validation/IModelValidator.cs
Normal file
13
src/api/MicCheck.Api/Common/Validation/IModelValidator.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
14
src/api/MicCheck.Api/Common/Validation/ValidationResult.cs
Normal file
14
src/api/MicCheck.Api/Common/Validation/ValidationResult.cs
Normal 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));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Features.Usage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
|
||||
21
src/api/MicCheck.Api/Data/DependencyRegistration.cs
Normal file
21
src/api/MicCheck.Api/Data/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Features.Usage;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
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);
|
||||
RuleFor(x => x.ProjectId).GreaterThan(0);
|
||||
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.");
|
||||
|
||||
if (model.ProjectId <= 0)
|
||||
result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
12
src/api/MicCheck.Api/Environments/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Environments/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
using FluentValidation;
|
||||
using System.Text.RegularExpressions;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record CreateFeatureRequest(
|
||||
string Name,
|
||||
FeatureType Type,
|
||||
string? InitialValue,
|
||||
string? Description
|
||||
);
|
||||
public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
|
||||
|
||||
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
|
||||
{
|
||||
public CreateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$")
|
||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
RuleFor(x => x.InitialValue)
|
||||
.MaximumLength(20_000)
|
||||
.When(x => x.InitialValue is not null);
|
||||
public ValidationResult Validate(CreateFeatureRequest model)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
using FluentValidation;
|
||||
using System.Text.RegularExpressions;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
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);
|
||||
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
|
||||
.Matches("^#[0-9A-Fa-f]{3,6}$")
|
||||
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrEmpty(model.Label))
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/api/MicCheck.Api/Features/DependencyRegistration.cs
Normal file
20
src/api/MicCheck.Api/Features/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features.Usage;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
public async Task<Feature> CreateAsync(
|
||||
int projectId,
|
||||
string name,
|
||||
FeatureType type,
|
||||
string? initialValue,
|
||||
string? description,
|
||||
CancellationToken ct = default)
|
||||
public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||
if (count >= MaxFeaturesPerProject)
|
||||
@@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task<Feature> UpdateAsync(
|
||||
int id, string name, string? description, CancellationToken ct = default)
|
||||
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Feature {id} not found.");
|
||||
@@ -162,11 +155,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
|
||||
EventType = WebhookEventTypes.FlagDeleted,
|
||||
EnvironmentId = env.Id,
|
||||
OrganizationId = project.OrganizationId,
|
||||
Data = new FlagDeletedData(
|
||||
null,
|
||||
DateTimeOffset.UtcNow,
|
||||
new FeatureSummary(feature.Id, feature.Name))
|
||||
});
|
||||
Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureStateResult
|
||||
public record FeatureStateResult
|
||||
{
|
||||
public required Feature Feature { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache)
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
||||
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
|
||||
public void Invalidate(int environmentId)
|
||||
=> cache.Remove(CacheKey(environmentId));
|
||||
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
|
||||
|
||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class Tag
|
||||
public record Tag
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public required string Label { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
}
|
||||
|
||||
@@ -8,23 +8,24 @@ namespace MicCheck.Api.Features;
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
[Route("api/v1/project/{projectId}")]
|
||||
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)
|
||||
{
|
||||
var tags = await tagService.ListByProjectAsync(projectId, ct);
|
||||
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)
|
||||
{
|
||||
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
|
||||
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)
|
||||
{
|
||||
var tag = await tagService.FindByIdAsync(id, ct);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
using FluentValidation;
|
||||
using System.Text.RegularExpressions;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record UpdateFeatureRequest(
|
||||
string Name,
|
||||
string? Description
|
||||
);
|
||||
public record UpdateFeatureRequest(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)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$")
|
||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||
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.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environment/{environmentId}/usage")]
|
||||
@@ -13,10 +13,7 @@ namespace MicCheck.Api.Features;
|
||||
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
|
||||
int environmentId,
|
||||
[FromQuery] int days = 14,
|
||||
CancellationToken ct = default)
|
||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
|
||||
{
|
||||
days = Math.Clamp(days, 1, 90);
|
||||
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
|
||||
12
src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
Normal file
12
src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
Normal 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; }
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
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(
|
||||
FeatureUsageMetrics metrics,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public class FeatureUsageMetrics : IDisposable
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public class FeatureUsageQueryService(IMicCheckDbContext db)
|
||||
{
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
|
||||
|
||||
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
|
||||
|
||||
public record DashboardUsageResponse(
|
||||
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
|
||||
IReadOnlyList<DailyUsage> DailyUsage);
|
||||
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
|
||||
12
src/api/MicCheck.Api/Identities/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Identities/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />
|
||||
|
||||
@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
|
||||
b.ToTable("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations
|
||||
b.ToTable("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
11
src/api/MicCheck.Api/Organizations/DependencyRegistration.cs
Normal file
11
src/api/MicCheck.Api/Organizations/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
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);
|
||||
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
|
||||
.WithMessage("Role must be 'User' or 'Admin'.");
|
||||
var result = new ValidationResult();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic
|
||||
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)
|
||||
{
|
||||
var member = await db.OrganizationUsers
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
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.Validation;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Environments;
|
||||
using MicCheck.Api.Features;
|
||||
@@ -15,14 +13,7 @@ using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Users;
|
||||
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.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
|
||||
@@ -42,44 +33,12 @@ try
|
||||
.WriteTo.Console());
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddControllers()
|
||||
builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
|
||||
.AddJsonOptions(options =>
|
||||
options.JsonSerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
|
||||
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationHandler.SchemeName, _ => { })
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
|
||||
.RequireClaim("EnvironmentId"));
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser());
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
|
||||
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
|
||||
.RequireClaim("OrganizationRole", "Admin"));
|
||||
});
|
||||
builder.Services.AddCommonServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -92,67 +51,20 @@ try
|
||||
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.AddMetrics();
|
||||
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<ApiKeyService>();
|
||||
builder.Services.AddScoped<DatabaseSeeder>();
|
||||
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
|
||||
builder.Services.AddScoped<FeatureEvaluationService>();
|
||||
builder.Services.AddScoped<IdentityResolutionService>();
|
||||
builder.Services.AddScoped<EnvironmentDocumentService>();
|
||||
builder.Services.AddSingleton<SegmentEvaluator>();
|
||||
builder.Services.AddSingleton<FlagCache>();
|
||||
builder.Services.AddScoped<AuditService>();
|
||||
builder.Services.AddScoped<AuditLogQueryService>();
|
||||
builder.Services.AddScoped<OrganizationService>();
|
||||
builder.Services.AddScoped<ProjectService>();
|
||||
builder.Services.AddScoped<EnvironmentService>();
|
||||
builder.Services.AddScoped<FeatureService>();
|
||||
builder.Services.AddScoped<FeatureStateService>();
|
||||
builder.Services.AddScoped<FeatureSegmentService>();
|
||||
builder.Services.AddScoped<SegmentService>();
|
||||
builder.Services.AddScoped<TagService>();
|
||||
builder.Services.AddScoped<WebhookService>();
|
||||
builder.Services.AddScoped<WebhookDispatcher>();
|
||||
builder.Services.AddScoped<AdminIdentityService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddSingleton<WebhookQueue>();
|
||||
builder.Services.AddHostedService<WebhookBackgroundService>();
|
||||
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
|
||||
builder.Services.AddSingleton<FeatureUsageMetrics>();
|
||||
builder.Services.AddScoped<FeatureUsageQueryService>();
|
||||
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
|
||||
builder.Services.AddHttpClient("Webhooks", client =>
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
|
||||
builder.Services.AddAuditServices();
|
||||
builder.Services.AddIdentitiesServices();
|
||||
builder.Services.AddEnvironmentsServices();
|
||||
builder.Services.AddSegmentsServices();
|
||||
builder.Services.AddOrganizationsServices();
|
||||
builder.Services.AddProjectsServices();
|
||||
builder.Services.AddFeaturesServices();
|
||||
builder.Services.AddUsersServices();
|
||||
builder.Services.AddWebhooksServices();
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("miccheck")
|
||||
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
|
||||
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
|
||||
: builder.Configuration.GetConnectionString("DefaultConnection")!);
|
||||
|
||||
builder.Services.AddDbContext<MicCheckDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
|
||||
builder.Services.AddDataServices(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
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);
|
||||
RuleFor(x => x.OrganizationId).GreaterThan(0);
|
||||
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.");
|
||||
|
||||
if (model.OrganizationId <= 0)
|
||||
result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
11
src/api/MicCheck.Api/Projects/DependencyRegistration.cs
Normal file
11
src/api/MicCheck.Api/Projects/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
|
||||
|
||||
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
|
||||
{
|
||||
public SetUserPermissionsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleForEach(x => x.Permissions)
|
||||
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
|
||||
.WithMessage("Invalid permission value.");
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
|
||||
|
||||
public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest>
|
||||
{
|
||||
public ValidationResult Validate(SetUserPermissionsRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.UserId <= 0)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Segments;
|
||||
|
||||
@@ -16,24 +16,38 @@ public record CreateSegmentRequest(
|
||||
string Name,
|
||||
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);
|
||||
RuleFor(x => x.Rules).NotNull();
|
||||
RuleForEach(x => x.Rules).ChildRules(rule =>
|
||||
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.");
|
||||
|
||||
if (model.Rules is null)
|
||||
{
|
||||
rule.RuleFor(r => r.Type)
|
||||
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
|
||||
.WithMessage("Rule type must be 'All', 'Any', or 'None'.");
|
||||
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
|
||||
result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
|
||||
return result;
|
||||
}
|
||||
|
||||
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();
|
||||
cond.RuleFor(c => c.Operator)
|
||||
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
|
||||
.WithMessage("Invalid operator.");
|
||||
});
|
||||
});
|
||||
if (string.IsNullOrEmpty(condition.Property))
|
||||
result.AddError(nameof(model.Rules), "'Property' must not be empty.");
|
||||
|
||||
if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
|
||||
result.AddError(nameof(model.Rules), "Invalid operator.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
12
src/api/MicCheck.Api/Segments/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Segments/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
|
||||
|
||||
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 MaxConditionsPerSegment = 100;
|
||||
|
||||
14
src/api/MicCheck.Api/Users/DependencyRegistration.cs
Normal file
14
src/api/MicCheck.Api/Users/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Common.Validation;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
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 _))
|
||||
.WithMessage("Url must be a valid absolute URL.");
|
||||
var result = new ValidationResult();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
17
src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs
Normal file
17
src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
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(
|
||||
WebhookQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookEvent
|
||||
public record WebhookEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WebhookRetryBackgroundService> logger) : BackgroundService
|
||||
|
||||
@@ -23,7 +23,7 @@ public class AdminApiIntegrationTests
|
||||
private List<FeatureState> _featureStates = null!;
|
||||
private List<AppEnvironment> _environments = null!;
|
||||
private List<Segment> _segments = null!;
|
||||
private Mock<AuditService> _auditService = null!;
|
||||
private Mock<IAuditService> _auditService = null!;
|
||||
private int _organizationId;
|
||||
|
||||
[SetUp]
|
||||
@@ -50,7 +50,7 @@ public class AdminApiIntegrationTests
|
||||
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
|
||||
|
||||
var webhookQueue = new WebhookQueue();
|
||||
_auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
||||
_auditService = new Mock<IAuditService>();
|
||||
_auditService.Setup(a => a.LogAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,85 @@ public class AuditServiceTests
|
||||
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]
|
||||
public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,14 @@ public class ApiKeyAuthenticationHandlerTests
|
||||
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]
|
||||
public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails()
|
||||
{
|
||||
|
||||
@@ -51,6 +51,14 @@ public class EnvironmentKeyAuthenticationHandlerTests
|
||||
Assert.That(result.None, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails()
|
||||
{
|
||||
var result = await AuthenticateAsync("");
|
||||
|
||||
Assert.That(result.Succeeded, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails()
|
||||
{
|
||||
|
||||
@@ -216,4 +216,10 @@ public class AuthServiceTests
|
||||
|
||||
Assert.That(response, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown()
|
||||
{
|
||||
Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user