Compare commits

..

15 Commits

Author SHA1 Message Date
James Wampler
a1de374d22 Move org switcher to header, self-host Inter, add feature tags, normalize API route casing
- Replace sidebar org dropdown with a header link/menu (left of theme toggle) that
  includes a "New Organization" entry opening the create dialog
- Self-host Inter via @fontsource-variable/inter instead of relying on system fonts
- Wire up real feature<->tag assignment (was UI-only before): expose Tags on
  FeatureResponse, add assign/remove endpoints, make tag chips in the feature
  detail dialog toggle assignment, and show up to 5 tags (+ellipsis) in the
  features table next to the segment-override icon
- Normalize all API routes to singular/plural REST convention: singular resource
  name when addressing one item by id/key (e.g. /feature/{id}), plural for
  list/create endpoints (e.g. /features). Updated every controller, the admin
  frontend API clients, and affected tests to match
2026-06-30 13:36:18 -07:00
James Wampler
5179f5479c Add feature-usage tracking and dashboard charts
Instruments FeatureEvaluationService with System.Diagnostics.Metrics (Counter + IMeterFactory). Accumulates per-feature/day evaluation counts in-process, flushed every 30s to a new FeatureUsageDaily Postgres table via PostgreSQL ON CONFLICT upsert. Caches dashboard query results for 180s in IMemoryCache. New GET /api/v1/environments/{id}/usage endpoint returns top-10 features last day and 14-day per-day breakdown. Admin dashboard shows two ApexCharts bar charts: top features and evaluations by day with per-flag hover tooltip.
2026-06-30 11:45:35 -07:00
James Wampler
5dcd39f564 Fix identity trait add and persistence bugs
Remove Key from UpsertTraitRequest body (comes from URL route). Emit 'updated' event from IdentityDetail on trait mutations and sync parent. Reload segment data on tab activation and dialog open.
2026-06-30 11:45:27 -07:00
James Wampler
5506151f19 Fix v-btn-toggle overlap in dialogs; add segment override icon to features grid
Replace v-btn-toggle with manual button pairs in RuleGroupEditor and
FeatureValueEditor — VSlideGroup (used internally) measures zero width
inside teleported dialogs, causing buttons to overlap and show scroll arrows.

