Compare commits

..

1 Commits

Author SHA1 Message Date
James Wampler
37ac4b47f2 Auto-install .NET SDK in CI scripts when runner lacks it
Some checks failed
CI / build-and-push (push) Failing after 12s
CI / deploy-qa (push) Has been skipped
Gitea runner build failed with "dotnet: command not found". Added
ensure_dotnet() to lib.sh, using the vendored dotnet-install.sh to
bootstrap the SDK into .dotnet/ when not already on PATH, called from
build.sh/test.sh/prepush.sh. Also trigger CI on build-runner-fix to
verify the fix.
2026-07-02 12:50:19 -07:00
203 changed files with 2794 additions and 9990 deletions

View File

@@ -1,7 +0,0 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
.git/
.husky/
docs/

View File

@@ -2,25 +2,15 @@
# (both look under .github/workflows/). Every non-checkout step just invokes a # (both look under .github/workflows/). Every non-checkout step just invokes a
# bash script under scripts/ci/, so the entire pipeline is reproducible by # bash script under scripts/ci/, so the entire pipeline is reproducible by
# running the same scripts locally - no marketplace build/test/push actions. # running the same scripts locally - no marketplace build/test/push actions.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI name: CI
on: on:
push: push:
paths-ignore: [badges/**] branches: [main, build-runner-fix]
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }} REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
@@ -35,25 +25,14 @@ jobs:
- name: Test - name: Test
run: ./scripts/ci/test.sh run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images - name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh run: ./scripts/ci/docker-build.sh
- name: Push Docker images - name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh run: ./scripts/ci/docker-push.sh
deploy-qa: deploy-qa:
needs: build-and-push needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
@@ -61,37 +40,9 @@ jobs:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node - uses: actions/checkout@v4
# in PATH, so checkout plain git instead of via marketplace action.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Deploy to QA - name: Deploy to QA
run: ./scripts/ci/deploy-qa.sh run: ./scripts/ci/deploy-qa.sh
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.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Smoke test QA deployment
run: ./scripts/ci/smoke-qa.sh

8
.gitignore vendored
View File

@@ -438,11 +438,6 @@ dist-ssr/
# TypeScript incremental build cache # TypeScript incremental build cache
*.tsbuildinfo *.tsbuildinfo
# Playwright
src/admin/test-results/
src/admin/playwright-report/
src/admin/e2e/.auth/
# Jest test coverage reports # Jest test coverage reports
coverage/ coverage/
.nyc_output/ .nyc_output/
@@ -476,6 +471,3 @@ ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK) # Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/ /.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/

View File

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

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

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

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

View File

@@ -1,11 +1,8 @@
name: miccheck-qa name: miccheck-qa
# Dedicated, network-isolated QA stack. Not connected to the dev compose # Dedicated, network-isolated QA stack. Not connected to the dev compose
# stack's network - runs entirely on its own bridge network below. The API has # stack's network - runs entirely on its own bridge network below, and the
# no published host port; Postgres is bound to DB_BIND_HOST (docker's bridge # database has no published host port.
# gateway IP, computed by deploy-qa.sh) so the smoke-qa CI job - itself a
# sibling container on that same default bridge - can seed/inspect data
# directly, while it stays unreachable off-box (unlike binding to 0.0.0.0).
services: services:
db: db:
@@ -13,9 +10,7 @@ services:
environment: environment:
POSTGRES_DB: miccheck POSTGRES_DB: miccheck
POSTGRES_USER: miccheck POSTGRES_USER: miccheck
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_PASSWORD: password
ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes: volumes:
- miccheck-qa-pgdata:/var/lib/postgresql/data - miccheck-qa-pgdata:/var/lib/postgresql/data
networks: networks:
@@ -31,7 +26,7 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}" ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: ${JWT_SECRET_KEY} Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck Jwt__Audience: MicCheck
@@ -41,7 +36,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

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

60
docker-compose.yml Executable file
View File

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

View File

@@ -1,46 +0,0 @@
# Integration + Playwright smoke suite — implementation summary
Implemented per `integration-smoke_plan.md`. All chunks built, verified against a real local Postgres + API + Vite stack, and the two pre-existing test suites (243 API unit tests, 306 admin Jest tests) still pass.
## Chunk A — Deterministic dev/QA seed admin user + fixed env key
- `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`: now also creates a seed admin user (`admin@miccheck.local` / `MicCheckQa!2026`, Admin role on the `Default` org) and gives the seeded `Development` environment a fixed API key (`env-qa-development`). Still gated behind `IsDevelopment()` in `Program.cs` and the existing idempotency guard.
- New unit tests: `tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs` (5 tests).
- Verified live: booted the API against local Postgres, logged in via `POST /api/v1/auth/login` with the seed creds (200 + tokens), and read `/api/v1/flags` with the fixed Development key (200, empty array as expected).
## Chunk B — Expanded API integration suite
Added to `tests/api/MicCheck.Api.Tests.Integration`, reusing the existing black-box HTTP+Npgsql harness:
- `Flags/FlagDisabledTests.cs` — disabled-flag variant of the existing enabled test.
- `Flags/FlagsApiAuthorizationTests.cs` — missing/unknown environment key → 401.
- `Environments/EnvironmentDocumentTests.cs` + `HttpFiles/EnvironmentDocument.http` — SDK bootstrap document endpoint.
- `Identities/IdentityOverrideSeed.cs` + `Identities/IdentityOverrideTests.cs` + `HttpFiles/Identity.http` — identity-override-beats-environment-default precedence.
- `Auth/AuthSeed.cs` + `Auth/AuthTests.cs` + `HttpFiles/Auth.http` — login liveness (valid creds → tokens, bad password → 401). Added `Microsoft.Extensions.Identity.Core` package reference so the seed can hash a password with the same `PasswordHasher<T>` the API uses, without a `ProjectReference` to the API (kept the black-box convention).
- Fixed a latent substring bug (`[..40]` on a string shorter than 40 chars) copied into the new seeds; left the pre-existing `FlagSeed.cs` alone since none of its callers hit the short-string case.
- Verified live: all 8 tests pass against a real Postgres + `dotnet run` API (`dotnet test ... --logger "console;verbosity=normal"` → 8/8 passed).
## Chunk C — Playwright admin e2e suite
New `src/admin/e2e/` (kept out of Jest's `roots`/`testMatch`, no config change needed since `e2e/` isn't under `src/` or `tests/`):
- `playwright.config.ts`, `e2e/global-setup.ts` (logs in once via the real `/login` form with the seed creds, saves `storageState`).
- `e2e/login.e2e.ts`, `e2e/navigation.e2e.ts`, `e2e/context-selection.e2e.ts`, `e2e/features.e2e.ts` (view, create, toggle).
- `package.json`: added `@playwright/test` devDependency and `e2e` / `e2e:install` scripts.
- Two bugs found and fixed while running against the real app: sidebar nav items are clickable `div`s, not `<a>` links (fixed the locator); a newly-created feature's toggle was clicked before its feature-state fetch settled, causing Vuetify's switch to visually revert (added a `waitForLoadState('networkidle')`).
- Verified live: all 6 tests pass against the real Vite dev server + local API (`npx playwright test` → 6/6 passed).
## Chunk D — CI post-deploy smoke job
- `deploy/qa/docker-compose.qa.yml`: bound Postgres to `127.0.0.1:55432` on the QA host so the integration harness can seed/clean directly (off-box still unreachable).
- `scripts/ci/smoke-qa.sh` (new): runs the API integration suite and the Playwright suite against `http://localhost:${QA_ADMIN_PORT}`.
- `scripts/ci/lib.sh`: added `ensure_node()` (mirrors `ensure_dotnet()`) — **the self-hosted `qa` runner has no Node.js today** (confirmed by the existing "avoid actions/checkout, it's Node-based" comment in `deploy-qa.sh`), so `smoke-qa.sh` needed a way to bootstrap `npm`/`npx` the same way it already bootstraps `dotnet`.
- `.github/workflows/ci.yml`: new `smoke-qa` job, `needs: deploy-qa`, runs on `[self-hosted, qa]`, checks out via plain git (matching `deploy-qa`), runs `smoke-qa.sh`.
### Known follow-up risk (not resolved, flagging for the user)
`npm run e2e:install` runs `playwright install --with-deps chromium`, which needs root/passwordless-sudo to apt-install browser OS dependencies. This could not be verified against the actual self-hosted `qa` runner (only tested locally, where `--with-deps` failed for lack of a sudo TTY — the browser itself still installed fine and tests ran). If the `qa` runner also lacks passwordless sudo, the first CI run of `smoke-qa` may fail on that step; the fix would be a one-time manual `sudo npx playwright install-deps chromium` on the runner, or granting the CI user passwordless sudo for that command.
## Verification performed this session
- `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 243/243 passed.
- `dotnet test tests/api/MicCheck.Api.Tests.Integration` against real local Postgres + API — 8/8 passed.
- `npm --prefix src/admin test` (Jest) — 306/306 passed, 28 suites, no collision with `e2e/`.
- `npx playwright test` (from `src/admin`) against real local Vite dev server + API — 6/6 passed.
- `docker compose -f deploy/qa/docker-compose.qa.yml config` — valid.
- `bash -n` on `smoke-qa.sh` and `lib.sh` — valid.
## Not yet done
- The `smoke-qa` CI job has not run on the actual self-hosted `qa` runner (no access to it from this session) — first real run should be watched, especially the Node bootstrap and the Playwright OS-deps risk above.

View File

@@ -1,75 +0,0 @@
# Plan: Integration + Playwright smoke suite that doubles as post-deploy QA validation
## Context
Today the repo has **one** integration test (`FlagEnabledTests` — seeds an enabled flag in Postgres, hits `GET /api/v1/flags`, asserts enabled) and **zero** browser/e2e tests. Unit coverage is already deep for the risky logic — flag/feature evaluation, segment evaluation (28 tests), webhooks, auth token/API-key/permission logic, audit. Per the 8090% unit / 1020% integration rule, we do **not** re-test that logic at integration. Instead we add a thin layer covering the **most important, most visible, real-Postgres-backed** flows that unit tests (all InMemory EF) cannot prove, and wire it to run **after `deploy-qa`** so every QA deploy is validated end-to-end.
Decisions locked with the user:
- **E2E hits the real deployed stack** (not mocked) — `http://localhost:${QA_ADMIN_PORT}` in CI, `http://localhost:5173` local dev.
- **Add a fixed dev/QA seed admin user** (QA DB is wiped `down -v` each deploy and reseeded on startup; seeding is already `IsDevelopment()`-guarded and QA runs `ASPNETCORE_ENVIRONMENT=Development`).
- **Add a post-deploy `smoke-qa` CI job** running the API integration suite + Playwright against QA.
Key constraints discovered:
- Integration harness is **black-box**: HTTP to a running API + **direct Npgsql** seed/cleanup (`Common/TestDatabase.cs`). It spins nothing up; needs a live API **and** direct DB reachability.
- QA compose (`deploy/qa/docker-compose.qa.yml`) publishes **only** the admin port; API + Postgres are network-internal. The smoke job runs on the **same self-hosted `qa` host** (localhost). So the DB port must be reachable from the host for direct seeding → publish Postgres bound to `127.0.0.1` on the QA host.
- `/health` + `/alive` + Scalar/OpenAPI are **Development-only** (`ServiceDefaults/Extensions.cs:113`). QA is Development so `/health` works there, but smoke assertions should lean on real auth/flag reads, not `/health`.
- Seeder (`Data/DatabaseSeeder.cs`) currently creates Org → Project → 3 Environments with **random** `env-{guid}` API keys and **no user**. For HTTP-only smoke we make the admin user + one env key **deterministic**.
## Implementation — 4 independently buildable/committable chunks
### Chunk A — Deterministic dev/QA seed admin user + fixed env key
Files: `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`, unit test in `tests/api/MicCheck.Api.Tests.Unit/Data/`.
- Inject `IPasswordHasher<User>` into `DatabaseSeeder` (mirror `AuthService.RegisterAsync` at `Common/Security/Authorization/AuthService.cs:54-82`: create `User{ IsActive=true, PasswordHash=hasher.HashPassword(...) }`, then `OrganizationUser{ Role=OrganizationRole.Admin }` linking it to the seeded `Default` org).
- Creds: `admin@miccheck.local` / `MicCheckQa!2026` (login verify has no complexity rule, so any value works; keep it in one shared constants spot referenced by tests).
- Give the seeded **Development** environment a **deterministic** `ApiKey` (e.g. `env-qa-development`) instead of a random guid; leave Staging/Production random. Lets HTTP-only smoke read `/flags` with a known key without direct DB access.
- Keep the existing `if (Organizations.AnyAsync) return;` idempotency guard — user + env are created in the same first-run block. No prod risk: invocation is already gated by `app.Environment.IsDevelopment()` (`Program.cs:160-166`).
- Unit test: seed against InMemory EF twice → asserts admin user exists once, is Admin on Default org, password verifies, Development env key is the fixed value.
### Chunk B — Expand API integration suite (`tests/api/MicCheck.Api.Tests.Integration`)
Reuse the existing harness verbatim: `Common/FlagApiHttpClient.cs` (drives `###`-delimited `.http` files with `{{var}}` substitution), `Common/TestDatabase.cs` (Npgsql seed/cleanup + `SnapshotRowsAsync` for failure dumps), `Common/IntegrationTestSettings.cs` (env-var / `.runsettings` config), and the `FlagSeed` INSERT…RETURNING + cascade-delete-by-Organization pattern (`Flags/FlagSeed.cs`). Keep DTOs redefined locally (no `ProjectReference` to the API) per the current convention. New `.http` files under `HttpFiles/` are auto-copied (`csproj` `CopyToOutputDirectory=PreserveNewest`).
Add these high-value scenarios (each the real Postgres wire contract, not re-covered logic):
1. **Flag disabled** — seed `Enabled=false`; assert `/flags` reports the feature `Enabled=false` (complements the existing enabled test; cheap variant of `FlagSeed`).
2. **Environment-document bootstrap** — new `HttpFiles/EnvironmentDocument.http` (`GET /api/v1/environment-document`, `X-Environment-Key`); assert 200 and the seeded feature state + project segment are present (shape: `Environments/EnvironmentDocumentResponse.cs`). This is the edge/client-SDK bootstrap path — high blast radius, untested.
3. **Identity override precedence** — new seed adding an identity-override FeatureState (and/or a segment override) + `HttpFiles/Identity.http` (`POST /api/v1/identity` with identifier, `X-Environment-Key`); assert the evaluated value reflects identity > segment > env-default precedence (`Features/FeatureEvaluationService.cs:43-146`). Richest runtime logic against real data.
4. **Auth liveness** — new `HttpFiles/Auth.http` (`POST /api/v1/auth/login`): bad creds → 401; the deterministic seed creds → 200 + tokens. Proves app + DB + auth are up without depending on `/health`.
5. **Unauthorized guard**`GET /api/v1/flags` with no / wrong `X-Environment-Key` → 401 (validates `EnvironmentKeyAuthenticationHandler`).
### Chunk C — Playwright admin e2e suite (new)
Location `src/admin/e2e/` (distinct from Jest, which grabs `*.spec.ts` under `tests/`; name specs `*.e2e.ts` and set Jest `roots`/Playwright `testDir` so they never collide). Add dev deps `@playwright/test`, a `playwright.config.ts` (`testDir: 'e2e'`, `baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173'`, chromium project, `globalSetup` for auth). Add `package.json` scripts: `"e2e": "playwright test"`, `"e2e:install": "playwright install --with-deps chromium"`.
The app is already richly instrumented with `data-testid` — drive real UI, no selectors guesswork. Auth: `globalSetup` logs in once via the real `/login` form (`login-form`/`email-input`/`password-input`/`login-submit`) with the Chunk-A seed creds and saves `storageState` for reuse.
Journeys (ordered most→least important; the seed provides `Default` org, `My Project`, and `Development/Staging/Production` envs, so context is selectable without creating anything):
1. **Login** — valid creds redirect off `/login`; invalid creds surface `login-error` (`views/LoginView.vue`).
2. **App shell + nav** — sidebar renders and routes (Dashboard/Features/Segments/Identities/Audit Logs/Settings) navigate (`layouts/components/NavItems.vue`).
3. **Select project + environment context**`project-selector`/`project-select` + `environment-tab-bar`/`env-select` (`components/nav/ProjectSelector.vue`, `EnvironmentTabBar.vue`); required before feature screens work (context persists to localStorage via `stores/context.ts`).
4. **View feature flags**`features-table` renders for the selected project (`views/FeaturesView.vue`).
5. **Create a feature flag**`create-feature-btn``FeatureDialog` (`feature-name-input`, `feature-type-select`, `feature-dialog-save`) → row appears (`components/features/FeatureDialog.vue`, API `api/features.ts`). Exercises full write path to real DB.
6. **Toggle a feature flag** — row switch `toggle-${id}` (or `feature-enabled-toggle` in `FeatureDetail.vue`); reload → state persisted (validates `api/featureStates.ts` → real Postgres).
Secondary/optional (add if cheap): create project, create environment via the sidebar dialogs.
### Chunk D — CI post-deploy smoke job
Files: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/smoke-qa.sh` (new), `.github/workflows/ci.yml`.
- **DB reachability**: add `ports: ["127.0.0.1:55432:5432"]` to the `db` service in the QA compose (localhost-bound only — the runner host is the sole consumer; not exposed off-box). Lets the integration harness seed Postgres directly.
- New `scripts/ci/smoke-qa.sh` (matches the repo's "every CI step is a reproducible script" convention):
- API integration: `dotnet test tests/api/MicCheck.Api.Tests.Integration -c Release --logger trx` with env `MICCHECK_API_BASE_URL=http://localhost:${QA_ADMIN_PORT}` and `MICCHECK_DB_CONNECTION_STRING=Host=localhost;Port=55432;Database=miccheck;Username=miccheck;Password=password`.
- Playwright: `npm --prefix src/admin ci` (or reuse install), `npm --prefix src/admin run e2e:install`, then `E2E_BASE_URL=http://localhost:${QA_ADMIN_PORT} npm --prefix src/admin run e2e`.
- `.github/workflows/ci.yml`: add job `smoke-qa` (`needs: deploy-qa`, `runs-on: [self-hosted, qa]`, env `QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}`) that checks out (plain-git step, matching `deploy-qa`) and runs `./scripts/ci/smoke-qa.sh`. Deploy is now gated by real end-to-end validation.
## Files touched (summary)
- Modify: `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`, `deploy/qa/docker-compose.qa.yml`, `.github/workflows/ci.yml`, `src/admin/package.json`.
- Add (API tests): `HttpFiles/EnvironmentDocument.http`, `HttpFiles/Identity.http`, `HttpFiles/Auth.http`, new `*Tests.cs` + seed helpers under `Flags/` (or a new `Identities/`, `Environments/`, `Auth/` folder) in `tests/api/MicCheck.Api.Tests.Integration`.
- Add (unit): seeder test under `tests/api/MicCheck.Api.Tests.Unit/Data/`.
- Add (e2e): `src/admin/playwright.config.ts`, `src/admin/e2e/*.e2e.ts`, `src/admin/e2e/global-setup.ts`.
- Add (CI): `scripts/ci/smoke-qa.sh`.
- Per CLAUDE.md, also drop this plan at `docs/plans/api/integration-smoke_plan.md` and an `_output.md` summary after implementation.
## Verification
- **Chunk A**: `dotnet test tests/api/MicCheck.Api.Tests.Unit` (new seeder test green). Boot API locally (`docker compose up`), confirm login with seed creds returns tokens and the Development env key equals the fixed value.
- **Chunk B**: bring up local stack (Postgres on `:5432`, API on `:5000`), `dotnet test tests/api/MicCheck.Api.Tests.Integration --settings local.runsettings` — all new scenarios green; failure messages show live DB snapshots.
- **Chunk C**: `npm --prefix src/admin run serve` (API on :5000), `E2E_BASE_URL=http://localhost:5173 npm --prefix src/admin run e2e` — all journeys pass headed and headless.
- **Chunk D**: push to `build-runner-fix`; watch CI — `smoke-qa` runs after `deploy-qa`, both `dotnet test` and Playwright green against `http://localhost:${QA_ADMIN_PORT}`. Confirm a deliberately-broken deploy (e.g. bad DB creds) makes `smoke-qa` fail.

View File

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

View File

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

View File

@@ -11,15 +11,8 @@ registry_login
export API_IMAGE ADMIN_IMAGE export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}" export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}" export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
# from the smoke-qa job container (a sibling on the default bridge), but not
# exposed on the host's public interface.
export DB_BIND_HOST="$(docker_bridge_gateway)"
[[ -n "$DB_BIND_HOST" ]] || fail "could not determine docker bridge gateway IP to bind the QA db port"
COMPOSE="docker compose -p miccheck-qa -f deploy/qa/docker-compose.qa.yml" COMPOSE="docker compose -p miccheck-qa -f deploy/qa/docker-compose.qa.yml"
log "Pulling latest :qa images" log "Pulling latest :qa images"
@@ -31,14 +24,9 @@ $COMPOSE down -v
log "Starting QA stack" log "Starting QA stack"
$COMPOSE up -d $COMPOSE up -d
log "Waiting for API health check via admin proxy" log "Waiting for API health check via admin proxy on port $QA_ADMIN_PORT"
# Checked with `docker compose exec` rather than curling the published host
# port: CI runs this script inside a runner container on its own bridge
# network, where "localhost:$QA_ADMIN_PORT" is the runner's own loopback, not
# the docker host's - it can never reach a host-published port. Exec'ing into
# the admin container and curling its own localhost sidesteps that entirely.
attempts=30 attempts=30
until $COMPOSE exec -T admin curl -fsS http://localhost/health >/dev/null 2>&1; do until curl -fsS "http://localhost:${QA_ADMIN_PORT}/api/v1/health" >/dev/null 2>&1; do
attempts=$((attempts - 1)) attempts=$((attempts - 1))
if [[ "$attempts" -le 0 ]]; then if [[ "$attempts" -le 0 ]]; then
fail "QA stack did not become healthy in time" fail "QA stack did not become healthy in time"

View File

@@ -52,103 +52,6 @@ ensure_dotnet() {
fi fi
export PATH="$install_dir:$PATH" export PATH="$install_dir:$PATH"
export DOTNET_ROOT="$install_dir" export DOTNET_ROOT="$install_dir"
ensure_dotnet_native_deps
}
# The .NET runtime is a native ELF binary that dynamically links libstdc++/libgcc.
# Bare/minimal images (e.g. the act hostexecutor container) may lack them entirely,
# which fails as an obscure symbol-relocation error rather than "command not found".
ensure_dotnet_native_deps() {
if ldconfig -p 2>/dev/null | grep -q 'libstdc++\.so\.6'; then
return 0
fi
log "libstdc++.so.6 missing; installing native runtime deps for dotnet"
local sudo_cmd=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then
sudo_cmd="sudo"
fi
if command -v apt-get > /dev/null 2>&1; then
$sudo_cmd apt-get update -y
$sudo_cmd apt-get install -y --no-install-recommends libstdc++6 libgcc-s1 libicu-dev ca-certificates
elif command -v apk > /dev/null 2>&1; then
# Alpine/musl runner - dotnet-install.sh falls back to the linux-musl-x64 SDK
# here, which still wants a real libstdc++/libgcc (not just gcompat).
$sudo_cmd apk add --no-cache libstdc++ libgcc icu-libs ca-certificates
else
fail "libstdc++.so.6 missing and neither apt-get nor apk is available; install a C++ runtime manually on this runner"
fi
}
# Installs Node.js into $CI_ROOT/.node from the official prebuilt tarball if
# `npm` isn't already on PATH, then prepends it to PATH for this process.
# Mirrors ensure_dotnet() above - keeps bare runners (no Node preinstalled,
# e.g. the self-hosted qa runner) working the same as a dev machine.
ensure_node() {
if command -v npm > /dev/null 2>&1; then
return 0
fi
# nodejs.org only ships glibc binaries; on a musl/Alpine runner (same one
# dotnet-install.sh detects and picks the linux-musl-x64 SDK for) that
# tarball fails to exec at all ("env: can't execute 'node'"). Prefer the
# distro's own package on musl instead of a broken glibc download.
if [[ ! -x "$CI_ROOT/.node/bin/node" ]] && command -v apk > /dev/null 2>&1; then
log "node not found on PATH; installing via apk (musl runner)"
local sudo_cmd=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then
sudo_cmd="sudo"
fi
$sudo_cmd apk add --no-cache nodejs npm
return 0
fi
local node_version="22.14.0"
local install_dir="$CI_ROOT/.node"
if [[ ! -x "$install_dir/bin/node" ]]; then
log "node not found on PATH; installing Node.js v$node_version"
local tarball="node-v${node_version}-linux-x64"
local url="https://nodejs.org/dist/v${node_version}/${tarball}.tar.xz"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$url" -o "/tmp/${tarball}.tar.xz"
elif command -v wget > /dev/null 2>&1; then
wget -q "$url" -O "/tmp/${tarball}.tar.xz"
else
fail "neither curl nor wget found on PATH; cannot download Node.js"
fi
mkdir -p "$install_dir"
tar -xJf "/tmp/${tarball}.tar.xz" -C "$install_dir" --strip-components=1
rm -f "/tmp/${tarball}.tar.xz"
fi
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
# bind QA's db port narrowly - reachable by the smoke-qa job container, not
# exposed off-box the way 0.0.0.0 would be.
docker_bridge_gateway() {
docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null \
|| ip route show default 2>/dev/null | awk '/default/ {print $3; exit}'
} }
registry_login() { registry_login() {

View File

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

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Post-deploy validation for the QA environment: runs the API integration
# suite (real HTTP + real Postgres, seeding/cleaning up its own data) and the
# Playwright admin e2e suite against the just-deployed QA stack. Intended to
# run immediately after deploy-qa.sh, on the same self-hosted qa runner. This
# job runs in its own job container, a sibling of the QA stack's containers
# on docker's default bridge network - not "localhost" from the host's point
# of view - so it reaches published ports via the bridge gateway IP, same as
# deploy-qa.sh binds the db port to.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_node
QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
CI_HOST="$(docker_bridge_gateway)"
[[ -n "$CI_HOST" ]] || fail "could not determine docker bridge gateway IP to reach the QA stack"
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=${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
log "Installing admin e2e dependencies"
npm --prefix src/admin ci
# Playwright's bundled Chromium is a glibc binary and `--with-deps` only knows
# apt - neither works on this musl/Alpine runner. Run the e2e leg inside
# Microsoft's official Playwright image instead (glibc, browsers preinstalled
# at /ms-playwright), as a sibling container reachable at the same bridge
# gateway IP used above. Pin the image tag to the exact resolved
# @playwright/test version so the test runner and browser build match.
#
# This script itself runs inside the runner's own job container, talking to
# the host's docker daemon over a mounted socket (docker-outside-of-docker) -
# `docker run -v "$CI_ROOT:/work"` would ask the *host* daemon to bind-mount a
# path that only exists inside this job container, which fails. `docker cp`
# instead copies the files by content, sidestepping the path mismatch.
PW_VERSION="$(node -p "require('./src/admin/node_modules/@playwright/test/package.json').version")"
log "Running Playwright admin e2e suite against $BASE_URL (playwright:v$PW_VERSION-noble)"
PW_CONTAINER="$(docker create -e "E2E_BASE_URL=$BASE_URL" -w /work/src/admin "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" npm run e2e)"
docker cp "$CI_ROOT/." "$PW_CONTAINER:/work"
pw_status=0
docker start -a "$PW_CONTAINER" || pw_status=$?
docker rm -f "$PW_CONTAINER" > /dev/null
[[ "$pw_status" -eq 0 ]] || fail "Playwright e2e suite failed (exit $pw_status)"
log "smoke-qa.sh complete - QA environment validated end to end"

View File

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

View File

@@ -1,26 +1,14 @@
var builder = DistributedApplication.CreateBuilder(args); var builder = DistributedApplication.CreateBuilder(args);
// Pinned to match tests/api/MicCheck.Api.Tests.Integration/local.runsettings and var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin();
// src/admin's docker-compose defaults, so those fixed targets work whether the
// stack is run via `docker compose` or via this AppHost.
var postgresUser = builder.AddParameter("postgres-username", "miccheck");
var postgresPassword = builder.AddParameter("postgres-password", "password", secret: true);
var postgres = builder.AddPostgres("postgres", postgresUser, postgresPassword, port: 5432)
.WithDataVolume("miccheck-pgdata")
.WithPgAdmin();
var miccheckDb = postgres.AddDatabase("miccheck"); var miccheckDb = postgres.AddDatabase("miccheck");
var api = builder.AddProject<Projects.MicCheck_Api>("api") var api = builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
.WithReference(miccheckDb)
.WaitFor(miccheckDb)
.WithHttpEndpoint(port: 5000, name: "http");
builder.AddViteApp("admin", "../admin", "serve") builder.AddViteApp("admin", "../admin", "serve")
.WithReference(api) .WithReference(api)
.WaitFor(api) .WaitFor(api)
.WithHttpEndpoint(port: 5173, name: "http")
.WithExternalHttpEndpoints(); .WithExternalHttpEndpoints();
builder.Build().Run(); builder.Build().Run();

View File

@@ -8,7 +8,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" /> <PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.0" /> <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.0" />
<PackageReference Include="MessagePack" Version="2.5.302" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -1,30 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
/**
* The seeded DB (DatabaseSeeder) provides exactly one org, one project ("My Project"), and
* three environments (Development/Staging/Production), so the selectors auto-populate without
* needing to create anything first. Most feature screens require both a project and an
* environment to be selected before they render real content.
*/
test('project and environment context is selectable and persists across a reload', async ({ page }) => {
await page.goto('/features');
const projectSelect = page.getByTestId('project-select').locator('input');
const envSelect = page.getByTestId('env-select').locator('input');
await expect(projectSelect).not.toHaveValue('');
await expect(envSelect).not.toHaveValue('');
// Explicitly re-select the project via the dropdown to prove the selector itself works,
// not just the auto-select-on-load behavior.
await page.getByTestId('project-select').click();
await page.getByRole('option', { name: 'My Project' }).click();
await expect(projectSelect).toHaveValue('My Project');
await page.reload();
await expect(page.getByTestId('project-select').locator('input')).not.toHaveValue('');
await expect(page.getByTestId('env-select').locator('input')).not.toHaveValue('');
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
test('the features table renders for the selected project', async ({ page }) => {
await page.goto('/features');
await expect(page.getByTestId('features-table')).toBeVisible();
await expect(page.getByText('Select a project')).toHaveCount(0);
});
test('creating a feature adds it to the table and its toggle can be flipped', async ({ page }) => {
const featureName = `e2e_feature_${Date.now()}`;
await page.goto('/features');
await page.getByTestId('create-feature-btn').click();
await page.getByTestId('feature-name-input').locator('input').fill(featureName);
await page.getByTestId('feature-dialog-save').click();
const row = page.getByRole('row', { name: new RegExp(featureName) });
await expect(row).toBeVisible();
// Creating a feature triggers a fresh fetch of feature states for the current environment;
// wait for it to settle so the toggle below acts on real, loaded state rather than racing it.
await page.waitForLoadState('networkidle');
const toggle = row.locator('input[type="checkbox"]');
const wasChecked = await toggle.isChecked();
// Assert on the real PATCH landing, not just the switch's transient DOM state: if the
// feature-state map hasn't loaded for this row yet, the click handler no-ops silently and
// the switch briefly flashes the native checkbox state before Vue snaps it back - which
// reads as "toggled" for an instant even though nothing was ever sent to the API.
const patchResponse = page.waitForResponse(
(res) => res.request().method() === 'PATCH' && res.url().includes('/featurestate/') && res.ok(),
);
await toggle.click({ force: true });
await patchResponse;
await expect(toggle).toBeChecked({ checked: !wasChecked });
// Reload to confirm the toggle was persisted to the real API/DB, not just local state.
await page.reload();
const reloadedRow = page.getByRole('row', { name: new RegExp(featureName) });
await expect(reloadedRow.locator('input[type="checkbox"]')).toBeChecked({ checked: !wasChecked });
});

View File

@@ -1,30 +0,0 @@
import { chromium, type FullConfig } from '@playwright/test';
import path from 'node:path';
/**
* Deterministic dev/QA seed admin credentials — see DatabaseSeeder.SeedAdminEmail /
* SeedAdminPassword in src/api/MicCheck.Api/Data/DatabaseSeeder.cs. Seeding only ever runs in
* Development, which is what both local dev and the QA deployment run as.
*/
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
export const authFile = path.join(__dirname, '.auth', 'admin.json');
export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:5173';
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL });
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
await page.getByTestId('login-submit').click();
// A successful login redirects off /login onto the authenticated shell.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page.context().storageState({ path: authFile });
await browser.close();
}

