Compare commits

..

12 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
James Wampler
6887d09f9c version-0.1 (#1)
Squash and merge of version-0.1

Co-authored-by: James Wampler <james@wamp.dev>
Co-committed-by: James Wampler <james@wamp.dev>
2026-07-01 13:30:07 -07:00
256 changed files with 12467 additions and 3205 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
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
@@ -439,6 +438,11 @@ dist-ssr/
# TypeScript incremental build cache
*.tsbuildinfo
# Playwright
src/admin/test-results/
src/admin/playwright-report/
src/admin/e2e/.auth/
# Jest test coverage reports
coverage/
.nyc_output/
@@ -469,3 +473,9 @@ lerna-debug.log*
# Windows thumbnail cache
Thumbs.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
Guidance for Claude Code (claude.ai/code) when working in this repo.
Guidance for Claude Code (claude.ai/code) in this repo.
## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
## Planned Structure
@@ -18,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
- Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not type (e.g. `features` namespace)
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage
- New features need unit tests covering logic as much as possible (both nunit and jest)
- Modified file: check missing test coverage, all tests pass
# Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- Do not use tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed` on classes-
- Wrap lines at 220 chars, single line if fewer
- Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations separate file.
- No tuples for return types. Prefer records or classes for multiple values
- No `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps with Moq
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage-
- Mock external deps w/ Moq
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
- New features need unit tests covering logic as much as possible
- Modified file: check missing test coverage
- No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests pass before commit
## 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
- Plan generated from `docs/<name>.md` → save as `docs/<name>_plan.md`
- Plan implemented from `docs/<name>.md` → save summary as `docs/<name>_output.md`
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.

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

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 James Wampler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

138
badges/coverage.svg Normal file
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)"
build_api() {
echo "Building API..."
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
echo "API done."
}
echo "Building solution..."
dotnet build "$SCRIPT_DIR/MicCheck.slnx"
build_admin() {
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
npm --prefix "$SCRIPT_DIR/src/admin" run build
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
echo "Admin done."
}
echo "Installing admin dependencies..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
case "${1:-all}" in
api) build_api ;;
admin) build_admin ;;
all) build_api && build_admin ;;
*)
echo "Usage: $0 [api|admin|all]"
exit 1
;;
esac
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" run build
echo "Running .NET unit tests..."
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
echo "Running Jest tests..."
npm --prefix "$SCRIPT_DIR/src/admin" test
echo "Build and tests complete."

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

@@ -0,0 +1,47 @@
# 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.
Built with .NET (API) and Vue.js + Vuetify (admin UI).
## Structure
```
src/
api/MicCheck.Api/ .NET API — REST endpoints, organized by feature (Features, Projects, Environments,
Segments, Identities, Organizations, Webhooks, Audit, Users)
admin/ Vue.js + Vuetify admin SPA
MicCheck.AppHost/ .NET Aspire orchestration host for local dev
MicCheck.ServiceDefaults/ Shared .NET Aspire service defaults (telemetry, health checks)
tests/
api/MicCheck.Api.Tests.Unit/ Unit tests
api/MicCheck.Api.Tests.Integration/ Integration tests
docs/
admin/ Admin implementation plans/output docs
api/ API implementation plans/output docs
```
Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Identities`) rather than by technical layer.
## Running locally
`MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
```sh
./dev-build.sh
aspire run --project src/MicCheck.AppHost
```
## Testing
```sh
dotnet test
```
## License
MIT — see [LICENSE](LICENSE).

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 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 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")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(port: 5173, name: "http")
.WithExternalHttpEndpoints();
builder.Build().Run();

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.0" />
<PackageReference Include="MessagePack" Version="2.5.302" />
</ItemGroup>
<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 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
location / {
try_files $uri $uri/ /index.html;

View File

@@ -8,6 +8,7 @@
"name": "mic-check-admin",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
@@ -25,6 +26,7 @@
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.0",
"@vitejs/plugin-vue": "^6.0.6",
@@ -2002,6 +2004,14 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -3343,6 +3353,21 @@
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -5987,16 +6012,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -8405,11 +8429,10 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -9426,6 +9449,50 @@
"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": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -7,9 +7,12 @@
"build:dev": "vite build --mode development",
"serve": "vite",
"preview": "vite preview",
"test": "jest --passWithNoTests"
"test": "jest --passWithNoTests",
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
@@ -27,6 +30,7 @@
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.0",
"@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

@@ -1,4 +1,4 @@
$font-family-custom: "Inter", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
$font-family-custom: "Inter Variable", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
"Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* stylelint-disable length-zero-no-unit */
@forward "../../../base/libs/vuetify/variables" with (

View File

@@ -3,7 +3,7 @@ import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '
export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> {
const { data } = await apiClient.get<ApiKeyResponse[]>(
`/v1/organisations/${organizationId}/api-keys`,
`/v1/organisation/${organizationId}/api-keys`,
);
return data;
}
@@ -13,12 +13,12 @@ export async function createApiKey(
request: CreateApiKeyRequest,
): Promise<CreateApiKeyResponse> {
const { data } = await apiClient.post<CreateApiKeyResponse>(
`/v1/organisations/${organizationId}/api-keys`,
`/v1/organisation/${organizationId}/api-keys`,
request,
);
return data;
}
export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
await apiClient.delete(`/v1/organisation/${organizationId}/api-key/${keyId}`);
}

View File

@@ -17,7 +17,7 @@ export async function listAuditLogsByOrganization(
filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/organisations/${orgId}/audit-logs`,
`/v1/organisation/${orgId}/audit-logs`,
{ params: toParams(filter) },
);
return data;
@@ -28,7 +28,7 @@ export async function listAuditLogsByProject(
filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/projects/${projectId}/audit-logs`,
`/v1/project/${projectId}/audit-logs`,
{ params: toParams(filter) },
);
return data;

View File

@@ -16,7 +16,7 @@ export async function listEnvironments(projectId: number, page = 1, pageSize = 1
}
export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> {
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environments/${apiKey}`);
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environment/${apiKey}`);
return data;
}
@@ -26,17 +26,17 @@ export async function createEnvironment(request: CreateEnvironmentRequest): Prom
}
export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environments/${apiKey}`, request);
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environment/${apiKey}`, request);
return data;
}
export async function deleteEnvironment(apiKey: string): Promise<void> {
await apiClient.delete(`/v1/environments/${apiKey}`);
await apiClient.delete(`/v1/environment/${apiKey}`);
}
export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>(
`/v1/environments/${apiKey}/clone`,
`/v1/environment/${apiKey}/clone`,
request,
);
return data;

View File

@@ -11,7 +11,7 @@ export async function listFeatureSegments(
featureId: number,
): Promise<FeatureSegmentResponse[]> {
const { data } = await apiClient.get<FeatureSegmentResponse[]>(
`/v1/environments/${envApiKey}/features/${featureId}/segments`,
`/v1/environment/${envApiKey}/feature/${featureId}/segments`,
);
return data;
}
@@ -22,7 +22,7 @@ export async function createFeatureSegment(
request: CreateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.post<FeatureSegmentResponse>(
`/v1/environments/${envApiKey}/features/${featureId}/segments`,
`/v1/environment/${envApiKey}/feature/${featureId}/segments`,
request,
);
return data;
@@ -35,7 +35,7 @@ export async function updateFeatureSegment(
request: UpdateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.put<FeatureSegmentResponse>(
`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`,
`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`,
request,
);
return data;
@@ -46,7 +46,7 @@ export async function deleteFeatureSegment(
featureId: number,
id: number,
): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`);
await apiClient.delete(`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`);
}
export async function listIdentitySegments(
@@ -54,7 +54,7 @@ export async function listIdentitySegments(
identityId: number,
): Promise<SegmentSummaryResponse[]> {
const { data } = await apiClient.get<SegmentSummaryResponse[]>(
`/v1/environments/${envApiKey}/identities/${identityId}/segments`,
`/v1/environment/${envApiKey}/identity/${identityId}/segments`,
);
return data;
}

View File

@@ -7,14 +7,14 @@ import type {
export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/featurestates`,
`/v1/environment/${envApiKey}/featurestates`,
);
return data;
}
export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> {
const { data } = await apiClient.get<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
`/v1/environment/${envApiKey}/featurestate/${id}`,
);
return data;
}
@@ -25,7 +25,7 @@ export async function updateFeatureState(
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
`/v1/environment/${envApiKey}/featurestate/${id}`,
request,
);
return data;
@@ -37,7 +37,7 @@ export async function patchFeatureState(
request: PatchFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.patch<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
`/v1/environment/${envApiKey}/featurestate/${id}`,
request,
);
return data;

View File

@@ -13,7 +13,7 @@ export async function listFeatures(
pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/projects/${projectId}/features`,
`/v1/project/${projectId}/features`,
{ params: { page, pageSize } },
);
return data;
@@ -21,7 +21,7 @@ export async function listFeatures(
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
);
return data;
}
@@ -31,7 +31,7 @@ export async function createFeature(
request: CreateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>(
`/v1/projects/${projectId}/features`,
`/v1/project/${projectId}/features`,
request,
);
return data;
@@ -43,7 +43,7 @@ export async function updateFeature(
request: UpdateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
request,
);
return data;
@@ -55,12 +55,34 @@ export async function patchFeature(
request: PatchFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
request,
);
return data;
}
export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
await apiClient.delete(`/v1/project/${projectId}/feature/${id}`);
}
export async function assignFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
}
export async function removeFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.delete<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
}

