From 8fc0ebb72436e4aeb37cd9996a791f4df7cbdd78 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 4 Jul 2026 17:28:13 -0700 Subject: [PATCH] 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)); + } +}