View File

@@ -1,32 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Unauthenticated — does not use the shared storageState fixture (see global-setup.ts) since
* it exercises the login flow itself.
*/
test.use({ storageState: { cookies: [], origins: [] } });
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
test('valid credentials sign the admin in and land on the authenticated shell', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
await page.getByTestId('login-submit').click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await expect(page.getByText('Features', { exact: true })).toBeVisible();
});
test('invalid credentials surface a login error and stay on the login page', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill('not-the-right-password');
await page.getByTestId('login-submit').click();
await expect(page.getByTestId('login-error')).toBeVisible();
await expect(page).toHaveURL(/\/login/);
});

View File

@@ -1,23 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
test('the sidebar renders and each primary link navigates to its view', async ({ page }) => {
await page.goto('/dashboard');
const links: Array<[name: string, path: string]> = [
['Dashboard', '/dashboard'],
['Features', '/features'],
['Segments', '/segments'],
['Identities', '/identities'],
['Audit Logs', '/audit-logs'],
['Settings', '/settings'],
];
for (const [name, path] of links) {
// Sidebar entries are clickable divs (VerticalNavLink), not <a> elements, so match by text.
await page.locator('li').filter({ hasText: name }).first().click();
await expect(page).toHaveURL(new RegExp(`${path}$`));
}
});