View File

@@ -15,7 +15,7 @@ export async function listIdentities(
pageSize = 100,
): Promise<PaginatedResponse<IdentityResponse>> {
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
`/v1/environments/${envApiKey}/identities`,
`/v1/environment/${envApiKey}/identities`,
{ params: { page, pageSize } },
);
return data;
@@ -26,7 +26,7 @@ export async function createIdentity(
request: CreateIdentityRequest,
): Promise<IdentityResponse> {
const { data } = await apiClient.post<IdentityResponse>(
`/v1/environments/${envApiKey}/identities`,
`/v1/environment/${envApiKey}/identities`,
request,
);
return data;
@@ -34,7 +34,7 @@ export async function createIdentity(
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
const { data } = await apiClient.get<IdentityResponse>(
`/v1/environments/${envApiKey}/identities/${id}`,
`/v1/environment/${envApiKey}/identity/${id}`,
);
return data;
}
@@ -46,7 +46,7 @@ export async function upsertIdentityTrait(
request: UpsertTraitRequest,
): Promise<TraitResponse> {
const { data } = await apiClient.put<TraitResponse>(
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`,
request,
);
return data;
@@ -58,12 +58,12 @@ export async function deleteIdentityTrait(
key: string,
): Promise<void> {
await apiClient.delete(
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`,
);
}
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
await apiClient.delete(`/v1/environment/${envApiKey}/identity/${id}`);
}
export async function getIdentityFeatureStates(
@@ -71,7 +71,7 @@ export async function getIdentityFeatureStates(
identityId: number,
): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestates`,
);
return data;
}
@@ -83,7 +83,7 @@ export async function setIdentityFeatureState(
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`,
request,
);
return data;
@@ -95,6 +95,6 @@ export async function deleteIdentityFeatureState(
featureId: number,
): Promise<void> {
await apiClient.delete(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`,
);
}

View File

@@ -20,7 +20,7 @@ export async function listOrganizations(page = 1, pageSize = 100): Promise<Organ
}
export async function getOrganization(id: number): Promise<OrganizationResponse> {
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisations/${id}`);
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisation/${id}`);
return data;
}
@@ -30,27 +30,27 @@ export async function createOrganization(request: CreateOrganizationRequest): Pr
}
export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisations/${id}`, request);
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisation/${id}`, request);
return data;
}
export async function deleteOrganization(id: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${id}`);
await apiClient.delete(`/v1/organisation/${id}`);
}
export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> {
const { data } = await apiClient.get<OrganizationMemberResponse[]>(
`/v1/organisations/${organizationId}/users`,
`/v1/organisation/${organizationId}/users`,
);
return data;
}
export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> {
await apiClient.post(`/v1/organisations/${organizationId}/users/invite`, request);
await apiClient.post(`/v1/organisation/${organizationId}/users/invite`, request);
}
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
await apiClient.delete(`/v1/organisation/${organizationId}/user/${userId}`);
}
export async function inviteOrganizationMembersByEmail(
@@ -58,7 +58,7 @@ export async function inviteOrganizationMembersByEmail(
request: InviteUsersByEmailRequest,
): Promise<InviteByEmailResult[]> {
const { data } = await apiClient.post<InviteByEmailResult[]>(
`/v1/organisations/${organizationId}/users/invite-by-email`,
`/v1/organisation/${organizationId}/users/invite-by-email`,
request,
);
return data;
@@ -66,14 +66,14 @@ export async function inviteOrganizationMembersByEmail(
export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.get<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link`,
`/v1/organisation/${organizationId}/invite-link`,
);
return data;
}
export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.post<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link/regenerate`,
`/v1/organisation/${organizationId}/invite-link/regenerate`,
);
return data;
}
@@ -83,5 +83,5 @@ export async function acceptInvite(token: string): Promise<void> {
}
export async function setPrimaryOrganization(id: number): Promise<void> {
await apiClient.put(`/v1/organisations/${id}/primary`);
await apiClient.put(`/v1/organisation/${id}/primary`);
}

View File

@@ -17,7 +17,7 @@ export async function listProjects(organizationId: number, page = 1, pageSize =
}
export async function getProject(id: number): Promise<ProjectResponse> {
const { data } = await apiClient.get<ProjectResponse>(`/v1/projects/${id}`);
const { data } = await apiClient.get<ProjectResponse>(`/v1/project/${id}`);
return data;
}
@@ -27,17 +27,17 @@ export async function createProject(request: CreateProjectRequest): Promise<Proj
}
export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.put<ProjectResponse>(`/v1/projects/${id}`, request);
const { data } = await apiClient.put<ProjectResponse>(`/v1/project/${id}`, request);
return data;
}
export async function deleteProject(id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${id}`);
await apiClient.delete(`/v1/project/${id}`);
}
export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> {
const { data } = await apiClient.get<UserPermissionResponse[]>(
`/v1/projects/${projectId}/user-permissions`,
`/v1/project/${projectId}/user-permissions`,
);
return data;
}
@@ -47,7 +47,7 @@ export async function setProjectUserPermissions(
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.post<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions`,
`/v1/project/${projectId}/user-permissions`,
request,
);
return data;
@@ -59,12 +59,12 @@ export async function updateProjectUserPermissions(
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.put<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions/${userId}`,
`/v1/project/${projectId}/user-permission/${userId}`,
request,
);
return data;
}
export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/user-permissions/${userId}`);
await apiClient.delete(`/v1/project/${projectId}/user-permission/${userId}`);
}

