Compare commits

..

11 Commits

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

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

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

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

Reviewed-on: #6
2026-07-05 15:07:26 -07:00
James Wampler
cae55e5737 Add coverage reporting/badges and split CI between Gitea and GitHub (#5)
All checks were successful
CI / build-and-push (push) Successful in 46s
CI / deploy-qa (push) Successful in 13s
CI / smoke-qa (push) Successful in 35s
Merge dotnet+jest coverage via reportgenerator, publish a self-hosted
coverage badge and build-status badges on the readme. Gitea remains the
full pipeline (build/test/docker push/deploy-qa/smoke); GitHub only
builds and tests since it has no registry secrets or qa runner.

Reviewed-on: #5
2026-07-05 10:14:30 -07:00
James Wampler
20188c61a2 Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4)
All checks were successful
CI / build-and-push (push) Successful in 48s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 20s
## Summary
- Extract `IMicCheckDbContext` and mock DB access with Moq instead of the EF InMemory provider, per CLAUDE.md testing guidelines.
- Rewrite HTTP-pipeline tests to exercise controllers/auth handlers directly instead of `WebApplicationFactory`.
- Remove tuple return types across the API in favor of named records (`PagedResult<T>`, `ProfileUpdateResult`, `ApiKeyCreationResult`).

## Test plan
- [x] `dotnet build` (full solution)
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 187/187 pass
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Integration` — 8/8 pass (live API + Postgres via Aspire)
- [x] Verified Aspire AppHost/dashboard starts and API responds

Reviewed-on: #4
2026-07-04 21:40:47 -07:00
James Wampler
1556b486d2 Fix CI build/deploy/smoke pipeline for the self-hosted qa runner (#3)
All checks were successful
CI / build-and-push (push) Successful in 39s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 18s
## Summary
- Fix a chain of QA CI issues: Docker build/health-check flakiness, NuGet vuln pins, then adds the post-deploy integration + Playwright smoke suite and works through everything needed to make it actually run on the self-hosted `qa` runner (musl/Alpine job container, no node/dotnet/curl preinstalled, docker-outside-of-docker networking).
- Adds a fixed dev/QA seed admin user + fixed Development environment API key so integration tests and e2e specs have a stable target.
- Pins Aspire's `AppHost.cs` ports/credentials to match the docker-compose local dev defaults.
- Adds `tests/api/MicCheck.Api.Tests.Integration` coverage for disabled flags, environment-document bootstrap, identity override precedence, auth login, and unauthorized access; adds a Playwright e2e suite under `src/admin/e2e` (login, nav, context selection, features CRUD/toggle).
- Adds a `smoke-qa` CI job that runs both suites against the just-deployed QA stack, working around: no curl/node/dotnet on the bare runner, musl vs glibc (Playwright browsers run via the official `mcr.microsoft.com/playwright` image instead), and the runner's job-container network isolation (reach the QA stack via the docker bridge gateway IP; `docker cp` instead of a bind mount to get files into the playwright container, since paths don't cross the docker-outside-of-docker boundary).

## Test plan
- [x] Unit tests pass (dotnet test tests/api/MicCheck.Api.Tests.Unit, 243 passed)
- [x] API integration suite passes against the real QA stack in CI
- [x] Playwright e2e suite passes against the real QA stack in CI (verified 3/3 locally against a real dev API + vite server for the flakiest spec)
- [x] Full CI pipeline (build → deploy-qa → smoke-qa) green end to end on the qa runner

Reviewed-on: #3
2026-07-04 18:53:05 -07:00
James Wampler
e10cba77ed qa-environment-aspire (#2)
Some checks failed
CI / build-and-push (push) Failing after 18s
CI / deploy-qa (push) Has been skipped
Reviewed-on: #2
Co-authored-by: James Wampler <james@wamp.dev>
Co-committed-by: James Wampler <james@wamp.dev>
2026-07-02 12:42:46 -07:00
225 changed files with 11582 additions and 2888 deletions

7
.dockerignore Normal file
View File

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

97
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,97 @@
# CI/CD pipeline for MicCheck. Read by both Gitea Actions and GitHub Actions
# (both look under .github/workflows/). Every non-checkout step just invokes a
# bash script under scripts/ci/, so the entire pipeline is reproducible by
# running the same scripts locally - no marketplace build/test/push actions.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI
on:
push:
paths-ignore: [badges/**]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Build
run: ./scripts/ci/build.sh
- name: Test
run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh
- name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh
deploy-qa:
needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa]
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node
# 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
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

12
.gitignore vendored
View File

@@ -408,7 +408,6 @@ FodyWeavers.xsd
# VS Code files for those working on multiple tools # VS Code files for those working on multiple tools
.vscode/* .vscode/*
!.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
@@ -439,6 +438,11 @@ 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/
@@ -469,3 +473,9 @@ lerna-debug.log*
# Windows thumbnail cache # Windows thumbnail cache
Thumbs.db Thumbs.db
ehthumbs.db ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/

1
.husky/pre-push Executable file
View File

@@ -0,0 +1 @@
exec ./scripts/ci/prepush.sh

15
.vscode/settings.json vendored
View File

@@ -1,15 +0,0 @@
{
"sqltools.connections": [
{
"ssh": "Disabled",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"askForPassword": true,
"driver": "PostgreSQL",
"name": "MicCheck - local",
"database": "miccheck",
"username": "miccheck"
}
]
}

View File

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

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

View File

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

138
badges/coverage.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 6.1 KiB

13
deploy/qa/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Copy to .env (or export in CI) and fill in real values.
# Used by scripts/ci/*.sh and deploy/qa/docker-compose.qa.yml.
# Gitea container registry
REGISTRY=gitea.example.com
REGISTRY_OWNER=your-org-or-user
REGISTRY_USER=ci-bot
REGISTRY_TOKEN=changeme
# QA environment
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001

View File

@@ -0,0 +1,66 @@
name: miccheck-qa
# 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
# no published host port; Postgres is bound to DB_BIND_HOST (docker's bridge
# 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:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes:
- miccheck-qa-pgdata:/var/lib/postgresql/data
networks:
- qa
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 10
api:
image: ${API_IMAGE}:qa
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}"
Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
networks:
- qa
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
admin:
image: ${ADMIN_IMAGE}:qa
ports:
- "${QA_ADMIN_PORT:-3001}:80"
networks:
- qa
depends_on:
api:
condition: service_started
networks:
qa:
name: miccheck-qa-net
volumes:
miccheck-qa-pgdata:
name: miccheck-qa-pgdata

View File

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

View File

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

View File

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

@@ -0,0 +1,75 @@
# 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.

28
package-lock.json generated Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "mic-check",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mic-check",
"devDependencies": {
"husky": "^9.1.7"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
}
}
}

11
package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "mic-check",
"private": true,
"description": "Repo-root package - hosts the Husky pre-push hook only.",
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.1.7"
}
}

View File

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

20
scripts/ci/build.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Compiles the API (and its dependents) and builds the admin SPA.
# Acts as the compile gate before tests/image builds run. TreatWarningsAsErrors
# is enabled across the solution, so this also fails on any compiler warning.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
log "Restoring and publishing MicCheck.Api (Release)"
dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release
log "Installing admin dependencies (npm ci)"
npm --prefix src/admin ci
log "Building admin SPA (vite build)"
npm --prefix src/admin run build
log "build.sh complete"

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

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

49
scripts/ci/deploy-qa.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Deploys the freshly-pushed :qa images to the dedicated, network-isolated QA
# stack and recreates the miccheck database in an empty state. Safe/idempotent
# to re-run: `down -v` removes the Postgres data volume, and the API's own
# startup logic (EF Core migrations + idempotent dev seeding) rebuilds it.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
# 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"
log "Pulling latest :qa images"
$COMPOSE pull
log "Tearing down existing QA stack and wiping the database volume"
$COMPOSE down -v
log "Starting QA stack"
$COMPOSE up -d
log "Waiting for API health check via admin proxy"
# 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
until $COMPOSE exec -T admin curl -fsS http://localhost/health >/dev/null 2>&1; do
attempts=$((attempts - 1))
if [[ "$attempts" -le 0 ]]; then
fail "QA stack did not become healthy in time"
fi
sleep 2
done
log "QA environment deployed and healthy at http://localhost:${QA_ADMIN_PORT}"

23
scripts/ci/docker-build.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Builds self-contained CI images for the API and admin apps and tags them
# with both the current git sha and "qa" (the tag the QA compose stack pulls).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
log "Building $API_IMAGE:$GIT_SHA / :qa"
docker build \
-f src/api/MicCheck.Api/Dockerfile.ci \
-t "$API_IMAGE:$GIT_SHA" \
-t "$API_IMAGE:qa" \
.
log "Building $ADMIN_IMAGE:$GIT_SHA / :qa"
docker build \
-f src/admin/Dockerfile.ci \
-t "$ADMIN_IMAGE:$GIT_SHA" \
-t "$ADMIN_IMAGE:qa" \
src/admin
log "docker-build.sh complete"

17
scripts/ci/docker-push.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Pushes the images built by docker-build.sh (git-sha and qa tags) to the
# Gitea container registry.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
for tag in "$GIT_SHA" qa; do
log "Pushing $API_IMAGE:$tag"
docker push "$API_IMAGE:$tag"
log "Pushing $ADMIN_IMAGE:$tag"
docker push "$ADMIN_IMAGE:$tag"
done
log "docker-push.sh complete"

160
scripts/ci/lib.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Shared helpers for scripts/ci/*.sh.
# Every script in this directory is meant to run identically in CI and on a
# developer's machine - no Gitea/GitHub-specific built-in actions, just bash.
set -euo pipefail
log() {
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"
}
fail() {
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ERROR: $*" >&2
exit 1
}
# Repo root, regardless of caller's cwd.
CI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Registry configuration. All values come from the environment (CI secrets or
# a developer's shell) - nothing is hardcoded, per project convention.
REGISTRY="${REGISTRY:-}"
REGISTRY_OWNER="${REGISTRY_OWNER:-}"
REGISTRY_USER="${REGISTRY_USER:-}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
GIT_SHA="$(git -C "$CI_ROOT" rev-parse --short HEAD)"
require_registry_vars() {
[[ -n "$REGISTRY" ]] || fail "REGISTRY env var is required (e.g. gitea.example.com)"
[[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)"
}
# Populates API_IMAGE / ADMIN_IMAGE, e.g. gitea.example.com/james/miccheck-api
image_names() {
require_registry_vars
API_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-api"
ADMIN_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-admin"
}
# Installs .NET into $CI_ROOT/.dotnet via the vendored dotnet-install.sh if
# `dotnet` isn't already on PATH, then prepends it to PATH for this process.
# Keeps bare runners (no SDK preinstalled) working the same as a dev machine.
ensure_dotnet() {
if command -v dotnet > /dev/null 2>&1; then
return 0
fi
local install_dir="$CI_ROOT/.dotnet"
if [[ ! -x "$install_dir/dotnet" ]]; then
log "dotnet not found on PATH; installing .NET SDK via dotnet-install.sh"
bash "$CI_ROOT/dotnet-install.sh" --channel LTS --install-dir "$install_dir"
fi
export PATH="$install_dir:$PATH"
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() {
require_registry_vars
[[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images"
[[ -n "$REGISTRY_TOKEN" ]] || fail "REGISTRY_TOKEN env var is required to push images"
log "Logging in to $REGISTRY as $REGISTRY_USER"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
}

25
scripts/ci/prepush.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Husky pre-push hook body. Compiles everything and runs the fast test suites
# (no Docker, no registry) so broken code/tests never leave the workstation.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
log "Building full solution (Debug)"
dotnet build MicCheck.slnx -c Debug
log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Debug
log "Installing admin dependencies (npm ci)"
npm --prefix src/admin ci
log "Running admin Jest tests"
npm --prefix src/admin test
log "Building admin SPA (vite build)"
npm --prefix src/admin run build
log "prepush.sh complete - ok to push"

View File

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

55
scripts/ci/smoke-qa.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/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"

26
scripts/ci/test.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Runs the fast test suites: .NET unit tests (EF InMemory, no DB/Docker needed)
# and the admin Jest suite. Integration tests are intentionally excluded here -
# they require a live API + Postgres (see readme in the Integration test project).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
# --results-directory doesn't clear prior runs - it adds a new GUID folder
# alongside old ones every time. On a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
# runs would otherwise get merged in by coverage.sh and silently skew the
# combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \
--collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \
--settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
log "Running admin Jest tests"
npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
log "test.sh complete"

View File

@@ -1,14 +1,26 @@
var builder = DistributedApplication.CreateBuilder(args); var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin(); // Pinned to match tests/api/MicCheck.Api.Tests.Integration/local.runsettings and
// 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").WithReference(miccheckDb).WaitFor(miccheckDb); var api = builder.AddProject<Projects.MicCheck_Api>("api")
.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,6 +8,7 @@
<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>

19
src/admin/Dockerfile.ci Normal file
View File

@@ -0,0 +1,19 @@
# Self-contained CI/QA image for the admin SPA. Unlike the dev Dockerfile
# (which serves a host-built ./dist via bind mount), this builds the SPA
# inside the image so it can be pushed to a registry and run standalone.
#
# Build context is src/admin:
# docker build -f src/admin/Dockerfile.ci -t miccheck-admin:qa src/admin
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

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

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

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

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

@@ -0,0 +1,23 @@
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,6 +20,14 @@ 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,6 +26,7 @@
"@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",
@@ -3352,6 +3353,21 @@
"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",
@@ -5996,16 +6012,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8414,11 +8429,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.2", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@@ -9435,6 +9449,50 @@
"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,7 +7,9 @@
"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",
@@ -28,6 +30,7 @@
"@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

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

@@ -274,12 +274,22 @@
data-testid="settings-default-enabled-toggle" data-testid="settings-default-enabled-toggle"
/> />
</div> </div>
<v-text-field
v-model="deleteConfirmName"
:label="`Type '${feature?.name}' to confirm deletion`"
variant="outlined"
density="compact"
class="mb-3"
hide-details
data-testid="delete-confirm-input"
/>
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-btn <v-btn
color="error" color="error"
variant="text" variant="text"
size="small" size="small"
:loading="isDeleting" :loading="isDeleting"
:disabled="deleteConfirmName !== feature?.name"
data-testid="delete-feature-btn" data-testid="delete-feature-btn"
@click="onDelete" @click="onDelete"
> >
@@ -440,6 +450,8 @@ const settingsForm = ref({
defaultEnabled: false, defaultEnabled: false,
}); });
const deleteConfirmName = ref('');
const rules = { const rules = {
required: (v: string) => !!v || 'Required', required: (v: string) => !!v || 'Required',
nameFormat: (v: string) => nameFormat: (v: string) =>
@@ -481,6 +493,7 @@ watch(
newSegmentId.value = null; newSegmentId.value = null;
newSegmentEnabled.value = true; newSegmentEnabled.value = true;
newSegmentPriority.value = 1; newSegmentPriority.value = 1;
deleteConfirmName.value = '';
syncSettingsForm(props.feature); syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null; editedValue.value = props.featureState?.value ?? null;

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory, RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
import { getAccessToken } from '@/api/client'; import { getAccessToken } from '@/api/client';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
@@ -102,7 +102,7 @@ const router = createRouter({
// ─── Auth Guard ─────────────────────────────────────────────────────────────── // ─── Auth Guard ───────────────────────────────────────────────────────────────
router.beforeEach((to) => { export function authGuard(to: RouteLocationNormalized) {
const isAuthenticated = !!getAccessToken(); const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isPublic = to.matched.some((record) => record.meta.public); const isPublic = to.matched.some((record) => record.meta.public);
@@ -116,6 +116,8 @@ router.beforeEach((to) => {
} }
return true; return true;
}); }
router.beforeEach(authGuard);
export default router; export default router;

View File

@@ -1,20 +1,63 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client'; import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import apiClient, { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
import type { TokenResponse } from '@/types/api';
// The interceptor internals rely on axios, which is hard to unit-test without // The response interceptor drives its retry/refresh flow entirely through the
// a real HTTP stack. These tests focus on the exported token-management helpers // apiClient axios instance, so we replace its transport (`adapter`) with a
// that the interceptors and auth store both depend on. // fake one we control. This exercises the real interceptor chain end-to-end
// (request auth header, 401 detection, refresh, retry, queueing) rather than
// just the token-storage helpers. The refresh call itself goes through the
// raw `axios.post` (not the apiClient instance, to avoid interceptor
// recursion), so that's mocked separately via jest.spyOn.
function unauthorizedError(config: InternalAxiosRequestConfig): AxiosError {
return new AxiosError(
'Request failed with status code 401',
'ERR_BAD_REQUEST',
config,
undefined,
{ status: 401, statusText: 'Unauthorized', data: {}, headers: {}, config },
);
}
function serverError(config: InternalAxiosRequestConfig): AxiosError {
return new AxiosError(
'Request failed with status code 500',
'ERR_BAD_RESPONSE',
config,
undefined,
{ status: 500, statusText: 'Internal Server Error', data: {}, headers: {}, config },
);
}
const tokenResponse: TokenResponse = {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: '2026-01-01T01:00:00Z',
};
describe('ApiClient', () => {
let originalLocation: Location;
describe('ApiClient token management', () => {
beforeEach(() => { beforeEach(() => {
clearAuthTokens(); clearAuthTokens();
localStorage.clear(); localStorage.clear();
jest.restoreAllMocks();
originalLocation = window.location;
// jsdom's window.location.href setter throws "not implemented" - stub it
// out so the redirect-on-refresh-failure path can be asserted on.
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
}); });
afterEach(() => { afterEach(() => {
clearAuthTokens(); clearAuthTokens();
window.location = originalLocation;
}); });
describe('Token management', () => {
describe('WhenTokensAreSet', () => { describe('WhenTokensAreSet', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => { it('ThenGetAccessTokenReturnsTheAccessToken', () => {
setAuthTokens('access-abc', 'refresh-xyz'); setAuthTokens('access-abc', 'refresh-xyz');
@@ -49,3 +92,183 @@ describe('ApiClient token management', () => {
}); });
}); });
}); });
describe('Request interceptor', () => {
describe('WhenAnAccessTokenIsSet', () => {
it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => {
setAuthTokens('access-abc', 'refresh-xyz');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
data: {}, status: 200, statusText: 'OK', headers: {}, config,
}));
apiClient.defaults.adapter = adapter;
await apiClient.get('/v1/whatever');
expect(adapter.mock.calls[0][0].headers.Authorization).toBe('Bearer access-abc');
});
});
describe('WhenNoAccessTokenIsSet', () => {
it('ThenTheRequestHasNoAuthorizationHeader', async () => {
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
data: {}, status: 200, statusText: 'OK', headers: {}, config,
}));
apiClient.defaults.adapter = adapter;
await apiClient.get('/v1/whatever');
expect(adapter.mock.calls[0][0].headers.Authorization).toBeUndefined();
});
});
});
describe('Response interceptor - 401 refresh flow', () => {
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
if (config.headers.Authorization === 'Bearer new-access-token') {
return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config };
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
const response = await apiClient.get('/v1/protected');
expect(postSpy).toHaveBeenCalledWith(
expect.stringContaining('/v1/auth/refresh'),
{ token: 'valid-refresh-token' },
expect.anything(),
);
expect(response.data).toEqual({ ok: true });
expect(adapter).toHaveBeenCalledTimes(2);
expect(getAccessToken()).toBe('new-access-token');
expect(localStorage.getItem('mic_access_token')).toBe('new-access-token');
expect(localStorage.getItem('mic_refresh_token')).toBe('new-refresh-token');
expect(localStorage.getItem('mic_expires_at')).toBe(tokenResponse.expiresAt);
});
});
describe('WhenTheRefreshRequestItselfFails', () => {
it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => {
setAuthTokens('expired-access-token', 'invalid-refresh-token');
localStorage.setItem('mic_access_token', 'expired-access-token');
localStorage.setItem('mic_refresh_token', 'invalid-refresh-token');
localStorage.setItem('mic_expires_at', '2026-01-01T00:00:00Z');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
jest.spyOn(axios, 'post').mockRejectedValue(new Error('refresh endpoint unreachable'));
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
expect(getAccessToken()).toBeNull();
expect(localStorage.getItem('mic_access_token')).toBeNull();
expect(localStorage.getItem('mic_refresh_token')).toBeNull();
expect(localStorage.getItem('mic_expires_at')).toBeNull();
expect(window.location.href).toBe('/login');
});
});
describe('WhenThereIsNoRefreshTokenAvailable', () => {
it('ThenTheOriginalErrorIsRejectedWithoutAttemptingRefresh', async () => {
setAuthTokens(null, null);
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post');
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
expect(postSpy).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(1);
});
});
describe('WhenARequestFailsWithANonAuthError', () => {
it('ThenTheErrorIsRejectedWithoutAttemptingRefresh', async () => {
setAuthTokens('access-abc', 'refresh-xyz');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw serverError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post');
await expect(apiClient.get('/v1/whatever')).rejects.toThrow();
expect(postSpy).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(1);
});
});
describe('WhenARetriedRequestFailsWith401Again', () => {
it('ThenTheErrorIsRejectedWithoutLoopingForever', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig & { _retried?: boolean }) => {
if (config._retried) {
throw unauthorizedError(config);
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
// One failed attempt + one retry after refresh; the retry itself
// is marked _retried so a second 401 does not trigger another refresh.
expect(adapter).toHaveBeenCalledTimes(2);
});
});
describe('WhenMultipleRequestsFailWith401WhileARefreshIsAlreadyInFlight', () => {
it('ThenAllRequestsAreQueuedAndRetriedWithTheRefreshedToken', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
let resolveRefresh!: (value: { data: TokenResponse }) => void;
const refreshPromise = new Promise<{ data: TokenResponse }>((resolve) => {
resolveRefresh = resolve;
});
jest.spyOn(axios, 'post').mockReturnValue(refreshPromise as any);
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
if (config.headers.Authorization === 'Bearer new-access-token') {
return { data: { url: config.url }, status: 200, statusText: 'OK', headers: {}, config };
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const first = apiClient.get('/v1/first');
const second = apiClient.get('/v1/second');
// Let both initial 401s resolve before the refresh completes.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
resolveRefresh({ data: tokenResponse });
const [firstResponse, secondResponse] = await Promise.all([first, second]);
expect(firstResponse.data).toEqual({ url: '/v1/first' });
expect(secondResponse.data).toEqual({ url: '/v1/second' });
expect(axios.post).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from '@jest/globals';
import { mount, type VueWrapper } from '@vue/test-utils';
import ConditionEditor from '@/components/segments/ConditionEditor.vue';
import type { SegmentCondition } from '@/types/api';
function mountEditor(condition: SegmentCondition) {
return mount(ConditionEditor, { props: { condition } });
}
describe('ConditionEditor', () => {
describe('WhenThePropertyFieldIsChanged', () => {
it('ThenUpdateConditionIsEmittedWithTheNewProperty', async () => {
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
const wrapper = mountEditor(condition);
await (wrapper.findComponent('[data-testid="condition-property"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'country');
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'country', operator: 'Equal', value: 'beta' }]);
});
});
describe('WhenTheOperatorIsChanged', () => {
it('ThenUpdateConditionIsEmittedWithTheNewOperator', async () => {
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
const wrapper = mountEditor(condition);
await (wrapper.findComponent('[data-testid="condition-operator"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'NotEqual');
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'NotEqual', value: 'beta' }]);
});
});
describe('WhenTheValueFieldIsChanged', () => {
it('ThenUpdateConditionIsEmittedWithTheNewValue', async () => {
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
const wrapper = mountEditor(condition);
await (wrapper.findComponent('[data-testid="condition-value"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'gamma');
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'Equal', value: 'gamma' }]);
});
});
describe('WhenTheOperatorNeedsNoValue', () => {
it.each(['IsTrue', 'IsFalse', 'IsSet', 'IsNotSet'] as const)(
'ThenNeitherTheValueFieldNorThePercentageSliderIsShownFor %s',
(operator) => {
const condition: SegmentCondition = { property: 'plan', operator, value: '' };
const wrapper = mountEditor(condition);
expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(false);
},
);
});
describe('WhenTheOperatorIsPercentageSplit', () => {
it('ThenThePercentageSliderIsShownInsteadOfTheValueField', () => {
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
const wrapper = mountEditor(condition);
expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
});
it('ThenAnInvalidStoredValueRendersAsZero', () => {
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: 'not-a-number' };
const wrapper = mountEditor(condition);
const slider = wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper<any>;
expect(slider.props('modelValue')).toBe(0);
});
it('ThenMovingTheSliderEmitsUpdateConditionWithTheValueAsAString', async () => {
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
const wrapper = mountEditor(condition);
await (wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper<any>).vm.$emit('update:modelValue', 60);
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'userId', operator: 'PercentageSplit', value: '60' }]);
});
});
describe('WhenTheRemoveButtonIsClicked', () => {
it('ThenRemoveIsEmitted', async () => {
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
const wrapper = mountEditor(condition);
await wrapper.find('[data-testid="remove-condition-btn"]').trigger('click');
expect(wrapper.emitted('remove')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,216 @@
import { describe, it, expect } from '@jest/globals';
import { mount, type VueWrapper } from '@vue/test-utils';
import RuleGroupEditor from '@/components/segments/RuleGroupEditor.vue';
import ConditionEditor from '@/components/segments/ConditionEditor.vue';
import type { SegmentRule } from '@/types/api';
function mountEditor(rule: SegmentRule, removable = false) {
return mount(RuleGroupEditor, { props: { rule, removable } });
}
describe('RuleGroupEditor', () => {
describe('WhenTheRuleTypeIsAll', () => {
it('ThenTheAndButtonIsHighlighted', () => {
const wrapper = mountEditor({ type: 'All', conditions: [] });
expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('flat');
expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('outlined');
});
});
describe('WhenTheRuleTypeIsAny', () => {
it('ThenTheOrButtonIsHighlighted', () => {
const wrapper = mountEditor({ type: 'Any', conditions: [] });
expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('outlined');
expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('flat');
});
});
describe('WhenTheOrButtonIsClicked', () => {
it('ThenUpdateRuleIsEmittedWithTypeAny', async () => {
const wrapper = mountEditor({ type: 'All', conditions: [] });
await wrapper.find('[data-testid="rule-type-or"]').trigger('click');
expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'Any', conditions: [] }]);
});
});
describe('WhenTheAndButtonIsClicked', () => {
it('ThenUpdateRuleIsEmittedWithTypeAll', async () => {
const wrapper = mountEditor({ type: 'Any', conditions: [] });
await wrapper.find('[data-testid="rule-type-and"]').trigger('click');
expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'All', conditions: [] }]);
});
});
describe('WhenRemovableIsFalse', () => {
it('ThenTheRemoveButtonIsNotShown', () => {
const wrapper = mountEditor({ type: 'All', conditions: [] }, false);
expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(false);
});
});
describe('WhenRemovableIsTrue', () => {
it('ThenTheRemoveButtonIsShownAndEmitsRemoveWhenClicked', async () => {
const wrapper = mountEditor({ type: 'All', conditions: [] }, true);
expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(true);
await wrapper.find('[data-testid="remove-rule-group-btn"]').trigger('click');
expect(wrapper.emitted('remove')).toBeTruthy();
});
});
describe('WhenTheRuleHasConditions', () => {
it('ThenAConditionEditorIsRenderedPerCondition', () => {
const wrapper = mountEditor({
type: 'All',
conditions: [
{ property: 'plan', operator: 'Equal', value: 'beta' },
{ property: 'country', operator: 'Equal', value: 'US' },
],
});
expect(wrapper.findAllComponents(ConditionEditor)).toHaveLength(2);
});
});
describe('WhenAConditionIsUpdated', () => {
it('ThenUpdateRuleIsEmittedWithThatConditionReplacedInPlace', async () => {
const wrapper = mountEditor({
type: 'All',
conditions: [
{ property: 'plan', operator: 'Equal', value: 'beta' },
{ property: 'country', operator: 'Equal', value: 'US' },
],
});
const conditionEditors = wrapper.findAllComponents(ConditionEditor);
await conditionEditors[1].vm.$emit('update:condition', { property: 'country', operator: 'Equal', value: 'CA' });
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [
{ property: 'plan', operator: 'Equal', value: 'beta' },
{ property: 'country', operator: 'Equal', value: 'CA' },
],
}]);
});
});
describe('WhenAConditionIsRemoved', () => {
it('ThenUpdateRuleIsEmittedWithThatConditionFilteredOut', async () => {
const wrapper = mountEditor({
type: 'All',
conditions: [
{ property: 'plan', operator: 'Equal', value: 'beta' },
{ property: 'country', operator: 'Equal', value: 'US' },
],
});
const conditionEditors = wrapper.findAllComponents(ConditionEditor);
await conditionEditors[0].vm.$emit('remove');
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [{ property: 'country', operator: 'Equal', value: 'US' }],
}]);
});
});
describe('WhenAddConditionIsClicked', () => {
it('ThenUpdateRuleIsEmittedWithANewEmptyConditionAppended', async () => {
const wrapper = mountEditor({
type: 'All',
conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }],
});
await wrapper.find('[data-testid="add-condition-btn"]').trigger('click');
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [
{ property: 'plan', operator: 'Equal', value: 'beta' },
{ property: '', operator: 'Equal', value: '' },
],
}]);
});
});
describe('WhenAddGroupIsClicked', () => {
it('ThenUpdateRuleIsEmittedWithANewChildGroupAppended', async () => {
const wrapper = mountEditor({ type: 'All', conditions: [] });
await wrapper.find('[data-testid="add-group-btn"]').trigger('click');
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [],
childRules: [{ type: 'All', conditions: [] }],
}]);
});
});
describe('WhenTheRuleHasChildRules', () => {
it('ThenANestedRuleGroupEditorIsRenderedPerChildAndMarkedRemovable', () => {
const wrapper = mountEditor({
type: 'All',
conditions: [],
childRules: [{ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }],
});
const nested = wrapper.findAllComponents(RuleGroupEditor);
expect(nested).toHaveLength(1);
expect(nested[0].props('rule')).toEqual({ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] });
expect(nested[0].props('removable')).toBe(true);
});
});
describe('WhenANestedRuleGroupIsUpdated', () => {
it('ThenUpdateRuleIsEmittedWithThatChildReplacedInPlace', async () => {
const wrapper = mountEditor({
type: 'All',
conditions: [],
childRules: [{ type: 'Any', conditions: [] }],
});
const nested = wrapper.findAllComponents(RuleGroupEditor);
const updatedChild: SegmentRule = { type: 'All', conditions: [{ property: 'x', operator: 'Equal', value: 'y' }] };
await nested[0].vm.$emit('update:rule', updatedChild);
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [],
childRules: [updatedChild],
}]);
});
});
describe('WhenANestedRuleGroupIsRemoved', () => {
it('ThenUpdateRuleIsEmittedWithThatChildFilteredOut', async () => {
const wrapper = mountEditor({
type: 'All',
conditions: [],
childRules: [
{ type: 'Any', conditions: [] },
{ type: 'All', conditions: [] },
],
});
const nested = wrapper.findAllComponents(RuleGroupEditor);
await nested[0].vm.$emit('remove');
expect(wrapper.emitted('update:rule')![0]).toEqual([{
type: 'All',
conditions: [],
childRules: [{ type: 'All', conditions: [] }],
}]);
});
});
});

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia, createPinia } from 'pinia';
import { setAuthTokens, clearAuthTokens } from '@/api/client'; import { clearAuthTokens } from '@/api/client';
import { authGuard } from '@/router';
// Minimal stub components for route testing // Minimal stub components for route testing
const Stub = { template: '<div />' }; const Stub = { template: '<div />' };
@@ -15,11 +16,17 @@ jest.mock('@/api/client', () => ({
import { getAccessToken } from '@/api/client'; import { getAccessToken } from '@/api/client';
function buildRouter() { function buildRouter() {
return createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } }, { path: '/login', name: 'Login', component: Stub, meta: { public: true } },
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } }, { path: '/register', name: 'Register', component: Stub, meta: { public: true } },
{
path: '/accept-invite/:token',
name: 'AcceptInvite',
component: Stub,
meta: { public: true, allowAuthenticated: true },
},
{ {
path: '/', path: '/',
component: Stub, component: Stub,
@@ -31,6 +38,9 @@ function buildRouter() {
}, },
], ],
}); });
// Wire up the real guard exported by the app router, not a re-implementation.
router.beforeEach(authGuard);
return router;
} }
describe('Router auth guard', () => { describe('Router auth guard', () => {
@@ -44,21 +54,10 @@ describe('Router auth guard', () => {
}); });
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => { describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenTheyAreRedirectedToLogin', async () => { it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
jest.mocked(getAccessToken).mockReturnValue(null); jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter(); const router = buildRouter();
// Wire up the same guard logic as the real router
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features'); await router.push('/features');
expect(router.currentRoute.value.name).toBe('Login'); expect(router.currentRoute.value.name).toBe('Login');
@@ -71,17 +70,6 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token'); jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter(); const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
// Start authenticated on dashboard, then try to go to login
await router.push('/'); await router.push('/');
await router.push('/login'); await router.push('/login');
@@ -94,19 +82,55 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token'); jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter(); const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features'); await router.push('/features');
expect(router.currentRoute.value.name).toBe('Features'); expect(router.currentRoute.value.name).toBe('Features');
}); });
}); });
describe('WhenUnauthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
it('ThenNavigationSucceeds', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
await router.push('/accept-invite/some-token');
expect(router.currentRoute.value.name).toBe('AcceptInvite');
});
});
describe('WhenAuthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
it('ThenTheyAreNotRedirectedToDashboard', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
await router.push('/accept-invite/some-token');
expect(router.currentRoute.value.name).toBe('AcceptInvite');
});
});
describe('WhenAuthenticatedUserNavigatesToRegister', () => {
it('ThenTheyAreRedirectedToDashboardBecauseRegisterHasNoAllowAuthenticatedException', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
await router.push('/');
await router.push('/register');
expect(router.currentRoute.value.name).toBe('Dashboard');
});
});
describe('WhenUnauthenticatedUserNavigatesToAProtectedRouteWithAQueryString', () => {
it('ThenTheFullPathIncludingTheQueryIsPreservedInTheRedirect', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
await router.push('/features?tag=beta');
expect(router.currentRoute.value.name).toBe('Login');
expect(router.currentRoute.value.query.redirect).toBe('/features?tag=beta');
});
});
}); });

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { setActivePinia, createPinia } from 'pinia';
import { useThemeStore } from '@/stores/theme';
const STORAGE_KEY = 'mic_theme';
function mockMatchMedia(initialMatches: boolean) {
let changeHandler: ((e: { matches: boolean }) => void) | undefined;
const mql = {
matches: initialMatches,
media: '(prefers-color-scheme: dark)',
addEventListener: jest.fn((event: string, handler: (e: { matches: boolean }) => void) => {
if (event === 'change') changeHandler = handler;
}),
removeEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
return {
triggerChange: (matches: boolean) => changeHandler?.({ matches }),
};
}
describe('ThemeStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
localStorage.clear();
});
describe('WhenNoModeIsStoredAndSystemPrefersLight', () => {
it('ThenModeDefaultsToSystemAndEffectiveThemeIsLight', () => {
mockMatchMedia(false);
const themeStore = useThemeStore();
expect(themeStore.mode).toBe('system');
expect(themeStore.effectiveTheme).toBe('light');
});
});
describe('WhenNoModeIsStoredAndSystemPrefersDark', () => {
it('ThenEffectiveThemeIsDark', () => {
mockMatchMedia(true);
const themeStore = useThemeStore();
expect(themeStore.mode).toBe('system');
expect(themeStore.effectiveTheme).toBe('dark');
});
});
describe('WhenAModeIsAlreadyStored', () => {
it('ThenTheStoredModeIsUsedAsTheInitialMode', () => {
localStorage.setItem(STORAGE_KEY, 'dark');
mockMatchMedia(false);
const themeStore = useThemeStore();
expect(themeStore.mode).toBe('dark');
expect(themeStore.effectiveTheme).toBe('dark');
});
});
describe('WhenSetModeIsCalledWithLight', () => {
it('ThenModeAndEffectiveThemeAreLightAndPersistedToStorage', () => {
mockMatchMedia(true);
const themeStore = useThemeStore();
themeStore.setMode('light');
expect(themeStore.mode).toBe('light');
expect(themeStore.effectiveTheme).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe('light');
});
});
describe('WhenSetModeIsCalledWithDark', () => {
it('ThenModeAndEffectiveThemeAreDarkAndPersistedToStorage', () => {
mockMatchMedia(false);
const themeStore = useThemeStore();
themeStore.setMode('dark');
expect(themeStore.mode).toBe('dark');
expect(themeStore.effectiveTheme).toBe('dark');
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark');
});
});
describe('WhenModeIsSystemAndTheOperatingSystemThemeChanges', () => {
it('ThenEffectiveThemeReactsToTheChange', () => {
const { triggerChange } = mockMatchMedia(false);
const themeStore = useThemeStore();
expect(themeStore.effectiveTheme).toBe('light');
triggerChange(true);
expect(themeStore.effectiveTheme).toBe('dark');
});
});
describe('WhenModeIsExplicitAndTheOperatingSystemThemeChanges', () => {
it('ThenEffectiveThemeIsUnaffected', () => {
const { triggerChange } = mockMatchMedia(false);
const themeStore = useThemeStore();
themeStore.setMode('light');
triggerChange(true);
expect(themeStore.effectiveTheme).toBe('light');
});
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from '@jest/globals';
import { validateEmail } from '@/utils/validation';
describe('validateEmail', () => {
describe('WhenTheValueIsEmpty', () => {
it('ThenItReturnsAnEmailRequiredMessage', () => {
expect(validateEmail('')).toBe('Email required');
});
});
describe('WhenTheValueIsOnlyWhitespace', () => {
it('ThenItReturnsAnEmailRequiredMessage', () => {
expect(validateEmail(' ')).toBe('Email required');
});
});
describe('WhenTheValueIsAValidEmail', () => {
it('ThenItReturnsTrue', () => {
expect(validateEmail('jane@example.com')).toBe(true);
});
});
describe('WhenTheValueHasSurroundingWhitespace', () => {
it('ThenItIsTrimmedBeforeValidationAndReturnsTrue', () => {
expect(validateEmail(' jane@example.com ')).toBe(true);
});
});
describe('WhenTheValueIsMissingTheAtSymbol', () => {
it('ThenItReturnsAnInvalidEmailMessage', () => {
expect(validateEmail('jane.example.com')).toBe('Invalid email');
});
});
describe('WhenTheValueIsMissingTheDomain', () => {
it('ThenItReturnsAnInvalidEmailMessage', () => {
expect(validateEmail('jane@example')).toBe('Invalid email');
});
});
describe('WhenTheValueContainsSpaces', () => {
it('ThenItReturnsAnInvalidEmailMessage', () => {
expect(validateEmail('jane doe@example.com')).toBe('Invalid email');
});
});
describe('WhenTheValueHasMultipleAtSymbols', () => {
it('ThenItReturnsAnInvalidEmailMessage', () => {
expect(validateEmail('jane@doe@example.com')).toBe('Invalid email');
});
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import AcceptInviteView from '@/views/AcceptInviteView.vue';
jest.mock('@/api/organizations');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as orgsApi from '@/api/organizations';
import { getAccessToken } from '@/api/client';
async function mountView(token = 'invite-token-123') {
const routes = [
{ path: '/accept-invite/:token', name: 'AcceptInvite', component: AcceptInviteView },
{ path: '/dashboard', component: { template: '<div />' } },
{ path: '/login', name: 'Login', component: { template: '<div />' } },
];
const router = createRouter({ history: createMemoryHistory(), routes });
router.push(`/accept-invite/${token}`);
await router.isReady();
return { wrapper: mount(AcceptInviteView, { global: { plugins: [router] } }), router };
}
describe('AcceptInviteView', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('WhenTheUserIsNotAuthenticated', () => {
it('ThenTheSignInPromptIsShownAndAcceptInviteIsNotCalled', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const { wrapper } = await mountView();
await flushPromises();
expect(wrapper.text()).toContain('Sign in to accept invite');
expect(orgsApi.acceptInvite).not.toHaveBeenCalled();
});
it('ThenTheSignInButtonNavigatesToLoginWithARedirectQuery', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const { wrapper, router } = await mountView('invite-token-123');
await flushPromises();
const pushSpy = jest.spyOn(router, 'push');
await wrapper.find('button').trigger('click');
expect(pushSpy).toHaveBeenCalledWith({
name: 'Login',
query: { redirect: '/accept-invite/invite-token-123' },
});
});
});
describe('WhenTheUserIsAuthenticatedAndTheInviteIsAccepted', () => {
it('ThenTheSuccessStateIsShown', async () => {
jest.mocked(getAccessToken).mockReturnValue('access-token');
jest.mocked(orgsApi.acceptInvite).mockResolvedValue(undefined);
const { wrapper } = await mountView('invite-token-123');
await flushPromises();
expect(orgsApi.acceptInvite).toHaveBeenCalledWith('invite-token-123');
expect(wrapper.text()).toContain("You're in!");
});
});
describe('WhenTheUserIsAuthenticatedButTheInviteIsInvalid', () => {
it('ThenTheErrorStateIsShown', async () => {
jest.mocked(getAccessToken).mockReturnValue('access-token');
jest.mocked(orgsApi.acceptInvite).mockRejectedValue(new Error('Invalid token'));
const { wrapper } = await mountView('bad-token');
await flushPromises();
expect(wrapper.text()).toContain('Invalid invite link');
});
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import LoginView from '@/views/LoginView.vue';
import { useAuthStore } from '@/stores/auth';
jest.mock('@/api/auth');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as authApi from '@/api/auth';
const tokenResponse = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: '2026-01-01T01:00:00Z',
};
function mountView() {
const routes = [
{ path: '/', component: { template: '<div />' } },
{ path: '/register', component: { template: '<div />' } },
];
const router = createRouter({ history: createMemoryHistory(), routes });
return { wrapper: mount(LoginView, { global: { plugins: [router] } }), router };
}
async function fillAndSubmit(wrapper: ReturnType<typeof mount>, email: string, password: string) {
await wrapper.find('[data-testid="email-input"] input').setValue(email);
await wrapper.find('[data-testid="password-input"] input').setValue(password);
await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
await flushPromises();
}
describe('LoginView', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenTheFormIsEmpty', () => {
it('ThenSubmitDoesNotCallLogin', async () => {
const { wrapper } = mountView();
await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
await flushPromises();
expect(authApi.login).not.toHaveBeenCalled();
});
});
describe('WhenTheEmailIsInvalid', () => {
it('ThenSubmitDoesNotCallLogin', async () => {
const { wrapper } = mountView();
await fillAndSubmit(wrapper, 'not-an-email', 'password123');
expect(authApi.login).not.toHaveBeenCalled();
});
});
describe('WhenCredentialsAreValidAndLoginSucceeds', () => {
it('ThenAuthStoreLoginIsCalledAndUserIsRedirectedHome', async () => {
jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
const { wrapper, router } = mountView();
const pushSpy = jest.spyOn(router, 'push');
await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
expect(authApi.login).toHaveBeenCalledWith({ email: 'jane@example.com', password: 'password123' });
expect(pushSpy).toHaveBeenCalledWith('/');
});
it('ThenAuthStoreBecomesAuthenticated', async () => {
jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
const { wrapper } = mountView();
const authStore = useAuthStore();
await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
expect(authStore.isAuthenticated).toBe(true);
});
});
describe('WhenLoginFails', () => {
it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
const { wrapper, router } = mountView();
const pushSpy = jest.spyOn(router, 'push');
await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
expect(wrapper.find('[data-testid="login-error"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Invalid email or password');
expect(pushSpy).not.toHaveBeenCalled();
});
it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
const { wrapper } = mountView();
await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
const submitBtn = wrapper.findComponent('[data-testid="login-submit"]');
expect(submitBtn.props('loading')).toBe(false);
});
});
describe('WhenThePasswordVisibilityIconIsClicked', () => {
it('ThenThePasswordFieldTypeToggles', async () => {
const { wrapper } = mountView();
const passwordInput = () => wrapper.find('[data-testid="password-input"] input');
expect(passwordInput().attributes('type')).toBe('password');
await wrapper.find('[data-testid="password-input"] .v-field__append-inner i').trigger('click');
expect(passwordInput().attributes('type')).toBe('text');
});
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import RegisterView from '@/views/RegisterView.vue';
import { useAuthStore } from '@/stores/auth';
jest.mock('@/api/auth');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as authApi from '@/api/auth';
const tokenResponse = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: '2026-01-01T01:00:00Z',
};
function mountView() {
const routes = [
{ path: '/', component: { template: '<div />' } },
{ path: '/login', component: { template: '<div />' } },
];
const router = createRouter({ history: createMemoryHistory(), routes });
return { wrapper: mount(RegisterView, { global: { plugins: [router] } }), router };
}
interface RegisterFields {
firstName: string;
lastName: string;
email: string;
organizationName: string;
password: string;
confirmPassword: string;
}
const validFields: RegisterFields = {
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com',
organizationName: 'Acme',
password: 'password123',
confirmPassword: 'password123',
};
async function fillAndSubmit(wrapper: ReturnType<typeof mount>, fields: Partial<RegisterFields> = {}) {
const values = { ...validFields, ...fields };
await wrapper.find('[data-testid="first-name-input"] input').setValue(values.firstName);
await wrapper.find('[data-testid="last-name-input"] input').setValue(values.lastName);
await wrapper.find('[data-testid="email-input"] input').setValue(values.email);
await wrapper.find('[data-testid="org-name-input"] input').setValue(values.organizationName);
await wrapper.find('[data-testid="password-input"] input').setValue(values.password);
await wrapper.find('[data-testid="confirm-password-input"] input').setValue(values.confirmPassword);
await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
await flushPromises();
}
describe('RegisterView', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenTheFormIsEmpty', () => {
it('ThenSubmitDoesNotCallRegister', async () => {
const { wrapper } = mountView();
await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
await flushPromises();
expect(authApi.register).not.toHaveBeenCalled();
});
});
describe('WhenThePasswordIsTooShort', () => {
it('ThenSubmitDoesNotCallRegister', async () => {
const { wrapper } = mountView();
await fillAndSubmit(wrapper, { password: 'short', confirmPassword: 'short' });
expect(authApi.register).not.toHaveBeenCalled();
});
});
describe('WhenTheConfirmPasswordDoesNotMatch', () => {
it('ThenSubmitDoesNotCallRegister', async () => {
const { wrapper } = mountView();
await fillAndSubmit(wrapper, { confirmPassword: 'somethingElse123' });
expect(authApi.register).not.toHaveBeenCalled();
});
});
describe('WhenAllFieldsAreValidAndRegisterSucceeds', () => {
it('ThenAuthStoreRegisterIsCalledWithFormValuesAndUserIsRedirectedHome', async () => {
jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
const { wrapper, router } = mountView();
const pushSpy = jest.spyOn(router, 'push');
await fillAndSubmit(wrapper);
expect(authApi.register).toHaveBeenCalledWith({
email: 'jane@example.com',
password: 'password123',
firstName: 'Jane',
lastName: 'Doe',
organizationName: 'Acme',
});
expect(pushSpy).toHaveBeenCalledWith('/');
});
it('ThenAuthStoreBecomesAuthenticated', async () => {
jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
const { wrapper } = mountView();
const authStore = useAuthStore();
await fillAndSubmit(wrapper);
expect(authStore.isAuthenticated).toBe(true);
});
});
describe('WhenRegisterFails', () => {
it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
const { wrapper, router } = mountView();
const pushSpy = jest.spyOn(router, 'push');
await fillAndSubmit(wrapper);
expect(wrapper.find('[data-testid="register-error"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Registration failed');
expect(pushSpy).not.toHaveBeenCalled();
});
it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
const { wrapper } = mountView();
await fillAndSubmit(wrapper);
const submitBtn = wrapper.findComponent('[data-testid="register-submit"]');
expect(submitBtn.props('loading')).toBe(false);
});
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue'; import SegmentsView from '@/views/SegmentsView.vue';
@@ -129,7 +129,8 @@ describe('SegmentsView', () => {
const wrapper = mountView(); const wrapper = mountView();
await flushPromises(); await flushPromises();
await wrapper.find('[data-testid="edit-segment-1"]').trigger('click'); const row = wrapper.findAll('tbody tr').find((r) => r.text().includes('beta_users'));
await row!.trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' }); const editor = wrapper.findComponent({ name: 'SegmentEditor' });
@@ -154,13 +155,6 @@ describe('SegmentsView', () => {
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true); expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
// Confirm button should be disabled before typing the name
expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined();
// Type segment name to enable button
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
@@ -181,9 +175,6 @@ describe('SegmentsView', () => {
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click'); await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();

View File

@@ -102,9 +102,10 @@ describe('SettingsView', () => {
}); });
describe('WhenInviteMemberIsClicked', () => { describe('WhenInviteMemberIsClicked', () => {
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => { it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined); jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]); { email: 'jane@example.com', success: true, error: null },
]);
const contextStore = useContextStore(); const contextStore = useContextStore();
contextStore.setOrganization(mockOrg); contextStore.setOrganization(mockOrg);
@@ -112,13 +113,19 @@ describe('SettingsView', () => {
const wrapper = mountView(); const wrapper = mountView();
await flushPromises(); await flushPromises();
await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '42'); await wrapper.find('[data-testid="add-member-btn"]').trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="invite-member-btn"]').trigger('click'); await (wrapper.findComponent('[data-testid="add-member-email-0"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'jane@example.com');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-add-members-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' }); expect(orgsApi.inviteOrganizationMembersByEmail).toHaveBeenCalledWith(
mockOrg.id,
{ invites: [{ email: 'jane@example.com', role: 'User' }] },
);
}); });
}); });
@@ -136,6 +143,8 @@ describe('SettingsView', () => {
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true); expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
await wrapper.find('[data-testid="remove-member-5"]').trigger('click'); await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5); expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
@@ -261,6 +270,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click'); await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5); expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
@@ -343,9 +354,6 @@ describe('SettingsView', () => {
await wrapper.find('[data-testid="delete-env-100"]').trigger('click'); await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Development');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
await flushPromises(); await flushPromises();
@@ -471,6 +479,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click'); await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1); expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,3 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>( public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);
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; set; } public required string Key { get; init; }
public required string Prefix { get; set; } public required string Prefix { get; init; }
public required string Name { get; set; } public required string Name { get; init; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

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

View File

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

View File

@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKeyService(MicCheckDbContext db) public class ApiKeyService(IMicCheckDbContext db)
{ {
public async Task<(ApiKey Key, string RawKey)> CreateAsync( public async Task<ApiKeyCreationResult> 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(MicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (apiKey, rawKey); return new ApiKeyCreationResult(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,3 +48,5 @@ public class ApiKeyService(MicCheckDbContext 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, MicCheckDbContext db) public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext 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, MicCheckDbContext db) public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "EnvironmentKey"; public const string SchemeName = "EnvironmentKey";

View File

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

View File

@@ -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(
MicCheckDbContext db, IMicCheckDbContext db,
ITokenService tokenService, ITokenService tokenService,
IPasswordHasher<User> passwordHasher) IPasswordHasher<User> passwordHasher)
{ {
@@ -81,7 +81,9 @@ public class AuthService(
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct); var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(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,9 +2,4 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirement : IAuthorizationRequirement public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -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(
MicCheckDbContext db, IMicCheckDbContext db,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement> : AuthorizationHandler<ProjectPermissionRequirement>
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,31 @@
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 public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{ {
private readonly MicCheckDbContext _db; /// <summary>
/// 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";
public DatabaseSeeder(MicCheckDbContext db) private const string SeedAdminPassword = "MicCheckQa!2026";
{
_db = db; /// <summary>Deterministic environment key for the seeded Development environment, so
} /// 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
@@ -24,8 +33,8 @@ public class DatabaseSeeder
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
{ {
@@ -33,21 +42,43 @@ public class DatabaseSeeder
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 = $"env-{Guid.NewGuid():N}", ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"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

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

View File

@@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
@@ -13,7 +14,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext public class MicCheckDbContext : DbContext, IMicCheckDbContext
{ {
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { } public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
@@ -42,3 +43,31 @@ public class MicCheckDbContext : DbContext
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

@@ -0,0 +1,28 @@
# Self-contained CI/QA image for MicCheck.Api. Unlike the dev Dockerfile
# (which expects a host-published /app to be bind-mounted), this builds the
# app inside the image so it can be pushed to a registry and run standalone.
#
# Build context must be the repo root (needs ServiceDefaults + the API project):
# docker build -f src/api/MicCheck.Api/Dockerfile.ci -t miccheck-api:qa .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj src/MicCheck.ServiceDefaults/
COPY src/api/MicCheck.Api/MicCheck.Api.csproj src/api/MicCheck.Api/
RUN dotnet restore src/api/MicCheck.Api/MicCheck.Api.csproj
COPY src/MicCheck.ServiceDefaults/ src/MicCheck.ServiceDefaults/
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
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
COPY --from=build /out .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(logs.ToList()); return Ok(result.Items.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 (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results)); return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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