View File

@@ -20,14 +20,6 @@ server {
proxy_read_timeout 30s; proxy_read_timeout 30s;
} }
# Proxy the API's health endpoint, which lives outside the /api prefix.
location = /health {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_upstream http://api:8080;
proxy_pass $api_upstream/health;
proxy_http_version 1.1;
}
# SPA fallback — all other paths serve index.html so Vue Router handles them # SPA fallback — all other paths serve index.html so Vue Router handles them
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@@ -26,7 +26,6 @@
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0", "@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0", "@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.0", "@types/node": "^20.12.0",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@@ -3353,21 +3352,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -6012,15 +5996,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4", "hasown": "^2.0.2",
"mime-types": "^2.1.35" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8429,10 +8414,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.15.0", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@@ -9449,50 +9435,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -7,9 +7,7 @@
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"serve": "vite", "serve": "vite",
"preview": "vite preview", "preview": "vite preview",
"test": "jest --passWithNoTests", "test": "jest --passWithNoTests"
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
@@ -30,7 +28,6 @@
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0", "@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0", "@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.0", "@types/node": "^20.12.0",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke-test suite for the admin SPA. Runs against a real, already-running deployment
* (local dev server or a deployed QA environment) — it does not mock the API and does not
* start any servers itself. See e2e/global-setup.ts for how authentication is bootstrapped.
*/
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
globalSetup: './e2e/global-setup.ts',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

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

