Compare commits
3 Commits
1b2ce263e5
...
25504a8a85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25504a8a85 | ||
|
|
404b1e9904 | ||
|
|
8fc0ebb724 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
46
docs/plans/api/integration-smoke_output.md
Normal file
46
docs/plans/api/integration-smoke_output.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Integration + Playwright smoke suite — implementation summary
|
||||
|
||||
Implemented per `integration-smoke_plan.md`. All chunks built, verified against a real local Postgres + API + Vite stack, and the two pre-existing test suites (243 API unit tests, 306 admin Jest tests) still pass.
|
||||
|
||||
## Chunk A — Deterministic dev/QA seed admin user + fixed env key
|
||||
- `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`: now also creates a seed admin user (`admin@miccheck.local` / `MicCheckQa!2026`, Admin role on the `Default` org) and gives the seeded `Development` environment a fixed API key (`env-qa-development`). Still gated behind `IsDevelopment()` in `Program.cs` and the existing idempotency guard.
|
||||
- New unit tests: `tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs` (5 tests).
|
||||
- Verified live: booted the API against local Postgres, logged in via `POST /api/v1/auth/login` with the seed creds (200 + tokens), and read `/api/v1/flags` with the fixed Development key (200, empty array as expected).
|
||||
|
||||
## Chunk B — Expanded API integration suite
|
||||
Added to `tests/api/MicCheck.Api.Tests.Integration`, reusing the existing black-box HTTP+Npgsql harness:
|
||||
- `Flags/FlagDisabledTests.cs` — disabled-flag variant of the existing enabled test.
|
||||
- `Flags/FlagsApiAuthorizationTests.cs` — missing/unknown environment key → 401.
|
||||
- `Environments/EnvironmentDocumentTests.cs` + `HttpFiles/EnvironmentDocument.http` — SDK bootstrap document endpoint.
|
||||
- `Identities/IdentityOverrideSeed.cs` + `Identities/IdentityOverrideTests.cs` + `HttpFiles/Identity.http` — identity-override-beats-environment-default precedence.
|
||||
- `Auth/AuthSeed.cs` + `Auth/AuthTests.cs` + `HttpFiles/Auth.http` — login liveness (valid creds → tokens, bad password → 401). Added `Microsoft.Extensions.Identity.Core` package reference so the seed can hash a password with the same `PasswordHasher<T>` the API uses, without a `ProjectReference` to the API (kept the black-box convention).
|
||||
- Fixed a latent substring bug (`[..40]` on a string shorter than 40 chars) copied into the new seeds; left the pre-existing `FlagSeed.cs` alone since none of its callers hit the short-string case.
|
||||
- Verified live: all 8 tests pass against a real Postgres + `dotnet run` API (`dotnet test ... --logger "console;verbosity=normal"` → 8/8 passed).
|
||||
|
||||
## Chunk C — Playwright admin e2e suite
|
||||
New `src/admin/e2e/` (kept out of Jest's `roots`/`testMatch`, no config change needed since `e2e/` isn't under `src/` or `tests/`):
|
||||
- `playwright.config.ts`, `e2e/global-setup.ts` (logs in once via the real `/login` form with the seed creds, saves `storageState`).
|
||||
- `e2e/login.e2e.ts`, `e2e/navigation.e2e.ts`, `e2e/context-selection.e2e.ts`, `e2e/features.e2e.ts` (view, create, toggle).
|
||||
- `package.json`: added `@playwright/test` devDependency and `e2e` / `e2e:install` scripts.
|
||||
- Two bugs found and fixed while running against the real app: sidebar nav items are clickable `div`s, not `<a>` links (fixed the locator); a newly-created feature's toggle was clicked before its feature-state fetch settled, causing Vuetify's switch to visually revert (added a `waitForLoadState('networkidle')`).
|
||||
- Verified live: all 6 tests pass against the real Vite dev server + local API (`npx playwright test` → 6/6 passed).
|
||||
|
||||
## Chunk D — CI post-deploy smoke job
|
||||
- `deploy/qa/docker-compose.qa.yml`: bound Postgres to `127.0.0.1:55432` on the QA host so the integration harness can seed/clean directly (off-box still unreachable).
|
||||
- `scripts/ci/smoke-qa.sh` (new): runs the API integration suite and the Playwright suite against `http://localhost:${QA_ADMIN_PORT}`.
|
||||
- `scripts/ci/lib.sh`: added `ensure_node()` (mirrors `ensure_dotnet()`) — **the self-hosted `qa` runner has no Node.js today** (confirmed by the existing "avoid actions/checkout, it's Node-based" comment in `deploy-qa.sh`), so `smoke-qa.sh` needed a way to bootstrap `npm`/`npx` the same way it already bootstraps `dotnet`.
|
||||
- `.github/workflows/ci.yml`: new `smoke-qa` job, `needs: deploy-qa`, runs on `[self-hosted, qa]`, checks out via plain git (matching `deploy-qa`), runs `smoke-qa.sh`.
|
||||
|
||||
### Known follow-up risk (not resolved, flagging for the user)
|
||||
`npm run e2e:install` runs `playwright install --with-deps chromium`, which needs root/passwordless-sudo to apt-install browser OS dependencies. This could not be verified against the actual self-hosted `qa` runner (only tested locally, where `--with-deps` failed for lack of a sudo TTY — the browser itself still installed fine and tests ran). If the `qa` runner also lacks passwordless sudo, the first CI run of `smoke-qa` may fail on that step; the fix would be a one-time manual `sudo npx playwright install-deps chromium` on the runner, or granting the CI user passwordless sudo for that command.
|
||||
|
||||
## Verification performed this session
|
||||
- `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 243/243 passed.
|
||||
- `dotnet test tests/api/MicCheck.Api.Tests.Integration` against real local Postgres + API — 8/8 passed.
|
||||
- `npm --prefix src/admin test` (Jest) — 306/306 passed, 28 suites, no collision with `e2e/`.
|
||||
- `npx playwright test` (from `src/admin`) against real local Vite dev server + API — 6/6 passed.
|
||||
- `docker compose -f deploy/qa/docker-compose.qa.yml config` — valid.
|
||||
- `bash -n` on `smoke-qa.sh` and `lib.sh` — valid.
|
||||
|
||||
## Not yet done
|
||||
- The `smoke-qa` CI job has not run on the actual self-hosted `qa` runner (no access to it from this session) — first real run should be watched, especially the Node bootstrap and the Playwright OS-deps risk above.
|
||||
75
docs/plans/api/integration-smoke_plan.md
Normal file
75
docs/plans/api/integration-smoke_plan.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Plan: Integration + Playwright smoke suite that doubles as post-deploy QA validation
|
||||
|
||||
## Context
|
||||
|
||||
Today the repo has **one** integration test (`FlagEnabledTests` — seeds an enabled flag in Postgres, hits `GET /api/v1/flags`, asserts enabled) and **zero** browser/e2e tests. Unit coverage is already deep for the risky logic — flag/feature evaluation, segment evaluation (28 tests), webhooks, auth token/API-key/permission logic, audit. Per the 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<User>` into `DatabaseSeeder` (mirror `AuthService.RegisterAsync` at `Common/Security/Authorization/AuthService.cs:54-82`: create `User{ IsActive=true, PasswordHash=hasher.HashPassword(...) }`, then `OrganizationUser{ Role=OrganizationRole.Admin }` linking it to the seeded `Default` org).
|
||||
- Creds: `admin@miccheck.local` / `MicCheckQa!2026` (login verify has no complexity rule, so any value works; keep it in one shared constants spot referenced by tests).
|
||||
- Give the seeded **Development** environment a **deterministic** `ApiKey` (e.g. `env-qa-development`) instead of a random guid; leave Staging/Production random. Lets HTTP-only smoke read `/flags` with a known key without direct DB access.
|
||||
- Keep the existing `if (Organizations.AnyAsync) return;` idempotency guard — user + env are created in the same first-run block. No prod risk: invocation is already gated by `app.Environment.IsDevelopment()` (`Program.cs:160-166`).
|
||||
- Unit test: seed against InMemory EF twice → asserts admin user exists once, is Admin on Default org, password verifies, Development env key is the fixed value.
|
||||
|
||||
### Chunk B — Expand API integration suite (`tests/api/MicCheck.Api.Tests.Integration`)
|
||||
Reuse the existing harness verbatim: `Common/FlagApiHttpClient.cs` (drives `###`-delimited `.http` files with `{{var}}` substitution), `Common/TestDatabase.cs` (Npgsql seed/cleanup + `SnapshotRowsAsync` for failure dumps), `Common/IntegrationTestSettings.cs` (env-var / `.runsettings` config), and the `FlagSeed` INSERT…RETURNING + cascade-delete-by-Organization pattern (`Flags/FlagSeed.cs`). Keep DTOs redefined locally (no `ProjectReference` to the API) per the current convention. New `.http` files under `HttpFiles/` are auto-copied (`csproj` `CopyToOutputDirectory=PreserveNewest`).
|
||||
|
||||
Add these high-value scenarios (each the real Postgres wire contract, not re-covered logic):
|
||||
1. **Flag disabled** — seed `Enabled=false`; assert `/flags` reports the feature `Enabled=false` (complements the existing enabled test; cheap variant of `FlagSeed`).
|
||||
2. **Environment-document bootstrap** — new `HttpFiles/EnvironmentDocument.http` (`GET /api/v1/environment-document`, `X-Environment-Key`); assert 200 and the seeded feature state + project segment are present (shape: `Environments/EnvironmentDocumentResponse.cs`). This is the edge/client-SDK bootstrap path — high blast radius, untested.
|
||||
3. **Identity override precedence** — new seed adding an identity-override FeatureState (and/or a segment override) + `HttpFiles/Identity.http` (`POST /api/v1/identity` with identifier, `X-Environment-Key`); assert the evaluated value reflects identity > segment > env-default precedence (`Features/FeatureEvaluationService.cs:43-146`). Richest runtime logic against real data.
|
||||
4. **Auth liveness** — new `HttpFiles/Auth.http` (`POST /api/v1/auth/login`): bad creds → 401; the deterministic seed creds → 200 + tokens. Proves app + DB + auth are up without depending on `/health`.
|
||||
5. **Unauthorized guard** — `GET /api/v1/flags` with no / wrong `X-Environment-Key` → 401 (validates `EnvironmentKeyAuthenticationHandler`).
|
||||
|
||||
### Chunk C — Playwright admin e2e suite (new)
|
||||
Location `src/admin/e2e/` (distinct from Jest, which grabs `*.spec.ts` under `tests/`; name specs `*.e2e.ts` and set Jest `roots`/Playwright `testDir` so they never collide). Add dev deps `@playwright/test`, a `playwright.config.ts` (`testDir: 'e2e'`, `baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173'`, chromium project, `globalSetup` for auth). Add `package.json` scripts: `"e2e": "playwright test"`, `"e2e:install": "playwright install --with-deps chromium"`.
|
||||
|
||||
The app is already richly instrumented with `data-testid` — drive real UI, no selectors guesswork. Auth: `globalSetup` logs in once via the real `/login` form (`login-form`/`email-input`/`password-input`/`login-submit`) with the Chunk-A seed creds and saves `storageState` for reuse.
|
||||
|
||||
Journeys (ordered most→least important; the seed provides `Default` org, `My Project`, and `Development/Staging/Production` envs, so context is selectable without creating anything):
|
||||
1. **Login** — valid creds redirect off `/login`; invalid creds surface `login-error` (`views/LoginView.vue`).
|
||||
2. **App shell + nav** — sidebar renders and routes (Dashboard/Features/Segments/Identities/Audit Logs/Settings) navigate (`layouts/components/NavItems.vue`).
|
||||
3. **Select project + environment context** — `project-selector`/`project-select` + `environment-tab-bar`/`env-select` (`components/nav/ProjectSelector.vue`, `EnvironmentTabBar.vue`); required before feature screens work (context persists to localStorage via `stores/context.ts`).
|
||||
4. **View feature flags** — `features-table` renders for the selected project (`views/FeaturesView.vue`).
|
||||
5. **Create a feature flag** — `create-feature-btn` → `FeatureDialog` (`feature-name-input`, `feature-type-select`, `feature-dialog-save`) → row appears (`components/features/FeatureDialog.vue`, API `api/features.ts`). Exercises full write path to real DB.
|
||||
6. **Toggle a feature flag** — row switch `toggle-${id}` (or `feature-enabled-toggle` in `FeatureDetail.vue`); reload → state persisted (validates `api/featureStates.ts` → real Postgres).
|
||||
|
||||
Secondary/optional (add if cheap): create project, create environment via the sidebar dialogs.
|
||||
|
||||
### Chunk D — CI post-deploy smoke job
|
||||
Files: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/smoke-qa.sh` (new), `.github/workflows/ci.yml`.
|
||||
|
||||
- **DB reachability**: add `ports: ["127.0.0.1:55432:5432"]` to the `db` service in the QA compose (localhost-bound only — the runner host is the sole consumer; not exposed off-box). Lets the integration harness seed Postgres directly.
|
||||
- New `scripts/ci/smoke-qa.sh` (matches the repo's "every CI step is a reproducible script" convention):
|
||||
- API integration: `dotnet test tests/api/MicCheck.Api.Tests.Integration -c Release --logger trx` with env `MICCHECK_API_BASE_URL=http://localhost:${QA_ADMIN_PORT}` and `MICCHECK_DB_CONNECTION_STRING=Host=localhost;Port=55432;Database=miccheck;Username=miccheck;Password=password`.
|
||||
- Playwright: `npm --prefix src/admin ci` (or reuse install), `npm --prefix src/admin run e2e:install`, then `E2E_BASE_URL=http://localhost:${QA_ADMIN_PORT} npm --prefix src/admin run e2e`.
|
||||
- `.github/workflows/ci.yml`: add job `smoke-qa` (`needs: deploy-qa`, `runs-on: [self-hosted, qa]`, env `QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}`) that checks out (plain-git step, matching `deploy-qa`) and runs `./scripts/ci/smoke-qa.sh`. Deploy is now gated by real end-to-end validation.
|
||||
|
||||
## Files touched (summary)
|
||||
- Modify: `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`, `deploy/qa/docker-compose.qa.yml`, `.github/workflows/ci.yml`, `src/admin/package.json`.
|
||||
- Add (API tests): `HttpFiles/EnvironmentDocument.http`, `HttpFiles/Identity.http`, `HttpFiles/Auth.http`, new `*Tests.cs` + seed helpers under `Flags/` (or a new `Identities/`, `Environments/`, `Auth/` folder) in `tests/api/MicCheck.Api.Tests.Integration`.
|
||||
- Add (unit): seeder test under `tests/api/MicCheck.Api.Tests.Unit/Data/`.
|
||||
- Add (e2e): `src/admin/playwright.config.ts`, `src/admin/e2e/*.e2e.ts`, `src/admin/e2e/global-setup.ts`.
|
||||
- Add (CI): `scripts/ci/smoke-qa.sh`.
|
||||
- Per CLAUDE.md, also drop this plan at `docs/plans/api/integration-smoke_plan.md` and an `_output.md` summary after implementation.
|
||||
|
||||
## Verification
|
||||
- **Chunk A**: `dotnet test tests/api/MicCheck.Api.Tests.Unit` (new seeder test green). Boot API locally (`docker compose up`), confirm login with seed creds returns tokens and the Development env key equals the fixed value.
|
||||
- **Chunk B**: bring up local stack (Postgres on `:5432`, API on `:5000`), `dotnet test tests/api/MicCheck.Api.Tests.Integration --settings local.runsettings` — all new scenarios green; failure messages show live DB snapshots.
|
||||
- **Chunk C**: `npm --prefix src/admin run serve` (API on :5000), `E2E_BASE_URL=http://localhost:5173 npm --prefix src/admin run e2e` — all journeys pass headed and headless.
|
||||
- **Chunk D**: push to `build-runner-fix`; watch CI — `smoke-qa` runs after `deploy-qa`, both `dotnet test` and Playwright green against `http://localhost:${QA_ADMIN_PORT}`. Confirm a deliberately-broken deploy (e.g. bad DB creds) makes `smoke-qa` fail.
|
||||
@@ -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"
|
||||
|
||||
30
scripts/ci/smoke-qa.sh
Executable file
30
scripts/ci/smoke-qa.sh
Executable file
@@ -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"
|
||||
@@ -1,14 +1,26 @@
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin();
|
||||
// Pinned to match tests/api/MicCheck.Api.Tests.Integration/local.runsettings and
|
||||
// src/admin's docker-compose defaults, so those fixed targets work whether the
|
||||
// stack is run via `docker compose` or via this AppHost.
|
||||
var postgresUser = builder.AddParameter("postgres-username", "miccheck");
|
||||
var postgresPassword = builder.AddParameter("postgres-password", "password", secret: true);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres", postgresUser, postgresPassword, port: 5432)
|
||||
.WithDataVolume("miccheck-pgdata")
|
||||
.WithPgAdmin();
|
||||
|
||||
var miccheckDb = postgres.AddDatabase("miccheck");
|
||||
|
||||
var api = builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
|
||||
var api = builder.AddProject<Projects.MicCheck_Api>("api")
|
||||
.WithReference(miccheckDb)
|
||||
.WaitFor(miccheckDb)
|
||||
.WithHttpEndpoint(port: 5000, name: "http");
|
||||
|
||||
builder.AddViteApp("admin", "../admin", "serve")
|
||||
.WithReference(api)
|
||||
.WaitFor(api)
|
||||
.WithHttpEndpoint(port: 5173, name: "http")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
30
src/admin/e2e/context-selection.e2e.ts
Normal file
30
src/admin/e2e/context-selection.e2e.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { authFile } from './global-setup';
|
||||
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
/**
|
||||
* The seeded DB (DatabaseSeeder) provides exactly one org, one project ("My Project"), and
|
||||
* three environments (Development/Staging/Production), so the selectors auto-populate without
|
||||
* needing to create anything first. Most feature screens require both a project and an
|
||||
* environment to be selected before they render real content.
|
||||
*/
|
||||
test('project and environment context is selectable and persists across a reload', async ({ page }) => {
|
||||
await page.goto('/features');
|
||||
|
||||
const projectSelect = page.getByTestId('project-select').locator('input');
|
||||
const envSelect = page.getByTestId('env-select').locator('input');
|
||||
|
||||
await expect(projectSelect).not.toHaveValue('');
|
||||
await expect(envSelect).not.toHaveValue('');
|
||||
|
||||
// Explicitly re-select the project via the dropdown to prove the selector itself works,
|
||||
// not just the auto-select-on-load behavior.
|
||||
await page.getByTestId('project-select').click();
|
||||
await page.getByRole('option', { name: 'My Project' }).click();
|
||||
await expect(projectSelect).toHaveValue('My Project');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('project-select').locator('input')).not.toHaveValue('');
|
||||
await expect(page.getByTestId('env-select').locator('input')).not.toHaveValue('');
|
||||
});
|
||||
39
src/admin/e2e/features.e2e.ts
Normal file
39
src/admin/e2e/features.e2e.ts
Normal file
@@ -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 });
|
||||
});
|
||||
30
src/admin/e2e/global-setup.ts
Normal file
30
src/admin/e2e/global-setup.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { chromium, type FullConfig } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Deterministic dev/QA seed admin credentials — see DatabaseSeeder.SeedAdminEmail /
|
||||
* SeedAdminPassword in src/api/MicCheck.Api/Data/DatabaseSeeder.cs. Seeding only ever runs in
|
||||
* Development, which is what both local dev and the QA deployment run as.
|
||||
*/
|
||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
|
||||
|
||||
export const authFile = path.join(__dirname, '.auth', 'admin.json');
|
||||
|
||||
export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:5173';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ baseURL });
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
|
||||
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
|
||||
await page.getByTestId('login-submit').click();
|
||||
|
||||
// A successful login redirects off /login onto the authenticated shell.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
await browser.close();
|
||||
}
|
||||
32
src/admin/e2e/login.e2e.ts
Normal file
32
src/admin/e2e/login.e2e.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Unauthenticated — does not use the shared storageState fixture (see global-setup.ts) since
|
||||
* it exercises the login flow itself.
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
|
||||
|
||||
test('valid credentials sign the admin in and land on the authenticated shell', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
|
||||
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
|
||||
await page.getByTestId('login-submit').click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
await expect(page.getByText('Features', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('invalid credentials surface a login error and stay on the login page', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
|
||||
await page.getByTestId('password-input').locator('input').fill('not-the-right-password');
|
||||
await page.getByTestId('login-submit').click();
|
||||
|
||||
await expect(page.getByTestId('login-error')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
23
src/admin/e2e/navigation.e2e.ts
Normal file
23
src/admin/e2e/navigation.e2e.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { authFile } from './global-setup';
|
||||
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
test('the sidebar renders and each primary link navigates to its view', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
const links: Array<[name: string, path: string]> = [
|
||||
['Dashboard', '/dashboard'],
|
||||
['Features', '/features'],
|
||||
['Segments', '/segments'],
|
||||
['Identities', '/identities'],
|
||||
['Audit Logs', '/audit-logs'],
|
||||
['Settings', '/settings'],
|
||||
];
|
||||
|
||||
for (const [name, path] of links) {
|
||||
// Sidebar entries are clickable divs (VerticalNavLink), not <a> elements, so match by text.
|
||||
await page.locator('li').filter({ hasText: name }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`${path}$`));
|
||||
}
|
||||
});
|
||||
60
src/admin/package-lock.json
generated
60
src/admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
29
src/admin/playwright.config.ts
Normal file
29
src/admin/playwright.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke-test suite for the admin SPA. Runs against a real, already-running deployment
|
||||
* (local dev server or a deployed QA environment) — it does not mock the API and does not
|
||||
* start any servers itself. See e2e/global-setup.ts for how authentication is bootstrapped.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.e2e.ts',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// Deterministic credentials for the development/QA seed admin user. Only ever created when
|
||||
/// <c>SeedAsync</c> runs, which is gated behind <c>IsDevelopment()</c> in Program.cs.
|
||||
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate
|
||||
/// without depending on per-run registration.
|
||||
/// </summary>
|
||||
public const string SeedAdminEmail = "admin@miccheck.local";
|
||||
public const string SeedAdminPassword = "MicCheckQa!2026";
|
||||
|
||||
public DatabaseSeeder(MicCheckDbContext db)
|
||||
/// <summary>Deterministic environment key for the seeded Development environment, so
|
||||
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
|
||||
public const string SeedDevelopmentEnvironmentKey = "env-qa-development";
|
||||
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly IPasswordHasher<User> _passwordHasher;
|
||||
|
||||
public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> 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);
|
||||
}
|
||||
}
|
||||
|
||||
55
tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs
Normal file
55
tests/api/MicCheck.Api.Tests.Integration/Auth/AuthSeed.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using MicCheck.Api.Tests.Integration.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// A stand-in for the real API's <c>User</c> type: <see cref="PasswordHasher{TUser}"/> 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 <c>ProjectReference</c> to the API.
|
||||
/// </summary>
|
||||
public class HashedUser;
|
||||
|
||||
public sealed record AuthSeed(int OrganizationId, int UserId, string Email, string Password)
|
||||
{
|
||||
public static async Task<AuthSeed> 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<HashedUser>().HashPassword(new HashedUser(), password);
|
||||
|
||||
var organizationId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"",
|
||||
("name", $"org_{unique}"),
|
||||
("createdAt", now));
|
||||
|
||||
var userId = await db.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
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));
|
||||
}
|
||||
}
|
||||
95
tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs
Normal file
95
tests/api/MicCheck.Api.Tests.Integration/Auth/AuthTests.cs
Normal file
@@ -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<string, string>
|
||||
{
|
||||
["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<LoginResponseDto>(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<string, string>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["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<EnvironmentDocumentDto>(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<FlagResponseDto> FeatureStates, EnvironmentProjectDto Project);
|
||||
private sealed record EnvironmentProjectDto(int Id, string Name, List<object> Segments);
|
||||
private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue);
|
||||
private sealed record FeatureSummaryDto(int Id, string Name);
|
||||
}
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["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<List<FlagResponseDto>>(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);
|
||||
}
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
### Login
|
||||
POST {{baseUrl}}/api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"email": "{{email}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
### GetEnvironmentDocument
|
||||
GET {{baseUrl}}/api/v1/environment-document
|
||||
X-Environment-Key: {{environmentKey}}
|
||||
Accept: application/json
|
||||
@@ -0,0 +1,4 @@
|
||||
### GetIdentity
|
||||
GET {{baseUrl}}/api/v1/identity?identifier={{identifier}}
|
||||
X-Environment-Key: {{environmentKey}}
|
||||
Accept: application/json
|
||||
@@ -0,0 +1,103 @@
|
||||
using MicCheck.Api.Tests.Integration.Common;
|
||||
|
||||
namespace MicCheck.Api.Tests.Integration.Identities;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>/api/v1/identity</c>.
|
||||
/// </summary>
|
||||
public sealed record IdentityOverrideSeed(
|
||||
int OrganizationId,
|
||||
int EnvironmentId,
|
||||
int FeatureId,
|
||||
string EnvironmentApiKey,
|
||||
string FeatureName,
|
||||
string Identifier)
|
||||
{
|
||||
public static async Task<IdentityOverrideSeed> 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<int>(
|
||||
"INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"",
|
||||
("name", $"org_{unique}"),
|
||||
("createdAt", now));
|
||||
|
||||
var projectId = await db.ExecuteScalarAsync<int>(
|
||||
"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<int>(
|
||||
"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<int>(
|
||||
"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<int>(
|
||||
"""
|
||||
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<int>(
|
||||
"INSERT INTO \"Identities\" (\"Identifier\", \"EnvironmentId\", \"CreatedAt\") VALUES (@identifier, @environmentId, @createdAt) RETURNING \"Id\"",
|
||||
("identifier", identifier),
|
||||
("environmentId", environmentId),
|
||||
("createdAt", now));
|
||||
|
||||
await db.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
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<string> 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));
|
||||
}
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["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<IdentityResponseDto>(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<object> Traits, List<FlagResponseDto> Flags);
|
||||
private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue);
|
||||
private sealed record FeatureSummaryDto(int Id, string Name);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
<PackageReference Include="NUnit" Version="4.5.1" />
|
||||
|
||||
@@ -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<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
_seeder = new DatabaseSeeder(_db, new PasswordHasher<User>());
|
||||
}
|
||||
|
||||
[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<User>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user