From 37ac4b47f28e9ec848a9a0cba2b5dfd1530b98bc Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 12:50:19 -0700 Subject: [PATCH 01/21] Auto-install .NET SDK in CI scripts when runner lacks it Gitea runner build failed with "dotnet: command not found". Added ensure_dotnet() to lib.sh, using the vendored dotnet-install.sh to bootstrap the SDK into .dotnet/ when not already on PATH, called from build.sh/test.sh/prepush.sh. Also trigger CI on build-runner-fix to verify the fix. --- .github/workflows/ci.yml | 2 +- .gitignore | 3 +++ scripts/ci/build.sh | 2 ++ scripts/ci/lib.sh | 17 +++++++++++++++++ scripts/ci/prepush.sh | 2 ++ scripts/ci/test.sh | 2 ++ 6 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9878d8b..cde47f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ name: CI on: push: - branches: [main] + branches: [main, build-runner-fix] jobs: build-and-push: diff --git a/.gitignore b/.gitignore index 555444c..5858b13 100755 --- a/.gitignore +++ b/.gitignore @@ -468,3 +468,6 @@ 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/ diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh index 9faa9a3..5049c75 100755 --- a/scripts/ci/build.sh +++ b/scripts/ci/build.sh @@ -6,6 +6,8 @@ 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 diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index 372a771..a121b4e 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -37,6 +37,23 @@ image_names() { 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" +} + registry_login() { require_registry_vars [[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images" diff --git a/scripts/ci/prepush.sh b/scripts/ci/prepush.sh index b9ce38c..148d87b 100755 --- a/scripts/ci/prepush.sh +++ b/scripts/ci/prepush.sh @@ -5,6 +5,8 @@ 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 diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh index 5d7c5db..e8c6cf7 100755 --- a/scripts/ci/test.sh +++ b/scripts/ci/test.sh @@ -6,6 +6,8 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh cd "$CI_ROOT" +ensure_dotnet + log "Running MicCheck.Api.Tests.Unit" dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx -- 2.49.1 From 926af91389ba10b772ee3bd3ac46531c20324eff Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 13:00:59 -0700 Subject: [PATCH 02/21] Pin Microsoft.OpenApi to patched 2.9.0 to fix NU1903 build failure Microsoft.AspNetCore.OpenApi 10.0.5 pulls in Microsoft.OpenApi 2.0.0 transitively, which has a known high-severity vulnerability (GHSA-v5pm-xwqc-g5wc) and fails the build with TreatWarningsAsErrors. Pinned a direct reference to the patched 2.9.0 (still 2.x, API-compatible). --- src/api/MicCheck.Api/MicCheck.Api.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/MicCheck.Api/MicCheck.Api.csproj b/src/api/MicCheck.Api/MicCheck.Api.csproj index b650ca2..da34818 100755 --- a/src/api/MicCheck.Api/MicCheck.Api.csproj +++ b/src/api/MicCheck.Api/MicCheck.Api.csproj @@ -12,6 +12,7 @@ + -- 2.49.1 From a81798cffe4023a720fd648842f88421d59dc358 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 13:01:37 -0700 Subject: [PATCH 03/21] Pin MessagePack to patched 2.5.302 in AppHost Aspire.Hosting.PostgreSQL/JavaScript pull in MessagePack 2.5.192 transitively, which has multiple known vulnerabilities and fails the build with TreatWarningsAsErrors. Pinned a direct reference to the patched 2.5.302 (same major, no API break). --- src/MicCheck.AppHost/MicCheck.AppHost.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/MicCheck.AppHost/MicCheck.AppHost.csproj b/src/MicCheck.AppHost/MicCheck.AppHost.csproj index 6de3e45..b5c1bb8 100755 --- a/src/MicCheck.AppHost/MicCheck.AppHost.csproj +++ b/src/MicCheck.AppHost/MicCheck.AppHost.csproj @@ -8,6 +8,7 @@ + -- 2.49.1 From 2dc40f72ce1d4fa89926801480fcce76cf1da536 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 20:48:15 -0700 Subject: [PATCH 04/21] Add .dockerignore to stop host obj/bin leaking into CI image builds Dockerfile.ci COPYs full project directories after restoring inside the container. Without a .dockerignore, the host's obj/bin (built with a different local SDK than the container's) got copied over the container's fresh restore output, corrupting project.assets.json and crashing ResolvePackageAssets with a NullReferenceException on publish. --- .dockerignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..505af2d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +.git/ +.husky/ +docs/ -- 2.49.1 From 80d450207c1f181336aacc81080e2952a25a9186 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 11:42:44 -0700 Subject: [PATCH 05/21] Replace actions/checkout with plain git in deploy-qa job Self-hosted qa runner has no node in PATH, so the Node-based actions/checkout@v4 action can't run there. Swap it for a bash git fetch/checkout, matching the pipeline's no-marketplace-action convention. --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cde47f5..6780403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,14 @@ jobs: JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} steps: - - uses: actions/checkout@v4 + # 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 -- 2.49.1 From 8d0cef08b1725eb559041302e66622340d6619b1 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 13:10:04 -0700 Subject: [PATCH 06/21] Fix api container healthcheck failing with no error logs aspnet base image ships neither curl nor wget, so the compose healthcheck's wget call failed silently at the exec level (visible only in docker's health-check log, not app stdout). Install curl in the Dockerfile.ci final stage and switch the healthcheck to use it. --- deploy/qa/docker-compose.qa.yml | 2 +- src/api/MicCheck.Api/Dockerfile.ci | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml index 01b3d24..b14a5ff 100644 --- a/deploy/qa/docker-compose.qa.yml +++ b/deploy/qa/docker-compose.qa.yml @@ -36,7 +36,7 @@ services: db: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"] interval: 10s timeout: 5s retries: 5 diff --git a/src/api/MicCheck.Api/Dockerfile.ci b/src/api/MicCheck.Api/Dockerfile.ci index 57fc0b5..aa9f36e 100644 --- a/src/api/MicCheck.Api/Dockerfile.ci +++ b/src/api/MicCheck.Api/Dockerfile.ci @@ -17,6 +17,11 @@ 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 -- 2.49.1 From b32f5d56db3599674a7fd4f1ec6fdf883ba6e564 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 13:15:09 -0700 Subject: [PATCH 07/21] Fix health check path mismatch causing 404s The app maps health checks at /health (ServiceDefaults), not /api/v1/health. Compose healthcheck and deploy-qa.sh's wait-loop both hit the wrong path. Also add an nginx location for /health since it lives outside the /api prefix that the admin proxy otherwise forwards unchanged. --- deploy/qa/docker-compose.qa.yml | 2 +- scripts/ci/deploy-qa.sh | 2 +- src/admin/nginx.conf | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml index b14a5ff..8054b3b 100644 --- a/deploy/qa/docker-compose.qa.yml +++ b/deploy/qa/docker-compose.qa.yml @@ -36,7 +36,7 @@ services: db: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"] interval: 10s timeout: 5s retries: 5 diff --git a/scripts/ci/deploy-qa.sh b/scripts/ci/deploy-qa.sh index cd62263..fdc7379 100755 --- a/scripts/ci/deploy-qa.sh +++ b/scripts/ci/deploy-qa.sh @@ -26,7 +26,7 @@ $COMPOSE up -d log "Waiting for API health check via admin proxy on port $QA_ADMIN_PORT" attempts=30 -until curl -fsS "http://localhost:${QA_ADMIN_PORT}/api/v1/health" >/dev/null 2>&1; do +until curl -fsS "http://localhost:${QA_ADMIN_PORT}/health" >/dev/null 2>&1; do attempts=$((attempts - 1)) if [[ "$attempts" -le 0 ]]; then fail "QA stack did not become healthy in time" diff --git a/src/admin/nginx.conf b/src/admin/nginx.conf index a3f6802..7feb6f8 100755 --- a/src/admin/nginx.conf +++ b/src/admin/nginx.conf @@ -20,6 +20,14 @@ server { proxy_read_timeout 30s; } + # Proxy the API's health endpoint, which lives outside the /api prefix. + location = /health { + resolver 127.0.0.11 valid=10s ipv6=off; + set $api_upstream http://api:8080; + proxy_pass $api_upstream/health; + proxy_http_version 1.1; + } + # SPA fallback — all other paths serve index.html so Vue Router handles them location / { try_files $uri $uri/ /index.html; -- 2.49.1 From 1b2ce263e587b391e4cac137f43c3e76d74849be Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 13:29:40 -0700 Subject: [PATCH 08/21] Fix deploy-qa health check unreachable from runner's network namespace The self-hosted runner runs in its own container on a separate bridge network, so curling localhost:$QA_ADMIN_PORT hit the runner's own loopback instead of the docker host's published port, never the QA stack. Exec into the admin container and curl its own localhost instead. Co-Authored-By: Claude Sonnet 5 --- scripts/ci/deploy-qa.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/ci/deploy-qa.sh b/scripts/ci/deploy-qa.sh index fdc7379..dffc4f6 100755 --- a/scripts/ci/deploy-qa.sh +++ b/scripts/ci/deploy-qa.sh @@ -24,9 +24,14 @@ $COMPOSE down -v log "Starting QA stack" $COMPOSE up -d -log "Waiting for API health check via admin proxy on port $QA_ADMIN_PORT" +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 curl -fsS "http://localhost:${QA_ADMIN_PORT}/health" >/dev/null 2>&1; do +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" -- 2.49.1 From 8fc0ebb72436e4aeb37cd9996a791f4df7cbdd78 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 17:28:13 -0700 Subject: [PATCH 09/21] Add integration + Playwright smoke suite for post-deploy QA validation Existing coverage was one integration test and zero e2e tests. Adds the thin, high-value integration/e2e layer unit tests can't reach (real Postgres wire contract, real browser flows) and wires it as a post-deploy smoke gate. - Seed a deterministic dev/QA admin user and fixed Development env key so HTTP-only tests can authenticate without per-run registration. - Expand the API integration suite: disabled-flag, unauthorized access, environment-document bootstrap, identity-override precedence, and auth login scenarios, reusing the existing black-box HTTP+Npgsql harness. - Add a Playwright suite for the admin SPA: login, nav, project/environment context selection, and feature create/toggle, against the real API. - Bind QA Postgres to 127.0.0.1 for direct seeding, add smoke-qa.sh and an ensure_node() bootstrap (the qa runner has no Node today), and wire a new smoke-qa CI job after deploy-qa. --- .github/workflows/ci.yml | 18 +++ .gitignore | 5 + deploy/qa/docker-compose.qa.yml | 8 +- docs/plans/api/integration-smoke_output.md | 46 ++++++++ docs/plans/api/integration-smoke_plan.md | 75 +++++++++++++ scripts/ci/lib.sh | 22 ++++ scripts/ci/smoke-qa.sh | 30 +++++ src/admin/e2e/context-selection.e2e.ts | 30 +++++ src/admin/e2e/features.e2e.ts | 39 +++++++ src/admin/e2e/global-setup.ts | 30 +++++ src/admin/e2e/login.e2e.ts | 32 ++++++ src/admin/e2e/navigation.e2e.ts | 23 ++++ src/admin/package-lock.json | 60 ++++++++++ src/admin/package.json | 5 +- src/admin/playwright.config.ts | 29 +++++ src/api/MicCheck.Api/Data/DatabaseSeeder.cs | 45 +++++++- .../Auth/AuthSeed.cs | 55 ++++++++++ .../Auth/AuthTests.cs | 95 ++++++++++++++++ .../Environments/EnvironmentDocumentTests.cs | 86 +++++++++++++++ .../Flags/FlagDisabledTests.cs | 81 ++++++++++++++ .../Flags/FlagsApiAuthorizationTests.cs | 57 ++++++++++ .../HttpFiles/Auth.http | 9 ++ .../HttpFiles/EnvironmentDocument.http | 4 + .../HttpFiles/Identity.http | 4 + .../Identities/IdentityOverrideSeed.cs | 103 ++++++++++++++++++ .../Identities/IdentityOverrideTests.cs | 87 +++++++++++++++ .../MicCheck.Api.Tests.Integration.csproj | 1 + .../Data/DatabaseSeederTests.cs | 91 ++++++++++++++++ 28 files changed, 1164 insertions(+), 6 deletions(-) create mode 100644 docs/plans/api/integration-smoke_output.md create mode 100644 docs/plans/api/integration-smoke_plan.md create mode 100755 scripts/ci/smoke-qa.sh create mode 100644 src/admin/e2e/context-selection.e2e.ts create mode 100644 src/admin/e2e/features.e2e.ts create mode 100644 src/admin/e2e/global-setup.ts create mode 100644 src/admin/e2e/login.e2e.ts create mode 100644 src/admin/e2e/navigation.e2e.ts create mode 100644 src/admin/playwright.config.ts create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Environments/EnvironmentDocumentTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Flags/FlagDisabledTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Flags/FlagsApiAuthorizationTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Auth.http create mode 100644 tests/api/MicCheck.Api.Tests.Integration/HttpFiles/EnvironmentDocument.http create mode 100644 tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Identity.http create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideSeed.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6780403..9423e48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,21 @@ jobs: - name: Deploy to QA run: ./scripts/ci/deploy-qa.sh + + smoke-qa: + needs: deploy-qa + runs-on: [self-hosted, qa] + env: + 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: Smoke test QA deployment + run: ./scripts/ci/smoke-qa.sh diff --git a/.gitignore b/.gitignore index 5858b13..452a514 100755 --- a/.gitignore +++ b/.gitignore @@ -438,6 +438,11 @@ dist-ssr/ # TypeScript incremental build cache *.tsbuildinfo +# Playwright +src/admin/test-results/ +src/admin/playwright-report/ +src/admin/e2e/.auth/ + # Jest test coverage reports coverage/ .nyc_output/ diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml index 8054b3b..737d9fe 100644 --- a/deploy/qa/docker-compose.qa.yml +++ b/deploy/qa/docker-compose.qa.yml @@ -1,8 +1,10 @@ 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, and the -# database has no published host port. +# stack's network - runs entirely on its own bridge network below. The API has +# no published host port; Postgres is bound to 127.0.0.1 only, so the smoke-qa +# CI job (running on this same host) can seed/inspect data directly while it +# stays unreachable off-box. services: db: @@ -11,6 +13,8 @@ services: POSTGRES_DB: miccheck POSTGRES_USER: miccheck POSTGRES_PASSWORD: password + ports: + - "127.0.0.1:55432:5432" volumes: - miccheck-qa-pgdata:/var/lib/postgresql/data networks: diff --git a/docs/plans/api/integration-smoke_output.md b/docs/plans/api/integration-smoke_output.md new file mode 100644 index 0000000..865ff06 --- /dev/null +++ b/docs/plans/api/integration-smoke_output.md @@ -0,0 +1,46 @@ +# Integration + Playwright smoke suite — implementation summary + +Implemented per `integration-smoke_plan.md`. All chunks built, verified against a real local Postgres + API + Vite stack, and the two pre-existing test suites (243 API unit tests, 306 admin Jest tests) still pass. + +## Chunk A — Deterministic dev/QA seed admin user + fixed env key +- `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`: now also creates a seed admin user (`admin@miccheck.local` / `MicCheckQa!2026`, Admin role on the `Default` org) and gives the seeded `Development` environment a fixed API key (`env-qa-development`). Still gated behind `IsDevelopment()` in `Program.cs` and the existing idempotency guard. +- New unit tests: `tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs` (5 tests). +- Verified live: booted the API against local Postgres, logged in via `POST /api/v1/auth/login` with the seed creds (200 + tokens), and read `/api/v1/flags` with the fixed Development key (200, empty array as expected). + +## Chunk B — Expanded API integration suite +Added to `tests/api/MicCheck.Api.Tests.Integration`, reusing the existing black-box HTTP+Npgsql harness: +- `Flags/FlagDisabledTests.cs` — disabled-flag variant of the existing enabled test. +- `Flags/FlagsApiAuthorizationTests.cs` — missing/unknown environment key → 401. +- `Environments/EnvironmentDocumentTests.cs` + `HttpFiles/EnvironmentDocument.http` — SDK bootstrap document endpoint. +- `Identities/IdentityOverrideSeed.cs` + `Identities/IdentityOverrideTests.cs` + `HttpFiles/Identity.http` — identity-override-beats-environment-default precedence. +- `Auth/AuthSeed.cs` + `Auth/AuthTests.cs` + `HttpFiles/Auth.http` — login liveness (valid creds → tokens, bad password → 401). Added `Microsoft.Extensions.Identity.Core` package reference so the seed can hash a password with the same `PasswordHasher` 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 `` 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. diff --git a/docs/plans/api/integration-smoke_plan.md b/docs/plans/api/integration-smoke_plan.md new file mode 100644 index 0000000..f6484d6 --- /dev/null +++ b/docs/plans/api/integration-smoke_plan.md @@ -0,0 +1,75 @@ +# Plan: Integration + Playwright smoke suite that doubles as post-deploy QA validation + +## Context + +Today the repo has **one** integration test (`FlagEnabledTests` — seeds an enabled flag in Postgres, hits `GET /api/v1/flags`, asserts enabled) and **zero** browser/e2e tests. Unit coverage is already deep for the risky logic — flag/feature evaluation, segment evaluation (28 tests), webhooks, auth token/API-key/permission logic, audit. Per the 80–90% unit / 10–20% 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` 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. diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index a121b4e..da40a33 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -54,6 +54,28 @@ ensure_dotnet() { export DOTNET_ROOT="$install_dir" } +# 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 + + 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" + curl -fsSL "https://nodejs.org/dist/v${node_version}/${tarball}.tar.xz" -o "/tmp/${tarball}.tar.xz" + 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" +} + registry_login() { require_registry_vars [[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images" diff --git a/scripts/ci/smoke-qa.sh b/scripts/ci/smoke-qa.sh new file mode 100755 index 0000000..8713237 --- /dev/null +++ b/scripts/ci/smoke-qa.sh @@ -0,0 +1,30 @@ +#!/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, so +# `localhost` resolves to the deployed containers. +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}" +BASE_URL="http://localhost:${QA_ADMIN_PORT}" + +export MICCHECK_API_BASE_URL="$BASE_URL" +export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=localhost;Port=55432;Database=miccheck;Username=miccheck;Password=password}" + +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 +npm --prefix src/admin run e2e:install + +log "Running Playwright admin e2e suite against $BASE_URL" +E2E_BASE_URL="$BASE_URL" npm --prefix src/admin run e2e + +log "smoke-qa.sh complete - QA environment validated end to end" diff --git a/src/admin/e2e/context-selection.e2e.ts b/src/admin/e2e/context-selection.e2e.ts new file mode 100644 index 0000000..4cee191 --- /dev/null +++ b/src/admin/e2e/context-selection.e2e.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { authFile } from './global-setup'; + +test.use({ storageState: authFile }); + +/** + * The seeded DB (DatabaseSeeder) provides exactly one org, one project ("My Project"), and + * three environments (Development/Staging/Production), so the selectors auto-populate without + * needing to create anything first. Most feature screens require both a project and an + * environment to be selected before they render real content. + */ +test('project and environment context is selectable and persists across a reload', async ({ page }) => { + await page.goto('/features'); + + const projectSelect = page.getByTestId('project-select').locator('input'); + const envSelect = page.getByTestId('env-select').locator('input'); + + await expect(projectSelect).not.toHaveValue(''); + await expect(envSelect).not.toHaveValue(''); + + // Explicitly re-select the project via the dropdown to prove the selector itself works, + // not just the auto-select-on-load behavior. + await page.getByTestId('project-select').click(); + await page.getByRole('option', { name: 'My Project' }).click(); + await expect(projectSelect).toHaveValue('My Project'); + + await page.reload(); + await expect(page.getByTestId('project-select').locator('input')).not.toHaveValue(''); + await expect(page.getByTestId('env-select').locator('input')).not.toHaveValue(''); +}); diff --git a/src/admin/e2e/features.e2e.ts b/src/admin/e2e/features.e2e.ts new file mode 100644 index 0000000..aa6c4ce --- /dev/null +++ b/src/admin/e2e/features.e2e.ts @@ -0,0 +1,39 @@ +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(); + + await toggle.click({ force: true }); + 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 }); +}); diff --git a/src/admin/e2e/global-setup.ts b/src/admin/e2e/global-setup.ts new file mode 100644 index 0000000..79a12dd --- /dev/null +++ b/src/admin/e2e/global-setup.ts @@ -0,0 +1,30 @@ +import { chromium, type FullConfig } from '@playwright/test'; +import path from 'node:path'; + +/** + * Deterministic dev/QA seed admin credentials — see DatabaseSeeder.SeedAdminEmail / + * SeedAdminPassword in src/api/MicCheck.Api/Data/DatabaseSeeder.cs. Seeding only ever runs in + * Development, which is what both local dev and the QA deployment run as. + */ +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local'; +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026'; + +export const authFile = path.join(__dirname, '.auth', 'admin.json'); + +export default async function globalSetup(config: FullConfig): Promise { + 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(); +} diff --git a/src/admin/e2e/login.e2e.ts b/src/admin/e2e/login.e2e.ts new file mode 100644 index 0000000..4054344 --- /dev/null +++ b/src/admin/e2e/login.e2e.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unauthenticated — does not use the shared storageState fixture (see global-setup.ts) since + * it exercises the login flow itself. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local'; +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026'; + +test('valid credentials sign the admin in and land on the authenticated shell', async ({ page }) => { + await page.goto('/login'); + + await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL); + await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD); + await page.getByTestId('login-submit').click(); + + await page.waitForURL((url) => !url.pathname.startsWith('/login')); + await expect(page.getByText('Features', { exact: true })).toBeVisible(); +}); + +test('invalid credentials surface a login error and stay on the login page', async ({ page }) => { + await page.goto('/login'); + + await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL); + await page.getByTestId('password-input').locator('input').fill('not-the-right-password'); + await page.getByTestId('login-submit').click(); + + await expect(page.getByTestId('login-error')).toBeVisible(); + await expect(page).toHaveURL(/\/login/); +}); diff --git a/src/admin/e2e/navigation.e2e.ts b/src/admin/e2e/navigation.e2e.ts new file mode 100644 index 0000000..a778028 --- /dev/null +++ b/src/admin/e2e/navigation.e2e.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { authFile } from './global-setup'; + +test.use({ storageState: authFile }); + +test('the sidebar renders and each primary link navigates to its view', async ({ page }) => { + await page.goto('/dashboard'); + + const links: Array<[name: string, path: string]> = [ + ['Dashboard', '/dashboard'], + ['Features', '/features'], + ['Segments', '/segments'], + ['Identities', '/identities'], + ['Audit Logs', '/audit-logs'], + ['Settings', '/settings'], + ]; + + for (const [name, path] of links) { + // Sidebar entries are clickable divs (VerticalNavLink), not elements, so match by text. + await page.locator('li').filter({ hasText: name }).first().click(); + await expect(page).toHaveURL(new RegExp(`${path}$`)); + } +}); diff --git a/src/admin/package-lock.json b/src/admin/package-lock.json index 63d9467..8af3fbc 100755 --- a/src/admin/package-lock.json +++ b/src/admin/package-lock.json @@ -26,6 +26,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.24.0", + "@playwright/test": "^1.61.1", "@types/jest": "^29.5.12", "@types/node": "^20.12.0", "@vitejs/plugin-vue": "^6.0.6", @@ -3352,6 +3353,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -9435,6 +9451,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/src/admin/package.json b/src/admin/package.json index 3e016ce..7af114c 100755 --- a/src/admin/package.json +++ b/src/admin/package.json @@ -7,7 +7,9 @@ "build:dev": "vite build --mode development", "serve": "vite", "preview": "vite preview", - "test": "jest --passWithNoTests" + "test": "jest --passWithNoTests", + "e2e": "playwright test", + "e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", @@ -28,6 +30,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.24.0", + "@playwright/test": "^1.61.1", "@types/jest": "^29.5.12", "@types/node": "^20.12.0", "@vitejs/plugin-vue": "^6.0.6", diff --git a/src/admin/playwright.config.ts b/src/admin/playwright.config.ts new file mode 100644 index 0000000..5ed936d --- /dev/null +++ b/src/admin/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Smoke-test suite for the admin SPA. Runs against a real, already-running deployment + * (local dev server or a deployed QA environment) — it does not mock the API and does not + * start any servers itself. See e2e/global-setup.ts for how authentication is bootstrapped. + */ +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.e2e.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + globalSetup: './e2e/global-setup.ts', + timeout: 30_000, + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs index 36a5d29..82df6a7 100755 --- a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs +++ b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs @@ -1,5 +1,7 @@ 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; @@ -7,11 +9,26 @@ namespace MicCheck.Api.Data; public class DatabaseSeeder { - private readonly MicCheckDbContext _db; + /// + /// Deterministic credentials for the development/QA seed admin user. Only ever created when + /// SeedAsync runs, which is gated behind IsDevelopment() in Program.cs. + /// Used by the API integration suite and the Playwright admin e2e suite to authenticate + /// without depending on per-run registration. + /// + public const string SeedAdminEmail = "admin@miccheck.local"; + public const string SeedAdminPassword = "MicCheckQa!2026"; - public DatabaseSeeder(MicCheckDbContext db) + /// Deterministic environment key for the seeded Development environment, so + /// HTTP-only integration/e2e tests can read flags without a direct DB connection. + public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; + + private readonly MicCheckDbContext _db; + private readonly IPasswordHasher _passwordHasher; + + public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher passwordHasher) { _db = db; + _passwordHasher = passwordHasher; } public async Task SeedAsync(CancellationToken ct = default) @@ -42,12 +59,34 @@ public class DatabaseSeeder _db.Environments.Add(new AppEnvironment { Name = name, - ApiKey = $"env-{Guid.NewGuid():N}", + ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"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); } } diff --git a/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs b/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs new file mode 100644 index 0000000..15eb5aa --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs @@ -0,0 +1,55 @@ +using MicCheck.Api.Tests.Integration.Common; +using Microsoft.AspNetCore.Identity; + +namespace MicCheck.Api.Tests.Integration.Auth; + +/// +/// A stand-in for the real API's User type: only uses +/// it as a type parameter (it doesn't inspect the instance), so any reference type works. Keeps +/// this project a true black box with no ProjectReference to the API. +/// +public class HashedUser; + +public sealed record AuthSeed(int OrganizationId, int UserId, string Email, string Password) +{ + public static async Task InsertAsync(TestDatabase db, string testRunTag, string password) + { + var rawUnique = $"{testRunTag}-{Guid.NewGuid():N}"; + var unique = rawUnique[..Math.Min(rawUnique.Length, 40)]; + var email = $"{unique}@integration.test"; + var now = DateTimeOffset.UtcNow; + + var passwordHash = new PasswordHasher().HashPassword(new HashedUser(), password); + + var organizationId = await db.ExecuteScalarAsync( + "INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"", + ("name", $"org_{unique}"), + ("createdAt", now)); + + var userId = await db.ExecuteScalarAsync( + """ + INSERT INTO "Users" ("Email", "PasswordHash", "FirstName", "LastName", "IsActive", "IsTwoFactorEnabled", "CreatedAt") + VALUES (@email, @passwordHash, 'Integration', 'Test', true, false, @createdAt) + RETURNING "Id" + """, + ("email", email), + ("passwordHash", passwordHash), + ("createdAt", now)); + + await db.ExecuteAsync( + """ + INSERT INTO "OrganizationUsers" ("OrganizationId", "UserId", "Role", "IsPrimary") + VALUES (@orgId, @userId, 1, true) + """, + ("orgId", organizationId), + ("userId", userId)); + + return new AuthSeed(organizationId, userId, email, password); + } + + public async Task CleanupAsync(TestDatabase db) + { + await db.ExecuteAsync("DELETE FROM \"Users\" WHERE \"Id\" = @userId", ("userId", UserId)); + await db.ExecuteAsync("DELETE FROM \"Organizations\" WHERE \"Id\" = @orgId", ("orgId", OrganizationId)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs new file mode 100644 index 0000000..b320b9d --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using MicCheck.Api.Tests.Integration.Common; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Integration.Auth; + +[TestFixture] +public class AuthTests +{ + private const string Password = "Integration!Test123"; + + private IntegrationTestSettings settings = null!; + private TestDatabase db = null!; + private FlagApiHttpClient client = null!; + private AuthSeed? seed; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + settings = IntegrationTestSettings.Load(); + db = new TestDatabase(settings.DbConnectionString); + client = new FlagApiHttpClient(settings); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => client.Dispose(); + + [SetUp] + public async Task SetUp() + => seed = await AuthSeed.InsertAsync(db, testRunTag: "Auth", password: Password); + + [TearDown] + public async Task TearDown() + { + if (seed is not null) + { + await seed.CleanupAsync(db); + seed = null; + } + } + + [Test] + public async Task WhenValidCredentialsAreSubmitted_ThenLoginReturnsAnAccessToken() + { + var current = seed!; + + using var response = await client.SendAsync( + httpFile: "Auth.http", + requestName: "Login", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["email"] = current.Email, + ["password"] = current.Password + }); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.That( + response.IsSuccessStatusCode, + Is.True, + $"POST /api/v1/auth/login returned {(int)response.StatusCode} {response.ReasonPhrase} for a valid seeded user. Body: {body}"); + + var login = JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new InvalidOperationException("Response body was not the expected login shape."); + + Assert.That(login.AccessToken, Is.Not.Null.And.Not.Empty); + Assert.That(login.RefreshToken, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task WhenAnIncorrectPasswordIsSubmitted_ThenLoginIsUnauthorized() + { + var current = seed!; + + using var response = await client.SendAsync( + httpFile: "Auth.http", + requestName: "Login", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["email"] = current.Email, + ["password"] = "definitely-the-wrong-password" + }); + + Assert.That( + (int)response.StatusCode, + Is.EqualTo(401), + $"Expected 401 for an incorrect password, got {(int)response.StatusCode} {response.ReasonPhrase}."); + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private sealed record LoginResponseDto(string AccessToken, string RefreshToken, DateTime ExpiresAt); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Environments/EnvironmentDocumentTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Environments/EnvironmentDocumentTests.cs new file mode 100644 index 0000000..971f4fa --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Environments/EnvironmentDocumentTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using MicCheck.Api.Tests.Integration.Common; +using MicCheck.Api.Tests.Integration.Flags; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Integration.Environments; + +[TestFixture] +public class EnvironmentDocumentTests +{ + private IntegrationTestSettings settings = null!; + private TestDatabase db = null!; + private FlagApiHttpClient client = null!; + private FlagSeed? seed; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + settings = IntegrationTestSettings.Load(); + db = new TestDatabase(settings.DbConnectionString); + client = new FlagApiHttpClient(settings); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => client.Dispose(); + + [SetUp] + public async Task SetUp() + => seed = await FlagSeed.InsertEnabledFlagAsync(db, testRunTag: "EnvDocument", enabled: true, value: null); + + [TearDown] + public async Task TearDown() + { + if (seed is not null) + { + await seed.CleanupAsync(db); + seed = null; + } + } + + [Test] + public async Task WhenAnEnvironmentHasFeatureStates_ThenTheEnvironmentDocumentIncludesThem() + { + var current = seed!; + + using var response = await client.SendAsync( + httpFile: "EnvironmentDocument.http", + requestName: "GetEnvironmentDocument", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["environmentKey"] = current.EnvironmentApiKey + }); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.That( + response.IsSuccessStatusCode, + Is.True, + $"GET /api/v1/environment-document returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}. DB state:\n{await current.DescribeAsync(db)}"); + + var document = JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new InvalidOperationException("Response body was not the expected environment document shape."); + + Assert.That( + document.Id, + Is.EqualTo(current.EnvironmentId), + $"Environment document Id did not match the seeded environment. DB state:\n{await current.DescribeAsync(db)}"); + + var match = document.FeatureStates.SingleOrDefault(f => f.Feature.Name == current.FeatureName); + + Assert.That( + match, + Is.Not.Null, + $"Feature '{current.FeatureName}' was not present in the environment document. Returned features: [{string.Join(", ", document.FeatureStates.Select(f => f.Feature.Name))}]. DB state:\n{await current.DescribeAsync(db)}"); + + Assert.That(match!.Enabled, Is.True); + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private sealed record EnvironmentDocumentDto(int Id, string ApiKey, List FeatureStates, EnvironmentProjectDto Project); + private sealed record EnvironmentProjectDto(int Id, string Name, List Segments); + private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue); + private sealed record FeatureSummaryDto(int Id, string Name); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagDisabledTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagDisabledTests.cs new file mode 100644 index 0000000..27af9d3 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagDisabledTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using MicCheck.Api.Tests.Integration.Common; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Integration.Flags; + +[TestFixture] +public class FlagDisabledTests +{ + private IntegrationTestSettings settings = null!; + private TestDatabase db = null!; + private FlagApiHttpClient client = null!; + private FlagSeed? seed; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + settings = IntegrationTestSettings.Load(); + db = new TestDatabase(settings.DbConnectionString); + client = new FlagApiHttpClient(settings); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => client.Dispose(); + + [SetUp] + public async Task SetUp() + => seed = await FlagSeed.InsertEnabledFlagAsync(db, testRunTag: "FlagDisabled", enabled: false, value: null); + + [TearDown] + public async Task TearDown() + { + if (seed is not null) + { + await seed.CleanupAsync(db); + seed = null; + } + } + + [Test] + public async Task WhenAFeatureIsDisabledInEnvironment_ThenTheFlagApiReportsItDisabled() + { + var current = seed!; + + using var response = await client.SendAsync( + httpFile: "Flags.http", + requestName: "GetFlags", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["environmentKey"] = current.EnvironmentApiKey + }); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.That( + response.IsSuccessStatusCode, + Is.True, + $"GET /api/v1/flags returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}. DB state:\n{await current.DescribeAsync(db)}"); + + var flags = JsonSerializer.Deserialize>(body, JsonOptions) + ?? throw new InvalidOperationException("Response body was not a JSON array."); + + var match = flags.SingleOrDefault(f => f.Feature.Name == current.FeatureName); + + Assert.That( + match, + Is.Not.Null, + $"Feature '{current.FeatureName}' was not present in the flags response. Returned features: [{string.Join(", ", flags.Select(f => f.Feature.Name))}]. DB state:\n{await current.DescribeAsync(db)}"); + + Assert.That( + match!.Enabled, + Is.False, + $"Feature '{current.FeatureName}' was reported as enabled, but the seeded FeatureState has Enabled=false. DB state:\n{await current.DescribeAsync(db)}"); + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue); + private sealed record FeatureSummaryDto(int Id, string Name); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagsApiAuthorizationTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagsApiAuthorizationTests.cs new file mode 100644 index 0000000..485781c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagsApiAuthorizationTests.cs @@ -0,0 +1,57 @@ +using MicCheck.Api.Tests.Integration.Common; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Integration.Flags; + +[TestFixture] +public class FlagsApiAuthorizationTests +{ + private IntegrationTestSettings settings = null!; + private FlagApiHttpClient client = null!; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + settings = IntegrationTestSettings.Load(); + client = new FlagApiHttpClient(settings); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => client.Dispose(); + + [Test] + public async Task WhenNoEnvironmentKeyIsProvided_ThenTheFlagsApiReturnsUnauthorized() + { + using var response = await client.SendAsync( + httpFile: "Flags.http", + requestName: "GetFlags", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["environmentKey"] = string.Empty + }); + + Assert.That( + (int)response.StatusCode, + Is.EqualTo(401), + $"Expected 401 for a missing environment key, got {(int)response.StatusCode} {response.ReasonPhrase}."); + } + + [Test] + public async Task WhenAnUnknownEnvironmentKeyIsProvided_ThenTheFlagsApiReturnsUnauthorized() + { + using var response = await client.SendAsync( + httpFile: "Flags.http", + requestName: "GetFlags", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["environmentKey"] = $"unknown_{Guid.NewGuid():N}" + }); + + Assert.That( + (int)response.StatusCode, + Is.EqualTo(401), + $"Expected 401 for an unrecognized environment key, got {(int)response.StatusCode} {response.ReasonPhrase}."); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Auth.http b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Auth.http new file mode 100644 index 0000000..4b1f9aa --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Auth.http @@ -0,0 +1,9 @@ +### Login +POST {{baseUrl}}/api/v1/auth/login +Content-Type: application/json +Accept: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/EnvironmentDocument.http b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/EnvironmentDocument.http new file mode 100644 index 0000000..ae86fdf --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/EnvironmentDocument.http @@ -0,0 +1,4 @@ +### GetEnvironmentDocument +GET {{baseUrl}}/api/v1/environment-document +X-Environment-Key: {{environmentKey}} +Accept: application/json diff --git a/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Identity.http b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Identity.http new file mode 100644 index 0000000..f571745 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Identity.http @@ -0,0 +1,4 @@ +### GetIdentity +GET {{baseUrl}}/api/v1/identity?identifier={{identifier}} +X-Environment-Key: {{environmentKey}} +Accept: application/json diff --git a/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideSeed.cs b/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideSeed.cs new file mode 100644 index 0000000..3ddbe16 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideSeed.cs @@ -0,0 +1,103 @@ +using MicCheck.Api.Tests.Integration.Common; + +namespace MicCheck.Api.Tests.Integration.Identities; + +/// +/// Seeds an environment-default FeatureState plus an identity-level override for the same +/// feature, so a test can assert that identity overrides take precedence over the environment +/// default when evaluating /api/v1/identity. +/// +public sealed record IdentityOverrideSeed( + int OrganizationId, + int EnvironmentId, + int FeatureId, + string EnvironmentApiKey, + string FeatureName, + string Identifier) +{ + public static async Task InsertAsync( + TestDatabase db, + string testRunTag, + bool environmentDefaultEnabled, + bool identityOverrideEnabled) + { + var rawUnique = $"{testRunTag}-{Guid.NewGuid():N}"; + var unique = rawUnique[..Math.Min(rawUnique.Length, 40)]; + var environmentApiKey = $"envkey_{Guid.NewGuid():N}"; + var featureName = $"feature_{unique}"; + var identifier = $"identity_{unique}"; + var now = DateTimeOffset.UtcNow; + + var organizationId = await db.ExecuteScalarAsync( + "INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"", + ("name", $"org_{unique}"), + ("createdAt", now)); + + var projectId = await db.ExecuteScalarAsync( + "INSERT INTO \"Projects\" (\"Name\", \"OrganizationId\", \"CreatedAt\", \"HideDisabledFlags\") VALUES (@name, @orgId, @createdAt, false) RETURNING \"Id\"", + ("name", $"project_{unique}"), + ("orgId", organizationId), + ("createdAt", now)); + + var environmentId = await db.ExecuteScalarAsync( + "INSERT INTO \"Environments\" (\"Name\", \"ApiKey\", \"ProjectId\", \"CreatedAt\") VALUES (@name, @apiKey, @projectId, @createdAt) RETURNING \"Id\"", + ("name", $"env_{unique}"), + ("apiKey", environmentApiKey), + ("projectId", projectId), + ("createdAt", now)); + + var featureId = await db.ExecuteScalarAsync( + "INSERT INTO \"Features\" (\"Name\", \"Type\", \"DefaultEnabled\", \"ProjectId\", \"CreatedAt\") VALUES (@name, 0, @defaultEnabled, @projectId, @createdAt) RETURNING \"Id\"", + ("name", featureName), + ("defaultEnabled", environmentDefaultEnabled), + ("projectId", projectId), + ("createdAt", now)); + + await db.ExecuteScalarAsync( + """ + INSERT INTO "FeatureStates" ("FeatureId", "EnvironmentId", "Enabled", "Value", "CreatedAt", "UpdatedAt", "Version") + VALUES (@featureId, @environmentId, @enabled, NULL, @createdAt, @updatedAt, 1) + RETURNING "Id" + """, + ("featureId", featureId), + ("environmentId", environmentId), + ("enabled", environmentDefaultEnabled), + ("createdAt", now), + ("updatedAt", now)); + + var identityId = await db.ExecuteScalarAsync( + "INSERT INTO \"Identities\" (\"Identifier\", \"EnvironmentId\", \"CreatedAt\") VALUES (@identifier, @environmentId, @createdAt) RETURNING \"Id\"", + ("identifier", identifier), + ("environmentId", environmentId), + ("createdAt", now)); + + await db.ExecuteScalarAsync( + """ + INSERT INTO "FeatureStates" ("FeatureId", "EnvironmentId", "IdentityId", "Enabled", "Value", "CreatedAt", "UpdatedAt", "Version") + VALUES (@featureId, @environmentId, @identityId, @enabled, NULL, @createdAt, @updatedAt, 1) + RETURNING "Id" + """, + ("featureId", featureId), + ("environmentId", environmentId), + ("identityId", identityId), + ("enabled", identityOverrideEnabled), + ("createdAt", now), + ("updatedAt", now)); + + return new IdentityOverrideSeed(organizationId, environmentId, featureId, environmentApiKey, featureName, identifier); + } + + public async Task CleanupAsync(TestDatabase db) + => await db.ExecuteAsync( + "DELETE FROM \"Organizations\" WHERE \"Id\" = @orgId", + ("orgId", OrganizationId)); + + public async Task DescribeAsync(TestDatabase db) + => await db.SnapshotRowsAsync( + """ + SELECT fs."Id" AS "FeatureStateId", fs."Enabled", fs."IdentityId", fs."FeatureSegmentId" + FROM "FeatureStates" fs + WHERE fs."FeatureId" = @featureId + """, + ("featureId", FeatureId)); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideTests.cs new file mode 100644 index 0000000..b315c4a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Identities/IdentityOverrideTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using MicCheck.Api.Tests.Integration.Common; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Integration.Identities; + +[TestFixture] +public class IdentityOverrideTests +{ + private IntegrationTestSettings settings = null!; + private TestDatabase db = null!; + private FlagApiHttpClient client = null!; + private IdentityOverrideSeed? seed; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + settings = IntegrationTestSettings.Load(); + db = new TestDatabase(settings.DbConnectionString); + client = new FlagApiHttpClient(settings); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => client.Dispose(); + + [SetUp] + public async Task SetUp() + => seed = await IdentityOverrideSeed.InsertAsync( + db, + testRunTag: "IdentityOverride", + environmentDefaultEnabled: false, + identityOverrideEnabled: true); + + [TearDown] + public async Task TearDown() + { + if (seed is not null) + { + await seed.CleanupAsync(db); + seed = null; + } + } + + [Test] + public async Task WhenAnIdentityHasAFeatureOverride_ThenItTakesPrecedenceOverTheEnvironmentDefault() + { + var current = seed!; + + using var response = await client.SendAsync( + httpFile: "Identity.http", + requestName: "GetIdentity", + variables: new Dictionary + { + ["baseUrl"] = settings.BaseUrl, + ["environmentKey"] = current.EnvironmentApiKey, + ["identifier"] = current.Identifier + }); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.That( + response.IsSuccessStatusCode, + Is.True, + $"GET /api/v1/identity returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}. DB state:\n{await current.DescribeAsync(db)}"); + + var identity = JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new InvalidOperationException("Response body was not the expected identity shape."); + + var match = identity.Flags.SingleOrDefault(f => f.Feature.Name == current.FeatureName); + + Assert.That( + match, + Is.Not.Null, + $"Feature '{current.FeatureName}' was not present in the identity response. DB state:\n{await current.DescribeAsync(db)}"); + + Assert.That( + match!.Enabled, + Is.True, + $"Expected the identity override (Enabled=true) to win over the environment default (Enabled=false). DB state:\n{await current.DescribeAsync(db)}"); + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private sealed record IdentityResponseDto(List Traits, List Flags); + private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue); + private sealed record FeatureSummaryDto(int Id, string Name); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj b/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj index 50f7983..2bd4547 100755 --- a/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj +++ b/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs new file mode 100644 index 0000000..45914fb --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs @@ -0,0 +1,91 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Data; + +[TestFixture] +public class DatabaseSeederTests +{ + private MicCheckDbContext _db = null!; + private DatabaseSeeder _seeder = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new MicCheckDbContext(options); + _seeder = new DatabaseSeeder(_db, new PasswordHasher()); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + [Test] + public async Task WhenTheDatabaseIsEmpty_ThenSeedingCreatesADeterministicAdminUser() + { + await _seeder.SeedAsync(); + + var admin = await _db.Users.SingleOrDefaultAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); + + Assert.That(admin, Is.Not.Null); + Assert.That(admin!.IsActive, Is.True); + } + + [Test] + public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminPasswordVerifiesAgainstTheKnownCredential() + { + await _seeder.SeedAsync(); + + var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); + var hasher = new PasswordHasher(); + + var result = hasher.VerifyHashedPassword(admin, admin.PasswordHash, DatabaseSeeder.SeedAdminPassword); + + Assert.That(result, Is.Not.EqualTo(PasswordVerificationResult.Failed)); + } + + [Test] + public async Task WhenTheDatabaseIsEmpty_ThenTheSeededAdminIsAnAdminOfTheDefaultOrganization() + { + await _seeder.SeedAsync(); + + var organization = await _db.Organizations.SingleAsync(o => o.Name == "Default"); + var admin = await _db.Users.SingleAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail); + var membership = await _db.OrganizationUsers + .SingleAsync(ou => ou.OrganizationId == organization.Id && ou.UserId == admin.Id); + + Assert.That(membership.Role, Is.EqualTo(OrganizationRole.Admin)); + Assert.That(membership.IsPrimary, Is.True); + } + + [Test] + public async Task WhenTheDatabaseIsEmpty_ThenTheSeededDevelopmentEnvironmentHasTheFixedApiKey() + { + await _seeder.SeedAsync(); + + var development = await _db.Environments.SingleAsync(e => e.Name == "Development"); + var staging = await _db.Environments.SingleAsync(e => e.Name == "Staging"); + var production = await _db.Environments.SingleAsync(e => e.Name == "Production"); + + Assert.That(development.ApiKey, Is.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); + Assert.That(staging.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); + Assert.That(production.ApiKey, Is.Not.EqualTo(DatabaseSeeder.SeedDevelopmentEnvironmentKey)); + } + + [Test] + public async Task WhenSeedingRunsTwice_ThenItDoesNotCreateDuplicateOrganizationsOrUsers() + { + await _seeder.SeedAsync(); + await _seeder.SeedAsync(); + + Assert.That(await _db.Organizations.CountAsync(), Is.EqualTo(1)); + Assert.That(await _db.Users.CountAsync(u => u.Email == DatabaseSeeder.SeedAdminEmail), Is.EqualTo(1)); + Assert.That(await _db.Environments.CountAsync(), Is.EqualTo(3)); + } +} -- 2.49.1 From 404b1e990485edbfe32050a305e85928e07bc354 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 17:55:06 -0700 Subject: [PATCH 10/21] Pin Aspire AppHost ports/credentials to match docker-compose defaults Aspire assigned random ports and an auto-generated Postgres password on each run, so the fixed targets baked into local.runsettings and docker-compose (localhost:5000/5173/5432, miccheck/password) didn't work when developing via Aspire instead of docker compose. Pins Postgres user/password/port and the api/admin HTTP endpoints so either workflow hits the same local addresses. --- src/MicCheck.AppHost/AppHost.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/MicCheck.AppHost/AppHost.cs b/src/MicCheck.AppHost/AppHost.cs index e654c5d..a99a11c 100755 --- a/src/MicCheck.AppHost/AppHost.cs +++ b/src/MicCheck.AppHost/AppHost.cs @@ -1,14 +1,26 @@ var builder = DistributedApplication.CreateBuilder(args); -var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin(); +// Pinned to match tests/api/MicCheck.Api.Tests.Integration/local.runsettings and +// src/admin's docker-compose defaults, so those fixed targets work whether the +// stack is run via `docker compose` or via this AppHost. +var postgresUser = builder.AddParameter("postgres-username", "miccheck"); +var postgresPassword = builder.AddParameter("postgres-password", "password", secret: true); + +var postgres = builder.AddPostgres("postgres", postgresUser, postgresPassword, port: 5432) + .WithDataVolume("miccheck-pgdata") + .WithPgAdmin(); var miccheckDb = postgres.AddDatabase("miccheck"); -var api = builder.AddProject("api").WithReference(miccheckDb).WaitFor(miccheckDb); +var api = builder.AddProject("api") + .WithReference(miccheckDb) + .WaitFor(miccheckDb) + .WithHttpEndpoint(port: 5000, name: "http"); builder.AddViteApp("admin", "../admin", "serve") .WithReference(api) .WaitFor(api) + .WithHttpEndpoint(port: 5173, name: "http") .WithExternalHttpEndpoints(); builder.Build().Run(); -- 2.49.1 From 25504a8a852e0792ec4b117c7669e2090b50031e Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 17:55:34 -0700 Subject: [PATCH 11/21] Claude.md tweaks --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a269aa7..1cfdd58 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,13 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr - 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- +- Do not use `sealed` - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. ## Testing - 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 -- 2.49.1 From 2d6ee2197bb78c4c60e8c57d955d848d8546091f Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:08:00 -0700 Subject: [PATCH 12/21] Fall back to wget in ensure_node when curl is unavailable The self-hosted qa runner has neither curl nor wget guaranteed; ensure_node previously assumed curl, breaking smoke-qa on that host. --- scripts/ci/lib.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index da40a33..d2f661b 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -68,7 +68,14 @@ ensure_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" - curl -fsSL "https://nodejs.org/dist/v${node_version}/${tarball}.tar.xz" -o "/tmp/${tarball}.tar.xz" + 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" -- 2.49.1 From 8427e4c8a1f68a7c6b58d3e058074390d7a1047f Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:12:15 -0700 Subject: [PATCH 13/21] Install native runtime deps for dotnet when missing on bare runners The self-hosted qa runner's minimal image lacked libstdc++/libgcc, which dotnet fails on with an obscure symbol-relocation error rather than a clear "missing dependency" message. --- scripts/ci/lib.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index d2f661b..82d082b 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -52,6 +52,29 @@ ensure_dotnet() { 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" + if command -v apt-get > /dev/null 2>&1; then + local sudo_cmd="" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then + sudo_cmd="sudo" + fi + $sudo_cmd apt-get update -y + $sudo_cmd apt-get install -y --no-install-recommends libstdc++6 libgcc-s1 libicu-dev ca-certificates + else + fail "libstdc++.so.6 missing and apt-get unavailable; install a C++ runtime manually on this runner" + fi } # Installs Node.js into $CI_ROOT/.node from the official prebuilt tarball if -- 2.49.1 From ec6bb389021b73833ba23535acd288682b0c659d Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:16:39 -0700 Subject: [PATCH 14/21] Add apk fallback for native dotnet deps on musl/Alpine runners dotnet-install.sh silently picks the linux-musl-x64 SDK on this runner, which has no apt-get - only apk - so the previous apt-only fix still failed. --- scripts/ci/lib.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index 82d082b..612b0de 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -65,15 +65,20 @@ ensure_dotnet_native_deps() { 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 - local sudo_cmd="" - if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then - sudo_cmd="sudo" - fi $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 apt-get unavailable; install a C++ runtime manually on this runner" + fail "libstdc++.so.6 missing and neither apt-get nor apk is available; install a C++ runtime manually on this runner" fi } -- 2.49.1 From c001cff9ba9293c0f702cc2074f5e3f28445afec Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:25:26 -0700 Subject: [PATCH 15/21] Reach QA's published ports from the smoke-qa job container via bridge gateway The runner executes each job in its own container on docker's default bridge network, so "localhost" never resolves to the docker host - smoke-qa's direct Postgres connection and Playwright/API HTTP calls need the bridge gateway IP instead. Bind the db port to that same gateway IP (not 0.0.0.0) so it stays reachable only from sibling containers, not off-box. --- deploy/qa/docker-compose.qa.yml | 9 +++++---- scripts/ci/deploy-qa.sh | 6 ++++++ scripts/ci/lib.sh | 10 ++++++++++ scripts/ci/smoke-qa.sh | 15 +++++++++++---- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml index 737d9fe..ca1c4f2 100644 --- a/deploy/qa/docker-compose.qa.yml +++ b/deploy/qa/docker-compose.qa.yml @@ -2,9 +2,10 @@ 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 127.0.0.1 only, so the smoke-qa -# CI job (running on this same host) can seed/inspect data directly while it -# stays unreachable off-box. +# 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: @@ -14,7 +15,7 @@ services: POSTGRES_USER: miccheck POSTGRES_PASSWORD: password ports: - - "127.0.0.1:55432:5432" + - "${DB_BIND_HOST:-127.0.0.1}:55432:5432" volumes: - miccheck-qa-pgdata:/var/lib/postgresql/data networks: diff --git a/scripts/ci/deploy-qa.sh b/scripts/ci/deploy-qa.sh index dffc4f6..91715cb 100755 --- a/scripts/ci/deploy-qa.sh +++ b/scripts/ci/deploy-qa.sh @@ -13,6 +13,12 @@ export API_IMAGE ADMIN_IMAGE export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY 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" diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index 612b0de..e0c98d6 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -111,6 +111,16 @@ ensure_node() { export PATH="$install_dir/bin:$PATH" } +# The IP address on which a container published on 0.0.0.0/ 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" diff --git a/scripts/ci/smoke-qa.sh b/scripts/ci/smoke-qa.sh index 8713237..3f86530 100755 --- a/scripts/ci/smoke-qa.sh +++ b/scripts/ci/smoke-qa.sh @@ -2,8 +2,11 @@ # 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, so -# `localhost` resolves to the deployed containers. +# 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" @@ -12,10 +15,14 @@ ensure_dotnet ensure_node QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}" -BASE_URL="http://localhost:${QA_ADMIN_PORT}" +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=localhost;Port=55432;Database=miccheck;Username=miccheck;Password=password}" +export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=password}" 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 -- 2.49.1 From 2d81b7655d57d1d8b0f86a3642d90da61a1e6fde Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:29:52 -0700 Subject: [PATCH 16/21] Install Node via apk on musl runners instead of nodejs.org's glibc tarball Same runner dotnet-install.sh detects as musl and picks linux-musl-x64 for - the glibc node tarball can't even exec there ("env: can't execute 'node'"). --- scripts/ci/lib.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index e0c98d6..14f1337 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -91,6 +91,20 @@ ensure_node() { 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 -- 2.49.1 From aeb4f036279f439aeb6567088813c31b72464f17 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:33:43 -0700 Subject: [PATCH 17/21] Run Playwright e2e in the official playwright container on musl runners Playwright's bundled Chromium is glibc-only and --with-deps assumes apt - both fail on this musl/Alpine runner. Run the e2e leg in mcr.microsoft.com/playwright (browsers preinstalled, pinned to the exact resolved @playwright/test version) instead, reachable at the same bridge gateway IP as the rest of the QA stack. --- scripts/ci/smoke-qa.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/ci/smoke-qa.sh b/scripts/ci/smoke-qa.sh index 3f86530..03a57ce 100755 --- a/scripts/ci/smoke-qa.sh +++ b/scripts/ci/smoke-qa.sh @@ -29,9 +29,19 @@ dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integrat log "Installing admin e2e dependencies" npm --prefix src/admin ci -npm --prefix src/admin run e2e:install -log "Running Playwright admin e2e suite against $BASE_URL" -E2E_BASE_URL="$BASE_URL" npm --prefix src/admin run e2e +# 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. +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)" +docker run --rm \ + -e "E2E_BASE_URL=$BASE_URL" \ + -v "$CI_ROOT:/work" -w /work/src/admin \ + "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ + npx playwright test log "smoke-qa.sh complete - QA environment validated end to end" -- 2.49.1 From cff98b5df4984a35b1340834f9c99d8c86478569 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:36:37 -0700 Subject: [PATCH 18/21] Use npm run e2e instead of npx playwright in the smoke container npx resolved the registry's generic "playwright" package instead of the locally installed @playwright/test bin, triggering an unwanted fresh install and "No tests found". npm run e2e uses the project's own node_modules/.bin unambiguously. --- scripts/ci/smoke-qa.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/smoke-qa.sh b/scripts/ci/smoke-qa.sh index 03a57ce..39c7927 100755 --- a/scripts/ci/smoke-qa.sh +++ b/scripts/ci/smoke-qa.sh @@ -42,6 +42,6 @@ docker run --rm \ -e "E2E_BASE_URL=$BASE_URL" \ -v "$CI_ROOT:/work" -w /work/src/admin \ "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ - npx playwright test + npm run e2e log "smoke-qa.sh complete - QA environment validated end to end" -- 2.49.1 From 37e00bf7563986be19cb1a5a1dd754abae05723b Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:39:06 -0700 Subject: [PATCH 19/21] Use docker cp instead of a bind mount to get repo files into the playwright container This script runs inside the runner's own job container and talks to the host's docker daemon over a mounted socket (docker-outside-of-docker). A bind mount path only exists inside this job container, not on the host the daemon resolves it against, so docker run -v failed with ENOENT. docker cp copies file contents instead, sidestepping the path mismatch. --- scripts/ci/smoke-qa.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/ci/smoke-qa.sh b/scripts/ci/smoke-qa.sh index 39c7927..8c986a4 100755 --- a/scripts/ci/smoke-qa.sh +++ b/scripts/ci/smoke-qa.sh @@ -36,12 +36,20 @@ npm --prefix src/admin ci # 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)" -docker run --rm \ - -e "E2E_BASE_URL=$BASE_URL" \ - -v "$CI_ROOT:/work" -w /work/src/admin \ - "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ - npm run e2e + +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" -- 2.49.1 From cfb6c5bde36d7fbb73819e7aee5e11b0edbc0862 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:42:10 -0700 Subject: [PATCH 20/21] Wait for the toggle PUT to land before reloading in the feature e2e test The click fires an async toggle-state update; reloading immediately after raced it and read back the pre-toggle value, flaking the persistence check. --- src/admin/e2e/features.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/admin/e2e/features.e2e.ts b/src/admin/e2e/features.e2e.ts index aa6c4ce..493f738 100644 --- a/src/admin/e2e/features.e2e.ts +++ b/src/admin/e2e/features.e2e.ts @@ -32,6 +32,10 @@ test('creating a feature adds it to the table and its toggle can be flipped', as await toggle.click({ force: true }); await expect(toggle).toBeChecked({ checked: !wasChecked }); + // The click's own toggle-state PUT is async; wait for it to land before reloading, + // otherwise the reload can race it and read back the pre-toggle value. + await page.waitForLoadState('networkidle'); + // 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) }); -- 2.49.1 From 5f3fe81758b31f8608234e88b259863968d4133f Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 18:48:01 -0700 Subject: [PATCH 21/21] Assert on the real featurestate PATCH landing, not the switch's transient DOM flash When the feature-state map isn't loaded yet for a row, the click handler no-ops silently and the native checkbox briefly flashes toggled before Vue snaps it back to the unchanged model-value - reading as success for an instant even though nothing was sent. Wait for the actual PATCH response so the test fails clearly instead of passing then flaking on reload. Verified 3/3 locally against a real dev API + vite server. --- src/admin/e2e/features.e2e.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/admin/e2e/features.e2e.ts b/src/admin/e2e/features.e2e.ts index 493f738..51eb481 100644 --- a/src/admin/e2e/features.e2e.ts +++ b/src/admin/e2e/features.e2e.ts @@ -29,13 +29,17 @@ test('creating a feature adds it to the table and its toggle can be flipped', as 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 }); - // The click's own toggle-state PUT is async; wait for it to land before reloading, - // otherwise the reload can race it and read back the pre-toggle value. - await page.waitForLoadState('networkidle'); - // 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) }); -- 2.49.1