View File

@@ -1,30 +1,33 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(MicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct) private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -60,6 +63,6 @@ public class AuditLogQueryService(IMicCheckDbContext db)
user != null ? user.FirstName + " " + user.LastName : null)) user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<AuditLogResponse>(total, items); return (total, items);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
namespace MicCheck.Api.Common;
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);

View File

@@ -1,3 +1,8 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results); public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);

View File

@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKey public class ApiKey
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Key { get; init; } public required string Key { get; set; }
public required string Prefix { get; init; } public required string Prefix { get; set; }
public required string Name { get; init; } public required string Name { get; set; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

@@ -1,9 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints public static class ApiKeyEndpoints
{ {
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)
@@ -15,9 +13,10 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct); var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt)); return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
}).WithName("CreateApiKey"); }).WithName("CreateApiKey");

View File

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

View File

@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKeyService(IMicCheckDbContext db) public class ApiKeyService(MicCheckDbContext db)
{ {
public async Task<ApiKeyCreationResult> CreateAsync( public async Task<(ApiKey Key, string RawKey)> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{ {
var rawKey = ApiKeyHasher.GenerateKey(); var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return new ApiKeyCreationResult(apiKey, rawKey); return (apiKey, rawKey);
} }
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,5 +48,3 @@ public class ApiKeyService(IMicCheckDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public record ApiKeyCreationResult(ApiKey Key, string RawKey);

View File

@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "ApiKey"; public const string SchemeName = "ApiKey";

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "EnvironmentKey"; public const string SchemeName = "EnvironmentKey";

View File

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

View File

@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService( public class AuthService(
IMicCheckDbContext db, MicCheckDbContext db,
ITokenService tokenService, ITokenService tokenService,
IPasswordHasher<User> passwordHasher) IPasswordHasher<User> passwordHasher)
{ {
@@ -81,9 +81,7 @@ public class AuthService(
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct); await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
foreach (var organizationUser in organizationUsers)
user.Organizations.Add(organizationUser);
var accessToken = tokenService.GenerateToken(user); var accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken(); var refreshTokenValue = GenerateSecureToken();

View File

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

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirementHandler( public class ProjectPermissionRequirementHandler(
IMicCheckDbContext db, MicCheckDbContext db,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement> : AuthorizationHandler<ProjectPermissionRequirement>
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,22 @@
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment; using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher) public class DatabaseSeeder
{ {
/// <summary> private readonly MicCheckDbContext _db;
/// Deterministic credentials for the development/QA seed admin user. Only ever created when
/// <c>SeedAsync</c> runs, which is gated behind <c>IsDevelopment()</c> in Program.cs.
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate
/// without depending on per-run registration.
/// </summary>
private const string SeedAdminEmail = "admin@miccheck.local";
private const string SeedAdminPassword = "MicCheckQa!2026"; public DatabaseSeeder(MicCheckDbContext db)
{
/// <summary>Deterministic environment key for the seeded Development environment, so _db = db;
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary> }
private const string SeedDevelopmentEnvironmentKey = "env-qa-development";
public async Task SeedAsync(CancellationToken ct = default) public async Task SeedAsync(CancellationToken ct = default)
{ {
if (await db.Organizations.AnyAsync(ct)) if (await _db.Organizations.AnyAsync(ct))
return; return;
var organization = new Organization var organization = new Organization
@@ -33,8 +24,8 @@ public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> password
Name = "Default", Name = "Default",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
db.Organizations.Add(organization); _db.Organizations.Add(organization);
await db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var project = new Project var project = new Project
{ {
@@ -42,43 +33,21 @@ public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> password
OrganizationId = organization.Id, OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
db.Projects.Add(project); _db.Projects.Add(project);
await db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" }; var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames) foreach (var name in environmentNames)
{ {
db.Environments.Add(new AppEnvironment _db.Environments.Add(new AppEnvironment
{ {
Name = name, Name = name,
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}", ApiKey = $"env-{Guid.NewGuid():N}",
ProjectId = project.Id, ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
} }
await db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var adminUser = new User
{
Email = SeedAdminEmail,
FirstName = "MicCheck",
LastName = "Admin",
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
PasswordHash = string.Empty
};
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword);
db.Users.Add(adminUser);
await db.SaveChangesAsync(ct);
db.OrganizationUsers.Add(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = adminUser.Id,
Role = OrganizationRole.Admin,
IsPrimary = true
});
await db.SaveChangesAsync(ct);
} }
} }

View File

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

View File

@@ -2,7 +2,6 @@ using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
@@ -14,7 +13,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext, IMicCheckDbContext public class MicCheckDbContext : DbContext
{ {
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { } public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
@@ -43,31 +42,3 @@ public class MicCheckDbContext : DbContext, IMicCheckDbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly); => modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
} }
public interface IMicCheckDbContext
{
DbSet<Organization> Organizations { get; }
DbSet<OrganizationUser> OrganizationUsers { get; }
DbSet<Project> Projects { get; }
DbSet<AppEnvironment> Environments { get; }
DbSet<Feature> Features { get; }
DbSet<FeatureState> FeatureStates { get; }
DbSet<FeatureSegment> FeatureSegments { get; }
DbSet<Tag> Tags { get; }
DbSet<Segment> Segments { get; }
DbSet<SegmentRule> SegmentRules { get; }
DbSet<SegmentCondition> SegmentConditions { get; }
DbSet<Identity> Identities { get; }
DbSet<IdentityTrait> IdentityTraits { get; }
DbSet<AuditLog> AuditLogs { get; }
DbSet<Webhook> Webhooks { get; }
DbSet<WebhookDeliveryLog> WebhookDeliveryLogs { get; }
DbSet<ApiKey> ApiKeys { get; }
DbSet<User> Users { get; }
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<UserProjectPermission> UserProjectPermissions { get; }
DbSet<FeatureUsageDaily> FeatureUsageDaily { get; }
int SaveChanges();
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -17,11 +17,6 @@ COPY src/api/MicCheck.Api/ src/api/MicCheck.Api/
RUN dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release -o /out --no-restore RUN dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release -o /out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 FROM mcr.microsoft.com/dotnet/aspnet:10.0
# curl is used by the docker-compose healthcheck; the base image ships neither
# curl nor wget, so the healthcheck silently fails as "unhealthy" without it.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /out . COPY --from=build /out .
EXPOSE 8080 EXPOSE 8080

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentDocumentService(IMicCheckDbContext db) public class EnvironmentDocumentService(MicCheckDbContext db)
{ {
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default) public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
{ {

View File

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

View File

@@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(result.Items.ToList()); return Ok(logs.ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("api/v1/environment/{apiKey}/identities")]
@@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results)); return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -7,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureEvaluationService( public class FeatureEvaluationService(
IMicCheckDbContext db, MicCheckDbContext db,
SegmentEvaluator segmentEvaluator, SegmentEvaluator segmentEvaluator,
FlagCache flagCache, FlagCache flagCache,
FeatureUsageMetrics usageMetrics) FeatureUsageMetrics usageMetrics)

View File

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureSegmentService(IMicCheckDbContext db) public class FeatureSegmentService(MicCheckDbContext db)
{ {
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync( public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
int featureId, int environmentId, CancellationToken ct = default) int featureId, int environmentId, CancellationToken ct = default)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(IMicCheckDbContext db) public class FeatureUsageQueryService(MicCheckDbContext db)
{ {
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default) public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{ {

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class TagService(IMicCheckDbContext db) public class TagService(MicCheckDbContext db)
{ {
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities; namespace MicCheck.Api.Identities;
public class AdminIdentityService(IMicCheckDbContext db) public class AdminIdentityService(MicCheckDbContext db)
{ {
public async Task<PagedResult<Identity>> ListAsync( public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync(
int environmentId, int page, int pageSize, CancellationToken ct = default) int environmentId, int page, int pageSize, CancellationToken ct = default)
{ {
var query = db.Identities var query = db.Identities
@@ -21,7 +20,7 @@ public class AdminIdentityService(IMicCheckDbContext db)
.Take(pageSize) .Take(pageSize)
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<Identity>(total, items); return (total, items);
} }
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default) public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)

View File

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

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities; namespace MicCheck.Api.Identities;
public class IdentityResolutionService(IMicCheckDbContext db) public class IdentityResolutionService(MicCheckDbContext db)
{ {
public async Task<Identity> ResolveAsync( public async Task<Identity> ResolveAsync(
int environmentId, int environmentId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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