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/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9878d8b..9423e48 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: @@ -42,7 +42,32 @@ 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 + + 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 555444c..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/ @@ -468,3 +473,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/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 diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml index 01b3d24..ca1c4f2 100644 --- a/deploy/qa/docker-compose.qa.yml +++ b/deploy/qa/docker-compose.qa.yml @@ -1,8 +1,11 @@ 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 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: @@ -11,6 +14,8 @@ services: POSTGRES_DB: miccheck POSTGRES_USER: miccheck POSTGRES_PASSWORD: password + ports: + - "${DB_BIND_HOST:-127.0.0.1}:55432:5432" volumes: - miccheck-qa-pgdata:/var/lib/postgresql/data networks: @@ -36,7 +41,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/health || exit 1"] interval: 10s timeout: 5s retries: 5 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/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/deploy-qa.sh b/scripts/ci/deploy-qa.sh index cd62263..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" @@ -24,9 +30,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}/api/v1/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" diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index 372a771..14f1337 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -37,6 +37,104 @@ 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" + + ensure_dotnet_native_deps +} + +# The .NET runtime is a native ELF binary that dynamically links libstdc++/libgcc. +# Bare/minimal images (e.g. the act hostexecutor container) may lack them entirely, +# which fails as an obscure symbol-relocation error rather than "command not found". +ensure_dotnet_native_deps() { + if ldconfig -p 2>/dev/null | grep -q 'libstdc++\.so\.6'; then + return 0 + fi + + log "libstdc++.so.6 missing; installing native runtime deps for dotnet" + local sudo_cmd="" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then + sudo_cmd="sudo" + fi + + if command -v apt-get > /dev/null 2>&1; then + $sudo_cmd apt-get update -y + $sudo_cmd apt-get install -y --no-install-recommends libstdc++6 libgcc-s1 libicu-dev ca-certificates + elif command -v apk > /dev/null 2>&1; then + # Alpine/musl runner - dotnet-install.sh falls back to the linux-musl-x64 SDK + # here, which still wants a real libstdc++/libgcc (not just gcompat). + $sudo_cmd apk add --no-cache libstdc++ libgcc icu-libs ca-certificates + else + fail "libstdc++.so.6 missing and neither apt-get nor apk is available; install a C++ runtime manually on this runner" + fi +} + +# Installs Node.js into $CI_ROOT/.node from the official prebuilt tarball if +# `npm` isn't already on PATH, then prepends it to PATH for this process. +# Mirrors ensure_dotnet() above - keeps bare runners (no Node preinstalled, +# e.g. the self-hosted qa runner) working the same as a dev machine. +ensure_node() { + if command -v npm > /dev/null 2>&1; then + return 0 + fi + + # nodejs.org only ships glibc binaries; on a musl/Alpine runner (same one + # dotnet-install.sh detects and picks the linux-musl-x64 SDK for) that + # tarball fails to exec at all ("env: can't execute 'node'"). Prefer the + # distro's own package on musl instead of a broken glibc download. + if [[ ! -x "$CI_ROOT/.node/bin/node" ]] && command -v apk > /dev/null 2>&1; then + log "node not found on PATH; installing via apk (musl runner)" + local sudo_cmd="" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then + sudo_cmd="sudo" + fi + $sudo_cmd apk add --no-cache nodejs npm + return 0 + fi + + local node_version="22.14.0" + local install_dir="$CI_ROOT/.node" + if [[ ! -x "$install_dir/bin/node" ]]; then + log "node not found on PATH; installing Node.js v$node_version" + local tarball="node-v${node_version}-linux-x64" + local url="https://nodejs.org/dist/v${node_version}/${tarball}.tar.xz" + if command -v curl > /dev/null 2>&1; then + curl -fsSL "$url" -o "/tmp/${tarball}.tar.xz" + elif command -v wget > /dev/null 2>&1; then + wget -q "$url" -O "/tmp/${tarball}.tar.xz" + else + fail "neither curl nor wget found on PATH; cannot download Node.js" + fi + mkdir -p "$install_dir" + tar -xJf "/tmp/${tarball}.tar.xz" -C "$install_dir" --strip-components=1 + rm -f "/tmp/${tarball}.tar.xz" + fi + export PATH="$install_dir/bin:$PATH" +} + +# 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/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/smoke-qa.sh b/scripts/ci/smoke-qa.sh new file mode 100755 index 0000000..8c986a4 --- /dev/null +++ b/scripts/ci/smoke-qa.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Post-deploy validation for the QA environment: runs the API integration +# suite (real HTTP + real Postgres, seeding/cleaning up its own data) and the +# Playwright admin e2e suite against the just-deployed QA stack. Intended to +# run immediately after deploy-qa.sh, on the same self-hosted qa runner. This +# job runs in its own job container, a sibling of the QA stack's containers +# on docker's default bridge network - not "localhost" from the host's point +# of view - so it reaches published ports via the bridge gateway IP, same as +# deploy-qa.sh binds the db port to. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" + +ensure_dotnet +ensure_node + +QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}" +CI_HOST="$(docker_bridge_gateway)" +[[ -n "$CI_HOST" ]] || fail "could not determine docker bridge gateway IP to reach the QA stack" +BASE_URL="http://${CI_HOST}:${QA_ADMIN_PORT}" + +log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports" + +export MICCHECK_API_BASE_URL="$BASE_URL" +export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=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 + +# Playwright's bundled Chromium is a glibc binary and `--with-deps` only knows +# apt - neither works on this musl/Alpine runner. Run the e2e leg inside +# Microsoft's official Playwright image instead (glibc, browsers preinstalled +# at /ms-playwright), as a sibling container reachable at the same bridge +# gateway IP used above. Pin the image tag to the exact resolved +# @playwright/test version so the test runner and browser build match. +# +# This script itself runs inside the runner's own job container, talking to +# the host's docker daemon over a mounted socket (docker-outside-of-docker) - +# `docker run -v "$CI_ROOT:/work"` would ask the *host* daemon to bind-mount a +# path that only exists inside this job container, which fails. `docker cp` +# instead copies the files by content, sidestepping the path mismatch. +PW_VERSION="$(node -p "require('./src/admin/node_modules/@playwright/test/package.json').version")" +log "Running Playwright admin e2e suite against $BASE_URL (playwright:v$PW_VERSION-noble)" + +PW_CONTAINER="$(docker create -e "E2E_BASE_URL=$BASE_URL" -w /work/src/admin "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" npm run e2e)" +docker cp "$CI_ROOT/." "$PW_CONTAINER:/work" +pw_status=0 +docker start -a "$PW_CONTAINER" || pw_status=$? +docker rm -f "$PW_CONTAINER" > /dev/null +[[ "$pw_status" -eq 0 ]] || fail "Playwright e2e suite failed (exit $pw_status)" + +log "smoke-qa.sh complete - QA environment validated end to end" 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 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(); 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 @@ + 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..51eb481 --- /dev/null +++ b/src/admin/e2e/features.e2e.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; +import { authFile } from './global-setup'; + +test.use({ storageState: authFile }); + +test('the features table renders for the selected project', async ({ page }) => { + await page.goto('/features'); + + await expect(page.getByTestId('features-table')).toBeVisible(); + await expect(page.getByText('Select a project')).toHaveCount(0); +}); + +test('creating a feature adds it to the table and its toggle can be flipped', async ({ page }) => { + const featureName = `e2e_feature_${Date.now()}`; + + await page.goto('/features'); + + await page.getByTestId('create-feature-btn').click(); + await page.getByTestId('feature-name-input').locator('input').fill(featureName); + await page.getByTestId('feature-dialog-save').click(); + + const row = page.getByRole('row', { name: new RegExp(featureName) }); + await expect(row).toBeVisible(); + + // Creating a feature triggers a fresh fetch of feature states for the current environment; + // wait for it to settle so the toggle below acts on real, loaded state rather than racing it. + await page.waitForLoadState('networkidle'); + + const toggle = row.locator('input[type="checkbox"]'); + const wasChecked = await toggle.isChecked(); + + // Assert on the real PATCH landing, not just the switch's transient DOM state: if the + // feature-state map hasn't loaded for this row yet, the click handler no-ops silently and + // the switch briefly flashes the native checkbox state before Vue snaps it back - which + // reads as "toggled" for an instant even though nothing was ever sent to the API. + const patchResponse = page.waitForResponse( + (res) => res.request().method() === 'PATCH' && res.url().includes('/featurestate/') && res.ok(), + ); + await toggle.click({ force: true }); + await patchResponse; + await expect(toggle).toBeChecked({ checked: !wasChecked }); + + // Reload to confirm the toggle was persisted to the real API/DB, not just local state. + await page.reload(); + const reloadedRow = page.getByRole('row', { name: new RegExp(featureName) }); + await expect(reloadedRow.locator('input[type="checkbox"]')).toBeChecked({ checked: !wasChecked }); +}); 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/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; 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/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 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 @@ + 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)); + } +}