View File

@@ -8,7 +8,7 @@ import type {
export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>(
`/v1/projects/${projectId}/segments`,
`/v1/project/${projectId}/segments`,
{ params: { page, pageSize } },
);
return data.results;
@@ -16,7 +16,7 @@ export async function listSegments(projectId: number, page = 1, pageSize = 100):
export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> {
const { data } = await apiClient.get<SegmentResponse>(
`/v1/projects/${projectId}/segments/${id}`,
`/v1/project/${projectId}/segment/${id}`,
);
return data;
}
@@ -26,7 +26,7 @@ export async function createSegment(
request: CreateSegmentRequest,
): Promise<SegmentResponse> {
const { data } = await apiClient.post<SegmentResponse>(
`/v1/projects/${projectId}/segments`,
`/v1/project/${projectId}/segments`,
request,
);
return data;
@@ -38,12 +38,12 @@ export async function updateSegment(
request: UpdateSegmentRequest,
): Promise<SegmentResponse> {
const { data } = await apiClient.put<SegmentResponse>(
`/v1/projects/${projectId}/segments/${id}`,
`/v1/project/${projectId}/segment/${id}`,
request,
);
return data;
}
export async function deleteSegment(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/segments/${id}`);
await apiClient.delete(`/v1/project/${projectId}/segment/${id}`);
}

View File

@@ -2,15 +2,15 @@ import apiClient from './client';
import type { TagResponse, CreateTagRequest } from '@/types/api';
export async function listTags(projectId: number): Promise<TagResponse[]> {
const { data } = await apiClient.get<TagResponse[]>(`/v1/projects/${projectId}/tags`);
const { data } = await apiClient.get<TagResponse[]>(`/v1/project/${projectId}/tags`);
return data;
}
export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> {
const { data } = await apiClient.post<TagResponse>(`/v1/projects/${projectId}/tags`, request);
const { data } = await apiClient.post<TagResponse>(`/v1/project/${projectId}/tags`, request);
return data;
}
export async function deleteTag(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`);
await apiClient.delete(`/v1/project/${projectId}/tag/${id}`);
}

View File

@@ -2,6 +2,6 @@ import apiClient from './client';
import type { DashboardUsageResponse } from '@/types/api';
export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> {
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environments/${environmentId}/usage`, { params: { days } });
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environment/${environmentId}/usage`, { params: { days } });
return data;
}

View File

@@ -2,20 +2,20 @@ import apiClient from './client';
import type { UserProfileResponse, UpdateProfileRequest, ChangePasswordRequest } from '@/types/api';
export async function getMe(): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>('/v1/users/me');
const { data } = await apiClient.get<UserProfileResponse>('/v1/user/me');
return data;
}
export async function updateMe(request: UpdateProfileRequest): Promise<UserProfileResponse> {
const { data } = await apiClient.put<UserProfileResponse>('/v1/users/me', request);
const { data } = await apiClient.put<UserProfileResponse>('/v1/user/me', request);
return data;
}
export async function changePassword(request: ChangePasswordRequest): Promise<void> {
await apiClient.post('/v1/users/me/change-password', request);
await apiClient.post('/v1/user/me/change-password', request);
}
export async function getUserById(id: number): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>(`/v1/users/${id}`);
const { data } = await apiClient.get<UserProfileResponse>(`/v1/user/${id}`);
return data;
}

View File