Add donut chart icon next to feature names in the features grid when segment
overrides exist; clicking opens FeatureDetail directly on the Segments tab.
2026-06-30 11:45:17 -07:00
James Wampler
47f8ab19bb Wrap long method signatures and remove unused using to comply with 220-char line limit 2026-06-30 11:45:08 -07:00
James Wampler
c57a13b350 Persist and restore selected project and environment across page reloads
- Add refreshOrganization/refreshProject/refreshEnvironment to context store — update value and localStorage without clearing child selections
- OrgSelector: use refreshOrganization when same org is already stored, preventing the chain that was clearing project and environment
- ProjectSelector: watch org ID (not object ref) so refresh doesn't retrigger; add autoSelectProject to restore stored project or auto-select single project
- EnvironmentTabBar: watch project ID; skip env-clear on initial mount; add autoSelectEnvironment to restore stored env or fall back to first
- Fix test fixtures missing isPrimary field; update EnvironmentTabBar tests to match v-select implementation; add tests for restore behavior
2026-06-30 11:44:55 -07:00
James Wampler
1fddec7281 Add primary organization flag with auto-selection on dashboard
Adds IsPrimary to OrganizationUser (per-user flag, one primary per user).
First org created is automatically set as primary. Selecting a different
org via the UI calls PUT /organisations/{id}/primary to persist the choice
server-side. Dashboard auto-selects: localStorage org (refreshed) →
primary org → single org when no explicit selection exists.
2026-06-30 11:44:43 -07:00
James Wampler
f2cc5245bc Fix register form layout and validation shift
Fix first/last name field overlap by using no-gutters row with
responsive padding. Override global hideDetails:'auto' Vuetify default
with :hide-details="false" so the details area is always rendered,
preventing 22px layout shift when validation messages appear.
2026-06-30 11:44:31 -07:00
James Wampler
f317a47041 Add admin Vite app to Aspire AppHost
Registers the Vue admin as an Aspire resource via AddViteApp, wired to
the API so Aspire injects service discovery env vars that the existing
Vite proxy config already reads.
2026-06-30 11:44:21 -07:00
James Wampler
f077fe1d8d Fix build errors and enable TreatWarningsAsErrors across all projects
Remove duplicate source files from old ApiKeys/, Authentication/, and
Authorization/ folders that were already migrated to Common/Security/.
Update test namespace usings to match. Pin EFC Relational 10.0.5 to
resolve MSB3277 version conflict with Npgsql's transitive dependency.
Add LangVersion and TreatWarningsAsErrors to AppHost and ServiceDefaults.
2026-06-30 11:44:10 -07:00
James Wampler
b871e0da32 WIP 2026-06-30 11:44:10 -07:00
James Wampler
d1a41d84d7 Add MicCheck.Api.Tests.Integration project
NUnit integration tests that target a running API by URL and seed/clean
data directly in Postgres. Settings (BaseUrl, DbConnectionString) come
from .runsettings or environment variables so tests can be pointed at
any environment. HTTP requests live in HttpFiles/*.http; assertion
failures include a fresh DB snapshot of the relevant rows. First test
covers the flag-enabled path.
2026-06-30 11:43:50 -07:00
James Wampler
d916549402 New project file, added Aspire, moved security 2026-06-30 11:42:37 -07:00
James Wampler
4c6df1f037 WIP 2026-04-18 19:45:16 -07:00
James Wampler
7545441a3e UX consistancy changes. 2026-04-16 18:41:01 -07:00
230 changed files with 3089 additions and 11973 deletions

View File

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

View File

@@ -1,97 +0,0 @@
# 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,6 +408,7 @@ FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
@@ -438,11 +439,6 @@ 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/
@@ -473,9 +469,3 @@ 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/

View File

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

15
.vscode/settings.json vendored Executable file
View File

@@ -0,0 +1,15 @@
{
"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) in this repo.
Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure
@@ -18,36 +18,32 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
- 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 logic as much as possible (both nunit and jest)
- Modified file: check missing test coverage, all tests pass
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage
# Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Match formatting/style from `.editorconfig`
- 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`
- 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-
- 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 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
- Mock external deps with Moq
- 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 pass before commit
## Claude
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
- Plans = `.md` files in `docs/`. Admin → `docs/admin/`, API → `docs/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`
- 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`
## Stack
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.

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

@@ -1,53 +1,44 @@
# CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
## Planned Structure
- `src/admin/`VueJS admin components
- `src/api/` - .NET API + REST endpoints
- `src/admin/`administrative components in VueJS
- `src/api/` - api and restful endpoints in .NET
- `tests/` — test suite
- `docs/` — documentation
## Best Practices
- 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
- 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.
# Coding
- 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.
- 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
## Testing
- 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
- 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
## Claude
- 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`
- 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
## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.

21
LICENSE
View File

@@ -1,21 +0,0 @@
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,6 +3,7 @@
<File Path=".editorconfig" />
<File Path=".gitignore" />
<File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" />
</Folder>
<Folder Name="/src/">

View File

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

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

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

@@ -1,66 +0,0 @@
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,19 +3,27 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Building solution..."
dotnet build "$SCRIPT_DIR/MicCheck.slnx"
echo "Installing admin dependencies..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
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."
}
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 "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."
case "${1:-all}" in
api) build_api ;;
admin) build_admin ;;
all) build_api && build_admin ;;
*)
echo "Usage: $0 [api|admin|all]"
exit 1
;;
esac

60
docker-compose.yml Executable file
View File

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

View File

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

View File

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

28
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,26 +0,0 @@
#!/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,26 +1,14 @@
var builder = DistributedApplication.CreateBuilder(args);
// 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 postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin();
var miccheckDb = postgres.AddDatabase("miccheck");
var api = builder.AddProject<Projects.MicCheck_Api>("api")
.WithReference(miccheckDb)
.WaitFor(miccheckDb)
.WithHttpEndpoint(port: 5000, name: "http");
var api = builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
builder.AddViteApp("admin", "../admin", "serve")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(port: 5173, name: "http")
.WithExternalHttpEndpoints();
builder.Build().Run();

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,14 +20,6 @@ server {
proxy_read_timeout 30s;
}
# Proxy 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

@@ -26,7 +26,6 @@
"@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",
@@ -3353,21 +3352,6 @@
"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",
@@ -6012,15 +5996,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
@@ -8429,10 +8414,11 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -9449,50 +9435,6 @@
"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,7 @@
"build:dev": "vite build --mode development",
"serve": "vite",
"preview": "vite preview",
"test": "jest --passWithNoTests",
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium"
"test": "jest --passWithNoTests"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
@@ -30,7 +28,6 @@
"@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

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

View File

@@ -274,22 +274,12 @@
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"
>
@@ -450,8 +440,6 @@ const settingsForm = ref({
defaultEnabled: false,
});
const deleteConfirmName = ref('');
const rules = {
required: (v: string) => !!v || 'Required',
nameFormat: (v: string) =>
@@ -493,7 +481,6 @@ watch(
newSegmentId.value = null;
newSegmentEnabled.value = true;
newSegmentPriority.value = 1;
deleteConfirmName.value = '';
syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null;

View File

@@ -1,6 +1,19 @@
<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">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>
<v-alert
v-if="errorMessage"
@@ -8,126 +21,98 @@
variant="tonal"
density="compact"
closable
class="my-2"
class="mb-2"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" />
</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>
<!-- 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-combobox
<v-text-field
ref="tagLabelInputRef"
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"
:rules="[rules.required]"
data-testid="new-tag-label-input"
/>
<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"
<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-add-line"
icon="ri-check-line"
size="small"
color="primary"
variant="flat"
:disabled="!newTagLabel.trim()"
:loading="isSubmitting"
data-testid="add-tag-btn"
@click="onAddTag"
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>
</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-form>
</v-card>
</v-dialog>
</v-expand-transition>
<p class="text-caption text-medium-emphasis mb-2">Click a tag to add or remove it from this feature.</p>
<!-- 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
:variant="isAssigned(tag) ? 'flat' : 'outlined'"
:color="tag.color"
:prepend-icon="isAssigned(tag) ? 'ri-check-line' : undefined"
:loading="togglingTagId === tag.id"
:data-testid="`tag-chip-${tag.label}`"
@click="onToggleTag(tag)"
@click:close="onDeleteTag(tag)"
>
{{ tag.label }}
</v-chip>
</div>
<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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { listTags, createTag } from '@/api/tags';
import { ref, onMounted, watch, nextTick } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags';
import { assignFeatureTag, removeFeatureTag } from '@/api/features';
import { useContextStore } from '@/stores/context';
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;
}
@@ -142,34 +127,31 @@ const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]);
const isLoading = ref(false);
const isSubmitting = ref(false);
const isCreating = ref(false);
const togglingTagId = ref<number | null>(null);
const showCreateForm = ref(false);
const newTagLabel = ref('');
const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null);
const showColorDialog = ref(false);
const customColorInputRef = ref<HTMLInputElement | null>(null);
function onSelectPresetColor(color: string): void {
newTagColor.value = color;
showColorDialog.value = false;
}
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 createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const tagLabelInputRef = ref<{ focus: () => void } | null>(null);
function isAssigned(tag: TagResponse): boolean {
return assignedTags.value.some((t) => t.id === tag.id);
return props.feature.tags.some((t) => t.id === tag.id);
}
function focusTagLabel(): void {
nextTick(() => tagLabelInputRef.value?.focus());
}
watch(showCreateForm, (open) => {
if (open) focusTagLabel();
});
const rules = {
required: (v: string) => !!v || 'Tag name is required',
};
async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
@@ -184,46 +166,59 @@ async function fetchTags(): Promise<void> {
}
}
async function onAddTag(): Promise<void> {
const label = newTagLabel.value.trim();
const projectId = contextStore.currentProject?.id;
if (!label || !projectId) return;
async function onCreateTag(): Promise<void> {
if (!createFormRef.value) return;
const { valid } = await createFormRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isCreating.value = true;
errorMessage.value = null;
isSubmitting.value = true;
try {
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);
}
const created = await createTag(projectId, {
label: newTagLabel.value.trim(),
color: newTagColor.value,
});
tags.value.push(created);
newTagLabel.value = '';
newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch {
errorMessage.value = 'Failed to add tag. Please try again.';
errorMessage.value = 'Failed to create tag. Please try again.';
} finally {
isCreating.value = false;
}
}
async function onToggleTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
togglingTagId.value = tag.id;
errorMessage.value = null;
try {
const updated = isAssigned(tag)
? await removeFeatureTag(projectId, props.feature.id, tag.id)
: await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
} catch {
errorMessage.value = 'Failed to update tag assignment. Please try again.';
} finally {
isSubmitting.value = false;
togglingTagId.value = null;
}
}
async function onRemoveTag(tag: TagResponse): Promise<void> {
async function onDeleteTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
errorMessage.value = null;
togglingTagId.value = tag.id;
try {
const updated = await removeFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
await deleteTag(projectId, tag.id);
tags.value = tags.value.filter((t) => t.id !== tag.id);
} catch {
errorMessage.value = 'Failed to remove tag. Please try again.';
} finally {
togglingTagId.value = null;
errorMessage.value = 'Failed to delete tag. Please try again.';
}
}

View File

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

View File

@@ -73,7 +73,7 @@
</template>
</v-tooltip>
<v-chip
v-for="tag in (item.tags ?? []).slice(0, 5)"
v-for="tag in item.tags.slice(0, 5)"
:key="tag.id"
size="x-small"
variant="flat"
@@ -83,9 +83,9 @@
{{ tag.label }}
</v-chip>
<span
v-if="(item.tags ?? []).length > 5"
v-if="item.tags.length > 5"
class="text-caption text-medium-emphasis"
:title="`${(item.tags ?? []).length - 5} more`"
:title="`${item.tags.length - 5} more`"
></span>
</div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">

View File

@@ -1,63 +1,20 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import apiClient, { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
import type { TokenResponse } from '@/types/api';
import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
// 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;
// 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.
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('Token management', () => {
describe('WhenTokensAreSet', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
setAuthTokens('access-abc', 'refresh-xyz');
@@ -92,183 +49,3 @@ describe('ApiClient', () => {
});
});
});
describe('Request interceptor', () => {
describe('WhenAnAccessTokenIsSet', () => {
it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => {
setAuthTokens('access-abc', 'refresh-xyz');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
data: {}, status: 200, statusText: 'OK', headers: {}, config,
}));
apiClient.defaults.adapter = adapter;
await apiClient.get('/v1/whatever');
expect(adapter.mock.calls[0][0].headers.Authorization).toBe('Bearer access-abc');
});
});
describe('WhenNoAccessTokenIsSet', () => {
it('ThenTheRequestHasNoAuthorizationHeader', async () => {
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
data: {}, status: 200, statusText: 'OK', headers: {}, config,
}));
apiClient.defaults.adapter = adapter;
await apiClient.get('/v1/whatever');
expect(adapter.mock.calls[0][0].headers.Authorization).toBeUndefined();
});
});
});
describe('Response interceptor - 401 refresh flow', () => {
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
if (config.headers.Authorization === 'Bearer new-access-token') {
return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config };
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
const response = await apiClient.get('/v1/protected');
expect(postSpy).toHaveBeenCalledWith(
expect.stringContaining('/v1/auth/refresh'),
{ token: 'valid-refresh-token' },
expect.anything(),
);
expect(response.data).toEqual({ ok: true });
expect(adapter).toHaveBeenCalledTimes(2);
expect(getAccessToken()).toBe('new-access-token');
expect(localStorage.getItem('mic_access_token')).toBe('new-access-token');
expect(localStorage.getItem('mic_refresh_token')).toBe('new-refresh-token');
expect(localStorage.getItem('mic_expires_at')).toBe(tokenResponse.expiresAt);
});
});
describe('WhenTheRefreshRequestItselfFails', () => {
it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => {
setAuthTokens('expired-access-token', 'invalid-refresh-token');
localStorage.setItem('mic_access_token', 'expired-access-token');
localStorage.setItem('mic_refresh_token', 'invalid-refresh-token');
localStorage.setItem('mic_expires_at', '2026-01-01T00:00:00Z');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
jest.spyOn(axios, 'post').mockRejectedValue(new Error('refresh endpoint unreachable'));
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
expect(getAccessToken()).toBeNull();
expect(localStorage.getItem('mic_access_token')).toBeNull();
expect(localStorage.getItem('mic_refresh_token')).toBeNull();
expect(localStorage.getItem('mic_expires_at')).toBeNull();
expect(window.location.href).toBe('/login');
});
});
describe('WhenThereIsNoRefreshTokenAvailable', () => {
it('ThenTheOriginalErrorIsRejectedWithoutAttemptingRefresh', async () => {
setAuthTokens(null, null);
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post');
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
expect(postSpy).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(1);
});
});
describe('WhenARequestFailsWithANonAuthError', () => {
it('ThenTheErrorIsRejectedWithoutAttemptingRefresh', async () => {
setAuthTokens('access-abc', 'refresh-xyz');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw serverError(config);
});
apiClient.defaults.adapter = adapter;
const postSpy = jest.spyOn(axios, 'post');
await expect(apiClient.get('/v1/whatever')).rejects.toThrow();
expect(postSpy).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(1);
});
});
describe('WhenARetriedRequestFailsWith401Again', () => {
it('ThenTheErrorIsRejectedWithoutLoopingForever', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig & { _retried?: boolean }) => {
if (config._retried) {
throw unauthorizedError(config);
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
// One failed attempt + one retry after refresh; the retry itself
// is marked _retried so a second 401 does not trigger another refresh.
expect(adapter).toHaveBeenCalledTimes(2);
});
});
describe('WhenMultipleRequestsFailWith401WhileARefreshIsAlreadyInFlight', () => {
it('ThenAllRequestsAreQueuedAndRetriedWithTheRefreshedToken', async () => {
setAuthTokens('expired-access-token', 'valid-refresh-token');
let resolveRefresh!: (value: { data: TokenResponse }) => void;
const refreshPromise = new Promise<{ data: TokenResponse }>((resolve) => {
resolveRefresh = resolve;
});
jest.spyOn(axios, 'post').mockReturnValue(refreshPromise as any);
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
if (config.headers.Authorization === 'Bearer new-access-token') {
return { data: { url: config.url }, status: 200, statusText: 'OK', headers: {}, config };
}
throw unauthorizedError(config);
});
apiClient.defaults.adapter = adapter;
const first = apiClient.get('/v1/first');
const second = apiClient.get('/v1/second');
// Let both initial 401s resolve before the refresh completes.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
resolveRefresh({ data: tokenResponse });
const [firstResponse, secondResponse] = await Promise.all([first, second]);
expect(firstResponse.data).toEqual({ url: '/v1/first' });
expect(secondResponse.data).toEqual({ url: '/v1/second' });
expect(axios.post).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -1,13 +1,12 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, DOMWrapper, type VueWrapper } from '@vue/test-utils';
import { mount, flushPromises, 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, FeatureResponse } from '@/types/api';
import type { ProjectResponse, TagResponse } from '@/types/api';
jest.mock('@/api/tags');
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
@@ -15,25 +14,18 @@ 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 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];
const mockTags: TagResponse[] = [
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
{ id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
];
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) {
function mountComponent() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(TagsChipInput, { props: { feature }, global: { plugins: [router] } });
return mount(TagsChipInput, { global: { plugins: [router] } });
}
describe('TagsChipInput', () => {
@@ -48,173 +40,100 @@ describe('TagsChipInput', () => {
describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent(mockFeature([]));
mountComponent();
await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
});
it('ThenAssignedTagChipsAreRendered', async () => {
it('ThenExistingTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([backendTag]));
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
});
it('ThenEmptyMessageIsShownWhenNoTagsAssigned', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
});
});
describe('WhenAddingANewTagName', () => {
it('ThenCreateTagAndAssignAreBothCalled', async () => {
describe('WhenCreateTagIsSubmitted', () => {
it('ThenCreateTagApiIsCalled', 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(mockFeature([]));
const wrapper = mountComponent();
await flushPromises();
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');
// 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');
await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith(mockProject.id, expect.objectContaining({ label: 'api' }));
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, newTag.id);
expect(tagsApi.createTag).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ label: 'api' }),
);
});
it('ThenUpdatedEventIsEmitted', async () => {
it('ThenNewTagAppearsInTheList', 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.listTags).mockResolvedValue([]);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([]));
const wrapper = mountComponent();
await flushPromises();
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.emitted('updated')?.[0]).toEqual([updatedFeature]);
});
});
describe('WhenTypedNameMatchesAnExistingTag', () => {
it('ThenExistingTagIsAssignedInsteadOfCreatingANewOne', async () => {
const updatedFeature = mockFeature([backendTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
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.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.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');
await flushPromises();
expect(input.props('items')).toEqual(['backend']);
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
});
});
it('ThenNoSuggestionsAreShownBelowTwoCharacters', async () => {
describe('WhenTagChipIsDeleted', () => {
it('ThenDeleteTagApiIsCalled', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
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]));
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(featuresApi.removeFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
});
it('ThenDeletedTagIsRemovedFromTheList', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
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);
});
});
});

View File

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

@@ -1,216 +0,0 @@
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,8 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { createRouter, createMemoryHistory } from 'vue-router';
import { setActivePinia, createPinia } from 'pinia';
import { clearAuthTokens } from '@/api/client';
import { authGuard } from '@/router';
import { setAuthTokens, clearAuthTokens } from '@/api/client';
// Minimal stub components for route testing
const Stub = { template: '<div />' };
@@ -16,17 +15,11 @@ jest.mock('@/api/client', () => ({
import { getAccessToken } from '@/api/client';
function buildRouter() {
const router = createRouter({
return 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,
@@ -38,9 +31,6 @@ 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', () => {
@@ -54,10 +44,21 @@ describe('Router auth guard', () => {
});
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
it('ThenTheyAreRedirectedToLogin', 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');
@@ -70,6 +71,17 @@ 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');
@@ -82,55 +94,19 @@ 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

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

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

@@ -1,84 +0,0 @@
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, TagResponse } from '@/types/api';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
@@ -25,13 +25,9 @@ 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', tags: [] },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] },
{ 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' },
];
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' },
@@ -89,43 +85,6 @@ 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

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

@@ -1,155 +0,0 @@
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 } from '@vue/test-utils';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue';
@@ -129,8 +129,7 @@ describe('SegmentsView', () => {
const wrapper = mountView();
await flushPromises();
const row = wrapper.findAll('tbody tr').find((r) => r.text().includes('beta_users'));
await row!.trigger('click');
await wrapper.find('[data-testid="edit-segment-1"]').trigger('click');
await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
@@ -155,6 +154,13 @@ 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();
@@ -175,6 +181,9 @@ 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,10 +102,9 @@ describe('SettingsView', () => {
});
describe('WhenInviteMemberIsClicked', () => {
it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
{ email: 'jane@example.com', success: true, error: null },
]);
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 }]);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
@@ -113,19 +112,13 @@ describe('SettingsView', () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="add-member-btn"]').trigger('click');
await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '42');
await wrapper.vm.$nextTick();
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 wrapper.find('[data-testid="invite-member-btn"]').trigger('click');
await flushPromises();
expect(orgsApi.inviteOrganizationMembersByEmail).toHaveBeenCalledWith(
mockOrg.id,
{ invites: [{ email: 'jane@example.com', role: 'User' }] },
);
expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' });
});
});
@@ -143,8 +136,6 @@ 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);
@@ -270,8 +261,6 @@ 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);
@@ -354,6 +343,9 @@ 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();
@@ -479,8 +471,6 @@ 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 record AuditLog
public class AuditLog
{
public int Id { get; init; }
public required string ResourceType { get; init; }

View File

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

View File

@@ -12,40 +12,48 @@ 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("organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
[HttpGet("api/v1/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 result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
}
[HttpGet("project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
[HttpGet("api/v1/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 result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
}
[HttpGet("environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
[HttpGet("api/v1/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 result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,8 @@
namespace MicCheck.Api.Common;
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; init; }
public required string Prefix { get; init; }
public required string Name { get; init; }
public required string Key { get; set; }
public required string Prefix { get; set; }
public required string Name { get; set; }
public int OrganizationId { get; init; }
public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -1,9 +1,7 @@
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)
@@ -15,9 +13,10 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{
var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt));
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
}).WithName("CreateApiKey");

View File

@@ -1,3 +1,9 @@
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(IMicCheckDbContext db)
public class ApiKeyService(MicCheckDbContext db)
{
public async Task<ApiKeyCreationResult> CreateAsync(
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{
var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db)
db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct);
return new ApiKeyCreationResult(apiKey, rawKey);
return (apiKey, rawKey);
}
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,5 +48,3 @@ public class ApiKeyService(IMicCheckDbContext 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, IMicCheckDbContext db)
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext 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, IMicCheckDbContext db)
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "EnvironmentKey";

View File

@@ -1,9 +1,7 @@
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(
IMicCheckDbContext db,
MicCheckDbContext db,
ITokenService tokenService,
IPasswordHasher<User> passwordHasher)
{
@@ -81,9 +81,7 @@ public class AuthService(
});
await db.SaveChangesAsync(ct);
var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct);
foreach (var organizationUser in organizationUsers)
user.Organizations.Add(organizationUser);
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
var accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
# Self-contained CI/QA image for MicCheck.Api. Unlike the dev Dockerfile
# (which expects a host-published /app to be bind-mounted), this builds the
# app inside the image so it can be pushed to a registry and run standalone.
#
# Build context must be the repo root (needs ServiceDefaults + the API project):
# docker build -f src/api/MicCheck.Api/Dockerfile.ci -t miccheck-api:qa .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj src/MicCheck.ServiceDefaults/
COPY src/api/MicCheck.Api/MicCheck.Api.csproj src/api/MicCheck.Api/
RUN dotnet restore src/api/MicCheck.Api/MicCheck.Api.csproj
COPY src/MicCheck.ServiceDefaults/ src/MicCheck.ServiceDefaults/
COPY src/api/MicCheck.Api/ src/api/MicCheck.Api/
RUN dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release -o /out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# curl is used by the docker-compose healthcheck; the base image ships neither
# curl nor wget, so the healthcheck silently fails as "unhealthy" without it.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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