@@ -8,7 +8,7 @@ import type {
// ─── Organization webhooks ────────────────────────────────────────────────────
export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisations/${orgId}/webhooks`);
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisation/${orgId}/webhooks`);
return data;
}
@@ -17,7 +17,7 @@ export async function createOrgWebhook(
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>(
`/v1/organisations/${orgId}/webhooks`,
`/v1/organisation/${orgId}/webhooks`,
request,
);
return data;
@@ -29,21 +29,21 @@ export async function updateOrgWebhook(
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>(
`/v1/organisations/${orgId}/webhooks/${webhookId}`,
`/v1/organisation/${orgId}/webhook/${webhookId}`,
request,
);
return data;
}
export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`);
await apiClient.delete(`/v1/organisation/${orgId}/webhook/${webhookId}`);
}
// ─── Environment webhooks ─────────────────────────────────────────────────────
export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(
`/v1/environments/${envApiKey}/webhooks`,
`/v1/environment/${envApiKey}/webhooks`,
);
return data;
}
@@ -53,7 +53,7 @@ export async function createEnvWebhook(
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>(
`/v1/environments/${envApiKey}/webhooks`,
`/v1/environment/${envApiKey}/webhooks`,
request,
);
return data;
@@ -65,14 +65,14 @@ export async function updateEnvWebhook(
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>(
`/v1/environments/${envApiKey}/webhooks/${webhookId}`,
`/v1/environment/${envApiKey}/webhook/${webhookId}`,
request,
);
return data;
}
export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`);
await apiClient.delete(`/v1/environment/${envApiKey}/webhook/${webhookId}`);
}
// ─── Delivery logs ────────────────────────────────────────────────────────────
@@ -82,7 +82,7 @@ export async function listWebhookDeliveries(
webhookId: number,
): Promise<WebhookDeliveryLogResponse[]> {
const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>(
`/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`,
`/v1/environment/${envApiKey}/webhook/${webhookId}/deliveries`,
);
return data;
}

View File

@@ -95,7 +95,7 @@
<v-divider class="my-5" />
<!-- Tags -->
<TagsChipInput />
<TagsChipInput v-if="feature" :feature="feature" @updated="onTagsUpdated" />
</template>
</v-tabs-window-item>
@@ -274,12 +274,22 @@
data-testid="settings-default-enabled-toggle"
/>
</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">
<v-btn
color="error"
variant="text"
size="small"
:loading="isDeleting"
:disabled="deleteConfirmName !== feature?.name"
data-testid="delete-feature-btn"
@click="onDelete"
>
@@ -440,6 +450,8 @@ const settingsForm = ref({
defaultEnabled: false,
});
const deleteConfirmName = ref('');
const rules = {
required: (v: string) => !!v || 'Required',
nameFormat: (v: string) =>
@@ -481,6 +493,7 @@ watch(
newSegmentId.value = null;
newSegmentEnabled.value = true;
newSegmentPriority.value = 1;
deleteConfirmName.value = '';
syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null;
@@ -558,6 +571,10 @@ async function onSaveSettings(): Promise<void> {
}
}
function onTagsUpdated(updated: FeatureResponse): void {
emit('updated', updated);
}
async function onDelete(): Promise<void> {
if (!props.feature || deleteConfirmName.value !== props.feature.name) return;
const projectId = contextStore.currentProject?.id;

View File

@@ -1,19 +1,6 @@
<template>
<div data-testid="tags-chip-input">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 font-weight-medium">Project Tags</span>
<v-btn
size="x-small"
variant="tonal"
color="primary"
prepend-icon="ri-add-line"
:loading="isCreating"
data-testid="create-tag-btn"
@click="showCreateForm = !showCreateForm"
>
New tag
</v-btn>
</div>
<span class="text-body-2 font-weight-medium">Tags</span>
<v-alert
v-if="errorMessage"
@@ -21,114 +8,167 @@
variant="tonal"
density="compact"
closable
class="mb-2"
class="my-2"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<!-- Create form -->
<v-expand-transition>
<v-card v-if="showCreateForm" variant="outlined" rounded="lg" class="mb-3 pa-3">
<v-form ref="createFormRef" @submit.prevent="onCreateTag">
<div class="d-flex align-center ga-2">
<v-text-field
ref="tagLabelInputRef"
v-model="newTagLabel"
label="Tag name"
variant="outlined"
density="compact"
hide-details
:rules="[rules.required]"
data-testid="new-tag-label-input"
/>
<input
v-model="newTagColor"
type="color"
style="width:36px;height:36px;border:none;padding:2px;cursor:pointer;border-radius:4px"
title="Tag colour"
data-testid="new-tag-color-input"
@blur="focusTagLabel"
/>
<v-btn
icon="ri-check-line"
size="small"
color="primary"
variant="flat"
type="submit"
:loading="isCreating"
data-testid="confirm-create-tag-btn"
@click.prevent="onCreateTag"
/>
<v-btn
icon="ri-close-line"
size="small"
variant="text"
@click="showCreateForm = false"
/>
</div>
</v-form>
</v-card>
</v-expand-transition>
<!-- Tag chips -->
<div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" />
</div>
<div v-else-if="tags.length > 0" class="d-flex flex-wrap ga-1" data-testid="tag-chips">
<v-chip
v-for="tag in tags"
:key="tag.id"
size="small"
closable
:color="tag.color"
:data-testid="`tag-chip-${tag.label}`"
@click:close="onDeleteTag(tag)"
>
{{ tag.label }}
</v-chip>
</div>
<template v-else>
<div v-if="assignedTags.length > 0" class="d-flex flex-wrap ga-1 my-2" data-testid="tag-chips">
<v-chip
v-for="tag in assignedTags"
:key="tag.id"
size="small"
variant="flat"
closable
:color="tag.color"
:loading="togglingTagId === tag.id"
:data-testid="`tag-chip-${tag.label}`"
@click:close="onRemoveTag(tag)"
>
{{ tag.label }}
</v-chip>
</div>
<p v-else class="text-body-2 text-medium-emphasis my-2" data-testid="tags-empty-message">
No tags assigned. Type a name below to add one.
</p>
<p
v-else
class="text-body-2 text-medium-emphasis"
data-testid="tags-empty-message"
>
No tags yet. Create one to organise your features.
</p>
<div class="d-flex align-center ga-2">
<v-combobox
v-model="newTagLabel"
:items="suggestions"
label="Tag name"
variant="outlined"
density="compact"
hide-details
hide-no-data
no-filter
data-testid="tag-name-input"
@keyup.enter="onAddTag"
/>
<button
type="button"
style="width:36px;height:36px;border:none;padding:0;cursor:pointer;border-radius:4px"
:style="{ background: newTagColor }"
title="Tag Color"
data-testid="tag-color-swatch"
@click="showColorDialog = true"
/>
<v-btn
icon="ri-add-line"
size="small"
color="primary"
variant="flat"
:disabled="!newTagLabel.trim()"
:loading="isSubmitting"
data-testid="add-tag-btn"
@click="onAddTag"
/>
</div>
</template>
<!-- Color picker dialog -->
<v-dialog v-model="showColorDialog" max-width="260" data-testid="color-dialog">
<v-card rounded="lg">
<v-card-title class="pa-4 pb-2 text-body-1">Tag Color</v-card-title>
<v-card-text class="pa-4 pt-0">
<div class="d-flex flex-wrap ga-2">
<button
v-for="color in presetColors"
:key="color"
type="button"
style="width:32px;height:32px;border:none;padding:0;cursor:pointer;border-radius:4px"
:style="{ background: color }"
:title="color"
:data-testid="`preset-color-${color}`"
@click="onSelectPresetColor(color)"
/>
<div style="position:relative;width:32px;height:32px">
<button
type="button"
class="d-flex align-center justify-center"
style="width:32px;height:32px;border:1px solid rgba(0,0,0,0.2);padding:0;cursor:pointer;border-radius:4px;background:conic-gradient(red,yellow,lime,aqua,blue,magenta,red)"
title="Custom Color"
data-testid="custom-color-trigger"
@click="customColorInputRef?.click()"
>
<v-icon size="16" color="white">ri-palette-line</v-icon>
</button>
<input
ref="customColorInputRef"
v-model="newTagColor"
type="color"
style="position:absolute;inset:0;width:32px;height:32px;opacity:0;pointer-events:none"
data-testid="custom-color-input"
@input="showColorDialog = false"
/>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags';
import { ref, computed, onMounted } from 'vue';
import { listTags, createTag } from '@/api/tags';
import { assignFeatureTag, removeFeatureTag } from '@/api/features';
import { useContextStore } from '@/stores/context';
import type { TagResponse } from '@/types/api';
import type { TagResponse, FeatureResponse } from '@/types/api';
const presetColors = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#795548',
];
interface Props {
feature: FeatureResponse;
}
const props = defineProps<Props>();
const emit = defineEmits<{
updated: [feature: FeatureResponse];
}>();
const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]);
const isLoading = ref(false);
const isCreating = ref(false);
const showCreateForm = ref(false);
const isSubmitting = ref(false);
const togglingTagId = ref<number | null>(null);
const newTagLabel = ref('');
const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const tagLabelInputRef = ref<{ focus: () => void } | null>(null);
const showColorDialog = ref(false);
const customColorInputRef = ref<HTMLInputElement | null>(null);
function focusTagLabel(): void {
nextTick(() => tagLabelInputRef.value?.focus());
function onSelectPresetColor(color: string): void {
newTagColor.value = color;
showColorDialog.value = false;
}
watch(showCreateForm, (open) => {
if (open) focusTagLabel();
const assignedTags = computed(() => props.feature.tags);
const suggestions = computed(() => {
const query = newTagLabel.value.trim().toLowerCase();
if (query.length < 2) return [];
const assignedIds = new Set(assignedTags.value.map((t) => t.id));
return tags.value
.filter((t) => !assignedIds.has(t.id) && t.label.toLowerCase().includes(query))
.map((t) => t.label);
});
const rules = {
required: (v: string) => !!v || 'Tag name is required',
};
function isAssigned(tag: TagResponse): boolean {
return assignedTags.value.some((t) => t.id === tag.id);
}
async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id;
@@ -144,41 +184,46 @@ async function fetchTags(): Promise<void> {
}
}
async function onCreateTag(): Promise<void> {
if (!createFormRef.value) return;
const { valid } = await createFormRef.value.validate();
if (!valid) return;
async function onAddTag(): Promise<void> {
const label = newTagLabel.value.trim();
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
if (!label || !projectId) return;
isCreating.value = true;
errorMessage.value = null;
isSubmitting.value = true;
try {
const created = await createTag(projectId, {
label: newTagLabel.value.trim(),
color: newTagColor.value,
});
tags.value.push(created);
let tag = tags.value.find((t) => t.label.toLowerCase() === label.toLowerCase());
if (!tag) {
tag = await createTag(projectId, { label, color: newTagColor.value });
tags.value.push(tag);
}
if (!isAssigned(tag)) {
togglingTagId.value = tag.id;
const updated = await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
}
newTagLabel.value = '';
newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch {
errorMessage.value = 'Failed to create tag. Please try again.';
errorMessage.value = 'Failed to add tag. Please try again.';
} finally {
isCreating.value = false;
isSubmitting.value = false;
togglingTagId.value = null;
}
}
async function onDeleteTag(tag: TagResponse): Promise<void> {
async function onRemoveTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
errorMessage.value = null;
togglingTagId.value = tag.id;
try {
await deleteTag(projectId, tag.id);
tags.value = tags.value.filter((t) => t.id !== tag.id);
const updated = await removeFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
} catch {
errorMessage.value = 'Failed to delete tag. Please try again.';
errorMessage.value = 'Failed to remove tag. Please try again.';
} finally {
togglingTagId.value = null;
}
}

View File

@@ -0,0 +1,164 @@
<template>
<div data-testid="org-header-switcher">
<v-menu data-testid="org-header-menu">
<template #activator="{ props: menuProps }">
<a
href="#"
class="org-header-link text-body-2 font-weight-medium d-flex align-center"
data-testid="org-header-link"
v-bind="menuProps"
@click.prevent
>
<VIcon icon="ri-building-line" size="18" class="me-1" />
{{ contextStore.currentOrganization?.name ?? 'Select organization' }}
<VIcon icon="ri-arrow-down-s-line" size="18" />
</a>
</template>
<v-list density="compact" data-testid="org-header-list">
<v-list-item
v-for="org in organizations"
:key="org.id"
:title="org.name"
:active="org.id === contextStore.currentOrganization?.id"
:data-testid="`org-header-item-${org.id}`"
@click="onOrgSelected(org)"
/>
<v-divider v-if="organizations.length" class="my-1" />
<v-list-item
title="New Organization"
prepend-icon="ri-add-line"
data-testid="org-header-create-item"
@click="openCreateDialog"
/>
</v-list>
</v-menu>
<!-- Create Organization Dialog -->
<v-dialog v-model="showDialog" max-width="420" data-testid="create-org-dialog">
<v-card rounded="lg">
<v-card-title class="pa-5 pb-3">Create organization</v-card-title>
<v-card-text class="pa-5 pt-0">
<v-text-field
v-model="newOrgName"
label="Organization name"
variant="outlined"
density="compact"
autofocus
:error-messages="createError ? [createError] : []"
data-testid="org-name-input"
@keydown.enter="onCreateConfirm"
/>
</v-card-text>
<v-card-actions class="pa-5 pt-0">
<v-spacer />
<v-btn variant="text" data-testid="create-org-cancel-btn" @click="closeDialog">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
:disabled="!newOrgName.trim()"
data-testid="create-org-confirm-btn"
@click="onCreateConfirm"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useContextStore } from '@/stores/context';
import { listOrganizations, createOrganization, setPrimaryOrganization } from '@/api/organizations';
import type { OrganizationResponse } from '@/types/api';
const contextStore = useContextStore();
const organizations = ref<OrganizationResponse[]>([]);
const showDialog = ref(false);
const newOrgName = ref('');
const isSaving = ref(false);
const createError = ref('');
async function fetchOrganizations(): Promise<void> {
try {
organizations.value = await listOrganizations();
autoSelectOrganization();
} catch {
organizations.value = [];
}
}
function autoSelectOrganization(): void {
if (contextStore.currentOrganization) {
// Refresh stored org with latest data from API (name may have changed)
const fresh = organizations.value.find(o => o.id === contextStore.currentOrganization!.id);
if (fresh) contextStore.refreshOrganization(fresh);
return;
}
const primary = organizations.value.find(o => o.isPrimary);
if (primary) {
contextStore.setOrganization(primary);
return;
}
if (organizations.value.length === 1) {
contextStore.setOrganization(organizations.value[0]);
}
}
async function onOrgSelected(org: OrganizationResponse): Promise<void> {
contextStore.setOrganization(org);
try {
await setPrimaryOrganization(org.id);
organizations.value = organizations.value.map(o => ({ ...o, isPrimary: o.id === org.id }));
} catch {
// Non-critical — selection is already saved to localStorage
}
}
function openCreateDialog(): void {
newOrgName.value = '';
createError.value = '';
showDialog.value = true;
}
function closeDialog(): void {
showDialog.value = false;
}
async function onCreateConfirm(): Promise<void> {
const name = newOrgName.value.trim();
if (!name) return;
isSaving.value = true;
createError.value = '';
try {
const created = await createOrganization({ name });
organizations.value = [...organizations.value, created];
contextStore.setOrganization(created);
closeDialog();
} catch {
createError.value = 'Failed to create organization. Please try again.';
} finally {
isSaving.value = false;
}
}
onMounted(fetchOrganizations);
</script>
<style lang="scss" scoped>
.org-header-link {
text-decoration: none;
color: inherit;
white-space: nowrap;
}
</style>

View File

@@ -3,6 +3,7 @@ import NavItems from '@/layouts/components/NavItems.vue';
import Footer from '@/layouts/components/Footer.vue';
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue';
import UserProfile from '@/layouts/components/UserProfile.vue';
import OrgHeaderSwitcher from '@/components/nav/OrgHeaderSwitcher.vue';
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
</script>
@@ -16,6 +17,7 @@ import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
<VSpacer />
<OrgHeaderSwitcher class="me-4" />
<NavbarThemeSwitcher class="me-1" />
<UserProfile />
</div>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue';
import VerticalNavSectionTitle from '@layouts/components/VerticalNavSectionTitle.vue';
import OrgSelector from '@/components/nav/OrgSelector.vue';
import ProjectSelector from '@/components/nav/ProjectSelector.vue';
import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
</script>
@@ -15,7 +14,6 @@ import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
<VerticalNavSectionTitle :item="{ heading: 'Context' }" />
<div class="nav-context-selectors px-4 d-flex flex-column ga-3">
<OrgSelector />
<ProjectSelector />
<EnvironmentTabBar />
</div>

View File

@@ -4,6 +4,7 @@ import VueApexCharts from 'vue3-apexcharts';
import App from './App.vue';
// Styles
import '@fontsource-variable/inter';
import '@core/scss/template/index.scss';
import '@layouts/styles/index.scss';
import router from './router';

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';
const routes: RouteRecordRaw[] = [
@@ -102,7 +102,7 @@ const router = createRouter({
// ─── Auth Guard ───────────────────────────────────────────────────────────────
router.beforeEach((to) => {
export function authGuard(to: RouteLocationNormalized) {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isPublic = to.matched.some((record) => record.meta.public);
@@ -116,6 +116,8 @@ router.beforeEach((to) => {
}
return true;
});
}
router.beforeEach(authGuard);
export default router;

View File

@@ -215,6 +215,7 @@ export interface FeatureResponse {
defaultEnabled: boolean;
projectId: number;
createdAt: string;
tags: TagResponse[];
}
export interface CreateFeatureRequest {

View File

@@ -53,7 +53,7 @@
<!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }">
<div>
<div class="d-flex align-center ga-1">
<div class="d-flex flex-wrap align-center ga-1">
<span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span>
<v-tooltip
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
@@ -72,6 +72,21 @@
/>
</template>
</v-tooltip>
<v-chip
v-for="tag in (item.tags ?? []).slice(0, 5)"
:key="tag.id"
size="x-small"
variant="flat"
:color="tag.color"
:data-testid="`feature-tag-chip-${item.id}-${tag.id}`"
>
{{ tag.label }}
</v-chip>
<span
v-if="(item.tags ?? []).length > 5"
class="text-caption text-medium-emphasis"
:title="`${(item.tags ?? []).length - 5} more`"
></span>
</div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }}

View File

@@ -1,51 +1,274 @@
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
// a real HTTP stack. These tests focus on the exported token-management helpers
// that the interceptors and auth store both depend on.
// The response interceptor drives its retry/refresh flow entirely through the
// apiClient axios instance, so we replace its transport (`adapter`) with a
// 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(() => {
clearAuthTokens();
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(() => {
clearAuthTokens();
window.location = originalLocation;
});
describe('WhenTokensAreSet', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
setAuthTokens('access-abc', 'refresh-xyz');
describe('Token management', () => {
describe('WhenTokensAreSet', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
setAuthTokens('access-abc', 'refresh-xyz');
expect(getAccessToken()).toBe('access-abc');
expect(getAccessToken()).toBe('access-abc');
});
});
describe('WhenTokensAreCleared', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens('access-abc', 'refresh-xyz');
clearAuthTokens();
expect(getAccessToken()).toBeNull();
});
});
describe('WhenSetAuthTokensIsCalledWithNull', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens(null, null);
expect(getAccessToken()).toBeNull();
});
});
describe('WhenTokensAreUpdated', () => {
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
setAuthTokens('first-token', 'first-refresh');
setAuthTokens('second-token', 'second-refresh');
expect(getAccessToken()).toBe('second-token');
});
});
});
describe('WhenTokensAreCleared', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens('access-abc', 'refresh-xyz');
clearAuthTokens();
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;
expect(getAccessToken()).toBeNull();
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('WhenSetAuthTokensIsCalledWithNull', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens(null, null);
describe('Response interceptor - 401 refresh flow', () => {
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
expect(getAccessToken()).toBeNull();
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('WhenTokensAreUpdated', () => {
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
setAuthTokens('first-token', 'first-refresh');
setAuthTokens('second-token', 'second-refresh');
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');
expect(getAccessToken()).toBe('second-token');
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

@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { mount, flushPromises, DOMWrapper, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import TagsChipInput from '@/components/features/TagsChipInput.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, TagResponse } from '@/types/api';
import type { ProjectResponse, TagResponse, FeatureResponse } from '@/types/api';
jest.mock('@/api/tags');
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
@@ -14,18 +15,25 @@ jest.mock('@/api/client', () => ({
}));
import * as tagsApi from '@/api/tags';
import * as featuresApi from '@/api/features';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockTags: TagResponse[] = [
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
{ id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
];
const backendTag: TagResponse = { id: 1, label: 'backend', color: '#ff0000', projectId: 10 };
const frontendTag: TagResponse = { id: 2, label: 'frontend', color: '#0000ff', projectId: 10 };
const mockTags: TagResponse[] = [backendTag, frontendTag];
function mountComponent() {
function mockFeature(tags: TagResponse[]): FeatureResponse {
return {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: null,
defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags,
};
}
function mountComponent(feature: FeatureResponse) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(TagsChipInput, { global: { plugins: [router] } });
return mount(TagsChipInput, { props: { feature }, global: { plugins: [router] } });
}
describe('TagsChipInput', () => {
@@ -40,100 +48,173 @@ describe('TagsChipInput', () => {
describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent();
mountComponent(mockFeature([]));
await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
});
it('ThenExistingTagChipsAreRendered', async () => {
it('ThenAssignedTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent();
const wrapper = mountComponent(mockFeature([backendTag]));
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(false);
});
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const wrapper = mountComponent();
it('ThenEmptyMessageIsShownWhenNoTagsAssigned', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
});
});
describe('WhenCreateTagIsSubmitted', () => {
it('ThenCreateTagApiIsCalled', async () => {
describe('WhenAddingANewTagName', () => {
it('ThenCreateTagAndAssignAreBothCalled', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
const updatedFeature = mockFeature([newTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent();
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
// Open the create form
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// Fill in the label
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
// Submit
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ label: 'api' }),
);
expect(tagsApi.createTag).toHaveBeenCalledWith(mockProject.id, expect.objectContaining({ label: 'api' }));
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, newTag.id);
});
it('ThenNewTagAppearsInTheList', async () => {
it('ThenUpdatedEventIsEmitted', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const updatedFeature = mockFeature([newTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent();
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
});
});
describe('WhenTagChipIsDeleted', () => {
it('ThenDeleteTagApiIsCalled', async () => {
describe('WhenTypedNameMatchesAnExistingTag', () => {
it('ThenExistingTagIsAssignedInsteadOfCreatingANewOne', async () => {
const updatedFeature = mockFeature([backendTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent();
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'backend');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises();
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
expect(tagsApi.createTag).not.toHaveBeenCalled();
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
});
});
describe('WhenTypingTwoOrMoreCharacters', () => {
it('ThenMatchingUnassignedTagsAreSuggested', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'ba');
await wrapper.vm.$nextTick();
expect(input.props('items')).toEqual(['backend']);
});
it('ThenDeletedTagIsRemovedFromTheList', async () => {
it('ThenNoSuggestionsAreShownBelowTwoCharacters', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'b');
await wrapper.vm.$nextTick();
expect(input.props('items')).toEqual([]);
});
});
describe('WhenChoosingATagColour', () => {
it('ThenSwatchClickOpensTheColourDialog', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
expect(wrapper.find('[data-testid="color-dialog"]').exists()).toBe(false);
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
expect(wrapper.findComponent({ name: 'VDialog' }).props('modelValue')).toBe(true);
});
it('ThenFifteenPresetColoursAreOffered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
const presets = new DOMWrapper(document.body).findAll('[data-testid^="preset-color-"]');
const distinctColors = new Set(presets.map((p) => p.attributes('data-testid')));
expect(distinctColors.size).toBe(15);
});
it('ThenSelectingAPresetUpdatesTheSwatchAndClosesTheDialog', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
const presets = new DOMWrapper(document.body).findAll('[data-testid="preset-color-#4CAF50"]');
await presets[presets.length - 1].trigger('click');
await wrapper.vm.$nextTick();
await flushPromises();
expect(wrapper.find('[data-testid="tag-color-swatch"]').attributes('style')).toContain('background: rgb(76, 175, 80)');
});
});
describe('WhenTagChipIsClosed', () => {
it('ThenRemoveFeatureTagApiIsCalled', async () => {
const updatedFeature = mockFeature([]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(featuresApi.removeFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([backendTag]));
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false);
expect(featuresApi.removeFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
});
});
});

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 { createRouter, createMemoryHistory } from 'vue-router';
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
const Stub = { template: '<div />' };
@@ -15,11 +16,17 @@ jest.mock('@/api/client', () => ({
import { getAccessToken } from '@/api/client';
function buildRouter() {
return createRouter({
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', name: 'Login', 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: '/',
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', () => {
@@ -44,21 +54,10 @@ describe('Router auth guard', () => {
});
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenTheyAreRedirectedToLogin', async () => {
it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
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');
expect(router.currentRoute.value.name).toBe('Login');
@@ -71,17 +70,6 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
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('/login');
@@ -94,19 +82,55 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
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');
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

@@ -4,7 +4,7 @@ import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import FeaturesView from '@/views/FeaturesView.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse, TagResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
@@ -25,9 +25,13 @@ const mockEnv: EnvironmentResponse = {
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeatures: FeatureResponse[] = [
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] },
];
function makeTag(id: number, label: string): TagResponse {
return { id, label, color: '#1565C0', projectId: 10, createdAt: '2026-01-01T00:00:00Z' };
}
const mockStates: FeatureStateResponse[] = [
{ id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
{ id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
@@ -85,6 +89,43 @@ describe('FeaturesView', () => {
});
});
describe('WhenFeatureHasTags', () => {
it('ThenUpToFiveTagChipsAreShownAndExtraAreEllipsised', async () => {
const sixTags = [1, 2, 3, 4, 5, 6].map((id) => makeTag(id, `tag-${id}`));
const taggedFeature: FeatureResponse = { ...mockFeatures[0], tags: sixTags };
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [taggedFeature] });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
const chips = sixTags.slice(0, 5).map((tag) => wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-${tag.id}"]`));
chips.forEach((chip) => expect(chip.exists()).toBe(true));
expect(wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-6"]`).exists()).toBe(false);
expect(wrapper.text()).toContain('…');
});
it('ThenMissingTagsDoesNotThrow', async () => {
const untaggedFeature = { ...mockFeatures[0], tags: undefined } as unknown as FeatureResponse;
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [untaggedFeature] });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
expect(wrapper.text()).toContain(untaggedFeature.name);
});
});
describe('WhenToggleEnabledIsClicked', () => {
it('ThenPatchFeatureStateIsCalled', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });

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 { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue';
@@ -129,7 +129,8 @@ describe('SegmentsView', () => {
const wrapper = mountView();
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();
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
@@ -154,13 +155,6 @@ describe('SegmentsView', () => {
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 flushPromises();
@@ -181,9 +175,6 @@ describe('SegmentsView', () => {
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
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 flushPromises();

View File

@@ -102,9 +102,10 @@ describe('SettingsView', () => {
});
describe('WhenInviteMemberIsClicked', () => {
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
{ email: 'jane@example.com', success: true, error: null },
]);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
@@ -112,13 +113,19 @@ describe('SettingsView', () => {
const wrapper = mountView();
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.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();
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);
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();
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
@@ -261,6 +270,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick();
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();
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.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 flushPromises();
@@ -471,6 +479,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick();
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();
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);

View File

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

View File

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

View File

@@ -12,48 +12,40 @@ namespace MicCheck.Api.Audit;
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
[Route("api/v1")]
public class AuditLogsController(
AuditLogQueryService auditLogQueryService,
OrganizationService organizationService,
ProjectService projectService,
EnvironmentService environmentService) : ControllerBase
{
[HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
[HttpGet("organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
}
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
[HttpGet("project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
}
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
[HttpGet("environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
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;
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()
{
@@ -12,7 +12,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public virtual async Task RecordAsync(
public async Task RecordAsync(
string resourceType,
string resourceId,
string action,
@@ -33,7 +33,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
}, JsonOptions);
}
var actorUserId = ResolveActorUserId();
var actorUserId = GetCurrentUserId();
var log = new AuditLog
{
@@ -68,7 +68,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
}
// Backward-compatible overload used by existing callers
public virtual async Task LogAsync(
public async Task LogAsync(
string resourceType,
string resourceId,
string action,
@@ -78,7 +78,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
string? changes = null,
CancellationToken ct = default)
{
var actorUserId = ResolveActorUserId();
var actorUserId = GetCurrentUserId();
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;
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;
public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);
public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);

View File

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

View File

@@ -1,33 +1,34 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints
{
public static void MapApiKeyEndpoints(this WebApplication app)
{
var group = app
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
.MapGroup("/api/v1/organisation/{organizationId:int}")
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
.WithTags("ApiKeys");
group.MapPost("/", 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(
organizationId, request.Name, request.ExpiresAt, ct);
var result = await apiKeyService.CreateAsync(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");
group.MapGet("/", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) =>
group.MapGet("/api-keys", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) =>
{
var keys = await apiKeyService.ListAsync(organizationId, ct);
return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
}).WithName("ListApiKeys");
group.MapDelete("/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
group.MapDelete("/api-key/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
{
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
return Results.NoContent();

View File

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

View File

@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
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)
{
var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(MicCheckDbContext db)
db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct);
return (apiKey, rawKey);
return new ApiKeyCreationResult(apiKey, rawKey);
}
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,3 +48,5 @@ public class ApiKeyService(MicCheckDbContext db)
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;
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)
{
public const string SchemeName = "ApiKey";

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
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)
{
public const string SchemeName = "EnvironmentKey";

View File

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

View File

@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService(
MicCheckDbContext db,
IMicCheckDbContext db,
ITokenService tokenService,
IPasswordHasher<User> passwordHasher)
{
@@ -81,7 +81,9 @@ public class AuthService(
});
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 refreshTokenValue = GenerateSecureToken();

View File

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

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirementHandler(
MicCheckDbContext db,
IMicCheckDbContext db,
IHttpContextAccessor httpContextAccessor)
: 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;
}
}

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