diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 8f2dd3c2..28781ffa 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -326,10 +326,15 @@ dotnet ef migrations script \ # FORCE ROW LEVEL SECURITY exists to defeat. ConnectionStrings:Migration must never # appear in API or worker runtime configuration # (docs/standards/05-database.md § Database roles). -dotnet ef database update \ - --project backend/src/Modules//LearnStack.Modules..Infrastructure \ - --startup-project backend/src/LearnStack.Api \ - --connection "$ConnectionStrings__Migration" +( + migration_cs="$ConnectionStrings__Migration" + unset ConnectionStrings__Migration + dotnet build backend/src/LearnStack.Api --configuration Release --nologo || exit "$?" + ConnectionStrings__Migration="$migration_cs" dotnet ef database update \ + --project backend/src/Modules//LearnStack.Modules..Infrastructure \ + --startup-project backend/src/LearnStack.Api \ + --configuration Release --no-build +) ``` `ConnectionStrings__Migration` is the environment spelling of diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 867d5cd5..5b124bf2 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -57,7 +57,7 @@ architecture test) plus any other invariant the change touches. See |-------|----------|-------------| | Scenario | Yes | A short name + setup + act + assert. | | Seed | Yes | Minimum tenants / orgs / users / customization data the scenario needs. | -| Required containers | Yes | **Postgres, always and only.** Not Valkey, not Kafka — nothing the backend runs calls them. Meilisearch / LiveKit / SeaweedFS only for a provider-contract test, and only from the phase that ships the adapter. | +| Required containers | Yes | PostgreSQL for application data; pinned Keycloak with its separate PostgreSQL for actual identity-provider proofs. Other providers require a shipping adapter consumer under ADR-0035. | | Tenant context | Yes | Which tenant + org the act phase runs as. | ## Workflow @@ -85,9 +85,29 @@ architecture test) plus any other invariant the change touches. See > Anything touching either carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, > which is how CI routes it to `backend-integration` rather than `backend`. -What the fixtures do today: - -- Spin **Postgres only** via Testcontainers. Not Valkey, not Kafka — nothing the +**P02b-6 provider fixture.** Real issuance uses +[`TenancyIdentity/KeycloakFixture`](../../../backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs) +with pinned Keycloak and its own PostgreSQL database. Share read-only provider +state through `SharedKeycloakRealm`; mutating classes and variants own separate +providers and restore case-owned changes before returning. +Its generated CA is trusted only by the fixture's HTTPS clients, with normal +hostname validation and a wrong-root refusal control. Never install a fixture CA +in the host trust store or bypass TLS/cookie rules to get a passing login. +Use real code/S256 authentication, keep credentials/tokens out of assertions and +logs, and never follow the unimplemented BFF callback to claim session coverage. +This fixture does not replace application-role isolation proofs. Every consuming +test class has the Docker trait, including classes outside `Database/`. +Real-token API qualification uses the separate nonparallel +`TenantRealmApiTestGroup`, combining this provider with `PublicReadFixture` while +retaining the existing physical read-only/application-role observer. Do not add +an application database to the provider-only collection. Profile protection uses +a separate class fixture, with serialized cases and owned restoration. Do not +mutate the shared read-only provider. No fixture-only grant or mapper may supply +a delivered self-service baseline that the committed import/reconciler lacks. + +What the database fixtures do today: + +- Spin **Postgres** via Testcontainers. Not Valkey, not Kafka — nothing the backend runs today calls either, and both sit behind the `gated` compose profile per [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md). - Provision the **four database roles** by running the same script the compose diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index dc33a7b3..06857e0d 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -54,7 +54,7 @@ the backend runs today calls them, so `make dev` starts 7 services and | OpenSSL CLI | Yes for frontend tests | Isolated TLS fixtures generate temporary certificates without installing trust. | | Node >=22.23.1 + pnpm | Yes | For the frontend; `frontend/package.json` sets the floor and CI pins `22.23.1`. | | Deployment mode | Yes | `Development` (default) / `SaaS` / `Dedicated` / `SelfHostedOnline` / `SelfHostedAirGapped` (per [Standards 12 § Deployment Modes](../../../docs/standards/12-infrastructure.md)). | -| `.env` (gitignored) | Optional | Local overrides; `.env.example` is the source of truth. | +| Selected private `.env` (gitignored) | Yes before stack startup | `.env.example` is the template; preserve existing secrets and private file permissions. | ## Workflow @@ -81,13 +81,21 @@ If any of these is missing, install: git clone learnstack cd learnstack -cp .env.example .env # creates the local-only file (gitignored) -# Edit .env to override any defaults; for first-run, leave as-is. +make .env # creates a missing local-only file with private permissions +# Before make dev, prepare KEYCLOAK_WEB_CLIENT_SECRET in that private file. (cd backend && dotnet restore LearnStack.slnx --locked-mode) (cd frontend && pnpm install --frozen-lockfile) ``` +Never overwrite an existing private source or regenerate its BFF secret on a +routine rerun. For a legacy source, first verify it is your regular, nonsymlink, +single-link file, then explicitly set `chmod 600 .env` and add a missing key. +The tool refuses shared permissions; Makefile creation does not repair them. +Follow [secret continuity and recovery](../../../infra/keycloak/README.md#secret-continuity-and-recovery) +if the selected source disagrees with an existing confidential client. Keep its +contents out of tool output, command arguments and committed files. + ### Step 3: Bring up the stack ```bash @@ -178,12 +186,15 @@ Three properties of that inventory matter while you are setting up: loopback HTTP through `make public-api` (5080), and the native web listener runs on loopback HTTPS through `make public-web` (3000). -To read the resolved truth rather than any document, ask the stack: +To inspect selected services without printing resolved credentials, ask the stack: ```bash -docker compose --env-file .env -f infra/compose/dev.yml config --format json +docker compose --env-file .env -f infra/compose/dev.yml config --services ``` +Do not publish complete resolved Compose configuration: it contains private +environment values. + ### Step 4: First-run bootstrap `make seed` starts the selected stack, waits for PostgreSQL before migrations, @@ -283,7 +294,9 @@ make public-api | `Bind for 127.0.0.1:5432 failed: port is already allocated` | Stop your local Postgres, or stop the other compose project holding the port — host ports are fixed in `dev.yml`, so two projects cannot both bind them. | | `relation "tenants" does not exist` | The owning Tenancy migrations have not landed or were not applied; check the active phase plan before adding an ad-hoc target. | | `unable to read app.tenant_id` | The `DbCommandInterceptor` tenant-context guard is unwired, or `TransactionBehavior` did not issue the `SET LOCAL` pair. It is deliberately **not** a connection-checkout interceptor — checkout precedes `BEGIN`. | -| Keycloak realm not found | Recreate local data with destructive `make clean`, then `make seed`. The realms are imported at compose boot from `infra/keycloak/realms/`, not by the seeder. | +| Keycloak realm not found | Verify the selected project, private source and Keycloak health/import. Fresh realms import on provider startup; a populated realm needs the shared bounded reconciliation from `make seed`. Follow the [realm guide](../../../infra/keycloak/README.md#fresh-import-and-warm-upgrade); do not wipe data as upgrade recovery. | +| `missing_web_secret` / `secret_conflict` | Follow [secret continuity and recovery](../../../infra/keycloak/README.md#secret-continuity-and-recovery); preserve the confidential client and existing accounts. | +| `lock_busy` | Follow [lock recovery](../../../infra/keycloak/README.md#lock-recovery); never delete a lock solely because it is old. | | A non-Development mode fails startup | Check that mode's required Sentry/telemetry configuration and [current readiness](../../../docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam). Every mode currently uses `NullEntitlementProvider`; starting Hub does not supply the Phase 02c adapter. | | LiveKit join fails with TURN error | coturn not reachable from the browser; check firewall + container network. | diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 4b4c1e08..7b40aecc 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -35,7 +35,7 @@ flags, and a triage map for the most common failure shapes. |-------|----------|-------------| | Suite | Yes | `unit` / `integration` / `architecture` / `contract` / `frontend` / `public-demo` (P02d-7). Phase 06 owns the broader Playwright Test/axe suite. | | Filter | No | `--filter ` to run a subset. | -| Docker available? | Integration (`Requires=Docker`) | A running daemon. Postgres only — no Valkey, no Kafka. | +| Docker available? | Integration (`Requires=Docker`) | A running daemon for PostgreSQL and the pinned Keycloak/PostgreSQL provider fixture. Other adapters retain their named triggers. | ## Workflow @@ -55,7 +55,7 @@ pnpm --version (cd backend && dotnet restore LearnStack.slnx) (cd frontend && pnpm install --frozen-lockfile) -# Docker must be running for the integration suite's Database/ cases +# Docker must be running for Database/ and TenancyIdentity/ provider cases docker info >/dev/null && echo "docker OK" ``` @@ -66,8 +66,8 @@ backend/tests/ LearnStack.Tests.Unit/ # No DB, no Docker. Pure unit tests. LearnStack.Tests.Architecture/ # Reflection + Roslyn + migration-scan rules. LearnStack.Tests.Integration/ # WebApplicationFactory HTTP tests (no Docker) AND the - # Testcontainers Postgres suite under Database/ (as - # learnstack_app), split by [Trait("Requires","Docker")]. + # Postgres (learnstack_app) and TenancyIdentity/ + # Keycloak/Postgres, split by [Trait("Requires","Docker")]. LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. frontend/apps/web/ # Guarded Vitest unit/component/control suites. @@ -118,15 +118,16 @@ before concluding a net is under you. | `MediatR_Pipeline_Order_Matches_Canonical_Sequence` | A behavior moved; the eight-step order is fixed by [ADR-0032](../../../docs/decisions/0032-exception-handling-logging-and-observability.md). | | `Integration_Event_TopicNames_FollowConvention` | Topic isn't `learnstack.{module}.{aggregate}`. | | `No_Source_Folder_Named_Verticals` | A `Verticals/` folder exists; ADR-0018 forbids it. | -| `Every_Database_Test_Carries_The_Docker_Trait` | A test class under `Database/`, or one anywhere that takes a database fixture, is missing `[Trait(RequiresDocker.Key, RequiresDocker.Value)]` and would run in the wrong CI job. | +| `Every_Database_Test_Carries_The_Docker_Trait` | A test class under `Database/`, or one anywhere referencing a database/Keycloak fixture or collection, is missing `[Trait(RequiresDocker.Key, RequiresDocker.Value)]` and would run in the wrong CI job. | | `Migrate_Target_Covers_Every_Migration_Chain` | A new chain exists that `make migrate` does not apply. | ### Step 5: Run integration tests -The assembly holds two kinds. The `WebApplicationFactory` HTTP tests need no -Docker; everything under `Database/` does, and **a running Docker daemon is -required** for it — Postgres only, no Valkey and no Kafka, because nothing the -backend runs calls either +The assembly holds Docker-free HTTP/offline tests and Docker-backed proofs. +Everything under `Database/` requires PostgreSQL; `TenancyIdentity/` also includes +the pinned Keycloak fixture with its separate PostgreSQL database. **A running +Docker daemon is required** for both provider populations. Neither needs Valkey +or Kafka, because nothing the backend runs calls either ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). The two are split by `[Trait("Requires","Docker")]`; CI further partitions Docker tests into native/ordinary execution jobs behind the required integration aggregate. Locally diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 16bb6e95..a7e9f7f3 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -6,7 +6,7 @@ description: > fixtures and data-only showcases. P02d-2 includes enabled locales, built-in and tenant-specific definitions, complete branding and scoped Course/Lesson content. DO NOT USE FOR: production provisioning, production reseeding, Hub licensing, - identity setup or domain-specific application branches. + production identity provisioning or domain-specific application branches. --- # Seeding a tenant @@ -56,20 +56,38 @@ to a BYPASSRLS/superuser role on every physical connection. ### 1. Check the environment and migrations +Prepare the selected private source before `make dev` or `make seed`: it must +include `KEYCLOAK_WEB_CLIENT_SECRET`, retain an existing confidential secret, +and be a current-user-owned regular, nonsymlink, single-link file without +group/other permissions. For a verified legacy file, explicitly run +`chmod 600 .env` (or the selected path); Makefile creation does not repair it. +Follow [secret continuity and recovery](../../../infra/keycloak/README.md#secret-continuity-and-recovery) +on mismatch. Do not read secrets into an agent transcript or place them on argv. + ```bash make seed ``` This target starts the selected stack and waits for PostgreSQL before migration. `scripts/seed.sh` then checks full Compose health and both realm issuers before -running the .NET tool. `scripts/compose.mjs` shares project/files/env selection -with the demo lifecycle; inherited Compose profiles cannot widen its default set. +running the fresh Release Seeder DLL and then the shared development-only realm +reconciler. Both combined entries reject an incomplete selected private realm +source before infrastructure work; older files must explicitly add the BFF +secret key. The [realm guide](../../../infra/keycloak/README.md) owns the exact +contract and delivery status. `scripts/compose.mjs` shares project/files/env +selection with the demo lifecycle; inherited Compose profiles cannot widen its +default set. +It verifies the effective Docker context before starting the selected stack. +Seed preparation reports fixed stage progress and closed failure reasons rather +than forwarding potentially sensitive child output. On cancellation/failure, +wait for owned cleanup; follow [lock recovery](../../../infra/keycloak/README.md#lock-recovery) +if the previous realm owner left its exclusive lock. The direct tool runs from `backend/`, where `global.json` pins the SDK: ```bash (cd backend && ConnectionStrings__Default="" \ - dotnet run --project src/LearnStack.Tools.Seeder --nologo) + dotnet run --project src/LearnStack.Tools.Seeder --configuration Release) ``` Do not put a real connection string on `argv` or print it. The script reads the @@ -223,4 +241,12 @@ localized schemas/bodies/pins supported by shipped contracts. Derive inventory a test expectations from the declaration. A new locale must be enabled before its translation is written. Domain-specific application behavior requires the owning phase's platform-feature decision; it cannot be hidden inside seed orchestration. -Keycloak users still come from compose realm imports, not this tool. +P02b-6 Step 1 declares development authentication personas in `SeedData.IdentityUsers` +and exposes credential-free `--identity-manifest`; that mode opens no data source. +Fresh Keycloak accounts come from the checked realm import. The accepted +[P02b-6 package](../../../docs/roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +owns the separate development reconciler, awaited by both combined seed/demo +entries after application seed convergence. Its +[Step 4 record](../../../docs/roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns real account/API qualification and the completed independent reviews. +Direct .NET seeding creates no provider account or application membership. diff --git a/.env.example b/.env.example index 15c18e8b..a432ad9f 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,10 @@ ConnectionStrings__OutboxDispatcher='Host=localhost;Port=5432;Database=learnstac # Authentication__Tenant__Issuer=https://identity.example/realms/learnstack KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=admin-dev-secret +# Server-only BFF client source. Old local .env files must add it explicitly; +# reconciliation verifies an existing confidential secret and never rotates it. +# Never pass this value to the API, public renderer or .NET Seeder. +KEYCLOAK_WEB_CLIENT_SECRET=learnstack-web-dev-secret # ─── Vault (-dev mode; token shared with Dapr secret-store component) ──── # Boots the `vault` compose service via `-dev-root-token-id` AND is passed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4de609e9..5ed8185d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,6 +289,10 @@ jobs: working-directory: frontend run: pnpm install --frozen-lockfile + - name: Prepare pinned provider fixture images within the whole-job budget + timeout-minutes: 2 + run: python3 scripts/ci-test-shards.py prepare-fixtures + - name: Verify stock browser for admission navigation proof run: | test -x /usr/bin/google-chrome @@ -656,7 +660,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: openapi-comparison + name: openapi-comparison-${{ github.run_id }}-${{ github.run_attempt }} path: artifacts/openapi if-no-files-found: error @@ -763,7 +767,7 @@ jobs: timeout-minutes: 1 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: public-demo-lighthouse + name: public-demo-lighthouse-${{ github.run_id }}-${{ github.run_attempt }} path: | artifacts/public-demo/receipt.json artifacts/public-demo/audit-*.json @@ -785,6 +789,16 @@ jobs: fetch-depth: 0 # link audit walks the full tree persist-credentials: false + - name: Set up pinned Node.js for offline realm controls + timeout-minutes: 1 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Development realm transport, ownership and convergence controls + timeout-minutes: 1 + run: python3 scripts/run-realm-node-tests.py + - name: Compose stack validates, with and without the gated profile run: | # Nothing validated these files before, and the profile they now carry @@ -796,6 +810,7 @@ jobs: # local development loop stops, for every developer, on a one-line edit. # The default projection is therefore the one that must be checked; the # gated one is checked too, since a broken edge there is invisible to it. + trap 'rm -f .env' EXIT cp .env.example .env for profile in "" "gated"; do label="${profile:-}" @@ -955,6 +970,9 @@ jobs: - name: CI supply-chain controls run: python3 scripts/test-ci-supply-chain.py + - name: Realm Node executed-case and JUnit gate controls + run: python3 scripts/test-realm-node-gate.py + - name: Read-only native environment and duration logging controls run: python3 scripts/test-ci-native-environment.py @@ -1108,6 +1126,6 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: leakwatch-results + name: leakwatch-results-${{ github.run_id }}-${{ github.run_attempt }} path: results.sarif if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index 742052fe..518c6fdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,27 +254,70 @@ owns validation and completed reviews. Step 5 completes production enforcement and both independent review rounds; its [local closeout](docs/roadmap/phase-02b-events-auth.md#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) separates source-bound CI receipts from live PR-head qualification and merge. -All five steps are locally complete and unmerged. A subsequent +All five steps are complete. A subsequent [review remediation](docs/roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) verifies external findings. ADR-0065 is Accepted on 2026-10-20 after maintainer review. Its implementation and local proofs are delivered; the remediation -record owns completed independent correction reviews and current PR-head CI -qualification. Realm reconciliation, BFF -sessions and phase exit remain -P02b-6–8. +record retains completed independent correction reviews and pre-merge receipts. +BFF sessions and phase exit remain P02b-7–8; P02b-6's current record follows below. The [second external-review follow-up](docs/roadmap/phase-02b-events-auth.md#p02b-5-second-external-review-follow-up-2026-10-20) owns subsequent runtime/proof corrections and their source-bound reviews. [ADR-0066](docs/decisions/0066-bounded-tenant-subject-admission.md) is Accepted — 2026-10-20. Its retained-subject bound, cleanup ownership and closed fault/refusal signals are implemented with local runtime/exported proofs. The [implementation record](docs/roadmap/phase-02b-events-auth.md#adr-0066-acceptance-and-implementation-2026-10-20) -owns source-bound validation, independent reviews and final-source CI qualification. +retains source-bound validation and independent reviews; the merge closeout below +owns final-source qualification. The [systematic correction record](docs/roadmap/phase-02b-events-auth.md#p02b-5-systematic-proof-and-document-corrections-2026-10-20) owns subsequent proof/diagnostic, document and measured CI timing reconciliation; all three correction steps and both independent review rounds are complete. -The live PR handoff owns exact current-source hosted qualification, including -fresh A/B qualification of the reviewed timing revision under ADR-0063. -No next packet or merge is authorized by this work. +**P02b-5 is complete and merged** through +[PR #33](https://github.com/HodeTech/LearnStack/pull/33) on 2026-10-20. Its +[merge closeout](docs/roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) +owns exact final-head CI, unchanged-plan A/B qualification and identical merge +trees. P02b-6's realm/TTL +[decision pass](docs/roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +is Accepted — 2026-10-21. The maintainer approved the package at `6d75e532` +and its four implementation steps. G12's realm/reconciliation/callback choices +and G13's issuance-TTL part are Accepted; G13's session part stays Open. +The dedicated issuance follow-up, +[ADR-0067](docs/decisions/0067-development-tenant-access-token-issuance.md), is +Accepted — 2026-10-21 after separate maintainer approval. It formalizes the +existing issuance choice without changing runtime or G13's Open session portion. +P02b-6 Step 1 is complete locally after both review rounds; its +[record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-1--executable-realm-and-identity-declaration-2026-10-21) +owns implementation, fresh-provider qualification and review status. +P02b-6 Step 2 is complete locally after both review rounds and fresh fix +reviews; its +[record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-2--owned-warm-realm-reconciliation-2026-10-21) +owns the Accepted server-information response allowance and current proofs. +Step 3 is complete locally after both review rounds and fresh correction +verification; its +[record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21) +owns actual seed/demo and cancellation evidence. Step 4's +[record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns completed local implementation/reviews and source-bound qualification receipts. +The correction record below owns source-bound hosted qualification; merge is +separate. +All four implementation steps and their recorded correction reviews are complete +locally. The subsequent systematic correction pass retains its own review and +current-head hosted qualification gates in the +[correction record](docs/roadmap/phase-02b-events-auth.md#p02b-6-systematic-corrections-and-hosted-qualification-2026-10-21). +PR review and merge remain separate. +The [additional correction record](docs/roadmap/phase-02b-events-auth.md#p02b-6-additional-verification-and-correction-pass-2026-10-22) +owns the current verified findings. +[ADR-0068](docs/decisions/0068-development-warm-realm-qualification.md) +is Accepted — 2026-10-22 under the maintainer's conditional approval after verified +review corrections. Its first correction step is complete locally after both +review rounds; the [stock qualification record](docs/roadmap/phase-02b-events-auth.md#adr-0068-step-1--qualified-stock-assignment-implementation-2026-10-22) +owns evidence. Its [second correction step](docs/roadmap/phase-02b-events-auth.md#adr-0068-step-2--owned-visibility-preparation-2026-10-22) +completes owned preparation and fresh complete planning after both independent +review rounds. Its [third correction step](docs/roadmap/phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +completes local historical entry, credential/MFA, signed API and self-service +proof after both independent review rounds. That record owns exact source-bound +hosted assignments and their evidence limits; merge remains separate. +P02b-7–8 remain pending; +Phase 02b remains in progress. Phase 06 retains the full renderer/Studio expansion. @@ -487,10 +530,18 @@ Then read the phase that is live and the one it stands on: completed both review rounds. Its merge closeout above owns final verification. P02b-4 is complete and merged through PR #32 on 2026-10-19; its merge closeout above owns final verification. P02b-5's decision package and ADR-0064 are Accepted; - all five implementation steps and both review rounds per step are locally - complete and unmerged. Its closeout above owns CI/handoff status. P02b-6–8 remain - pending, with each - packet accepting its gates first. + all five implementation steps and correction reviews are complete and merged + through PR #33 on 2026-10-20. Its merge closeout above owns verification. + P02b-6's realm/TTL decision package and four-step plan above are Accepted — + 2026-10-21. Its [Step 1 record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-1--executable-realm-and-identity-declaration-2026-10-21) + owns the executable fresh import and provider proofs. Step 2's record above + owns the completed local reconciler and Accepted response-budget decision; + Step 3 is complete locally after both review rounds and fresh corrections; + its record above owns actual lifecycle evidence. Step 4's record above owns + local implementation and source-bound qualification receipts; the correction + record above owns final hosted qualification and its evidence limits. + P02b-7–8 retain their + own decision passes; G13's session part stays Open. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. `02d` sorts after `02b`/`02c` but runs **before** them; the roadmap dependency diff --git a/Makefile b/Makefile index cc5babd8..919e5ec4 100644 --- a/Makefile +++ b/Makefile @@ -131,18 +131,9 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta @# SECURITY exists to defeat — every isolation test would then pass against @# policies that constrain nothing. This target is where the credential lives. @# - @# `--connection` is passed explicitly rather than letting the startup project - @# resolve one, because that would be ConnectionStrings:Default — the - @# learnstack_app role, which holds USAGE but not CREATE on schema public and - @# fails with `permission denied for schema public`. The tempting fix for that - @# error (granting it CREATE) is the ownership mistake above. - @# - @# `--connection` alone is NOT enough, which is why the value is also exported. - @# `dotnet ef` consumes `--connection` in its own parser and applies it to the - @# context AFTER the design-time factory has returned, so the factory never - @# sees it in `args` and throws first on a workstation whose value lives only - @# in `.env`. Measured. The export is what lets the factory construct; the - @# flag is what EF then applies. + @# Every design-time factory reads only ConnectionStrings__Migration, never + @# the startup application's Default connection. Export to that confined + @# factory; do not duplicate the credential on the process command line. @# Four things this recipe does that a naive version does not, each because the @# naive version was measured doing the wrong thing: @# @@ -178,7 +169,10 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta @# 7. Build the startup graph once in Release, with restore for standalone use. @# Every EF invocation uses that fresh output; the demo launches the same API. @# Explicit failures also stop old Make versions without shell errexit. + @# 8. Save and unexport the migration credential before restore/build; only + @# the no-build EF child receives it, through its process environment. @migration_cs="$${ConnectionStrings__Migration:-}"; \ + unset ConnectionStrings__Migration; \ migration_env="$${LEARNSTACK_COMPOSE_ENV_FILE:-.env}"; \ if [ -z "$$migration_cs" ] && [ -f "$$migration_env" ]; then \ migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" "$$migration_env" \ @@ -204,7 +198,6 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta echo "expected shape is the key/value list in .env.example."; \ exit 1; \ fi; \ - export ConnectionStrings__Migration="$$migration_cs"; \ (cd backend && dotnet tool restore >/dev/null) || exit $$?; \ (cd backend && dotnet build src/LearnStack.Api --configuration Release --nologo) || exit $$?; \ found=0; \ @@ -218,11 +211,10 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta applied="$$applied$$proj "; \ found=1; \ echo "==> $$(basename $$proj)"; \ - (cd backend && dotnet ef database update \ + (cd backend && ConnectionStrings__Migration="$$migration_cs" dotnet ef database update \ --project "../$$proj" \ --startup-project src/LearnStack.Api \ - --configuration Release --no-build \ - --connection "$$migration_cs") || failed=1; \ + --configuration Release --no-build) || failed=1; \ done; \ if [ "$$found" = "0" ]; then \ echo "No project carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ @@ -307,7 +299,9 @@ wait-postgres: ## Wait for the selected stack's PostgreSQL before migrations. seed-config: .env ./scripts/seed.sh --validate-config -seed: seed-config dev wait-postgres migrate ## Bring the stack up, apply migrations, seed demo data (idempotent). +seed: seed-config ## Start the selected stack, migrate, and converge application data and the development realm. + @# The Node owner runs dev/wait/migrate with a credential allowlist. Making + @# them prerequisites here would leak inherited Keycloak secrets into EF. ./scripts/seed.sh # ─── Bootstrap ──────────────────────────────────────────────────────────── @@ -331,6 +325,6 @@ hooks: ## Activate the repo's pre-commit hook (.githooks/pre-commit). @# `cp -n` returns NON-ZERO on BSD/macOS when the target exists, so a bare @# `cp -n` aborts this rule for every developer whose `.env` predates the @# last `.env.example` edit — i.e. everyone, the first time they pull one. - @[ -f .env ] || cp .env.example .env + @umask 077; [ -f .env ] || cp .env.example .env @touch .env @printf "$(CYAN).env ready (copied from .env.example if missing).$(RESET)\n" diff --git a/README.md b/README.md index fd68e274..82aa0f9b 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,22 @@ owns final verification. [merge closeout](docs/roadmap/phase-02b-events-auth.md#p02b-4-merge-and-closeout-2026-10-19) records all nine job-runtime steps, three CI steps, independent reviews and both complete final-source hosted assignments. Production business and recurrence -definitions remain empty. **P02b-5 API authentication is locally complete and -unmerged** under Accepted ADR-0064/0065/0066. All five steps completed both independent -review rounds and verified corrections. Its -[Step 5 closeout](docs/roadmap/phase-02b-events-auth.md#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) -records implementation, exact CI evidence and final PR-head qualification limits. -Realm reconciliation, BFF sessions and phase exit remain P02b-6–8; browser sign-in -is not implemented. +definitions remain empty. **P02b-5 API authentication is complete and merged** +through [PR #33](https://github.com/HodeTech/LearnStack/pull/33) on 2026-10-20, +under Accepted ADR-0064/0065/0066. All five steps and verified correction bundles +completed their independent review rounds. Its +[merge closeout](docs/roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) +records final-source CI, unchanged-plan A/B qualification and identical merge trees. +P02b-6 implements real issuance, protected profiles and safe shared seed/demo +reconciliation. Its +[Step 4 record](docs/roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +retains completed implementation/review evidence and source-bound qualification +receipts. ADR-0068's +[three correction steps](docs/roadmap/phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +complete local historical upgrade/credential/API/self-service proof and both +independent review rounds. Qualification applies to the recorded source and scope; +the correction record owns exact hosted assignments and merge remains separate. +BFF sessions and phase exit remain P02b-7–8; browser sign-in is not implemented. Phase 02b remains in progress. [P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is **complete and merged**: Education domain, schema and isolation proofs. @@ -144,8 +153,8 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) is merged; the [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) -owns final verification. P02b-0 through P02b-4 are complete. P02b-0 has an entry -verification; P02b-1–4 have the merge closeouts above for final source/merge +owns final verification. P02b-0 through P02b-5 are complete. P02b-0 has an entry +verification; P02b-1–5 have the merge closeouts above for final source/merge verification. P02b-3 Steps 1–5 implement durable acceptance, shared inbox/ledger, independent subscriber scheduling and both useful defaults with semantic Seeder handoff. Its @@ -164,7 +173,7 @@ page consumers and P02d-7 the two-host browser demo. | **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation, create-only provisioning defaults and semantic built-in seed verification | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) | | **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) | | **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Full versioned curriculum in [Phase 05](docs/roadmap/phase-05-education-learning-content.md) | -| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, durable events/subscriber delivery, background jobs and the closed tenant bearer/AccountSite boundary | Realm reconciliation and BFF sessions in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, durable events/subscriber delivery, background jobs, the closed tenant bearer/AccountSite boundary and development realm reconciliation | BFF sessions and phase-exit enforcement in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | | **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Full renderer/Studio and Playwright Test/axe in [Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and @@ -202,13 +211,22 @@ make hooks # optional: install the repository Git hooks Continue with the manual HTTPS prerequisites below, then run `make demo`. It creates missing private configuration and restores dependencies without changing -Git hooks. `make seed` remains available for infrastructure/database-only use. +Git hooks. `make seed` converges application data and the development identity +realm without starting the API or web. Direct .NET Seeder remains data-only. The seed provisions `demo-english` and `demo-yoga`, their organizations and host mappings, enabled locales, distinct branding, built-in and tenant-authored definitions, and eight scoped courses, ten lessons and twenty-seven translations. It uses the ordinary authoring pipeline and verifies completed acts before skipping them on rerun. [SeedData](backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns the complete inventory. +Both combined entries then await the same bounded development-realm reconciler. +Older private files must explicitly add `KEYCLOAK_WEB_CLIENT_SECRET`; existing +passwords/MFA and provider IDs are preserved. Prepare this secret before the +first `make dev` import, and retain it across reruns. Older files also need +explicit private permissions after verifying their ownership/type; Makefile +creation does not repair existing files. The +[realm guide](infra/keycloak/README.md#secret-continuity-and-recovery) owns setup, +refusal, safe secret recovery and qualification details. ### 3. Prepare local HTTPS and start the applications @@ -275,8 +293,8 @@ Supply `Authentication:Tenant:Issuer` too: the canonical HTTPS tenant-realm URL, with loopback HTTP allowed only in `Development`. The development configuration defaults to `http://localhost:8080/realms/learnstack`; issuer configuration is validated without contacting Keycloak at startup. Credential-free public reads -remain available when Keycloak is stopped. Real realm issuance/reconciliation and -browser sessions remain P02b-6 and P02b-7 respectively. +remain available when Keycloak is stopped. P02b-6 owns current realm issuance/ +reconciliation qualification; browser sessions remain P02b-7. **P02d-5 is complete: ingress, visitor budgets and public entry.** The web bootstraps the captured host through the API, redirects using enabled/default @@ -319,7 +337,7 @@ three product surfaces. See the | Database | PostgreSQL 18 | Migrations, tenant / organization RLS and integration proofs | | Frontend | Next.js 15 · React 19 · TypeScript | App/UI scaffolds; generated SDK and injected transport implemented | | Observability | OpenTelemetry · Serilog → OTLP | Cross-cutting backend instrumentation implemented | -| Identity | Keycloak | P02b-5 tenant API bearer boundary active; real realm reconciliation and browser sessions remain P02b-6–7 | +| Identity | Keycloak | Tenant API bearer boundary, real issuance and safe development realm reconciliation implemented; browser sessions remain P02b-7 | | Storage and search | SeaweedFS · PostgreSQL full-text search | Selected architecture; feature delivery belongs to Phases 04–05 | | Live classroom | LiveKit through a provider adapter | Planned in Phase 08c, starting with Cloud and retaining a self-hosted path | diff --git a/backend/src/LearnStack.Api/Authentication/TenantAuthenticationBackchannel.cs b/backend/src/LearnStack.Api/Authentication/TenantAuthenticationBackchannel.cs index f197ad30..7e2ae96e 100644 --- a/backend/src/LearnStack.Api/Authentication/TenantAuthenticationBackchannel.cs +++ b/backend/src/LearnStack.Api/Authentication/TenantAuthenticationBackchannel.cs @@ -11,13 +11,26 @@ internal sealed class TenantAuthenticationBackchannel : IDisposable internal static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); internal const int MaximumDocumentBytes = 256 * 1024; - internal HttpClient Client { get; } = new(new SocketsHttpHandler + public TenantAuthenticationBackchannel() : this(new SocketsHttpHandler()) { } + + // Test-owned certificate authorities use the normal TLS chain/hostname checks. + // The transport bounds remain mandatory for every supplied handler. + internal TenantAuthenticationBackchannel(SocketsHttpHandler handler) { - AllowAutoRedirect = false, - ConnectTimeout = Timeout, - UseCookies = false, - }) - { Timeout = Timeout, MaxResponseContentBufferSize = MaximumDocumentBytes }; + ArgumentNullException.ThrowIfNull(handler); + if (handler.SslOptions.RemoteCertificateValidationCallback is not null) + throw new InvalidOperationException("Tenant authentication certificate validation cannot be replaced."); + handler.AllowAutoRedirect = false; + handler.ConnectTimeout = Timeout; + handler.UseCookies = false; + Client = new HttpClient(handler) + { + Timeout = Timeout, + MaxResponseContentBufferSize = MaximumDocumentBytes, + }; + } + + internal HttpClient Client { get; } public void Dispose() => Client.Dispose(); } diff --git a/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs b/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs index 50c64873..c87c895f 100644 --- a/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs +++ b/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs @@ -72,6 +72,11 @@ void Labels(string kind, IReadOnlyDictionary labels) } } } + foreach (var user in SeedData.IdentityUsers) + { + Id(user.UserId.Value); + Add("identity-username", user.Username); + } // Built-ins retain their semantic vocabulary; their tenant-owned identities // are generated at initialization and are not demo literals. Add("content-type-key", BuiltInCustomizations.Card.Key); diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index 2ecae1cb..c82846a6 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -15,6 +15,19 @@ Console.WriteLine(JsonSerializer.Serialize(DemoManifest.Create(), JsonSerializerOptions.Web)); return 0; } +if (args is ["--identity-manifest"]) +{ + try + { + Console.WriteLine(JsonSerializer.Serialize(SeedIdentityManifest.Create(), JsonSerializerOptions.Web)); + return 0; + } + catch (InvalidOperationException) + { + Console.Error.WriteLine("seed: development identity declaration is inconsistent."); + return 2; + } +} if (args.Length != 0) { Console.Error.WriteLine("seed: unsupported arguments."); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedData.cs b/backend/src/LearnStack.Tools.Seeder/SeedData.cs index c04a9880..e684d243 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedData.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedData.cs @@ -27,6 +27,20 @@ public static class SeedData YogaCurriculum()); public static readonly IReadOnlyList All = [English, Yoga]; + // Authentication personas are development data, not application memberships. + // The legacy usernames retain their Keycloak subjects/passwords on warm upgrades. + public static readonly ImmutableArray IdentityUsers = + [ + new(UserId.From(Id("01930000-0000-7000-8000-00000000a001")), English, + "demo-admin@tenant-a.test", "Demo", "Admin", "tenant-admin", new("account", "manage-account"), "tenant-a"), + new(UserId.From(Id("01930000-0000-7000-8000-00000000a002")), English, + "demo-learner@tenant-a.test", "Demo", "Learner", "tenant-learner", new("account", "manage-account"), "tenant-a"), + new(UserId.From(Id("01930000-0000-7000-8000-00000000b001")), Yoga, + "demo-admin@tenant-b.test", "Demo", "Admin", "tenant-admin", new("account", "manage-account")), + new(UserId.From(Id("01930000-0000-7000-8000-00000000b002")), Yoga, + "demo-learner@tenant-b.test", "Demo", "Learner", "tenant-learner", new("account", "manage-account")), + ]; + // Provisioning audits both sanctioned roots; follow-on organization and host each add one. // Each tenant initializes two built-ins once; authored definitions audit registration and publication. public static long ExpectedAuditWrites => Inventory.Tenants * 4L + Inventory.Locales @@ -168,6 +182,10 @@ public sealed record SeedTenant(TenantId TenantId, string Slug, string DisplayNa bool MapHostToDefaultOrganization, SeedCurriculum? Curriculum = null); public sealed record SeedOrganization(OrganizationId OrganizationId, string Slug, string DisplayName); +public sealed record SeedIdentity(UserId UserId, SeedTenant Tenant, string Username, + string FirstName, string LastName, string RealmRole, SeedAccountCapability AccountCapability, + string? LegacyTenantAttribute = null); +public sealed record SeedAccountCapability(string ClientId, string Role); public sealed record SeedCurriculum(ImmutableArray Locales, SeedContentType ContentType, SeedTaxonomy Taxonomy, SeedTheme Theme, ImmutableArray Courses); public sealed record SeedLocale(string Locale, bool IsEnabled, bool IsDefault, short Sort); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedIdentityManifest.cs b/backend/src/LearnStack.Tools.Seeder/SeedIdentityManifest.cs new file mode 100644 index 00000000..fc15991a --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/SeedIdentityManifest.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Tools.Seeder; + +/// Projects development identity facts without credentials or persistence access. +public sealed record SeedIdentityManifest(IReadOnlyList Users) +{ + public static SeedIdentityManifest Create() => Project(SeedData.IdentityUsers); + + private static SeedIdentityManifest Project(ImmutableArray users) + { + var scopeIds = SeedData.All.SelectMany(tenant => new[] + { tenant.TenantId.Value, tenant.DefaultOrganization.OrganizationId.Value, tenant.SecondOrganization.OrganizationId.Value }).ToHashSet(); + if (users.Length != 4 || users.Select(user => user.UserId).Distinct().Count() != users.Length + || users.Select(user => user.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count() != users.Length + || users.Any(user => !user.UserId.IsInitialized() || user.UserId.Value == Guid.Empty + || user.UserId == UserId.SystemActor || user.UserId.Value.Version != 7 + || scopeIds.Contains(user.UserId.Value) || !SeedData.All.Contains(user.Tenant) + || user.LegacyTenantAttribute is not null && !SeedData.IdentityUsers.Any(declared => + declared.LegacyTenantAttribute is not null && declared.UserId == user.UserId + && declared.Tenant == user.Tenant && declared.Username == user.Username + && declared.LegacyTenantAttribute == user.LegacyTenantAttribute) + || user.AccountCapability != new SeedAccountCapability("account", "manage-account"))) + { + throw new InvalidOperationException("Development identity declaration is inconsistent."); + } + + return new(users.Select(user => new IdentityManifestUser(user.UserId.Value, + user.Tenant.TenantId.Value, + user.Tenant.MapHostToDefaultOrganization ? user.Tenant.DefaultOrganization.OrganizationId.Value : null, + user.Tenant.Host, user.Username, user.Username, user.FirstName, user.LastName, + user.RealmRole, user.AccountCapability, user.LegacyTenantAttribute)).ToArray()); + } +} + +/// The credential-free wire projection consumed by development realm tooling. +public sealed record IdentityManifestUser(Guid UserId, Guid TenantId, Guid? OrganizationId, + string Host, string Username, string Email, string FirstName, string LastName, + string RealmRole, SeedAccountCapability AccountCapability, string? LegacyTenantAttribute); diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index 4d00bb79..4dd89575 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -104,6 +104,11 @@ public void Demo_Identity_Projection_Is_Complete_And_The_Frontend_Snapshot_Match } } } + foreach (var identity in SeedData.IdentityUsers) + { + values.Should().Contain(new DemoIdentity("id", identity.UserId.Value.ToString("D"))); + values.Should().Contain(new DemoIdentity("identity-username", identity.Username)); + } values.Where(row => row.Kind == "id").Select(row => row.Value).Should().OnlyHaveUniqueItems(); values.Should().NotContain(row => row.Value == "Published" || row.Value == "enrollment_required" || row.Value == "en"); var fixture = Path.Combine(RepositoryPaths.RepoRoot(), "frontend/apps/web/src/test/fixtures/demo-identities.json"); @@ -119,7 +124,10 @@ public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Da var identity = vocabulary.Values.First(row => row.Kind == "tenant-name").Value; var id = vocabulary.Values.First(row => row.Kind == "id").Value; var key = vocabulary.Values.First(row => row.Kind == "content-type-key" && !vocabulary.PlatformValues.Contains(row.Value)).Value; - foreach (var value in new[] { identity, key, Guid.Parse(id).ToString("B").ToUpperInvariant(), Guid.Parse(id).ToString("N") }) + var username = SeedData.IdentityUsers[0].Username; + var actor = SeedData.IdentityUsers[0].UserId.Value.ToString("D"); + foreach (var value in new[] { identity, key, username, actor, + Guid.Parse(id).ToString("B").ToUpperInvariant(), Guid.Parse(id).ToString("N") }) DemoLiteralViolations("Probe.cs", $"class Probe {{ string Value = {JsonSerializer.Serialize(value)}; }}", vocabulary) .Should().ContainSingle("each backend identity/customization/alternate UUID spelling must be seen"); var project = Path.GetFullPath("/repo/backend/src/Probe"); @@ -282,7 +290,8 @@ public void Seeder_Reference_Fence_Detects_Compiled_And_Restored_Edges() private static readonly string[] IdentityKinds = [ "id", "tenant-slug", "tenant-name", "host", "organization-slug", "organization-name", "content-type-key", "taxonomy-key", "definition-label", "band-key", "band-label", - "course-key", "course-slug", "course-title", "lesson-slug", "lesson-title", "property-name", "field-label" + "course-key", "course-slug", "course-title", "lesson-slug", "lesson-title", "property-name", "field-label", + "identity-username" ]; private static readonly string[] ProductionSourceRoots = [RepositoryPaths.BackendSrc(), Path.Combine(RepositoryPaths.RepoRoot(), "backend/analyzers")]; diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 14415cd9..125b92d7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -893,6 +893,10 @@ public void Every_Database_Test_Carries_The_Docker_Trait() files.Should().Contain(file => file.Item1.StartsWith("Database/", StringComparison.Ordinal), "the Docker-bound suite lives there, and a rule that scans nothing passes"); + var declarations = files.SelectMany(file => CSharpSyntaxTree.ParseText(file.Item2).GetRoot() + .DescendantNodes().OfType().Select(type => type.Identifier.ValueText)).ToHashSet(StringComparer.Ordinal); + DockerFixtures.Should().OnlyContain(name => declarations.Contains(name), + "renaming a fixture must update the Docker source census rather than silently remove its coverage"); UntraitedDockerTests(files).Should().BeEmpty( "every test class under Database/, and every one that takes a database fixture, " @@ -921,6 +925,17 @@ public void The_Docker_Trait_Sweep_Can_Actually_Fail() ("RootForgotTests.cs", "public class RootForgotTests(Database.SchemaFixture schema) { [Fact] public void A() { } }"), ("RootTraitedTests.cs", $"{Qualified} public class RootTraitedTests(Database.SchemaFixture schema) {{ [Theory] public void A() {{ }} }}"), ("HttpOnlyTests.cs", "public class HttpOnlyTests { [Fact] public void A() { } }"), + ("IdentityMissingTests.cs", "public class IdentityMissingTests(KeycloakFixture realm) { [Fact] public void A() { } }"), + ("IdentityCollectionMissingTests.cs", "[Collection(SharedKeycloakRealm.Name)] public class IdentityCollectionMissingTests { [Fact] public void A() { } }"), + ("IdentityTraitedTests.cs", $"{Trait} public class IdentityTraitedTests(KeycloakFixture realm) {{ [Fact] public void A() {{ }} }}"), + ("IdentityApiCollectionMissingTests.cs", "[Collection(TenantRealmApiTestGroup.Name)] public class IdentityApiCollectionMissingTests { [Fact] public void A() { } }"), + ("IdentityApiCollectionTraitedTests.cs", $"{Trait} [Collection(TenantRealmApiTestGroup.Name)] public class IdentityApiCollectionTraitedTests {{ [Fact] public void A() {{ }} }}"), + ("IdentityApiPartialMain.cs", "namespace ApiPartial; [Collection(TenantRealmApiTestGroup.Name)] public partial class SplitTests { }"), + ("IdentityApiPartialCase.cs", "namespace ApiPartial; public partial class SplitTests { [Fact] public void A() { } }"), + ("IdentityApiPartialTraitedMain.cs", $"namespace ApiTraited; {Trait} [Collection(TenantRealmApiTestGroup.Name)] public partial class SplitTests {{ }}"), + ("IdentityApiPartialTraitedCase.cs", "namespace ApiTraited; public partial class SplitTests { [Fact] public void A() { } }"), + ("PublicReadMissingTests.cs", "public class PublicReadMissingTests(PublicReadFixture database) { [Fact] public void A() { } }"), + ("PublicReadTraitedTests.cs", $"{Trait} public class PublicReadTraitedTests(PublicReadFixture database) {{ [Fact] public void A() {{ }} }}"), ("Database/PartialMain.cs", $"namespace Valid; {Trait} public partial class SplitTests {{ }}"), ("Database/PartialCase.cs", "namespace Valid; public partial class SplitTests { [Fact] public void A() { } }"), ("Database/MissingPartialMain.cs", "namespace Missing; public partial class SplitTests { }"), @@ -934,7 +949,8 @@ public void The_Docker_Trait_Sweep_Can_Actually_Fail() ("Database/NonPartialTrait.cs", $"namespace Unmerged; {Trait} public class UnmergedTests {{ }}"), ("Database/NonPartialCase.cs", "namespace Unmerged; public class UnmergedTests { [Fact] public void A() { } }"), ]).Should().Equal("Database/CommentOnly.cs", "Database/ForeignTrait.cs", "Database/ForgotTests.cs", "Database/MissingPartialCase.cs", - "Database/NonPartialCase.cs", "Database/SameFile.cs", "RootForgotTests.cs"); + "Database/NonPartialCase.cs", "Database/SameFile.cs", "IdentityApiCollectionMissingTests.cs", "IdentityApiPartialCase.cs", + "IdentityCollectionMissingTests.cs", "IdentityMissingTests.cs", "PublicReadMissingTests.cs", "RootForgotTests.cs"); const string Native = "[Trait(NativeCrashProof.Key, NativeCrashProof.Value)]"; const string Namespace = "namespace LearnStack.Tests.Integration.Database; "; @@ -1267,8 +1283,9 @@ private static bool IsDockerTrait(AttributeSyntax attribute) or ("Database.RequiresDocker.Key", "Database.RequiresDocker.Value"); } - /// The fixtures that start a PostgreSQL container, by the names a test takes them under. - private static readonly string[] DockerFixtures = ["SchemaFixture", "PostgresFixture", "SharedSchema"]; + /// Fixture and collection identifiers that own PostgreSQL or Keycloak containers. + private static readonly string[] DockerFixtures = + ["SchemaFixture", "PostgresFixture", "PublicReadFixture", "SharedSchema", "KeycloakFixture", "SharedKeycloakRealm", "TenantRealmApiTestGroup"]; [Fact] public void Migrate_Target_Covers_Every_Migration_Chain() diff --git a/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPortTests.cs b/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPortTests.cs new file mode 100644 index 00000000..982d57eb --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPortTests.cs @@ -0,0 +1,66 @@ +using Docker.DotNet.Models; +using FluentAssertions; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// Offline controls for publication intent and independently observed Docker bindings. +public sealed class ContainerLoopbackPortTests +{ + [Fact] + public void Publication_requires_one_loopback_binding_in_both_configuration_and_runtime() + { + const string port = "5432/tcp"; + var parameters = new CreateContainerParameters + { + HostConfig = new HostConfig + { + PortBindings = new Dictionary> + { [port] = [new() { HostIP = "0.0.0.0", HostPort = "" }] }, + }, + }; + ContainerLoopbackPorts.Configure(parameters, port); + var inspection = new ContainerInspectResponse + { + HostConfig = parameters.HostConfig, + NetworkSettings = new NetworkSettings + { + Ports = new Dictionary> + { [port] = [new() { HostIP = "127.0.0.1", HostPort = "35432" }] }, + }, + }; + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeTrue(); + foreach (var address in new[] { "", "0.0.0.0", "::", "192.0.2.1" }) + { + inspection.NetworkSettings.Ports[port][0].HostIP = address; + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeFalse(); + } + inspection.NetworkSettings.Ports[port][0].HostIP = "127.0.0.1"; + foreach (var mapped in new[] { "", "0", "-1", "65536", "invalid" }) + { + inspection.NetworkSettings.Ports[port][0].HostPort = mapped; + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeFalse(); + } + inspection.NetworkSettings.Ports[port][0].HostPort = "35432"; + parameters.HostConfig.PortBindings[port][0].HostIP = "0.0.0.0"; + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeFalse(); + ContainerLoopbackPorts.Configure(parameters, port); + inspection.NetworkSettings.Ports["8443/tcp"] = [new() { HostIP = "127.0.0.1", HostPort = "35443" }]; + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeFalse(); + inspection.NetworkSettings.Ports.Remove("8443/tcp"); + ContainerLoopbackPorts.IsExclusive(inspection, port).Should().BeTrue(); + + foreach (var invalid in new[] + { + new CreateContainerParameters(), + new() { HostConfig = new HostConfig { PortBindings = new Dictionary>() } }, + new() { HostConfig = new HostConfig { PortBindings = new Dictionary> { [port] = [] } } }, + new() { HostConfig = new HostConfig { PortBindings = new Dictionary> { [port] = [new(), new()] } } }, + new() { HostConfig = new HostConfig { PortBindings = new Dictionary> { ["8443/tcp"] = [new()] } } }, + }) + { + Action configure = () => ContainerLoopbackPorts.Configure(invalid, port); + configure.Should().Throw(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPorts.cs b/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPorts.cs new file mode 100644 index 00000000..7b081355 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ContainerLoopbackPorts.cs @@ -0,0 +1,23 @@ +using Docker.DotNet.Models; + +namespace LearnStack.Tests.Integration; + +/// One publication address for concurrently started, locally consumed fixture ports. +internal static class ContainerLoopbackPorts +{ + internal static void Configure(CreateContainerParameters parameters, string port) + { + var bindings = parameters.HostConfig?.PortBindings; + if (bindings is null || bindings.Count != 1 || !bindings.TryGetValue(port, out var values) || values.Count != 1) + throw new InvalidOperationException("Fixture port inventory is invalid."); + values[0].HostIP = "127.0.0.1"; + } + + internal static bool IsExclusive(ContainerInspectResponse container, string port) => + container.HostConfig?.PortBindings is { Count: 1 } configured + && configured.TryGetValue(port, out var intended) && intended.Count == 1 && intended[0].HostIP == "127.0.0.1" + && container.NetworkSettings?.Ports is { } actual + && actual.All(entry => entry.Key == port || entry.Value is null or { Count: 0 }) + && actual.TryGetValue(port, out var published) && published is { Count: 1 } + && published[0].HostIP == "127.0.0.1" && ushort.TryParse(published[0].HostPort, out var mapped) && mapped != 0; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlement.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlement.cs index 2952cf50..5d7a5dcb 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlement.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlement.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using System.Globalization; using System.Reflection; +using System.Runtime.ExceptionServices; using FluentAssertions; using LearnStack.Application.Jobs; using LearnStack.Infrastructure.Jobs; @@ -7,6 +9,8 @@ using LearnStack.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Time; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; @@ -19,8 +23,15 @@ public sealed partial class BackgroundJobPositiveSdkTests [Theory] [InlineData(false)] [InlineData(true)] - public Task Actual_Outcome_ACK_settles_renewal_before_its_held_cleanup_can_release_the_native_identity(bool knownFailure) => - AssertHeldOutcomeSettlementAsync(knownFailure, retainedRecovery: false); + public async Task Actual_Outcome_ACK_settles_renewal_before_its_held_cleanup_can_release_the_native_identity(bool knownFailure) + { + await AssertHeldOutcomeSettlementAsync(knownFailure, retainedRecovery: false); + if (knownFailure) + { + await AssertExplicitOwnerLaterFinalizeAttributionAsync(); + await AssertExplicitOwnerPrepareRenewalRefusalAsync(); + } + } [Fact] public Task Actual_stored_recovery_selection_repairs_the_early_finalize_hint_only_after_its_real_cleanup() => @@ -37,7 +48,9 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta await provider.SetModeAsync(resource, knownFailure ? "not_repeatable" : "succeeded"); var bounds = new BackgroundJobRuntimeOptions { - RenewalInterval = TimeSpan.FromSeconds(1), + // A distinct validated cadence identifies only the actual renewal + // pacing timers. Every deadline and lease duration stays unchanged. + RenewalInterval = NativeSettlementTimeProvider.RenewalPacing, HandoffRepairGrace = TimeSpan.FromSeconds(15), }; await using var host = Fixture.Host(database, provider.Settings, configure: services => @@ -68,17 +81,27 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta ActivityContext.TryParse(original.Traceparent, null, out var parent).Should().BeTrue(); await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(database.OutboxConnectionString)); using var globals = new NativeSdkGlobalFilterSnapshot(); - var time = new NativeLeaseTimerObservation(); + var time = new NativeSettlementTimeProvider(retainedRecovery); var runtime = NativeSdkRuntime(host.Services, source, bounds, time); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var release = new ManualResetEventSlim(); BackgroundJobExecutionControl? captured = null; + BackgroundJobExecutionControl? finalizer = null; BackgroundJobCommitGate? outcomeGate = null; var held = 0; using var listener = new ActivityListener { ShouldListenTo = activitySource => activitySource.Name == BackgroundJobObservation.InstrumentationName, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + if (activity.OperationName != "job.execution" || activity.TraceId != parent.TraceId) return; + var controller = (BackgroundJobControl)NativeSdkField(runtime, "_jobs"); + lock (NativeSdkField(controller, "_sync")) + if (((Dictionary)NativeSdkField(controller, "_executions")) + .TryGetValue(job.Value, out var execution) && execution.Gate.Kind == BackgroundJobCheckpointKind.Finalize) + finalizer = execution; + }, ActivityStopped = activity => { if (activity.OperationName != "job.external_checkpoint" || activity.TraceId != parent.TraceId) return; @@ -96,17 +119,37 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta }, }; ActivitySource.AddActivityListener(listener); + var boundary = "start"; + long? observedAttempts = null; + Task? outcomeTask = null; + ExceptionDispatchInfo? failure = null; + var pacingReported = false; + void ReportPacing() + { + if (pacingReported) return; + pacingReported = true; + try { output.WriteLine(time.Diagnostic); } + catch (Exception) { /* The original failure owns the result. */ } + } try { using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await runtime.StartAsync(startup.Token); + if (!retainedRecovery) + { + boundary = "deadline_armed"; + await time.ProveNormalDeadlineArmedBeforePacingAsync(startup.Token); + boundary = "start"; + } await entered.Task.WaitAsync(startup.Token); + boundary = "held_outcome"; var execution = captured!; execution.Identity!.IsRecovery.Should().Be(retainedRecovery); if (retainedRecovery) typeof(BackgroundJobExecutionControl).GetField("_externalProvider", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(execution).Should().BeNull("retained completed facts select without a Reconcile/provider scope"); var actual = (Task)NativeSdkField(execution, "_externalOutcome"); + outcomeTask = actual; actual.IsCompleted.Should().BeFalse("the actual Activity cleanup remains blocked after the physical COMMIT ACK"); outcomeGate!.Kind.Should().Be(BackgroundJobCheckpointKind.Outcome); outcomeGate.CommitAcknowledged.Should().BeTrue(); @@ -116,9 +159,15 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta runtime.Snapshot.Control!.Executions.Should().Be(1); var changes = time.Timer(execution).Changes; var controller = (BackgroundJobControl)NativeSdkField(runtime, "_jobs"); - var progress = NativeSdkRenewalProgress(controller); using var observer = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + boundary = "renewal_first"; + await time.WaitForFirstCallbackAsync(observer.Token); + var progress = NativeSdkRenewalProgress(controller); + time.ReleaseFirst(); + boundary = "renewal_progress"; while (NativeSdkRenewalProgress(controller) == progress) await Task.Delay(10, observer.Token); + boundary = "renewal_second"; + await time.WaitForSecondTimerAsync(observer.Token); execution.RenewalRefused.Should().BeFalse("the real renewal loop must not interpret settled ownership as a failed renewal"); execution.RunGate.IsClosed.Should().BeFalse(); time.Timer(execution).Changes.Should().Be(changes); @@ -134,6 +183,7 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta // Force the legal interleaving: a new-generation opaque hint has // already completed while the old native identity forbids routing. // Its ACK postpones repair; vendor success is not domain completion. + boundary = "early_hint"; using (var early = new CancellationTokenSource(TimeSpan.FromSeconds(10))) { NativeFinalizeHint? hint = null; @@ -154,35 +204,490 @@ private async Task AssertHeldOutcomeSettlementAsync(bool knownFailure, bool reta } actual.IsCompleted.Should().BeFalse(); controller.CanRoute(job).Should().BeFalse(); - (await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job)).Should().Be(1); + boundary = "held_count_read"; + observedAttempts = await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job); + boundary = "held_count_assert"; + observedAttempts.Value.Should().Be(1); + time.AssertSecondHeld(); release.Set(); + boundary = "completion_wait"; using var completed = new CancellationTokenSource(TimeSpan.FromSeconds(80)); while ((await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, job))!.Phase != (knownFailure ? "terminal_pending" : "completed") || runtime.Snapshot.Control!.Executions != 0) await Task.Delay(20, completed.Token); - (await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job)).Should().Be(2); + time.AssertSecondHeld(); + time.ReleaseAll(); + boundary = "count_read"; + observedAttempts = null; + observedAttempts = await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job); + boundary = "count_assert"; + observedAttempts.Value.Should().Be(2); + boundary = "normal_attempts"; (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, job))!.NormalAttempts.Should().Be(1); + boundary = "generation"; (await GenerationAsync(database, job)).Should().Be(execution.Identity!.WakeGeneration + 1, "repair resends the same eligibility generation instead of admitting another external attempt"); + boundary = "outcome"; var acknowledged = await actual; acknowledged.Outcome!.IsStoredCompleted.Should().Be(retainedRecovery); + boundary = "provider_counts"; (await provider.CountsAsync(acknowledged.Outcome.OperationId)).Should().Be((1L, 0L, knownFailure ? 0L : 1L)); } + catch (Exception error) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, boundary, observedAttempts, + captured ?? time.Original, finalizer, outcomeTask, runtime.Snapshot.Control); + } finally { + // Seal and release both real callback holds before any stop/join. + time.ReleaseAll(); release.Set(); - runtime.RequestStop(); + var runtimeJoined = false; using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await runtime.StopAsync(cleanup.Token); - await runtime.Completion.WaitAsync(cleanup.Token); + try + { + runtime.RequestStop(); + await runtime.StopAsync(cleanup.Token); + await runtime.Completion.WaitAsync(cleanup.Token); + runtimeJoined = true; + } + catch (Exception error) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, "cleanup", observedAttempts, + captured ?? time.Original, finalizer, outcomeTask, runtime.Snapshot.Control); + } + } + try + { + await time.JoinAsync(runtimeJoined).WaitAsync(cleanup.Token); + time.ThrowIfFaulted(); + } + catch (Exception error) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, "timer_cleanup", observedAttempts, + captured ?? time.Original, finalizer, outcomeTask, runtime.Snapshot.Control); + } + } + if (failure is not null) ReportPacing(); + } + failure?.Throw(); + try + { + held.Should().Be(1); + runtime.Snapshot.Control!.Executions.Should().Be(0); + finalizer.Should().NotBeNull(); + finalizer.Should().NotBeSameAs(captured); + // Process totals include later Finalize identities. The held + // Outcome's sticky first cause survives settlement and removal. + NativeSettlementCause(captured).Should().Be("none"); + } + catch + { + await WriteNativeSettlementFailureAsync(database, job, finalized, "post_cleanup", observedAttempts, + captured, finalizer, outcomeTask, runtime.Snapshot.Control); + ReportPacing(); + throw; } - held.Should().Be(1); - runtime.Snapshot.Control!.Executions.Should().Be(0); - runtime.Snapshot.Control.LeaseLossElapsed.Should().Be(0); - runtime.Snapshot.Control.LeaseLossRenewalRefused.Should().Be(0); } + private async Task AssertExplicitOwnerLaterFinalizeAttributionAsync() + { + // Independent counterexample: real PostgreSQL/publication/control owners + // are invoked explicitly. This phase does not claim stock Hangfire handoff. + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await BackgroundJobSchemaSeed.SeedTenantsAsync(database.AppConnectionString); + await using var provider = await BackgroundJobProviderJournalDatabase.CreateAsync(schema.Postgres); + var prepared = OrganizationId.From(Guid.CreateVersion7()); + var finalized = OrganizationId.From(Guid.CreateVersion7()); + var resource = Guid.CreateVersion7(); + await provider.SetModeAsync(resource, "not_repeatable"); + await using var host = Fixture.Host(database, provider.Settings); + var submitted = await Fixture.SubmitAsync(host.Services, Fixture.Parameters(resource, prepared, finalized)); + submitted.IsSuccess.Should().BeTrue(); + var job = submitted.Value; + var executor = host.Services.GetRequiredService(); + var routing = host.Services.GetRequiredService(); + await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(database.OutboxConnectionString)); + var runtime = new BackgroundJobRuntimeGate(Fixture.Registry.Infrastructure); + var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = TimeSpan.FromSeconds(1) }; + var controller = new BackgroundJobControl(source, Fixture.Registry, executor, runtime, bounds, + new SystemGuidFactory(), TimeProvider.System); + var store = new PostgresBackgroundJobControlStore(source, Fixture.Registry, bounds, new SystemGuidFactory(), runtime); + var generation = new HangfireGenerationWork(Fixture.Registry.Infrastructure.Pools["external"], runtime); + BackgroundJobExecutionControl? original = null, finalizer = null; + await using var proxy = new CommitResponseDroppingProxy(database.AppConnectionString, + holdCommitRequestWhen: () => finalizer?.Gate is { Kind: BackgroundJobCheckpointKind.Finalize, CommitStarted: true }, + lifetime: TimeSpan.FromMinutes(2)); + await using var finalizeHost = Fixture.Host(database, provider.Settings) + .WithWebHostBuilder(builder => builder.UseSetting("ConnectionStrings:Default", proxy.ConnectionString)); + var finalizeExecutor = finalizeHost.Services.GetRequiredService(); + using var releaseCallback = new ManualResetEventSlim(); + var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationTokenRegistration? callback = null; + var callbackIssued = false; + HangfireGenerationWork.Invocation? originalWork = null, finalizeWork = null; + TaskCompletionSource>? originalAdmissionFlight = null, finalizeAdmissionFlight = null; + TaskCompletionSource>? externalFlight = null, dispositionFlight = null; + TaskCompletionSource>? businessFlight = null; + Task? business = null; + Task? outcome = null; + Task? originalJoin = null; + Task? originalPrivateCompletion = null, finalizePrivateCompletion = null; + var originalJoined = false; + var originalCompleted = false; + var finalizerJoined = false; + var boundary = "later_original"; + long? observedAttempts = null; + ExceptionDispatchInfo? failure = null; + try + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var claim = await routing.TryClaimAsync(job, await GenerationAsync(database, job)); + claim.Should().NotBeNull(); + originalWork = generation.TryBegin()!; + original = controller.TryBegin(claim!, originalWork)!; + originalWork.HoldExecution(original); + originalAdmissionFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var admission = IssueNativeSettlementFlight(originalAdmissionFlight, + () => executor.ExecutePublishedAsync(claim!, original.Publication, original.AdmissionGate, null, originalWork.Token)); + originalWork.HoldAdmission(admission); + original.HoldAdmission(admission); + (await admission.WaitAsync(deadline.Token)).Outcome.Should().Be(JobStartExecutionOutcome.Started); + await original.WaitForPublicationAsync().WaitAsync(deadline.Token); + externalFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var external = IssueNativeSettlementFlight(externalFlight, () => controller.ExecuteExternalNormalAsync(original)); + (await external.WaitAsync(deadline.Token)).Should().BeTrue(); + outcome = (Task)NativeSdkField(original, "_externalOutcome"); + var acknowledged = await outcome; + acknowledged.CommitAcknowledged.Should().BeTrue(); + original.LeaseWorkSettled.Should().BeTrue(); + NativeSettlementCause(original).Should().Be("none"); + originalJoin = original.JoinAsync(operationCleanupConfirmed: true); + originalJoined = await originalJoin.WaitAsync(deadline.Token); + originalJoined.Should().BeTrue(); + originalPrivateCompletion = originalWork.CompleteAsync(originalJoined); + await originalPrivateCompletion.WaitAsync(deadline.Token); + originalCompleted = true; + controller.Snapshot.Executions.Should().Be(0); + + boundary = "later_admission"; + var finalizeClaim = await routing.TryClaimAsync(job, await GenerationAsync(database, job)); + finalizeClaim.Should().NotBeNull(); + finalizeWork = generation.TryBegin()!; + finalizer = controller.TryBegin(finalizeClaim!, finalizeWork)!; + finalizeWork.HoldExecution(finalizer); + finalizeAdmissionFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var finalizeAdmission = IssueNativeSettlementFlight(finalizeAdmissionFlight, + () => finalizeExecutor.ExecutePublishedAsync(finalizeClaim!, finalizer.Publication, + finalizer.AdmissionGate, null, finalizeWork.Token)); + finalizeWork.HoldAdmission(finalizeAdmission); + finalizer.HoldAdmission(finalizeAdmission); + var admitted = await finalizeAdmission.WaitAsync(deadline.Token); + admitted.Outcome.Should().Be(JobStartExecutionOutcome.Started); + admitted.CleanupFailed.Should().BeFalse(); + await finalizer.WaitForPublicationAsync().WaitAsync(deadline.Token); + finalizer.Gate.Kind.Should().Be(BackgroundJobCheckpointKind.Finalize); + finalizer.Identity!.SelectedNormalAttemptId.Should().Be(original.Identity!.AttemptId); + finalizer.Identity.AttemptId.Should().NotBe(original.Identity.AttemptId); + businessFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + business = IssueNativeSettlementFlight(businessFlight, + () => finalizeExecutor.ExecuteBusinessAsync(admitted.Receipt!, finalizer.Gate, finalizeWork.Token)); + finalizer.HoldBusiness(business); + finalizeWork.HoldBusiness(business); + boundary = "later_commit_held"; + await proxy.CommitRequestHeld.WaitAsync(deadline.Token); + proxy.HeldCommitRequests.Should().Be(1); + var completedCommitsBeforeRelease = proxy.CompletedCommits; + finalizer.Gate.CommitStarted.Should().BeTrue(); + finalizer.Gate.CommitAcknowledged.Should().BeFalse(); + finalizer.RenewalPermitted.Should().BeTrue(); + await using (var connection = await source.OpenConnectionAsync(deadline.Token)) + await using (var command = new NpgsqlCommand( + "SELECT id FROM background_jobs WHERE id=@id FOR NO KEY UPDATE SKIP LOCKED", connection) + { CommandTimeout = 5 }) + { + command.Parameters.AddWithValue("id", job.Value); + (await command.ExecuteScalarAsync(deadline.Token)).Should().BeNull("the actual finalizer still owns its row lock"); + } + // Register after Npgsql issued its COMMIT. The owned callback holds + // cancellation propagation while the already-admitted COMMIT settles. + callback = finalizeWork.Token.Register(() => + { + callbackEntered.TrySetResult(); + if (!releaseCallback.Wait(TimeSpan.FromSeconds(30))) + throw new TimeoutException("Finalize cancellation callback was not released."); + }); + callbackIssued = true; + boundary = "later_refusal"; + controller.Start(); + await callbackEntered.Task.WaitAsync(deadline.Token); + business.IsCompleted.Should().BeFalse("the actual COMMIT request and cancellation callback remain held"); + NativeSettlementCause(original).Should().Be("none"); + NativeSettlementCause(finalizer).Should().Be("renewal_refused"); + controller.Snapshot.LeaseLossRenewalRefused.Should().Be(1, "the real renewal lane refused the held Finalize identity"); + controller.Snapshot.LeaseLossElapsed.Should().Be(0); + finalizer.Gate.CommitAcknowledged.Should().BeFalse("the actual Finalize COMMIT request remains held before ACK"); + finalizer.RunGate.IsClosed.Should().BeTrue(); + boundary = "later_ack"; + proxy.ReleaseHeldRequest(); + while (!finalizer.Gate.CommitAcknowledged) await Task.Delay(10, deadline.Token); + proxy.CompletedCommits.Should().Be(completedCommitsBeforeRelease + 1); + proxy.DroppedCommitRequests.Should().Be(0); + proxy.DroppedCommitResponses.Should().Be(0); + releaseCallback.Set(); + await callback.Value.DisposeAsync(); + callback = null; + var completed = await business.WaitAsync(deadline.Token); + completed.CommitAcknowledged.Should().BeTrue(); + completed.CleanupFailed.Should().BeFalse(); + dispositionFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var disposition = IssueNativeSettlementFlight(dispositionFlight, () => finalizer.FinishExternalFinalizeAsync(completed, store)); + (await disposition.WaitAsync(deadline.Token)).Should().BeTrue(); + boundary = "later_facts"; + observedAttempts = await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job); + observedAttempts.Value.Should().Be(2); + var facts = await ReadNativeSettlementFactsAsync(database, job, finalized, original.Identity.WakeGeneration); + facts.State.Should().Be("available"); + facts.Normal.Should().Be(1); + facts.Finalize.Should().Be(1); + facts.NormalCounter.Should().Be(1); + facts.FinalizeCounter.Should().Be(1); + facts.Finalized.Should().Be(1); + facts.GenerationDelta.Should().Be(1); + (await provider.CountsAsync(acknowledged.Outcome!.OperationId)).Should().Be((1L, 0L, 0L)); + } + catch (Exception error) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, boundary, observedAttempts, + original, finalizer, outcome, controller.Snapshot); + } + finally + { + // Stop future issuance and release every test-owned barrier before + // awaiting the actual flights. A returned waiter is not cleanup. + generation.RequestHostStop(); + controller.RequestStop(); + proxy.ReleaseHeldRequest(); + releaseCallback.Set(); + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + async Task CleanupStepAsync(Func step) + { + try { await step().WaitAsync(cleanup.Token); return true; } + catch (Exception error) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, "later_cleanup", observedAttempts, + original, finalizer, outcome, controller.Snapshot); + } + return false; + } + } + var callbackJoined = callback is not { } registration + || await CleanupStepAsync(() => registration.DisposeAsync().AsTask()); + // Attempt every independent join, even when a preceding flight or + // callback failed. Never call Join(true) on an unfinished operation. + var originalAdmissionJoined = await CleanupStepAsync(() => originalAdmissionFlight?.Task.Unwrap() ?? Task.CompletedTask); + var finalizeAdmissionJoined = await CleanupStepAsync(() => finalizeAdmissionFlight?.Task.Unwrap() ?? Task.CompletedTask); + var externalJoined = await CleanupStepAsync(() => externalFlight?.Task.Unwrap() ?? Task.CompletedTask); + var businessJoined = await CleanupStepAsync(() => businessFlight?.Task.Unwrap() ?? Task.CompletedTask); + var dispositionJoined = await CleanupStepAsync(() => dispositionFlight?.Task.Unwrap() ?? Task.CompletedTask); + var originalAdmissionClean = originalAdmissionJoined && NativeSettlementAdmissionClean(originalAdmissionFlight); + var finalizeAdmissionClean = finalizeAdmissionJoined && NativeSettlementAdmissionClean(finalizeAdmissionFlight); + var externalClean = externalJoined && (externalFlight is null || externalFlight.Task.Result.Result); + var businessClean = businessJoined && (businessFlight is null || businessFlight.Task.Result.Result is + { CleanupFailed: false, SourceOwner.CleanupConfirmed: true }); + var dispositionClean = dispositionJoined && (dispositionFlight is null || dispositionFlight.Task.Result.Result); + var originalClean = originalAdmissionClean && externalClean; + var finalizeClean = callbackJoined && finalizeAdmissionClean && businessClean && dispositionClean; + if (finalizer is not null && finalizeClean) + await CleanupStepAsync(async () => + { + finalizerJoined = await finalizer.JoinAsync(operationCleanupConfirmed: true); + if (finalizerJoined) + { + finalizePrivateCompletion = finalizeWork!.CompleteAsync(cleanupConfirmed: true); + await finalizePrivateCompletion; + } + }); + if (original is not null && !originalCompleted && originalClean) + await CleanupStepAsync(async () => + { + originalJoin ??= original.JoinAsync(operationCleanupConfirmed: true); + if (!originalJoined) originalJoined = await originalJoin; + if (originalJoined) + { + originalPrivateCompletion ??= originalWork!.CompleteAsync(cleanupConfirmed: true); + await originalPrivateCompletion; + originalCompleted = true; + } + }); + var controlJoined = await CleanupStepAsync(() => controller.Completion); + var generationJoined = await CleanupStepAsync(() => generation.Completion); + if (failure is not null) + { + // The first failure marker describes its pre-cleanup boundary; + // this separate closed witness records actual cleanup afterwards. + try + { + output.WriteLine("Native settlement cleanup;originalAdmission={0};finalizeAdmission={1};external={2};business={3};disposition={4};callback={5};original={6};finalize={7};control={8};generation={9};executions={10};running={11};retained={12};privateWork={13}.", + NativeSettlementCleanupState(originalAdmissionFlight, originalAdmissionClean), + NativeSettlementCleanupState(finalizeAdmissionFlight, finalizeAdmissionClean), + NativeSettlementCleanupState(externalFlight, externalClean), + NativeSettlementCleanupState(businessFlight, businessClean), + NativeSettlementCleanupState(dispositionFlight, dispositionClean), + !callbackIssued ? "not_issued" : callbackJoined ? "joined" : "incomplete", NativeSettlementCleanupState(original, originalJoined && originalCompleted), + NativeSettlementCleanupState(finalizer, finalizerJoined), NativeSettlementCleanupState(controller, controlJoined), + NativeSettlementCleanupState(generation, generationJoined), NativeSettlementNumber(controller.Snapshot.Executions, 64), + NativeSettlementNumber(generation.Snapshot.Running, 64), NativeSettlementNumber(generation.Snapshot.Retained, 64), + generationJoined && (originalWork is null || originalPrivateCompletion?.IsCompletedSuccessfully == true) + && (finalizeWork is null || finalizePrivateCompletion?.IsCompletedSuccessfully == true) ? "joined" : "incomplete"); + } + catch (Exception) { /* Cleanup diagnostics cannot replace the original failure. */ } + } + } + failure?.Throw(); + try + { + originalJoined.Should().BeTrue(); + finalizerJoined.Should().BeTrue(); + controller.Snapshot.Executions.Should().Be(0); + NativeSettlementCause(finalizer).Should().Be("renewal_refused"); + controller.Snapshot.LeaseLossRenewalRefused.Should().Be(1); + controller.Snapshot.LeaseLossElapsed.Should().Be(0); + NativeSettlementCause(original).Should().Be("none", "the original Outcome identity remains settled despite the later Finalize refusal"); + } + catch + { + await WriteNativeSettlementFailureAsync(database, job, finalized, "later_post_cleanup", observedAttempts, + original, finalizer, outcome, controller.Snapshot); + throw; + } + } + + private static Task IssueNativeSettlementFlight(TaskCompletionSource> owner, Func> issue) + { + // The caller publishes this outer owner before entering any synchronous + // operation prefix. Production controls still hold the actual Task. + try + { + var actual = issue(); + owner.SetResult(actual); + return actual; + } + catch (Exception error) { owner.SetException(error); throw; } + } + + private static bool NativeSettlementAdmissionClean(TaskCompletionSource>? flight) => + flight is not null && flight.Task.IsCompletedSuccessfully && flight.Task.Result.IsCompletedSuccessfully + && flight.Task.Result.Result is { CleanupFailed: false, SourceOwner.CleanupConfirmed: true }; + + private static string NativeSettlementCleanupState(object? owner, bool joined) => owner is null ? "not_issued" + : joined ? "joined" : "incomplete"; + + private async Task WriteNativeSettlementFailureAsync(DisposableSchemaDatabase database, BackgroundJobId job, + OrganizationId finalized, string boundary, long? observedAttempts, BackgroundJobExecutionControl? original, + BackgroundJobExecutionControl? finalizer, Task? outcomeTask, BackgroundJobControlHealth? health) + { + // Output is case-bound and finite. Neither a failed read nor output may + // replace the original exception, including a failure during cleanup. + try + { + var facts = await ReadNativeSettlementFactsAsync(database, job, finalized, original?.Identity?.WakeGeneration); + var outcome = outcomeTask is null ? "unknown" : !outcomeTask.IsCompleted ? "pending" + : outcomeTask.IsCompletedSuccessfully ? "succeeded" : outcomeTask.IsCanceled ? "cancelled" : "failed"; + output.WriteLine("Native settlement failure;boundary={0};observedAttempts={1};snapshot={2};total={3};normal={4};finalize={5};normalCounter={6};finalizeCounter={7};phase={8};terminal={9};generationDelta={10};executions={11};outcome={12};finalized={13};originalCause={14};finalizeCause={15};renewalLosses={16};elapsedLosses={17}.", + boundary, NativeSettlementNumber(observedAttempts, 12), facts.State, + NativeSettlementNumber(facts.Total, 12), NativeSettlementNumber(facts.Normal, 6), NativeSettlementNumber(facts.Finalize, 6), + NativeSettlementNumber(facts.NormalCounter, 6), NativeSettlementNumber(facts.FinalizeCounter, 6), facts.Phase, facts.Terminal, + NativeSettlementNumber(facts.GenerationDelta, 12, -12), NativeSettlementNumber(health?.Executions, 64), + outcome, NativeSettlementNumber(facts.Finalized, 1), NativeSettlementCause(original), NativeSettlementCause(finalizer), + NativeSettlementNumber(health?.LeaseLossRenewalRefused, 64), NativeSettlementNumber(health?.LeaseLossElapsed, 64)); + } + catch (Exception) { /* Diagnostics cannot replace the original failure. */ } + } + + private static string NativeSettlementCause(BackgroundJobExecutionControl? execution) + { + if (execution is null) return "unknown"; + lock (NativeSdkField(execution, "_sync")) + return (BackgroundJobLeaseLossCause)(int)NativeSdkField(execution, "_firstLeaseLoss") switch + { + BackgroundJobLeaseLossCause.None => "none", + BackgroundJobLeaseLossCause.RenewalRefused => "renewal_refused", + BackgroundJobLeaseLossCause.Elapsed => "elapsed", + _ => "other", + }; + } + + private static async Task ReadNativeSettlementFactsAsync(DisposableSchemaDatabase database, + BackgroundJobId job, OrganizationId finalized, long? originalGeneration) + { + try + { + // A separate bounded observation after failure is not the failed + // assertion's count. The marker retains both values distinctly. + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var connection = new NpgsqlConnection(database.AppConnectionString); + await connection.OpenAsync(deadline.Token); + await using var transaction = await connection.BeginTransactionAsync(deadline.Token); + await using (var announce = new NpgsqlCommand(""" + SELECT set_config('app.tenant_id',@tenant,true),set_config('app.organization_id','',true),set_config('app.scope','',true) + """, connection, transaction) { CommandTimeout = 5 }) + { + announce.Parameters.AddWithValue("tenant", SchemaFixture.TenantA.ToString("D")); + await announce.ExecuteNonQueryAsync(deadline.Token); + } + await using var command = new NpgsqlCommand(""" + SELECT current_user::text,j.phase,j.terminal_reason,j.normal_attempts,j.finalize_attempts,j.wake_generation, + (SELECT count(*) FROM background_job_attempts a WHERE a.job_id=j.id), + (SELECT count(*) FROM background_job_attempts a WHERE a.job_id=j.id AND a.stage='normal'), + (SELECT count(*) FROM background_job_attempts a WHERE a.job_id=j.id AND a.stage='finalize'), + (SELECT count(*) FROM organizations o WHERE o.id=@finalized) + FROM background_jobs j WHERE j.id=@job + """, connection, transaction) { CommandTimeout = 5 }; + command.Parameters.AddWithValue("job", job.Value); + command.Parameters.AddWithValue("finalized", finalized.Value); + await using var row = await command.ExecuteReaderAsync(deadline.Token); + if (!await row.ReadAsync(deadline.Token)) return new("missing"); + if (row.GetString(0) != "learnstack_app") return new("unavailable"); + var phase = row.GetString(1) switch + { + "pending" or "preparing" or "prepared" or "executing" or "reconciling" or "finalize_pending" + or "finalizing" or "terminal_pending" or "completed" or "quarantined" => row.GetString(1), + _ => "other", + }; + var terminal = row.IsDBNull(2) ? "none" : row.GetString(2) switch + { + "external_retry_forbidden" or "permanent_failure" or "retry_exhausted" or "finalize_refused" + or "finalize_exhausted" or "retired_definition" or "invalid_envelope" => row.GetString(2), + _ => "other", + }; + return new("available", phase, terminal, row.GetInt64(6), row.GetInt64(7), row.GetInt64(8), + row.GetInt32(3), row.GetInt32(4), originalGeneration is { } generation ? checked(row.GetInt64(5) - generation) : null, + row.GetInt64(9)); + } + catch (Exception) { return new("unavailable"); } + } + + private static string NativeSettlementNumber(long? value, long maximum, long minimum = 0) => value is null ? "unknown" + : value < minimum || value > maximum ? "out_of_range" : value.Value.ToString(CultureInfo.InvariantCulture); + + private sealed record NativeSettlementFacts(string State, string Phase = "unknown", string Terminal = "unknown", + long? Total = null, long? Normal = null, long? Finalize = null, long? NormalCounter = null, + long? FinalizeCounter = null, long? GenerationDelta = null, long? Finalized = null); + private static async Task PersistRetainedSdkOutcomeAsync(DisposableSchemaDatabase database, ConfirmedStartReceipt receipt, BackgroundJobRetainedExternalObservation observation) { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs new file mode 100644 index 00000000..65b5e36e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs @@ -0,0 +1,280 @@ +using System.Runtime.ExceptionServices; +using FluentAssertions; +using LearnStack.Application.Jobs; +using LearnStack.Infrastructure.Jobs; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Time; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class BackgroundJobPositiveSdkTests +{ + private async Task AssertExplicitOwnerPrepareRenewalRefusalAsync() + { + // This independent real-owner counterexample does not claim a stock + // handoff or identify the missing historical CI interleaving. + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await BackgroundJobSchemaSeed.SeedTenantsAsync(database.AppConnectionString); + await using var provider = await BackgroundJobProviderJournalDatabase.CreateAsync(schema.Postgres); + var prepared = OrganizationId.From(Guid.CreateVersion7()); + var finalized = OrganizationId.From(Guid.CreateVersion7()); + var resource = Guid.CreateVersion7(); + await provider.SetModeAsync(resource, "succeeded"); + BackgroundJobExecutionControl? original = null; + await using var prepareProxy = new CommitResponseDroppingProxy(database.AppConnectionString, + holdCommitRequestWhen: () => original?.Gate is + { Kind: BackgroundJobCheckpointKind.Prepare, CommitStarted: true, CommitAcknowledged: false }, + lifetime: TimeSpan.FromMinutes(2)); + await using var renewalProxy = new CommitResponseDroppingProxy(database.OutboxConnectionString, + commitsToPass: int.MaxValue, lifetime: TimeSpan.FromMinutes(2)); + renewalProxy.HoldNextRenewalCommitResponse(loseResponse: false); + await using var host = Fixture.Host(database, provider.Settings) + .WithWebHostBuilder(builder => builder.UseSetting("ConnectionStrings:Default", prepareProxy.ConnectionString)); + var submitted = await Fixture.SubmitAsync(host.Services, Fixture.Parameters(resource, prepared, finalized)); + submitted.IsSuccess.Should().BeTrue(); + var job = submitted.Value; + var executor = host.Services.GetRequiredService(); + var routing = host.Services.GetRequiredService(); + await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(renewalProxy.ConnectionString)); + var gate = new BackgroundJobRuntimeGate(Fixture.Registry.Infrastructure); + var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = TimeSpan.FromSeconds(1) }; + var controller = new BackgroundJobControl(source, Fixture.Registry, executor, gate, bounds, + new SystemGuidFactory(), TimeProvider.System); + var generation = new HangfireGenerationWork(Fixture.Registry.Infrastructure.Pools["external"], gate); + HangfireGenerationWork.Invocation? work = null; + TaskCompletionSource>? admissionFlight = null; + TaskCompletionSource>? externalFlight = null; + Task? business = null; + BackgroundJobRenewalOperation? renewal = null; + CancellationTokenRegistration? callback = null; + var callbackIssued = false; + using var releaseCallback = new ManualResetEventSlim(); + var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? originalJoin = null; + Task? privateCompletion = null; + var originalJoined = false; + var boundary = "prepare_admission"; + long? observedAttempts = null; + ExceptionDispatchInfo? failure = null; + try + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var claim = await routing.TryClaimAsync(job, await GenerationAsync(database, job)); + claim.Should().NotBeNull(); + work = generation.TryBegin()!; + original = controller.TryBegin(claim!, work)!; + work.HoldExecution(original); + admissionFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var admission = IssueNativeSettlementFlight(admissionFlight, + () => executor.ExecutePublishedAsync(claim!, original.Publication, original.AdmissionGate, null, work.Token)); + work.HoldAdmission(admission); + original.HoldAdmission(admission); + (await admission.WaitAsync(deadline.Token)).Outcome.Should().Be(JobStartExecutionOutcome.Started); + await original.WaitForPublicationAsync().WaitAsync(deadline.Token); + original.Gate.Kind.Should().Be(BackgroundJobCheckpointKind.Prepare); + externalFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); + var external = IssueNativeSettlementFlight(externalFlight, () => controller.ExecuteExternalNormalAsync(original)); + boundary = "prepare_commit_held"; + await prepareProxy.CommitRequestHeld.WaitAsync(deadline.Token); + business = (Task)NativeSdkField(original, "_business"); + var completedCommitsBeforeRelease = prepareProxy.CompletedCommits; + prepareProxy.HeldCommitRequests.Should().Be(1); + original.Gate.CommitStarted.Should().BeTrue(); + original.Gate.CommitAcknowledged.Should().BeFalse(); + original.RenewalPermitted.Should().BeTrue(); + await using (var connection = await source.OpenConnectionAsync(deadline.Token)) + await using (var command = new NpgsqlCommand( + "SELECT id FROM background_jobs WHERE id=@id FOR NO KEY UPDATE SKIP LOCKED", connection) + { CommandTimeout = 5 }) + { + command.Parameters.AddWithValue("id", job.Value); + (await command.ExecuteScalarAsync(deadline.Token)).Should().BeNull("the actual Prepare transaction owns its row lock"); + } + // Register after the original COMMIT is issued. Holding this actual + // callback prevents cancellation overtaking the admitted COMMIT ACK. + callback = work.Token.Register(() => + { + callbackEntered.TrySetResult(); + if (!releaseCallback.Wait(TimeSpan.FromSeconds(30))) + throw new TimeoutException("Prepare cancellation callback was not released."); + }); + callbackIssued = true; + boundary = "prepare_renewal_held"; + controller.Start(); + await renewalProxy.RenewalCommitHeld.WaitAsync(deadline.Token); + renewal = (BackgroundJobRenewalOperation)NativeSdkField(controller, "_renewalFlight"); + renewal.Completion.IsCompleted.Should().BeFalse(); + renewalProxy.ReleaseRenewalCommitResponse(); + boundary = "prepare_refusal"; + var actualRenewal = await renewal.Completion.WaitAsync(deadline.Token); + (await renewal.TimelyReturn.WaitAsync(deadline.Token)).Should().BeTrue(); + actualRenewal.CommitAcknowledged.Should().BeTrue(); + actualRenewal.CommitUncertain.Should().BeFalse(); + actualRenewal.Failed.Should().BeFalse(); + actualRenewal.CleanupConfirmed.Should().BeTrue(); + actualRenewal.Value.Should().NotBeNull().And.BeEmpty("the real locked row is omitted by the successful renewal batch"); + var renewalProbe = actualRenewal.Probes.Should().ContainSingle().Which; + renewalProbe.Boundary.Should().Be(BackgroundJobCommitBoundary.Renewal); + renewalProbe.JobId.Should().Be(job.Value); + renewalProbe.AttemptId.Should().Be(original.Identity!.AttemptId.Value); + renewalProbe.Token.Should().Be(original.Identity.Token); + await callbackEntered.Task.WaitAsync(deadline.Token); + NativeSettlementCause(original).Should().Be("renewal_refused"); + original.RunGate.IsClosed.Should().BeTrue(); + controller.Snapshot.LeaseLossRenewalRefused.Should().Be(1); + controller.Snapshot.LeaseLossElapsed.Should().Be(0); + original.Gate.CommitAcknowledged.Should().BeFalse(); + business.IsCompleted.Should().BeFalse(); + boundary = "prepare_ack"; + prepareProxy.ReleaseHeldRequest(); + while (!original.Gate.CommitAcknowledged) await Task.Delay(10, deadline.Token); + prepareProxy.CompletedCommits.Should().Be(completedCommitsBeforeRelease + 1); + prepareProxy.DroppedCommitRequests.Should().Be(0); + prepareProxy.DroppedCommitResponses.Should().Be(0); + renewalProxy.DroppedCommitResponses.Should().Be(0); + releaseCallback.Set(); + await callback.Value.DisposeAsync(); + callback = null; + (await external.WaitAsync(deadline.Token)).Should().BeTrue(); + var completed = await business.WaitAsync(deadline.Token); + completed.CommitAcknowledged.Should().BeTrue(); + completed.CleanupFailed.Should().BeFalse(); + original.LeaseWorkSettled.Should().BeFalse("Prepare ACK does not settle the remaining external work"); + original.RenewalPermitted.Should().BeFalse(); + boundary = "prepare_facts"; + observedAttempts = await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, job); + observedAttempts.Value.Should().Be(1); + var facts = await ReadNativeSettlementFactsAsync(database, job, finalized, original.Identity!.WakeGeneration); + facts.State.Should().Be("available"); + facts.Phase.Should().Be("prepared"); + facts.Normal.Should().Be(1); + facts.Finalize.Should().Be(0); + facts.NormalCounter.Should().Be(1); + facts.FinalizeCounter.Should().Be(0); + facts.GenerationDelta.Should().Be(0); + facts.Finalized.Should().Be(0); + var operation = await ReadUnarmedPrepareOperationAsync(database, job, deadline.Token); + (await provider.CountsAsync(operation)).Should().Be((0L, 0L, 0L)); + (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, prepared.Value)).Should().Be(1); + } + catch (Exception error) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, boundary, observedAttempts, + original, null, null, controller.Snapshot); + } + finally + { + generation.RequestHostStop(); + controller.RequestStop(); + prepareProxy.ReleaseHeldRequest(); + renewalProxy.ReleaseRenewalCommitResponse(); + releaseCallback.Set(); + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + async Task CleanupStepAsync(Func step) + { + try { await step().WaitAsync(cleanup.Token); return true; } + catch (Exception error) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(error); + await WriteNativeSettlementFailureAsync(database, job, finalized, "prepare_cleanup", observedAttempts, + original, null, null, controller.Snapshot); + } + return false; + } + } + var callbackJoined = callback is not { } registration + || await CleanupStepAsync(() => registration.DisposeAsync().AsTask()); + var admissionJoined = await CleanupStepAsync(() => admissionFlight?.Task.Unwrap() ?? Task.CompletedTask); + var externalJoined = await CleanupStepAsync(() => externalFlight?.Task.Unwrap() ?? Task.CompletedTask); + // An early assertion can precede the normal capture. The actual + // external flight owns and awaits this task; retain its issued + // business flight separately before judging cleanup evidence. + if (business is null && original is not null) + lock (NativeSdkField(original, "_sync")) + business = (Task?)typeof(BackgroundJobExecutionControl) + .GetField("_business", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(original); + var businessJoined = await CleanupStepAsync(() => business ?? Task.CompletedTask); + var renewalJoined = await CleanupStepAsync(() => renewal?.Completion ?? Task.CompletedTask); + var admissionClean = admissionJoined && NativeSettlementAdmissionClean(admissionFlight); + var externalClean = externalJoined && (externalFlight is null || externalFlight.Task.Result.Result); + var businessClean = businessJoined && (business is null || business.Result is + { CleanupFailed: false, SourceOwner.CleanupConfirmed: true }); + var renewalClean = renewalJoined && (renewal is null || renewal.Completion.Result.CleanupConfirmed); + if (original is not null && callbackJoined && admissionClean && externalClean && businessClean && renewalClean) + await CleanupStepAsync(async () => + { + originalJoin ??= original.JoinAsync(operationCleanupConfirmed: true); + originalJoined = await originalJoin; + if (originalJoined) + { + privateCompletion = work!.CompleteAsync(cleanupConfirmed: true); + await privateCompletion; + } + }); + var controlJoined = await CleanupStepAsync(() => controller.Completion); + var generationJoined = await CleanupStepAsync(() => generation.Completion); + if (failure is not null) + { + try + { + output.WriteLine("Native settlement cleanup;originalAdmission={0};finalizeAdmission=not_issued;external={1};business={2};disposition=not_issued;callback={3};original={4};finalize=not_issued;control={5};generation={6};executions={7};running={8};retained={9};privateWork={10}.", + NativeSettlementCleanupState(admissionFlight, admissionClean), NativeSettlementCleanupState(externalFlight, externalClean), + NativeSettlementCleanupState(business, businessClean), !callbackIssued ? "not_issued" : callbackJoined ? "joined" : "incomplete", + NativeSettlementCleanupState(original, originalJoined && privateCompletion?.IsCompletedSuccessfully == true), + NativeSettlementCleanupState(controller, controlJoined && renewalClean), NativeSettlementCleanupState(generation, generationJoined), + NativeSettlementNumber(controller.Snapshot.Executions, 64), NativeSettlementNumber(generation.Snapshot.Running, 64), + NativeSettlementNumber(generation.Snapshot.Retained, 64), generationJoined + && (work is null || privateCompletion?.IsCompletedSuccessfully == true) ? "joined" : "incomplete"); + } + catch (Exception) { /* Cleanup diagnostics cannot replace the original failure. */ } + } + } + failure?.Throw(); + try + { + originalJoined.Should().BeTrue(); + controller.Snapshot.Executions.Should().Be(0); + NativeSettlementCause(original).Should().Be("renewal_refused"); + controller.Snapshot.LeaseLossRenewalRefused.Should().Be(1); + controller.Snapshot.LeaseLossElapsed.Should().Be(0); + } + catch + { + await WriteNativeSettlementFailureAsync(database, job, finalized, "prepare_post_cleanup", observedAttempts, + original, null, null, controller.Snapshot); + throw; + } + } + + private static async Task ReadUnarmedPrepareOperationAsync(DisposableSchemaDatabase database, + BackgroundJobId job, CancellationToken token) + { + await using var app = new NpgsqlConnection(database.AppConnectionString); + await app.OpenAsync(token); + await using var transaction = await app.BeginTransactionAsync(token); + await SchemaQueries.AssertApplicationRoleAsync(app, transaction); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, null); + await using var command = new NpgsqlCommand(""" + SELECT j.operation_id,a.execute_intent_at IS NULL,a.outcome IS NULL,a.outcome_kind IS NULL + FROM background_jobs j JOIN background_job_attempts a ON a.id=j.current_attempt_id + WHERE j.id=@job AND j.phase='prepared' AND a.stage='normal' + """, (NpgsqlConnection)app, (NpgsqlTransaction)transaction) { CommandTimeout = 5 }; + command.Parameters.AddWithValue("job", job.Value); + await using var row = await command.ExecuteReaderAsync(token); + (await row.ReadAsync(token)).Should().BeTrue(); + row.GetBoolean(1).Should().BeTrue(); + row.GetBoolean(2).Should().BeTrue(); + row.GetBoolean(3).Should().BeTrue(); + return row.GetGuid(0); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs new file mode 100644 index 00000000..ddd9e614 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs @@ -0,0 +1,376 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.Infrastructure.Jobs; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class BackgroundJobPositiveSdkTests +{ + /// Schedules two actual pacing callbacks; all clocks, leases and stage timers stay real. + [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "The test joins runtime, actual timers and the factory before disposing barriers; an incomplete join retains them.")] + private sealed class NativeSettlementTimeProvider(bool retainedRecovery) : TimeProvider + { + internal static readonly TimeSpan RenewalPacing = TimeSpan.FromMilliseconds(1125); + private readonly object _sync = new(); + private readonly NativeLeaseTimerObservation _leases = new(); + private readonly ManualResetEventSlim _firstArmed = new(); + private readonly ManualResetEventSlim _allowFirstCreation = new(); + private readonly CancellationTokenSource _abort = new(); + private readonly AsyncLocal _deadlineSlot = new(); + private readonly TaskCompletionSource _deadlineCreated = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _deadlineWaiting = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _firstFactoryEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _firstCreated = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _secondCreated = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _leaseReturned = new(TaskCreationOptions.RunContinuationsAsynchronously); + private SettlementPacingTimer? _deadline, _first, _second; + private BackgroundJobExecutionControl? _original; + private bool _closing, _leaseIssued, _normalSlotIssued; + private int _fault, _leaseState, _deadlineWaitState, _cleanupState; + + internal BackgroundJobExecutionControl? Original { get { lock (_sync) return _original; } } + internal ObservedNativeLeaseTimer Timer(BackgroundJobExecutionControl execution) => _leases.Timer(execution); + public override long TimestampFrequency => System.TimestampFrequency; + public override long GetTimestamp() => System.GetTimestamp(); + public override DateTimeOffset GetUtcNow() => System.GetUtcNow(); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + // ObserveDeadlineAsync creates its Task.Delay timer in the next + // synchronous prefix after the normal lease factory returns. The + // shared consumed bit also closes copies of this execution context. + if (_deadlineSlot.Value is { } slot && Interlocked.CompareExchange(ref slot.Consumed, 1, 0) == 0) + return CreateNormalDeadlineTimer(slot, callback, state, dueTime, period); + if (state is BackgroundJobExecutionControl execution) + { + var hold = false; + lock (_sync) + { + if (!_closing && !_leaseIssued) + { + _leaseIssued = true; + _original = execution; + hold = true; + } + } + try + { + // Normal publication must return this real lease promptly: + // its already-computed deadline timer is created next. + var actual = _leases.CreateTimer(callback, state, dueTime, period); + if (!hold) return actual; + if (execution.Identity is not null) RecordFault(PacingFault.Ambiguous); + if (!retainedRecovery) + { + lock (_sync) + if (!_closing) + { + _deadlineSlot.Value = new(execution); + _normalSlotIssued = true; + } + Volatile.Write(ref _leaseState, 2); + return actual; + } + // Recovery re-debits its lease after this actual factory + // returns and has no normal deadline observer to postpone. + Volatile.Write(ref _leaseState, 1); + try + { + if (_firstArmed.Wait(TimeSpan.FromSeconds(30), _abort.Token)) + Volatile.Write(ref _leaseState, 2); + else { RecordFault(PacingFault.Timeout); Volatile.Write(ref _leaseState, 3); } + } + catch (OperationCanceledException) when (_abort.IsCancellationRequested) + { Volatile.Write(ref _leaseState, 3); } + return actual; + } + finally { if (hold) _leaseReturned.TrySetResult(); } + } + + // This fixture deliberately uses a distinct validated cadence. The + // native heartbeat/shared waits remain one second; typed execution + // and invocation timers cannot be mistaken for pacing. + if (state is HangfireGenerationWork.Invocation || dueTime != RenewalPacing || period != Timeout.InfiniteTimeSpan) + return System.CreateTimer(callback, state, dueTime, period); + SettlementPacingTimer? selected = null; + TaskCompletionSource? publication = null; + lock (_sync) + { + if (!_closing && _first is null) + { + selected = _first = new(this, callback, state, TimeSpan.FromSeconds(30)); + publication = _firstCreated; + } + else if (!_closing && _second is null && _first!.Forwarding) + { + // The second watchdog covers the existing 10-second hint + // wait, 80-second completion wait and bounded observations. + // It does not extend any of those caller-owned deadlines. + selected = _second = new(this, callback, state, TimeSpan.FromMinutes(2)); + publication = _secondCreated; + } + else if (!_closing && (_second is null || !_second.Released)) + RecordFault(PacingFault.Ambiguous); + } + if (selected is null) return System.CreateTimer(callback, state, dueTime, period); + try + { + if (ReferenceEquals(selected, _first) && !retainedRecovery) + { + _firstFactoryEntered.TrySetResult(); + try + { + if (!_allowFirstCreation.Wait(TimeSpan.FromSeconds(30), _abort.Token)) + RecordFault(PacingFault.Timeout); + } + catch (OperationCanceledException) when (_abort.IsCancellationRequested) { } + } + selected.Create(dueTime, period); + publication!.TrySetResult(selected); + if (ReferenceEquals(selected, _first)) _firstArmed.Set(); + return selected; + } + catch (Exception error) + { + RecordFault(PacingFault.TimerFailed); + publication!.TrySetException(error); + throw; + } + finally { selected.MarkFactoryReturned(); } + } + + private ITimer CreateNormalDeadlineTimer(DeadlineFactorySlot slot, TimerCallback callback, + object? state, TimeSpan dueTime, TimeSpan period) + { + SettlementPacingTimer? selected = null; + lock (_sync) + { + if (!_closing) + { + if (retainedRecovery || !ReferenceEquals(slot.Execution, _original) || slot.Execution.Identity is not null + || _deadline is not null || state is BackgroundJobExecutionControl or HangfireGenerationWork.Invocation + || dueTime <= TimeSpan.Zero || period != Timeout.InfiniteTimeSpan) + RecordFault(PacingFault.Ambiguous); + else selected = _deadline = new(this, callback, state, TimeSpan.Zero, holdCallback: false); + } + } + if (selected is null) + { + _deadlineCreated.TrySetException(new InvalidOperationException("The actual normal deadline timer was not selected.")); + return System.CreateTimer(callback, state, dueTime, period); + } + try + { + // Arm the original System timer before the publication hold. + // Its callback is never gated, and its duration is unchanged. + selected.Create(dueTime, period); + _deadlineCreated.TrySetResult(selected); + Volatile.Write(ref _deadlineWaitState, 1); + _deadlineWaiting.TrySetResult(); + try + { + if (_firstArmed.Wait(TimeSpan.FromSeconds(30), _abort.Token)) + Volatile.Write(ref _deadlineWaitState, 2); + else { RecordFault(PacingFault.Timeout); Volatile.Write(ref _deadlineWaitState, 3); } + } + catch (OperationCanceledException) when (_abort.IsCancellationRequested) + { Volatile.Write(ref _deadlineWaitState, 3); } + return selected; + } + catch (Exception error) + { + RecordFault(PacingFault.TimerFailed); + _deadlineCreated.TrySetException(error); + throw; + } + finally { selected.MarkFactoryReturned(); } + } + + internal async Task ProveNormalDeadlineArmedBeforePacingAsync(CancellationToken token) + { + await _deadlineCreated.Task.WaitAsync(token); + await Task.WhenAll(_deadlineWaiting.Task, _firstFactoryEntered.Task).WaitAsync(token); + AssertNormalDeadlineHeld(); + var before = System.GetTimestamp(); + // Deliberately spend real time under the existing startup budget. + // The old lease-first hold cannot reach this armed-deadline witness. + await Task.Delay(TimeSpan.FromMilliseconds(125), System, token); + if (System.GetElapsedTime(before) < TimeSpan.FromMilliseconds(100)) RecordFault(PacingFault.Ambiguous); + AssertNormalDeadlineHeld(); + _allowFirstCreation.Set(); + } + + private void AssertNormalDeadlineHeld() + { + lock (_sync) + { + if (retainedRecovery || _original?.Identity is not null || !_leaseReturned.Task.IsCompleted + || _deadline is null || !_deadline.Created || _deadline.FactoryReturned + || Volatile.Read(ref _deadlineWaitState) != 1 || _first is null || _first.Created) + RecordFault(PacingFault.Ambiguous); + if (_original is not null) _ = _leases.Timer(_original); + else RecordFault(PacingFault.Ambiguous); + } + ThrowIfFaulted(); + } + + private sealed class DeadlineFactorySlot(BackgroundJobExecutionControl execution) + { + internal BackgroundJobExecutionControl Execution { get; } = execution; + internal int Consumed; + } + + internal async Task WaitForFirstCallbackAsync(CancellationToken token) + { + var first = await _firstCreated.Task.WaitAsync(token); + await first.Entered.WaitAsync(token); + ThrowIfFaulted(); + } + + internal void ReleaseFirst() + { + SettlementPacingTimer first; + lock (_sync) first = _first ?? throw new InvalidOperationException("The actual first pacing timer is absent."); + first.Release(); + } + + internal async Task WaitForSecondTimerAsync(CancellationToken token) + { + await _secondCreated.Task.WaitAsync(token); + AssertSecondHeld(); + } + + internal void AssertSecondHeld() + { + lock (_sync) + if (_first is null || _second is null || !_first.Forwarding || _second.Released) + RecordFault(PacingFault.Ambiguous); + ThrowIfFaulted(); + } + + internal void ReleaseAll() + { + SettlementPacingTimer? first, second; + lock (_sync) { _closing = true; first = _first; second = _second; } + // Sealing precedes every release: later factories always pass through. + _abort.Cancel(); + _allowFirstCreation.Set(); + first?.Release(); + second?.Release(); + } + + internal async Task JoinAsync(bool runtimeJoined) + { + Volatile.Write(ref _cleanupState, 2); + SettlementPacingTimer? deadline, first, second; + bool leaseIssued, normalSlotIssued; + lock (_sync) + { deadline = _deadline; first = _first; second = _second; leaseIssued = _leaseIssued; normalSlotIssued = _normalSlotIssued; } + try + { + await Task.WhenAll(deadline?.JoinAsync() ?? Task.CompletedTask, first?.JoinAsync() ?? Task.CompletedTask, + second?.JoinAsync() ?? Task.CompletedTask, leaseIssued ? _leaseReturned.Task : Task.CompletedTask); + if (!runtimeJoined) { Volatile.Write(ref _cleanupState, 2); return; } + if (normalSlotIssued && deadline is null) RecordFault(PacingFault.Ambiguous); + // Actual runtime completion closes the factory census. Timer + // disposal joins queued callbacks, including cancellation before + // entry; no callback-return signal is manufactured for that case. + deadline?.DisposeBarrier(); + first?.DisposeBarrier(); + second?.DisposeBarrier(); + _firstArmed.Dispose(); + _allowFirstCreation.Dispose(); + _abort.Dispose(); + Volatile.Write(ref _cleanupState, 1); + } + catch { Volatile.Write(ref _cleanupState, 2); throw; } + } + + internal void ThrowIfFaulted() + { + if (Volatile.Read(ref _fault) != 0) + throw new InvalidOperationException("Actual renewal pacing observation failed."); + } + + internal string Diagnostic + { + get + { + lock (_sync) + return $"Native settlement pacing;lease={Volatile.Read(ref _leaseState) switch { 0 => "unseen", 1 => "waiting", 2 => "released", _ => "aborted" }};" + + $"deadline={_deadline?.State ?? "not_issued"};" + + $"first={_first?.State ?? "not_issued"};second={_second?.State ?? "not_issued"};" + + $"fault={(PacingFault)Volatile.Read(ref _fault) switch { PacingFault.None => "none", PacingFault.Ambiguous => "ambiguous", PacingFault.Timeout => "timeout", PacingFault.CallbackFailed => "callback_failed", _ => "timer_failed" }};" + + $"cleanup={Volatile.Read(ref _cleanupState) switch { 0 => "not_started", 1 => "joined", _ => "incomplete" }}."; + } + } + + private void RecordFault(PacingFault fault) => Interlocked.CompareExchange(ref _fault, (int)fault, 0); + private enum PacingFault { None, Ambiguous, Timeout, CallbackFailed, TimerFailed } + + private sealed class SettlementPacingTimer(NativeSettlementTimeProvider owner, TimerCallback callback, + object? callbackState, TimeSpan watchdog, bool holdCallback = true) : ITimer + { + private readonly ManualResetEventSlim _release = new(); + private readonly TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _factoryReturned = new(TaskCreationOptions.RunContinuationsAsynchronously); + private ITimer? _actual; + private int _released, _forwarding, _returned, _joined, _joinFailed; + internal Task Entered => _entered.Task; + internal bool Created => _created.Task.IsCompletedSuccessfully; + internal bool FactoryReturned => _factoryReturned.Task.IsCompleted; + internal bool Forwarding => Volatile.Read(ref _forwarding) != 0; + internal bool Released => Volatile.Read(ref _released) != 0; + internal string State => Volatile.Read(ref _joinFailed) != 0 ? "incomplete" + : Volatile.Read(ref _joined) != 0 ? _entered.Task.IsCompleted ? "joined" : "cancelled" + : Volatile.Read(ref _returned) != 0 ? "returned" : _entered.Task.IsCompleted ? "entered" : Created ? "armed" : "creating"; + + internal void Create(TimeSpan dueTime, TimeSpan period) + { + try + { + _actual = TimeProvider.System.CreateTimer(static state => ((SettlementPacingTimer)state!).Invoke(), this, dueTime, period); + _created.TrySetResult(_actual); + } + catch (Exception error) { _created.TrySetException(error); throw; } + } + + private void Invoke() + { + _entered.TrySetResult(); + try + { + if (holdCallback && !_release.Wait(watchdog)) owner.RecordFault(PacingFault.Timeout); + Volatile.Write(ref _forwarding, 1); + callback(callbackState); + } + catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) + { owner.RecordFault(PacingFault.CallbackFailed); } + finally { Volatile.Write(ref _returned, 1); } + } + + internal void Release() { Volatile.Write(ref _released, 1); _release.Set(); } + internal void MarkFactoryReturned() => _factoryReturned.TrySetResult(); + public bool Change(TimeSpan dueTime, TimeSpan period) => _actual!.Change(dueTime, period); + // Task.Delay can dispose its timer inside this very callback. Never + // wait for that callback from these synchronous disposal semantics. + public void Dispose() => _actual!.Dispose(); + public ValueTask DisposeAsync() => _actual!.DisposeAsync(); + internal async Task JoinAsync() + { + try + { + await _factoryReturned.Task; + var actual = await _created.Task; + await actual.DisposeAsync(); + if (_entered.Task.IsCompleted && Volatile.Read(ref _returned) == 0) + throw new InvalidOperationException("Actual pacing callback cleanup is incomplete."); + Volatile.Write(ref _joined, 1); + } + catch { Volatile.Write(ref _joinFailed, 1); throw; } + } + internal void DisposeBarrier() => _release.Dispose(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs index a367904d..d04b59df 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.cs @@ -12,7 +12,7 @@ namespace LearnStack.Tests.Integration.Database; /// Natural real-store SDK/provider flow. Construction-only host does not claim native Hangfire handoff. [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed partial class BackgroundJobPositiveSdkTests(SchemaFixture schema) +public sealed partial class BackgroundJobPositiveSdkTests(SchemaFixture schema, Xunit.Abstractions.ITestOutputHelper output) { private static BackgroundJobPositiveSdkFixture Fixture => BackgroundJobPositiveSdkFixture.Shared; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs index 90db0d9b..75d30834 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using System.Globalization; using System.Reflection; +using System.Runtime.ExceptionServices; using FluentAssertions; using LearnStack.Infrastructure.Jobs; using LearnStack.Infrastructure.Persistence; @@ -65,12 +67,20 @@ public async Task A_held_actual_native_callback_preserves_durable_progress_and_b }, }; ActivitySource.AddActivityListener(listener); + var boundary = "start"; + ExceptionDispatchInfo? failure = null; + string? failureBoundary = null; + string? failureFacts = null; try { await runtime.StartAsync(); + boundary = "callback_entry"; var heldThread = await entered.Task.WaitAsync(TimeSpan.FromSeconds(30)); + boundary = "native_retained"; await RecurrenceEventuallyAsync(() => Task.FromResult(runtime.Snapshot.Control!.Recurrence!.NativeRetained)); + boundary = "execution"; await executed.Task.WaitAsync(TimeSpan.FromSeconds(30)); + boundary = "durable_progress"; await RecurrenceEventuallyAsync(async () => { await using var dispatcher = await PostgresFixture.OpenAsync(database.OutboxConnectionString); @@ -80,6 +90,7 @@ await RecurrenceEventuallyAsync(async () => // fan-out. Its independent cooldown cannot mask a missing hard // single-flight guard. Read the published owner under its lock; // callback scheduling alone is not a native-start witness. + boundary = "held_assertions"; lock (NativeHoldField(recurrence, "_sync")) { NativeHoldField>(recurrence, "_nativeAfter").Keys @@ -92,44 +103,89 @@ await RecurrenceEventuallyAsync(async () => runtime.Snapshot.IsHealthy.Should().BeFalse(); returned.Task.IsCompleted.Should().BeFalse(); Volatile.Read(ref nativeCallbacks).Should().Be(1, "the held actual native call cannot be replaced"); + boundary = "applications"; await RecurrenceEventuallyAsync(() => Task.FromResult(recurrence.Snapshot.ApplicationOwners == 0)); + boundary = "cancelled_stop"; using var callerWait = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); await FluentActions.Awaiting(() => runtime.StopAsync(callerWait.Token)).Should().ThrowAsync(); - await JoinNativeHoldSiblingsAsync(runtime, control, recurrence); + await JoinNativeHoldSiblingsAsync(runtime, control, recurrence, value => boundary = value); // Every other real owner has positively joined. A bounded negative // observation now tests the parent's child pin, rather than merely // observing ordinary pool/maintenance shutdown still in progress. + boundary = "parent_pin"; await FluentActions.Awaiting(() => control.Completion.WaitAsync(TimeSpan.FromMilliseconds(250))) .Should().ThrowAsync(); runtime.Completion.IsCompleted.Should().BeFalse(); runtime.Snapshot.Control!.Recurrence!.CleanupConfirmed.Should().BeFalse(); + boundary = "source_pin"; source.Dispose(); application.Dispose(); source.IsDisposalComplete.Should().BeFalse(); application.IsDisposalComplete.Should().BeFalse(); returned.Task.IsCompleted.Should().BeFalse(); } + catch (Exception error) + { + CaptureFailure(error, boundary); + } finally { release.Set(); using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + // Keep the original shared stop/completion deadline and independent + // physical-thread wait. A later cleanup failure cannot replace the + // first fault, and one failed cleanup step cannot suppress another. + await CleanupStepAsync("cleanup_stop", () => runtime.StopAsync(cleanup.Token)); + await CleanupStepAsync("cleanup_completion", () => runtime.Completion.WaitAsync(cleanup.Token)); + if (entered.Task.IsCompletedSuccessfully) + await CleanupStepAsync("cleanup_thread", () => RecurrenceEventuallyAsync( + () => Task.FromResult(!entered.Task.Result.IsAlive))); + // Early faults have not reached the held-source assertions. Request + // the same real disposal on that path; only the owners' positive + // completion can release either retained source. + await CleanupStepAsync("cleanup_sources", () => { source.Dispose(); return Task.CompletedTask; }); + await CleanupStepAsync("cleanup_sources", () => { application.Dispose(); return Task.CompletedTask; }); + await CleanupStepAsync("cleanup_sources", () => RecurrenceEventuallyAsync( + () => Task.FromResult(source.IsDisposalComplete && application.IsDisposalComplete))); + } + try + { + boundary = "post_cleanup"; + returned.Task.IsCompletedSuccessfully.Should().BeTrue(); + runtime.Snapshot.CleanupConfirmed.Should().BeTrue(); + source.HasUnconfirmedCleanup.Should().BeFalse(); + application.HasUnconfirmedCleanup.Should().BeFalse(); + } + catch (Exception error) { CaptureFailure(error, boundary); } + if (failure is not null) + { try { - await runtime.StopAsync(cleanup.Token); - await runtime.Completion.WaitAsync(cleanup.Token); - } - finally - { - if (entered.Task.IsCompletedSuccessfully) - await RecurrenceEventuallyAsync(() => Task.FromResult(!entered.Task.Result.IsAlive)); + output.WriteLine("Recurrence native failure;boundary={0};{1}.", failureBoundary, failureFacts); + output.WriteLine("Recurrence native cleanup;{0}.", Facts()); } + catch (Exception) { } // Diagnostic transport cannot replace the test fault. + failure.Throw(); + } + + string Facts() => NativeHoldFacts(runtime, control, recurrence, source, application, + entered.Task, returned.Task, executed.Task, Volatile.Read(ref nativeCallbacks)); + + void CaptureFailure(Exception error, string at) + { + if (failure is not null) return; + failure = ExceptionDispatchInfo.Capture(error); + failureBoundary = at; + try { failureFacts = Facts(); } + catch (Exception) { } // Even a failed snapshot keeps the original fault. + } + + async Task CleanupStepAsync(string at, Func step) + { + try { await step(); } + catch (Exception error) { CaptureFailure(error, at); } } - returned.Task.IsCompletedSuccessfully.Should().BeTrue(); - runtime.Snapshot.CleanupConfirmed.Should().BeTrue(); - await RecurrenceEventuallyAsync(() => Task.FromResult(source.IsDisposalComplete && application.IsDisposalComplete)); - source.HasUnconfirmedCleanup.Should().BeFalse(); - application.HasUnconfirmedCleanup.Should().BeFalse(); } private static T NativeHoldField(object owner, string name) => @@ -137,12 +193,15 @@ private static T NativeHoldField(object owner, string name) => ?? throw new InvalidOperationException($"The actual native-hold owner field {name} is missing.")); private static async Task JoinNativeHoldSiblingsAsync(BackgroundJobRuntime runtime, - BackgroundJobControl control, BackgroundJobRecurrenceControl recurrence) + BackgroundJobControl control, BackgroundJobRecurrenceControl recurrence, Action? boundary = null) { + boundary?.Invoke("bridge_stop_publication"); await RecurrenceEventuallyAsync(() => Task.FromResult( typeof(BackgroundJobRuntime).GetField("_bridgeStop", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(runtime) is Task)); + boundary?.Invoke("control_loops_publication"); var loops = await NativeHoldField>(control, "_loopsPublished").Task; + boundary?.Invoke("recurrence_loop_publication"); var recurrenceLoop = await NativeHoldField>(recurrence, "_loopPublished").Task; var siblings = NativeHoldField>(runtime, "_pools").Select(pool => pool.Completion) .Concat(loops).Append(NativeHoldField(control, "_stopRelay")) @@ -152,9 +211,90 @@ await RecurrenceEventuallyAsync(() => Task.FromResult( .Append(NativeHoldField(runtime, "_bridgeStop")) .Append(NativeHoldField(runtime, "_controlCancellation")); if (recurrenceLoop is not null) siblings = siblings.Append(recurrenceLoop); + boundary?.Invoke("sibling_join"); await Task.WhenAll(siblings).WaitAsync(TimeSpan.FromSeconds(30)); + boundary?.Invoke("sibling_assertions"); runtime.Snapshot.BootstrapJoined.Should().BeTrue(); control.Snapshot.Executions.Should().Be(0); control.Snapshot.PendingOutcomes.Should().Be(0); } + + // Failure-only bounded facts: no I/O, SQL, payload, raw error, identifier or + // reflection name crosses this diagnostic boundary. Each getter can fail + // independently without hiding the actual test exception. + private static string NativeHoldFacts(BackgroundJobRuntime runtime, BackgroundJobControl control, + BackgroundJobRecurrenceControl recurrence, PrivilegedDataSource source, ApplicationDataSourceLifetime application, + Task entered, Task returned, Task executed, int callbacks) + { + return string.Join(';', + [ + "callbacks=" + Count(callbacks), "entered=" + State(entered), "returned=" + State(returned), + "executed=" + State(executed), "thread=" + Read(() => entered.IsCompletedSuccessfully + ? entered.Result.IsAlive ? "alive" : "returned" : "not_entered"), + "nativeOutstanding=" + Read(() => Boolean(recurrence.Snapshot.NativeOutstanding)), + "nativeRetained=" + Read(() => Boolean(recurrence.Snapshot.NativeRetained)), + "sqlHealthy=" + Read(() => Boolean(recurrence.Snapshot.SqlHealthy)), + "applications=" + Read(() => Count(recurrence.Snapshot.ApplicationOwners)), + "retainedApplications=" + Read(() => Count(recurrence.Snapshot.RetainedApplications)), + "pendingHistory=" + Read(() => Count(recurrence.Snapshot.PendingHistory)), + "executions=" + Read(() => Count(control.Snapshot.Executions)), + "pendingOutcomes=" + Read(() => Count(control.Snapshot.PendingOutcomes)), + "bootstrap=" + Read(() => Boolean(runtime.Snapshot.BootstrapJoined)), + "pools=" + Read(() => + { + lock (NativeHoldField(runtime, "_sync")) + return States(NativeHoldField>(runtime, "_pools") + .Select(pool => pool.Completion).ToArray(), 8); + }), + "loops=" + Read(() => + { + lock (NativeHoldField(control, "_sync")) + { + var published = NativeHoldField>(control, "_loopsPublished").Task; + // ScheduleLoops publishes renewal, control, terminal in this + // exact order. A partial startup remains a shorter list. + return published.IsCompletedSuccessfully ? States(published.Result, 3) : Published(published); + } + }), + "controlRelay=" + Read(() => State(NativeHoldField(control, "_stopRelay"))), + "recurrenceRelay=" + Read(() => State(NativeHoldField(recurrence, "_stopRelay"))), + "shared=" + Read(() => + { + lock (NativeHoldField(runtime, "_sync")) + return State(Field(runtime, "_shared")?.Completion); + }), + "bridgeLoop=" + RuntimeTask("_bridgeLoop"), "bridgeStop=" + RuntimeTask("_bridgeStop"), + "controlCancellation=" + RuntimeTask("_controlCancellation"), + "recurrenceLoop=" + Read(() => + { + lock (NativeHoldField(recurrence, "_sync")) + { + var published = NativeHoldField>(recurrence, "_loopPublished").Task; + return published.IsCompletedSuccessfully ? State(published.Result) : Published(published); + } + }), + "control=" + State(control.Completion), "recurrence=" + State(recurrence.Completion), + "runtime=" + State(runtime.Completion), + "sourceDisposed=" + Read(() => Boolean(source.IsDisposalComplete)), + "applicationDisposed=" + Read(() => Boolean(application.IsDisposalComplete)), + "sourceUnconfirmed=" + Read(() => Boolean(source.HasUnconfirmedCleanup)), + "applicationUnconfirmed=" + Read(() => Boolean(application.HasUnconfirmedCleanup)), + ]); + + string RuntimeTask(string field) => Read(() => + { + lock (NativeHoldField(runtime, "_sync")) return State(Field(runtime, field)); + }); + + static T? Field(object owner, string name) where T : class => + (T?)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner); + static string Read(Func read) { try { return read(); } catch (Exception) { return "unavailable"; } } + static string Boolean(bool value) => value ? "true" : "false"; + static string Count(int value) => value is >= 0 and <= 64 ? value.ToString(CultureInfo.InvariantCulture) : "out_of_range"; + static string State(Task? task) => task is null ? "not_issued" : task.IsCompletedSuccessfully ? "succeeded" + : task.IsFaulted ? "faulted" : task.IsCanceled ? "canceled" : "pending"; + static string Published(Task task) => task.IsCompleted ? State(task) : "unpublished"; + static string States(Task[] tasks, int limit) => tasks.Length == 0 ? "not_issued" + : tasks.Length > limit ? "unavailable" : string.Join(',', tasks.Select(State)); + } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.cs index e8eae515..81ba00f4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.cs @@ -13,7 +13,7 @@ namespace LearnStack.Tests.Integration.Database; /// Real confined-role manifest activation, coalescing and atomic progress under PostgreSQL locks. [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed partial class BackgroundJobRecurrenceStoreTests(SchemaFixture schema) +public sealed partial class BackgroundJobRecurrenceStoreTests(SchemaFixture schema, Xunit.Abstractions.ITestOutputHelper output) { private static BackgroundJobRecurrenceFixture Fixture => BackgroundJobRecurrenceFixture.Shared; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs index 59c39ba2..ebaf78b2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs @@ -8,7 +8,8 @@ namespace LearnStack.Tests.Integration.Database; /// /// Forwards a real PostgreSQL session, withholding exactly one completed COMMIT's -/// acknowledgement. The optional held socket proves cancellation independently of disconnect. +/// acknowledgement, or holding one request before forwarding its real ACK. +/// The optional held socket proves cancellation independently of disconnect. /// internal sealed class CommitResponseDroppingProxy : IAsyncDisposable { @@ -20,6 +21,10 @@ internal sealed class CommitResponseDroppingProxy : IAsyncDisposable private readonly bool _rejectReconciliationConnections; private readonly bool _holdCommitResponse; private readonly bool _dropCommitRequest; + private readonly Func? _holdCommitRequestWhen; + private readonly TaskCompletionSource _commitRequestHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseHeldRequest = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _heldCommitRequests; private readonly TaskCompletionSource _commitRequestDropped = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _issuedCommits, _droppedCommitRequests; private readonly TaskCompletionSource _commitResponseHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -58,14 +63,17 @@ internal sealed class CommitResponseDroppingProxy : IAsyncDisposable public CommitResponseDroppingProxy(string connectionString, bool rejectReconciliationConnections = false, int commitsToPass = 0, bool holdCommitResponse = false, TimeSpan? lifetime = null, bool dropCommitRequest = false, - Guid? observedCheckpointJob = null) + Guid? observedCheckpointJob = null, Func? holdCommitRequestWhen = null) { ArgumentOutOfRangeException.ThrowIfNegative(commitsToPass); + if (holdCommitRequestWhen is not null && (dropCommitRequest || holdCommitResponse)) + throw new ArgumentException("A held request must forward its actual acknowledgement.", nameof(holdCommitRequestWhen)); _stopping = new(lifetime ?? TimeSpan.FromSeconds(30)); _commitsToPass = commitsToPass; _rejectReconciliationConnections = rejectReconciliationConnections; _holdCommitResponse = holdCommitResponse; _dropCommitRequest = dropCommitRequest; + _holdCommitRequestWhen = holdCommitRequestWhen; _observedCheckpointJob = observedCheckpointJob; var target = new NpgsqlConnectionStringBuilder(connectionString); _targetHost = target.Host ?? throw new ArgumentException("The test proxy requires a PostgreSQL host.", nameof(connectionString)); @@ -90,6 +98,9 @@ public CommitResponseDroppingProxy(string connectionString, bool rejectReconcili public int CompletedCommits => Volatile.Read(ref _completedCommits); public int AcceptedConnections => Volatile.Read(ref _acceptedConnections); public Task CommitResponseHeld => _commitResponseHeld.Task; + public Task CommitRequestHeld => _commitRequestHeld.Task; + public int HeldCommitRequests => Volatile.Read(ref _heldCommitRequests); + public void ReleaseHeldRequest() => _releaseHeldRequest.TrySetResult(); public Task CommitRequestDropped => _commitRequestDropped.Task; public int DroppedCommitRequests => Volatile.Read(ref _droppedCommitRequests); public void ReleaseHeldResponse() => _releaseHeldResponse.TrySetResult(); @@ -190,7 +201,7 @@ private async Task ForwardSessionAsync(TcpClient client) await using var clientStream = client.GetStream(); await using var serverStream = server.GetStream(); var checkpointLock = new CheckpointLockSession(_observedCheckpointJob); - var toServer = _dropCommitRequest || _observedCheckpointJob is not null + var toServer = _dropCommitRequest || _holdCommitRequestWhen is not null || _observedCheckpointJob is not null ? ForwardRequestsAsync(clientStream, serverStream, checkpointLock, stopping.Token) : clientStream.CopyToAsync(serverStream, stopping.Token); var toClient = ForwardResponsesAsync(serverStream, clientStream, checkpointLock, stopping.Token); @@ -245,6 +256,15 @@ private async Task ForwardRequestsAsync(NetworkStream client, NetworkStream serv _commitRequestDropped.TrySetResult(); return; } + if (_holdCommitRequestWhen is not null && header[0] == (byte)'Q' && body.AsSpan().SequenceEqual("COMMIT\0"u8) + && _holdCommitRequestWhen() && Interlocked.Increment(ref _issuedCommits) > _commitsToPass + && Interlocked.CompareExchange(ref _heldCommitRequests, 1, 0) == 0) + { + // Hold the real COMMIT before PostgreSQL releases its row lock, + // then forward both request and acknowledgement without alteration. + _commitRequestHeld.TrySetResult(); + await _releaseHeldRequest.Task.WaitAsync(token); + } await server.WriteAsync(header, token); await server.WriteAsync(body, token); } @@ -368,7 +388,7 @@ private async Task ForwardResponsesAsync(NetworkStream server, NetworkStream cli quarantineRead = false; } if (header[0] == (byte)'C' && body.AsSpan().SequenceEqual("COMMIT\0"u8) - && Interlocked.Increment(ref _completedCommits) > _commitsToPass && !_dropCommitRequest + && Interlocked.Increment(ref _completedCommits) > _commitsToPass && !_dropCommitRequest && _holdCommitRequestWhen is null && Interlocked.CompareExchange(ref _droppedCommitResponses, 1, 0) == 0) { // The real server has committed. Forward neither CommandComplete diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index f5b0fc0d..5c61e2cb 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -30,6 +30,13 @@ public sealed partial class DatabaseRoleTests : IClassFixture public DatabaseRoleTests(PostgresFixture postgres) => _postgres = postgres; + [Fact] + public async Task ThePublishedDatabasePortIsExclusivelyLoopback() + { + (await _postgres.PublishedPortsAreLoopbackAsync()).Should().BeTrue( + "application and identity fixtures must share one publication address"); + } + [Theory] [InlineData("learnstack_migration", false)] [InlineData("learnstack_app", false)] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index 342b5ec5..659fc5bc 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using DotNet.Testcontainers.Configurations; using Npgsql; using Testcontainers.PostgreSql; using Xunit; @@ -103,6 +104,8 @@ public sealed class PostgresFixture : IAsyncLifetime .WithEnvironment("LEARNSTACK_APP_PW", AppPassword) .WithEnvironment("LEARNSTACK_PLATFORM_PW", PlatformPassword) .WithEnvironment("LEARNSTACK_OUTBOX_PW", OutboxPassword) + // Keep concurrent publishers in one allocator address family, including identity fixtures. + .WithCreateParameterModifier(parameters => ContainerLoopbackPorts.Configure(parameters, "5432/tcp")) .Build(); /// Owns every table. `dotnet ef database update` and nothing else. @@ -117,10 +120,27 @@ public sealed class PostgresFixture : IAsyncLifetime /// BYPASSRLS; only the outbox dispatcher's equivalent. public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); + internal async Task PublishedPortsAreLoopbackAsync() + { + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); + var actual = await docker.Containers.InspectContainerAsync(_container.Id); + return ContainerLoopbackPorts.IsExclusive(actual, "5432/tcp"); + } + public async Task InitializeAsync() { await _container.StartAsync(); + try + { + if (!await PublishedPortsAreLoopbackAsync()) + throw new InvalidOperationException("Application fixture published port is not exclusively loopback."); + } + catch + { + await _container.DisposeAsync(); + throw; + } var script = await File.ReadAllTextAsync(RepositoryPath.RolesScript()); await _container.CopyAsync(System.Text.Encoding.UTF8.GetBytes(script), ContainerScriptPath); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/learnstack-24705a7a.json b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/learnstack-24705a7a.json new file mode 100644 index 00000000..df048175 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/learnstack-24705a7a.json @@ -0,0 +1,154 @@ +{ + "realm": "learnstack", + "displayName": "LearnStack — Tenant Users", + "displayNameHtml": "LearnStack — Tenant Users", + "enabled": true, + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 1800, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "sslRequired": "external", + "internationalizationEnabled": true, + "supportedLocales": ["en", "tr"], + "defaultLocale": "en", + "roles": { + "realm": [ + { + "name": "tenant-admin", + "description": "Tenant administrator — manages users, content, billing within a tenant.", + "composite": false, + "clientRole": false + }, + { + "name": "tenant-instructor", + "description": "Instructor — authors content + runs live sessions within a tenant.", + "composite": false, + "clientRole": false + }, + { + "name": "tenant-learner", + "description": "Learner — enrols in courses + attends sessions within a tenant.", + "composite": false, + "clientRole": false + } + ] + }, + "clients": [ + { + "clientId": "learnstack-api", + "name": "LearnStack API (confidential service)", + "description": "Backend .NET API. Confidential client; standard flow + service-account. ROPC disabled, webOrigins empty. Dev posture (fullScopeAllowed=true) documented in infra/keycloak/README.md.", + "enabled": true, + "publicClient": false, + "secret": "__HISTORICAL_TEST_API_SECRET__", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "redirectUris": [ + "http://localhost:5080/*", + "http://localhost:3000/*" + ], + "webOrigins": [], + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "1800", + "pkce.code.challenge.method": "S256" + }, + "fullScopeAllowed": true + }, + { + "clientId": "learnstack-web", + "name": "LearnStack Web (public PKCE)", + "description": "Next.js apps/web. Public client, PKCE-enforced, no secret. Dev posture (fullScopeAllowed=true) documented in infra/keycloak/README.md.", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:3000/*" + ], + "webOrigins": ["http://localhost:3000"], + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:3000/*" + }, + "fullScopeAllowed": true + } + ], + "users": [ + { + "username": "demo-admin@tenant-a.test", + "email": "demo-admin@tenant-a.test", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "__HISTORICAL_TEST_PASSWORD__", + "temporary": false + } + ], + "realmRoles": ["tenant-admin"], + "attributes": { + "tenant_id": ["tenant-a"] + } + }, + { + "username": "demo-learner@tenant-a.test", + "email": "demo-learner@tenant-a.test", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "Learner", + "credentials": [ + { + "type": "password", + "value": "__HISTORICAL_TEST_PASSWORD__", + "temporary": false + } + ], + "realmRoles": ["tenant-learner"], + "attributes": { + "tenant_id": ["tenant-a"] + } + } + ], + "smtpServer": { + "host": "mailpit", + "port": "1025", + "from": "no-reply@learnstack.dev", + "fromDisplayName": "LearnStack Dev", + "replyTo": "no-reply@learnstack.dev", + "ssl": "false", + "starttls": "false", + "auth": "false" + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakAdminClient.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakAdminClient.cs new file mode 100644 index 00000000..7d5793e3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakAdminClient.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Admin-only setup/readback for the isolated provider; responses are never used as diagnostics. +internal sealed class KeycloakAdminClient : IDisposable +{ + private readonly HttpClient _client; + private readonly Func> _authenticate; + private readonly TimeProvider _clock; + private readonly SemaphoreSlim _authentication = new(1, 1); + private KeycloakAdminCredential? _credential; + private long _authenticatedAt; + + internal KeycloakAdminClient(Uri baseAddress, X509Certificate2 rootCertificate, string realm, + Func> authenticate) + : this(KeycloakHttp.Create(new Uri(baseAddress, "admin/realms/" + Uri.EscapeDataString(realm) + "/"), + rootCertificate), authenticate, TimeProvider.System) + { } + + internal KeycloakAdminClient(HttpClient client, Func> authenticate, + TimeProvider clock) => (_client, _authenticate, _clock) = (client, authenticate, clock); + + internal async Task AuthenticateAsync(CancellationToken cancellationToken = default) + { + await _authentication.WaitAsync(cancellationToken); + try + { + if (_credential is not null + && _clock.GetElapsedTime(_authenticatedAt) < _credential.Lifetime - KeycloakHttp.RequestBudget) return; + var started = _clock.GetTimestamp(); + var credential = await _authenticate(cancellationToken); + if (string.IsNullOrEmpty(credential.AccessToken) || credential.Lifetime <= KeycloakHttp.RequestBudget) + throw new InvalidOperationException("Keycloak fixture administrator credential is invalid."); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credential.AccessToken); + _authenticatedAt = started; + _credential = credential; + } + finally { _authentication.Release(); } + } + + internal async Task GetAsync(string path = "") + { + await AuthenticateAsync(); + using var json = await KeycloakHttp.JsonAsync(_client, HttpMethod.Get, path); + return json.RootElement.Clone(); + } + + internal async Task UserAsync(string username) + { + var users = await GetAsync(QueryHelpers.AddQueryString("users", new Dictionary + { + ["username"] = username, + ["exact"] = "true", + })); + if (users.GetArrayLength() != 1) + throw new InvalidOperationException("Keycloak fixture expected exactly one matching account."); + return users[0]; + } + + internal async Task ClientAsync(string clientId) + { + var clients = await GetAsync(QueryHelpers.AddQueryString("clients", "clientId", clientId)); + if (clients.GetArrayLength() != 1) + throw new InvalidOperationException("Keycloak fixture expected exactly one matching client."); + return clients[0]; + } + + internal Task PutAsync(string path, object value) => WriteAsync(HttpMethod.Put, path, value); + internal Task PostAsync(string path, object value) => WriteAsync(HttpMethod.Post, path, value); + internal Task DeleteAsync(string path) => WriteAsync(HttpMethod.Delete, path, null); + + private async Task WriteAsync(HttpMethod method, string path, object? value) + { + // Refresh before sending; a refused or uncertain mutation is never replayed. + await AuthenticateAsync(); + using var response = await KeycloakHttp.SendAsync(_client, method, path, + value is null ? null : JsonContent.Create(value)); + return response.StatusCode; + } + + public void Dispose() { _client.Dispose(); _authentication.Dispose(); } +} + +/// In-memory provider credential with no token-bearing generated diagnostic representation. +internal sealed class KeycloakAdminCredential(string accessToken, TimeSpan lifetime) +{ + internal string AccessToken { get; } = accessToken; + internal TimeSpan Lifetime { get; } = lifetime; + public override string ToString() => "Keycloak administrator credential (sensitive value omitted)"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs new file mode 100644 index 00000000..17ba35a3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs @@ -0,0 +1,296 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.RegularExpressions; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.WebUtilities; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +internal enum KeycloakPkce { S256, Plain, Missing } + +/// Actual cookie/login-form code flow; consumes redirects without fetching the reserved BFF callback. +internal sealed partial class KeycloakBrowserFlow : IDisposable +{ + private static readonly string[] RegistrationRequiredFields = ["email", "firstName", "lastName", "password", "password-confirm"]; + private readonly Uri _baseAddress; + private readonly HttpClient _client; + private readonly string _realm; + + internal KeycloakBrowserFlow(Uri baseAddress, X509Certificate2 rootCertificate, string realm = "learnstack") + { + _baseAddress = baseAddress; + _realm = KeycloakFixture.CheckedRealm(realm); + _client = KeycloakHttp.Create(baseAddress, rootCertificate, new CookieContainer()); + } + + internal async Task AuthorizeAsync(string username, string password, string redirectUri, + KeycloakPkce pkce = KeycloakPkce.S256, string clientId = "learnstack-web", string scope = "openid profile email", + Func>? otpCode = null) + { + var state = RandomValue(); + var verifier = RandomValue(); + var nonce = RandomValue(); + var query = new Dictionary + { + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["scope"] = scope, + ["state"] = state, + ["nonce"] = nonce, + }; + if (pkce != KeycloakPkce.Missing) + { + query["code_challenge"] = pkce == KeycloakPkce.Plain ? verifier + : Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + query["code_challenge_method"] = pkce == KeycloakPkce.Plain ? "plain" : "S256"; + } + var path = QueryHelpers.AddQueryString("realms/" + _realm + "/protocol/openid-connect/auth", query); + using var initial = await KeycloakHttp.SendAsync(_client, HttpMethod.Get, path); + var callback = Callback(initial, redirectUri, state, verifier, clientId, nonce); + if (callback is not null) return callback; + if (initial.StatusCode != HttpStatusCode.OK) + return KeycloakAuthorization.Refused(initial.StatusCode); + var action = LoginAction(await KeycloakHttp.ReadBodyAsync(initial)); + using var login = await KeycloakHttp.SendAsync(_client, HttpMethod.Post, action, + new FormUrlEncodedContent(new Dictionary + { + ["username"] = username, + ["password"] = password, + ["credentialId"] = "", + })); + var completed = Callback(login, redirectUri, state, verifier, clientId, nonce); + if (completed is not null) return completed; + var html = await KeycloakHttp.ReadBodyAsync(login); + if (!html.Contains("kc-otp-login-form", StringComparison.Ordinal)) + return KeycloakAuthorization.Refused(login.StatusCode, LoginRefusal(html)); + var otpAction = OtpAction(html, out var otpFields); + if (otpCode is null) return KeycloakAuthorization.Refused(login.StatusCode, "mfa_challenge_required", mfaChallenged: true); + otpFields["otp"] = await otpCode(); + using var otp = await KeycloakHttp.SendAsync(_client, HttpMethod.Post, otpAction, + new FormUrlEncodedContent(otpFields)); + var authorized = Callback(otp, redirectUri, state, verifier, clientId, nonce); + if (authorized is not null) return authorized.WithMfaChallenge(); + var refused = await KeycloakHttp.ReadBodyAsync(otp); + return KeycloakAuthorization.Refused(otp.StatusCode, + refused.Contains("id=\"input-error-otp\"", StringComparison.Ordinal) ? "invalid_otp" : LoginRefusal(refused), + mfaChallenged: true); + } + + // The pinned registration form is exercised as delivered, including any hidden inputs. + // Only the fixed provider registration endpoint and its same-origin action are accepted. + internal async Task RegisterAsync(string username, string password, string redirectUri, + IReadOnlyCollection>? injectedAttributes = null) + { + var state = RandomValue(); + var verifier = RandomValue(); + var nonce = RandomValue(); + var path = QueryHelpers.AddQueryString("realms/" + _realm + "/protocol/openid-connect/registrations", + new Dictionary + { + ["client_id"] = "learnstack-web", + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["scope"] = "openid profile email", + ["state"] = state, + ["nonce"] = nonce, + ["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), + ["code_challenge_method"] = "S256", + }); + using var page = await KeycloakHttp.SendAsync(_client, HttpMethod.Get, path); + if (page.StatusCode != HttpStatusCode.OK) + throw new InvalidOperationException("Keycloak fixture ordinary registration form was refused."); + var html = await KeycloakHttp.ReadBodyAsync(page); + var form = FormTag().Matches(html).Cast().SingleOrDefault(match => + Attributes(match.Value).GetValueOrDefault("id") == "kc-register-form") + ?? throw new InvalidOperationException("Keycloak fixture ordinary registration form is missing."); + var formAttributes = Attributes(form.Value); + if (formAttributes.GetValueOrDefault("method")?.ToLowerInvariant() != "post" + || !formAttributes.TryGetValue("action", out var action) + || !Uri.TryCreate(_baseAddress, action, out var uri) || !SameOrigin(uri, _baseAddress) + || uri.AbsolutePath != "/realms/" + _realm + "/login-actions/registration") + throw new InvalidOperationException("Keycloak fixture registration action is outside the provider boundary."); + var end = html.IndexOf("", form.Index + form.Length, StringComparison.OrdinalIgnoreCase); + if (end < 0) throw new InvalidOperationException("Keycloak fixture registration form is incomplete."); + var fields = new Dictionary(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + foreach (Match input in InputTag().Matches(html[form.Index..end])) + { + var attributes = Attributes(input.Value); + if (!attributes.TryGetValue("name", out var name)) continue; + names.Add(name); + if (attributes.GetValueOrDefault("type")?.Equals("hidden", StringComparison.OrdinalIgnoreCase) == true) + fields.Add(name, attributes.GetValueOrDefault("value") ?? ""); + } + if (!RegistrationRequiredFields.All(names.Contains)) + throw new InvalidOperationException("Keycloak fixture registration form lacks ordinary required fields."); + fields["email"] = username; + fields["firstName"] = "Provider"; + fields["lastName"] = "Registration"; + fields["password"] = password; + fields["password-confirm"] = password; + if (names.Contains("username")) fields["username"] = username; + var submittedFields = fields.ToList(); + if (injectedAttributes is not null) submittedFields.AddRange(injectedAttributes); + using var submitted = await KeycloakHttp.SendAsync(_client, HttpMethod.Post, uri.AbsoluteUri, + new FormUrlEncodedContent(submittedFields)); + var callback = Callback(submitted, redirectUri, state, verifier, "learnstack-web", nonce); + // A refusal may redisplay the form; no raw form or error body is retained or diagnosed. + return new KeycloakRegistration(submitted.StatusCode, callback?.Authorized == true, names); + } + + private static Dictionary Attributes(string tag) => Attribute().Matches(tag).ToDictionary( + match => match.Groups["name"].Value, match => WebUtility.HtmlDecode(match.Groups["value"].Value), + StringComparer.OrdinalIgnoreCase); + + private static string LoginRefusal(string html) + { + if (html.Contains("Invalid username or password", StringComparison.OrdinalIgnoreCase)) return "invalid_credentials"; + if (html.Contains("Cookie not found", StringComparison.OrdinalIgnoreCase)) return "cookie_missing"; + if (html.Contains("kc-update-profile-form", StringComparison.Ordinal)) return "profile_action_required"; + if (html.Contains("kc-passwd-update-form", StringComparison.Ordinal)) return "password_action_required"; + if (html.Contains("kc-totp-settings-form", StringComparison.Ordinal)) return "mfa_action_required"; + return "other_login_refusal"; + } + + internal async Task RedeemAsync(KeycloakAuthorization authorization, + string? secret = KeycloakFixture.WebClientSecret, string? verifier = null, bool omitVerifier = false) + { + if (authorization.Code is null) + throw new InvalidOperationException("Keycloak fixture cannot exchange a refused authorization."); + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = authorization.ClientId, + ["code"] = authorization.Code, + ["redirect_uri"] = authorization.RedirectUri, + }; + if (secret is not null) form["client_secret"] = secret; + if (!omitVerifier) form["code_verifier"] = verifier ?? authorization.Verifier; + var exchange = await KeycloakTokenExchange.RequestAsync(_client, form, _realm); + if (exchange.StatusCode == HttpStatusCode.OK) + { + try + { + using var id = JsonDocument.Parse(Base64UrlEncoder.DecodeBytes(exchange.IdToken.Split('.')[1])); + if (!id.RootElement.TryGetProperty("nonce", out var nonce) || nonce.ValueKind != JsonValueKind.String + || nonce.GetString() != authorization.Nonce) throw new InvalidOperationException(); + } + catch (Exception) { throw new InvalidOperationException("Keycloak fixture ID token nonce did not round-trip."); } + } + return exchange; + } + + internal Task RequestTokenAsync(IReadOnlyDictionary form) => + KeycloakTokenExchange.RequestAsync(_client, form, _realm); + + private string LoginAction(string html) + { + foreach (Match form in FormTag().Matches(html)) + { + var attributes = Attribute().Matches(form.Value).ToDictionary( + match => match.Groups["name"].Value, match => WebUtility.HtmlDecode(match.Groups["value"].Value), + StringComparer.OrdinalIgnoreCase); + if (!attributes.TryGetValue("id", out var id) || id != "kc-form-login") continue; + if (!attributes.TryGetValue("action", out var action) + || !Uri.TryCreate(_baseAddress, action, out var uri) + || !SameOrigin(uri, _baseAddress) + || uri.AbsolutePath != "/realms/" + _realm + "/login-actions/authenticate") + throw new InvalidOperationException("Keycloak fixture login action is outside the provider boundary."); + return uri.AbsoluteUri; + } + throw new InvalidOperationException("Keycloak fixture expected the ordinary login form."); + } + + private string OtpAction(string html, out Dictionary fields) + { + var form = FormTag().Matches(html).Cast().SingleOrDefault(match => + Attributes(match.Value).GetValueOrDefault("id") == "kc-otp-login-form") + ?? throw new InvalidOperationException("Keycloak fixture expected the pinned OTP challenge."); + var attributes = Attributes(form.Value); + var end = html.IndexOf("", form.Index + form.Length, StringComparison.OrdinalIgnoreCase); + if (attributes.GetValueOrDefault("method")?.ToLowerInvariant() != "post" + || !attributes.TryGetValue("action", out var action) + || !Uri.TryCreate(_baseAddress, action, out var uri) || !SameOrigin(uri, _baseAddress) + || uri.AbsolutePath != "/realms/" + _realm + "/login-actions/authenticate" || end < 0) + throw new InvalidOperationException("Keycloak fixture OTP action is outside the provider boundary."); + var inputs = InputTag().Matches(html[form.Index..end]).Cast().Select(match => Attributes(match.Value)).ToArray(); + var hidden = inputs.Where(input => input.GetValueOrDefault("type")?.Equals("hidden", StringComparison.OrdinalIgnoreCase) == true).ToArray(); + if (inputs.Count(input => input.GetValueOrDefault("name") == "otp") != 1 + || hidden.Length != 1 || hidden[0].GetValueOrDefault("name") != "selectedCredentialId" + || !Guid.TryParse(hidden[0].GetValueOrDefault("value"), out _) + || html[form.Index..end].Contains("id=\"kc-otp-credential-", StringComparison.Ordinal)) + throw new InvalidOperationException("Keycloak fixture OTP form differs from the pinned single-credential form."); + fields = new Dictionary { ["selectedCredentialId"] = hidden[0]["value"] }; + return uri.AbsoluteUri; + } + + private KeycloakAuthorization? Callback(HttpResponseMessage response, string redirectUri, + string state, string verifier, string clientId, string nonce) + { + if (response.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther)) return null; + var location = response.Headers.Location; + var expected = new Uri(redirectUri); + if (location is null || !location.IsAbsoluteUri || !SameOrigin(location, expected) + || location.AbsolutePath != expected.AbsolutePath || location.Fragment.Length != 0) + throw new InvalidOperationException("Keycloak fixture authorization returned an unexpected redirect."); + var query = QueryHelpers.ParseQuery(location.Query); + if (query["state"].Count != 1 || query["state"][0] != state) + throw new InvalidOperationException("Keycloak fixture authorization state did not round-trip."); + if (query["iss"].Count != 1 || query["iss"][0] != new Uri(_baseAddress, "realms/" + _realm).AbsoluteUri) + throw new InvalidOperationException("Keycloak fixture authorization issuer did not round-trip."); + if (query.TryGetValue("error", out var error)) + return KeycloakAuthorization.Refused(response.StatusCode, KeycloakHttp.ClosedOAuthError(error.ToString())); + if (query["code"].Count != 1 || string.IsNullOrEmpty(query["code"][0])) + throw new InvalidOperationException("Keycloak fixture authorization code is missing."); + return new KeycloakAuthorization(response.StatusCode, query["code"][0], verifier, redirectUri, clientId, null, nonce); + } + + private static bool SameOrigin(Uri first, Uri second) => first.Scheme == second.Scheme + && first.Host == second.Host && first.Port == second.Port; + + private static string RandomValue() => Base64Url(RandomNumberGenerator.GetBytes(32)); + private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + [GeneratedRegex("]*>", RegexOptions.IgnoreCase, 1000)] + private static partial Regex FormTag(); + + [GeneratedRegex("]*>", RegexOptions.IgnoreCase, 1000)] + private static partial Regex InputTag(); + + [GeneratedRegex("(?[a-zA-Z-]+)\\s*=\\s*[\"'](?[^\"']*)[\"']", RegexOptions.None, 1000)] + private static partial Regex Attribute(); + + public void Dispose() => _client.Dispose(); +} + +/// Sensitive values deliberately have no record-generated diagnostic representation. +internal sealed class KeycloakAuthorization(HttpStatusCode statusCode, string? code, string verifier, + string redirectUri, string clientId, string? error, string nonce = "", bool mfaChallenged = false) +{ + internal HttpStatusCode StatusCode { get; } = statusCode; + internal string? Code { get; } = code; + internal string Verifier { get; } = verifier; + internal string RedirectUri { get; } = redirectUri; + internal string ClientId { get; } = clientId; + internal string? Error { get; } = error; + internal string Nonce { get; } = nonce; + internal bool Authorized => Code is not null; + internal bool MfaChallenged { get; } = mfaChallenged; + internal KeycloakAuthorization WithMfaChallenge() => new(StatusCode, Code, Verifier, RedirectUri, ClientId, Error, Nonce, true); + internal static KeycloakAuthorization Refused(HttpStatusCode status, string? error = null, bool mfaChallenged = false) => + new(status, null, "", "", "", error, mfaChallenged: mfaChallenged); + public override string ToString() => "Keycloak authorization result (sensitive values omitted)"; +} + +/// Only closed registration facts and non-sensitive form field names leave the browser helper. +internal sealed class KeycloakRegistration(HttpStatusCode statusCode, bool authorized, IReadOnlySet fields) +{ + internal HttpStatusCode StatusCode { get; } = statusCode; + internal bool Authorized { get; } = authorized; + internal IReadOnlySet Fields { get; } = fields; + public override string ToString() => "Keycloak registration result (sensitive values omitted)"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.StockProof.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.StockProof.cs new file mode 100644 index 00000000..5b67a9aa --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.StockProof.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Docker.DotNet; +using DotNet.Testcontainers.Configurations; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class KeycloakFixture +{ + internal async Task StockProviderResourcesAsync() + { + if (_keycloak is null || _database is null || _network is null) + throw new InvalidOperationException("Stock proof fixture resources are not initialized."); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); + var provider = await docker.Containers.InspectContainerAsync(_keycloak.Id, deadline.Token); + var image = await docker.Images.InspectImageAsync(provider.Image, deadline.Token); + if (provider.Image != image.ID || !image.ID.StartsWith("sha256:", StringComparison.Ordinal) + || image.RepoDigests is null || image.RepoDigests.Count == 0) + throw new InvalidOperationException("Stock proof provider image identity is unavailable."); + if (provider.NetworkSettings?.Networks is not { } networks + || !networks.TryGetValue(_network.Name, out var network) || string.IsNullOrEmpty(network?.NetworkID)) + throw new InvalidOperationException("Stock proof owned network identity is unavailable."); + return new StockProviderResources(_keycloak.Id, _database.Id, network.NetworkID, + image.ID, image.RepoDigests.Order(StringComparer.Ordinal).ToArray(), image.Os, image.Architecture, + image.Descriptor?.Digest, image.Descriptor?.MediaType, provider.ImageManifestDescriptor?.Digest); + } + + internal async Task StockProviderVersionAsync() + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var client = KeycloakHttp.Create(new Uri(BaseAddress, "admin/"), RootCertificate); + // The authenticated server-info exception has a separate fixed 512 KiB bound. + client.MaxResponseContentBufferSize = 512 * 1024; + var credential = await AuthenticateAdministratorAsync(deadline.Token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credential.AccessToken); + using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Get, "serverinfo", cancellationToken: deadline.Token); + if (response.StatusCode != HttpStatusCode.OK) + throw new InvalidOperationException("Stock proof provider version request was refused."); + var body = await response.Content.ReadAsByteArrayAsync(deadline.Token); + if (body.Length > 512 * 1024) throw new InvalidOperationException("Stock proof provider version exceeded its bound."); + try + { + using var json = JsonDocument.Parse(body); + return json.RootElement.GetProperty("systemInfo").GetProperty("version").GetString() + ?? throw new InvalidOperationException("Stock proof provider version is absent."); + } + catch (JsonException) { throw new InvalidOperationException("Stock proof provider version is malformed."); } + } + + internal static async Task AssertStockResourcesAbsentAsync(StockProviderResources resources) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); + foreach (var id in new[] { resources.ProviderContainerId, resources.DatabaseContainerId }) + { + try { await docker.Containers.InspectContainerAsync(id, deadline.Token); } + catch (DockerApiException exception) when (exception.StatusCode == HttpStatusCode.NotFound) { continue; } + throw new InvalidOperationException("Stock proof owned container remains after joined cleanup."); + } + try { await docker.Networks.InspectNetworkAsync(resources.NetworkId, cancellationToken: deadline.Token); } + catch (DockerApiException exception) when (exception.StatusCode == HttpStatusCode.NotFound) { return; } + throw new InvalidOperationException("Stock proof owned network remains after joined cleanup."); + } +} + +internal sealed record StockProviderResources(string ProviderContainerId, string DatabaseContainerId, + string NetworkId, string ImageId, string[] RepoDigests, string OperatingSystem, string Architecture, + string? ImageDescriptorDigest, string? ImageDescriptorMediaType, string? PlatformManifestDigest); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs new file mode 100644 index 00000000..8f649ba4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs @@ -0,0 +1,238 @@ +using System.Net; +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Networks; +using Microsoft.Extensions.Logging.Abstractions; +using LearnStack.Tools.Seeder; +using Testcontainers.PostgreSql; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// A separate provider database; never a substitute for application-role isolation tests. +public sealed partial class KeycloakFixture : IAsyncLifetime, IAsyncDisposable +{ + internal const string Image = "quay.io/keycloak/keycloak:26.6.2"; + internal const string WebClientSecret = "keycloak-fixture-web-secret"; + internal const string DemoPassword = "demo-dev-secret"; + private const string AdminUsername = "fixture-admin"; + private readonly string _adminPassword = Guid.NewGuid().ToString("N"); + private readonly string _databasePassword = Guid.NewGuid().ToString("N"); + private readonly string? _tenantRealmJson; + private INetwork? _network; + private PostgreSqlContainer? _database; + private IContainer? _keycloak; + private KeycloakTls? _tls; + private HttpMessageHandler? _readinessHandler; + private int _disposed; + + public KeycloakFixture() { } + private KeycloakFixture(string? tenantRealmJson) => _tenantRealmJson = tenantRealmJson; + + internal Uri BaseAddress { get; private set; } = null!; // Set only after successful container startup. + internal string Issuer => IssuerFor("learnstack"); + internal TimeSpan StartupElapsed { get; private set; } + internal string IssuerFor(string realm) => new Uri(BaseAddress, "realms/" + CheckedRealm(realm)).AbsoluteUri; + + internal static string CheckedRealm(string realm) => realm is "learnstack" or "learnstack-hub" + ? realm : throw new InvalidOperationException("Keycloak fixture realm is outside the closed provider boundary."); + internal X509Certificate2 RootCertificate => _tls?.RootCertificate + ?? throw new InvalidOperationException("Keycloak fixture certificate authority is not initialized."); + + internal static async Task StartAsync(string? tenantRealmJson = null) + { + var fixture = new KeycloakFixture(tenantRealmJson); + await fixture.InitializeAsync(); + return fixture; + } + + public async Task InitializeAsync() + { + var started = Stopwatch.GetTimestamp(); + using var startup = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var stage = "certificate"; + try + { + _tls = new KeycloakTls(); + _readinessHandler = KeycloakHttp.CreateHandler(_tls.RootCertificate); + stage = "network"; + _network = new NetworkBuilder().WithLogger(NullLogger.Instance).Build(); + await _network.CreateAsync(startup.Token); + stage = "database"; + _database = new PostgreSqlBuilder("postgres:18.4-alpine") + .WithLogger(NullLogger.Instance) + .WithNetwork(_network) + .WithNetworkAliases("identity-database") + .WithDatabase("keycloak") + .WithUsername("keycloak") + .WithPassword(_databasePassword) + .WithCreateParameterModifier(parameters => + { + ContainerLoopbackPorts.Configure(parameters, "5432/tcp"); + stage = "database_create_request"; + }) + .Build(); + // These are last-reached SDK milestones, not exact failing HTTP operations. + _database.Created += (_, _) => stage = "database_start"; + _database.Starting += (_, _) => stage = "database_readiness"; + _database.Started += (_, _) => stage = "database_started"; + await _database.StartAsync(startup.Token); + stage = "import_input"; + var tenantImport = _tenantRealmJson ?? await File.ReadAllTextAsync(RealmPath("learnstack"), startup.Token); + var hubImport = await File.ReadAllBytesAsync(RealmPath("learnstack-hub"), startup.Token); + _keycloak = new ContainerBuilder(Image) + .WithLogger(NullLogger.Instance) + .WithNetwork(_network) + .WithPortBinding(8443, true) + .WithCreateParameterModifier(parameters => ContainerLoopbackPorts.Configure(parameters, "8443/tcp")) + .WithEnvironment("KC_DB", "postgres") + .WithEnvironment("KC_DB_URL", "jdbc:postgresql://identity-database:5432/keycloak") + .WithEnvironment("KC_DB_USERNAME", "keycloak") + .WithEnvironment("KC_DB_PASSWORD", _databasePassword) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", AdminUsername) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", _adminPassword) + .WithEnvironment("KEYCLOAK_WEB_CLIENT_SECRET", WebClientSecret) + // byte[] destinations are files. Testcontainers' string/string overload treats the target as a directory. + .WithResourceMapping(Encoding.UTF8.GetBytes(tenantImport), "/opt/keycloak/data/import/learnstack-realm.json") + .WithResourceMapping(hubImport, "/opt/keycloak/data/import/learnstack-hub-realm.json") + .WithResourceMapping(_tls.ServerCertificatePem, "/opt/keycloak/conf/fixture-cert.pem") + .WithResourceMapping(_tls.ServerPrivateKeyPem, "/opt/keycloak/conf/fixture-key.pem", 1000, 0, + UnixFileModes.UserRead | UnixFileModes.UserWrite) + .WithCommand("start-dev", "--import-realm", "--log-level=ERROR", "--http-enabled=false", "--https-port=8443", + "--https-certificate-file=/opt/keycloak/conf/fixture-cert.pem", "--https-certificate-key-file=/opt/keycloak/conf/fixture-key.pem") + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request + .ForPort(8443).UsingTls(true).UsingHttpMessageHandler(_readinessHandler) + .ForPath("/realms/learnstack/.well-known/openid-configuration") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); + stage = "provider_start"; + await _keycloak.StartAsync(startup.Token); + stage = "mapped_endpoint"; + BaseAddress = new UriBuilder(Uri.UriSchemeHttps, _keycloak.Hostname, + _keycloak.GetMappedPublicPort(8443)).Uri; + if (!BaseAddress.IsLoopback) throw new InvalidOperationException("Keycloak fixture endpoint is not loopback."); + stage = "loopback_bindings"; + await AssertLoopbackBindingsAsync(startup.Token); + stage = "discovery"; + using var client = KeycloakHttp.Create(BaseAddress, RootCertificate); + using var discovery = await KeycloakHttp.JsonAsync(client, HttpMethod.Get, + "realms/learnstack/.well-known/openid-configuration", cancellationToken: startup.Token); + if (discovery.RootElement.GetProperty("issuer").GetString() != Issuer) + throw new InvalidOperationException("Keycloak fixture issuer does not match the mapped endpoint."); + startup.Token.ThrowIfCancellationRequested(); + StartupElapsed = Stopwatch.GetElapsedTime(started); + } + catch (Exception exception) + { + // Startup exceptions can include container configuration, credentials or raw provider logs. + // Cleanup still attempts every owned resource before this closed diagnostic is emitted. + var cleanup = "completed"; + try { await DisposeAsync(); } catch (Exception) { cleanup = "failed"; } + var reason = KeycloakStartupFailure.Classify(exception); + throw new InvalidOperationException("Keycloak fixture startup failed at " + stage + "/" + reason + + "; owned cleanup " + cleanup + "."); + } + } + + internal async Task CreateAdminClientAsync(string realm = "learnstack") + { + var client = new KeycloakAdminClient(BaseAddress, RootCertificate, CheckedRealm(realm), AuthenticateAdministratorAsync); + try { await client.AuthenticateAsync(); return client; } + catch { client.Dispose(); throw; } + } + + private async Task AuthenticateAdministratorAsync(CancellationToken cancellationToken) + { + using var client = KeycloakHttp.Create(BaseAddress, RootCertificate); + // Only the isolated master administrator uses admin-cli's password flow. Tenant proofs use code/S256. + using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Post, + "realms/master/protocol/openid-connect/token", new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "admin-cli", + ["username"] = AdminUsername, + ["password"] = _adminPassword, + }), cancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + throw new InvalidOperationException("Keycloak fixture administrator authentication failed."); + using var token = await KeycloakHttp.ReadJsonAsync(response, cancellationToken); + var accessToken = token.RootElement.GetProperty("access_token").GetString(); + var expiresIn = token.RootElement.GetProperty("expires_in").GetInt32(); + if (string.IsNullOrEmpty(accessToken) || expiresIn <= 5) + throw new InvalidOperationException("Keycloak fixture administrator credential is invalid."); + return new KeycloakAdminCredential(accessToken, TimeSpan.FromSeconds(expiresIn)); + } + + internal async Task AssertLoopbackBindingsAsync(CancellationToken cancellationToken = default) + { + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()).Build(); + foreach (var (container, port) in new[] { (_database as IContainer, "5432/tcp"), (_keycloak, "8443/tcp") }) + { + if (container is null) throw new InvalidOperationException("Keycloak fixture container is not initialized."); + var actual = await docker.Containers.InspectContainerAsync(container.Id, cancellationToken); + if (!ContainerLoopbackPorts.IsExclusive(actual, port)) + throw new InvalidOperationException("Keycloak fixture published port is not exclusively loopback."); + } + } + + internal KeycloakBrowserFlow Browser(string realm = "learnstack") => new(BaseAddress, RootCertificate, CheckedRealm(realm)); + + // The child owns this pipe. Bootstrap credentials have no public projection or diagnostic record. + internal async Task WriteReconciliationInputAsync(Stream destination, string scenario, + CancellationToken cancellationToken) + { + using var desired = JsonDocument.Parse(await File.ReadAllTextAsync(RealmPath("learnstack"), cancellationToken)); + await JsonSerializer.SerializeAsync(destination, new + { + baseUrl = BaseAddress.AbsoluteUri, + expectedIssuer = Issuer, + adminUsername = AdminUsername, + adminPassword = _adminPassword, + webSecret = WebClientSecret, + manifest = SeedIdentityManifest.Create(), + desired = desired.RootElement, + ca = RootCertificate.ExportCertificatePem(), + scenario, + }, JsonSerializerOptions.Web, cancellationToken); + } + + internal static string RealmPath(string realm) => Path.Combine(RepositoryRoot(), "infra", "keycloak", "realms", realm + ".json"); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !(File.Exists(Path.Combine(directory.FullName, "CLAUDE.md")) + && Directory.Exists(Path.Combine(directory.FullName, "infra", "keycloak", "realms")))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("Keycloak fixture repository root is missing."); + } + + public async Task DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + var failed = false; + if (_keycloak is not null) + try { await _keycloak.DisposeAsync(); } catch (Exception) { failed = true; } + if (_database is not null) + try { await _database.DisposeAsync(); } catch (Exception) { failed = true; } + if (_network is not null) + try { await _network.DisposeAsync(); } catch (Exception) { failed = true; } + try { _readinessHandler?.Dispose(); } catch (Exception) { failed = true; } + try { _tls?.Dispose(); } catch (Exception) { failed = true; } + if (failed) throw new InvalidOperationException("Keycloak fixture owned cleanup failed."); + } + + async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); +} + +[CollectionDefinition(Name)] +public sealed class SharedKeycloakRealm : ICollectionFixture +{ + public const string Name = "tenant-keycloak-realm"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakHttp.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakHttp.cs new file mode 100644 index 00000000..3942f8b0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakHttp.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Provider-test transport with closed diagnostics, finite waits and no redirect following. +internal static class KeycloakHttp +{ + internal static readonly TimeSpan RequestBudget = TimeSpan.FromSeconds(5); + private const int MaximumBodyBytes = 256 * 1024; + + internal static HttpClient Create(Uri baseAddress, X509Certificate2 rootCertificate, CookieContainer? cookies = null) => + new(CreateHandler(rootCertificate, cookies)) + { BaseAddress = baseAddress, Timeout = RequestBudget, MaxResponseContentBufferSize = MaximumBodyBytes }; + + internal static SocketsHttpHandler CreateHandler(X509Certificate2 rootCertificate, CookieContainer? cookies = null) + { + var policy = new X509ChainPolicy + { + TrustMode = X509ChainTrustMode.CustomRootTrust, + RevocationMode = X509RevocationMode.NoCheck, + }; + policy.CustomTrustStore.Add(rootCertificate); + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + UseProxy = false, + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = RequestBudget, + UseCookies = cookies is not null, + CookieContainer = cookies ?? new CookieContainer(), + // The normal TLS hostname and chain checks remain active. Only this owned CA is trusted. + SslOptions = new SslClientAuthenticationOptions { CertificateChainPolicy = policy }, + }; + } + + internal static async Task SendAsync(HttpClient client, HttpMethod method, + string path, HttpContent? content = null, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(method, path) { Content = content }; + try + { + var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken); + if (response.Content.Headers.ContentEncoding.Count == 0) return response; + response.Dispose(); + throw new InvalidOperationException("Keycloak fixture compressed response was refused."); + } + catch (OperationCanceledException) { throw; } + catch (HttpRequestException) + { + // HttpRequestException can contain an authorization URL, including a code or a login-action token. + throw new InvalidOperationException("Keycloak fixture HTTP request failed."); + } + } + + internal static async Task JsonAsync(HttpClient client, HttpMethod method, + string path, HttpContent? content = null, CancellationToken cancellationToken = default) + { + using var response = await SendAsync(client, method, path, content, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException("Keycloak fixture JSON request was refused."); + return await ReadJsonAsync(response, cancellationToken); + } + + internal static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + try { return JsonDocument.Parse(await ReadBodyAsync(response, cancellationToken)); } + catch (JsonException) { throw new InvalidOperationException("Keycloak fixture JSON response is malformed."); } + } + + internal static async Task ReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + try + { + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length > MaximumBodyBytes) + throw new InvalidOperationException("Keycloak fixture response exceeded its body bound."); + return System.Text.Encoding.UTF8.GetString(bytes); + } + catch (OperationCanceledException) { throw; } + catch (HttpRequestException) + { + throw new InvalidOperationException("Keycloak fixture response could not be read within its body bound."); + } + } + + internal static string? OAuthError(JsonElement json) + { + if (!json.TryGetProperty("error", out var error)) return null; + return ClosedOAuthError(error.GetString()); + } + + internal static string? ClosedOAuthError(string? error) => error switch + { + "invalid_request" or "invalid_client" or "invalid_grant" or "invalid_scope" or "unauthorized_client" or "access_denied" => error, + null => null, + _ => "other_provider_error", + }; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs new file mode 100644 index 00000000..79090683 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Owns a real Node child and a private pipe; no credential enters argv, inherited environment or disk. +internal static class KeycloakReconciliationDriver +{ + internal static async Task RunAsync(KeycloakFixture fixture, string scenario = "normal") + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(140)); + var start = new ProcessStartInfo("node") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add(Path.Combine(Path.GetDirectoryName(KeycloakFixture.RealmPath("learnstack"))!, + "..", "..", "..", "backend", "tests", "LearnStack.Tests.Integration", "TenancyIdentity", "reconciliation-driver.mjs")); + var executablePath = Environment.GetEnvironmentVariable("PATH"); + start.Environment.Clear(); + if (executablePath is not null) start.Environment["PATH"] = executablePath; + using var child = new Process { StartInfo = start }; + var started = false; + var failed = false; + var cleanupFailed = false; + ReconciliationOutcome? outcome = null; + Task? output = null; + Task? errors = null; + try + { + started = child.Start(); + if (!started) throw new InvalidOperationException(); + output = ReadBoundedAsync(child.StandardOutput, deadline.Token); + errors = ReadBoundedAsync(child.StandardError, deadline.Token); + await fixture.WriteReconciliationInputAsync(child.StandardInput.BaseStream, scenario, deadline.Token); + child.StandardInput.Close(); + await Task.WhenAll(child.WaitForExitAsync(deadline.Token), output, errors); + if ((await errors).Length != 0) throw new InvalidOperationException(); + using var json = JsonDocument.Parse(await output); + var result = json.RootElement; + var status = result.GetProperty("outcome").GetString(); + if (status is not ("success" or "failure")) throw new InvalidOperationException(); + var success = status == "success"; + if (child.ExitCode != (success ? 0 : 1)) throw new InvalidOperationException(); + var mutations = result.GetProperty("mutations").GetInt32(); + if (mutations < 0 || mutations > 256) throw new InvalidOperationException(); + var preparationDispatched = result.TryGetProperty("preparationDispatched", out var preparation); + if (preparationDispatched && preparation.ValueKind != JsonValueKind.True) throw new InvalidOperationException(); + var stage = success ? null : result.GetProperty("stage").GetString(); + var reason = success ? null : result.GetProperty("reason").GetString(); + // Only the driver's closed vocabulary can leave this process owner. + if (!success && (!Stages.Contains(stage, StringComparer.Ordinal) || !Reasons.Contains(reason, StringComparer.Ordinal))) + throw new InvalidOperationException(); + var counts = result.GetProperty("requests").EnumerateObject().ToDictionary(value => value.Name, value => value.Value.GetInt32()); + if (counts.Count != RequestPhases.Length || counts.Any(value => !RequestPhases.Contains(value.Key, StringComparer.Ordinal) + || value.Value < 0 || value.Value > 65536)) throw new InvalidOperationException(); + var milliseconds = result.GetProperty("elapsedMilliseconds").GetDouble(); + if (!double.IsFinite(milliseconds) || milliseconds < 0 || milliseconds > 140000) throw new InvalidOperationException(); + outcome = new ReconciliationOutcome(success, mutations, stage, reason, preparationDispatched, + counts, TimeSpan.FromMilliseconds(milliseconds)); + } + catch (Exception) + { + failed = true; + } + finally + { + if (started) + { + try + { + if (!child.HasExited) + { + try { child.Kill(entireProcessTree: true); } + catch (InvalidOperationException) when (child.HasExited) { } + } + await child.WaitForExitAsync(CancellationToken.None); + // Readers are owned too, even when input serialization or the child failed first. + if (output is not null) try { await output; } catch (Exception) { } + if (errors is not null) try { await errors; } catch (Exception) { } + } + catch (Exception) + { + cleanupFailed = true; + } + } + } + if (cleanupFailed) throw new InvalidOperationException("Keycloak reconciliation driver owned cleanup failed."); + if (failed || outcome is null) + throw new InvalidOperationException("Keycloak reconciliation driver failed after owned child cleanup."); + return outcome; + } + + private static readonly string[] Stages = + ["preflight", "discovery", "authentication", "version", "manifest", "lock", "plan", "prepare", "apply", "readback", "cleanup"]; + private static readonly string[] Reasons = + ["configuration", "identity_conflict", "mapper_conflict", "secret_conflict", "unsupported", "response", "status", + "redirect", "deadline", "cancelled", "transport", "lock_busy", "body_limit", "readback_conflict", "cleanup", "missing_web_secret"]; + private static readonly string[] RequestPhases = + ["discovery", "authentication", "version", "initialPlan", "prepare", "freshPlan", "apply", "readback"]; + + private static async Task ReadBoundedAsync(StreamReader reader, CancellationToken cancellationToken) + { + var text = new StringBuilder(); + var buffer = new char[512]; + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken)) > 0) + { + if (text.Length + count > 4096) throw new InvalidOperationException("Reconciliation child output exceeded its fixed bound."); + text.Append(buffer, 0, count); + } + return text.ToString(); + } +} + +internal sealed record ReconciliationOutcome(bool Success, int Mutations, string? Stage, string? Reason, + bool PreparationDispatched = false, IReadOnlyDictionary? Requests = null, TimeSpan Elapsed = default); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakStartupFailure.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakStartupFailure.cs new file mode 100644 index 00000000..2095de4b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakStartupFailure.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Text.Json; +using Docker.DotNet; +using DotNet.Testcontainers.Containers; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Pure finite diagnostics; this type never starts or owns provider resources. +internal static class KeycloakStartupFailure +{ + internal static string Classify(Exception exception) => exception switch + { + ContainerNotRunningException => "container_exit", + OperationCanceledException => "deadline", + TimeoutException => "readiness_timeout", + HttpRequestException => "http_transport", + DockerApiException { StatusCode: HttpStatusCode.BadRequest } => "docker_bad_request", + DockerApiException { StatusCode: HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden } => "docker_access_denied", + DockerApiException { StatusCode: HttpStatusCode.NotFound } => "docker_not_found", + DockerApiException { StatusCode: HttpStatusCode.Conflict } => "docker_conflict", + DockerApiException { StatusCode: HttpStatusCode.TooManyRequests } => "docker_rate_limited", + DockerApiException { StatusCode: HttpStatusCode.InternalServerError } docker => ClassifyServerError(docker.ResponseBody), + DockerApiException => "docker_api_error", + ResourceReaperException => "resource_reaper", + System.Net.Sockets.SocketException => "socket_error", + UnauthorizedAccessException => "filesystem_access_denied", + IOException => "filesystem_io", + ArgumentException => "argument", + InvalidOperationException => "invalid_state", + NotSupportedException => "unsupported_operation", + KeyNotFoundException => "missing_mapping", + _ => "provider_error", + }; + + private static string ClassifyServerError(string? responseBody) + { + if (responseBody is null || responseBody.Length > 16384) + return "docker_server_error"; + + try + { + using var body = JsonDocument.Parse(responseBody, new JsonDocumentOptions { MaxDepth = 2 }); + if (body.RootElement.ValueKind != JsonValueKind.Object) + return "docker_server_error"; + + string? message = null; + foreach (var property in body.RootElement.EnumerateObject()) + { + if (message is not null || !property.NameEquals("message") + || property.Value.ValueKind != JsonValueKind.String) + return "docker_server_error"; + message = property.Value.GetString(); + } + + if (message is null) + return "docker_server_error"; + + // Moby 28.0.4 (6430e49a) / 28.5.2 (89c5e8fd): daemon/start_linux.go, + // libnetwork/drivers/bridge/port_mapping_linux.go and portallocator/portallocator.go. + // These select fixed daemon failure families, never a cause or ownership claim. + const string networkPrefix = "failed to set up container networking:"; + const string taskPrefix = "failed to create task for container:"; + if (message.StartsWith(networkPrefix, StringComparison.Ordinal)) + { + if (message.LastIndexOf(networkPrefix, StringComparison.Ordinal) != 0) + return "docker_server_error"; + if (message.Contains("failed: port is already allocated", StringComparison.Ordinal)) + return "docker_port_allocated"; + if (message.Contains("failed to listen on TCP socket:", StringComparison.Ordinal)) + return "docker_port_listen"; + if (message.Contains("failed to start userland proxy for port mapping ", StringComparison.Ordinal)) + return "docker_port_proxy"; + if (message.Contains("failed to bind host port ", StringComparison.Ordinal)) + return "docker_port_bind"; + return "docker_network_error"; + } + + if (message.StartsWith(taskPrefix, StringComparison.Ordinal) + && message.LastIndexOf(taskPrefix, StringComparison.Ordinal) == 0) + return "docker_runtime_task"; + return "docker_server_error"; + } + catch (JsonException) + { + return "docker_server_error"; + } + catch (InvalidOperationException) + { + return "docker_server_error"; + } + catch (ArgumentException) + { + return "docker_server_error"; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTls.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTls.cs new file mode 100644 index 00000000..b07a5c52 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTls.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// +/// An owned provider CA and loopback server certificate. Clients trust only this root and retain +/// ordinary TLS hostname validation; no certificate-validation bypass or HTTP cookie workaround. +/// +internal sealed class KeycloakTls : IDisposable +{ + internal X509Certificate2 RootCertificate { get; } + internal byte[] ServerCertificatePem { get; } + internal byte[] ServerPrivateKeyPem { get; } + + internal KeycloakTls() + { + var now = DateTimeOffset.UtcNow; + using var authorityKey = RSA.Create(2048); + var authorityRequest = new CertificateRequest("CN=LearnStack identity fixture root", authorityKey, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + authorityRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 0, true)); + authorityRequest.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + authorityRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(authorityRequest.PublicKey, false)); + using var authority = authorityRequest.CreateSelfSigned(now.AddMinutes(-5), now.AddHours(25)); + + using var serverKey = RSA.Create(2048); + var serverRequest = new CertificateRequest("CN=localhost", serverKey, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + serverRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + serverRequest.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + serverRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new("1.3.6.1.5.5.7.3.1") }, true)); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + names.AddIpAddress(IPAddress.Loopback); + names.AddIpAddress(IPAddress.IPv6Loopback); + serverRequest.CertificateExtensions.Add(names.Build()); + var serial = RandomNumberGenerator.GetBytes(16); + serial[0] &= 0x7f; + serial[^1] |= 1; + using var server = serverRequest.Create(authority, now.AddMinutes(-5), now.AddHours(24), serial); + + ServerCertificatePem = Encoding.ASCII.GetBytes(server.ExportCertificatePem()); + ServerPrivateKeyPem = Encoding.ASCII.GetBytes(serverKey.ExportPkcs8PrivateKeyPem()); + RootCertificate = X509CertificateLoader.LoadCertificate(authority.Export(X509ContentType.Cert)); + } + + public void Dispose() + { + CryptographicOperations.ZeroMemory(ServerPrivateKeyPem); + RootCertificate.Dispose(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTokenExchange.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTokenExchange.cs new file mode 100644 index 00000000..b9fea857 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTokenExchange.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// In-memory credentials with safe diagnostics and an observed exchange interval. +internal sealed class KeycloakTokenExchange +{ + internal HttpStatusCode StatusCode { get; private init; } + internal string? Error { get; private init; } + internal string AccessToken { get; private init; } = ""; + internal string IdToken { get; private init; } = ""; + internal string RefreshToken { get; private init; } = ""; + internal KeycloakIssuanceWindow Window { get; private init; } = null!; // Every exchange records a window. + + internal static async Task RequestAsync(HttpClient client, IReadOnlyDictionary form, + string realm = "learnstack") + { + realm = KeycloakFixture.CheckedRealm(realm); + var started = DateTimeOffset.UtcNow; + var timestamp = Stopwatch.GetTimestamp(); + using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Post, + "realms/" + realm + "/protocol/openid-connect/token", new FormUrlEncodedContent(form)); + var completed = DateTimeOffset.UtcNow; + var elapsed = Stopwatch.GetElapsedTime(timestamp); + using var json = await KeycloakHttp.ReadJsonAsync(response); + var body = json.RootElement; + return new KeycloakTokenExchange + { + StatusCode = response.StatusCode, + Error = KeycloakHttp.OAuthError(body), + AccessToken = Read(body, "access_token"), + IdToken = Read(body, "id_token"), + RefreshToken = Read(body, "refresh_token"), + Window = new KeycloakIssuanceWindow(started, completed, elapsed), + }; + } + + private static string Read(JsonElement body, string name) => body.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : ""; + + public override string ToString() => "Keycloak token exchange (credentials omitted)"; +} + +/// +/// The disposable Docker provider shares the host clock. Its explicit two-second qualification allowance +/// is smaller than the API's existing skew; it is test evidence, never production acceptance policy. +/// +internal sealed record KeycloakIssuanceWindow(DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc, TimeSpan Elapsed) +{ + internal static readonly TimeSpan ClockTolerance = TimeSpan.FromSeconds(2); + + internal bool AcceptsFresh(JsonElement payload, int lifespan) + { + var wall = CompletedUtc - StartedUtc; + if (wall < TimeSpan.Zero || Elapsed < TimeSpan.Zero + || Elapsed > KeycloakHttp.RequestBudget + TimeSpan.FromSeconds(1) + || (wall - Elapsed).Duration() > ClockTolerance + || !payload.TryGetProperty("iat", out var issued) || issued.ValueKind != JsonValueKind.Number || !issued.TryGetInt64(out var iat) + || !payload.TryGetProperty("exp", out var expires) || expires.ValueKind != JsonValueKind.Number || !expires.TryGetInt64(out var exp)) return false; + var tolerance = (long)ClockTolerance.TotalSeconds; + return iat >= StartedUtc.ToUnixTimeSeconds() - tolerance + && iat <= CompletedUtc.ToUnixTimeSeconds() + tolerance + && exp >= StartedUtc.ToUnixTimeSeconds() + lifespan - tolerance + && exp <= CompletedUtc.ToUnixTimeSeconds() + lifespan + tolerance; + } +} + +internal static class KeycloakTokenVerifier +{ + internal static Task ValidateAsync(KeycloakFixture fixture, string token, string audience, + string realm = "learnstack", bool allowMissingSubject = false) + { + var clientId = realm == "learnstack-hub" ? "learnstack-hub-web" : "learnstack-web"; + return ValidateProfileAsync(fixture, token, audience, realm, clientId, + audience == clientId ? "ID" : "Bearer", allowMissingSubject); + } + + // Stock legacy console access has no audience. That closed expectation still + // requires signature, issuer, lifetime, purpose, subject and account-console azp. + internal static Task ValidateConsoleAsync(KeycloakFixture fixture, string token, + bool idToken, bool accountAudience = true) => ValidateProfileAsync(fixture, token, + idToken ? "account-console" : accountAudience ? "account" : null, + "learnstack", "account-console", idToken ? "ID" : "Bearer", allowMissingSubject: false); + + private static async Task ValidateProfileAsync(KeycloakFixture fixture, string token, string? audience, + string realm, string clientId, string purposeType, bool allowMissingSubject) + { + try + { + realm = KeycloakFixture.CheckedRealm(realm); + using var client = KeycloakHttp.Create(fixture.BaseAddress, fixture.RootCertificate); + using var keys = await KeycloakHttp.JsonAsync(client, HttpMethod.Get, + "realms/" + realm + "/protocol/openid-connect/certs"); + var result = await new JsonWebTokenHandler { MapInboundClaims = false }.ValidateTokenAsync(token, + new TokenValidationParameters + { + ValidIssuer = fixture.IssuerFor(realm), + ValidAudience = audience, + RequireAudience = audience is not null, + IssuerSigningKeys = new JsonWebKeySet(keys.RootElement.GetRawText()).GetSigningKeys(), + RequireSignedTokens = true, + RequireExpirationTime = true, + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ValidTypes = ["JWT"], + ClockSkew = TimeSpan.FromSeconds(30), + }); + if (!result.IsValid) throw new InvalidOperationException("Provider signature/profile validation failed."); + var header = Header(token); + if (!header.TryGetProperty("kid", out var keyId) || keyId.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(keyId.GetString())) throw new InvalidOperationException(); + using var payload = JsonDocument.Parse(Base64UrlEncoder.DecodeBytes(token.Split('.')[1])); + var root = payload.RootElement; + if ((!allowMissingSubject && (!root.TryGetProperty("sub", out var subject) + || subject.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(subject.GetString()))) + || !root.TryGetProperty("azp", out var party) || party.ValueKind != JsonValueKind.String + || party.GetString() != clientId + || !root.TryGetProperty("typ", out var purpose) || purpose.ValueKind != JsonValueKind.String + || purpose.GetString() != purposeType + || (audience is null && root.TryGetProperty("aud", out _))) throw new InvalidOperationException(); + return root.Clone(); + } + catch (Exception) + { + // Stock-handler failures and parser exceptions must never stringify a raw credential. + throw new InvalidOperationException("Keycloak fixture token validation failed."); + } + } + + internal static JsonElement Header(string token) + { + try + { + using var header = JsonDocument.Parse(Base64UrlEncoder.DecodeBytes(token.Split('.')[0])); + return header.RootElement.Clone(); + } + catch (Exception) { throw new InvalidOperationException("Keycloak fixture token header is malformed."); } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTransportTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTransportTests.cs new file mode 100644 index 00000000..cde454e4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakTransportTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Offline transport-owner controls, with no provider or Docker dependency. +public sealed class KeycloakTransportTests +{ + [Fact] + public void Startup_failure_diagnostics_distinguish_closed_Docker_faults_without_provider_text() + { + const string canary = "private-provider-response-canary"; + var statuses = new[] + { + (HttpStatusCode.BadRequest, "docker_bad_request"), + (HttpStatusCode.Unauthorized, "docker_access_denied"), + (HttpStatusCode.Forbidden, "docker_access_denied"), + (HttpStatusCode.NotFound, "docker_not_found"), + (HttpStatusCode.Conflict, "docker_conflict"), + (HttpStatusCode.TooManyRequests, "docker_rate_limited"), + (HttpStatusCode.InternalServerError, "docker_server_error"), + (HttpStatusCode.BadGateway, "docker_api_error"), + ((HttpStatusCode)999, "docker_api_error"), + }; + foreach (var (status, reason) in statuses) + { + var exception = new Docker.DotNet.DockerApiException(status, canary); + exception.Message.Should().Contain(canary, "the control must contain raw provider text"); + KeycloakStartupFailure.Classify(exception).Should().Be(reason).And.NotContain(canary); + } + + const string network = "failed to set up container networking: "; + const string privateDetail = canary + " 127.0.0.1:5432 /private/provider/path Private.Provider.Type"; + static string Body(string message) => JsonSerializer.Serialize(new { message }); + var daemonMessages = new[] + { + (network + "driver failed programming external connectivity on endpoint " + privateDetail + + ": Bind for " + privateDetail + " failed: port is already allocated", "docker_port_allocated"), + (network + "failed to bind host port for " + privateDetail, "docker_port_bind"), + (network + "failed to bind host port 5432 for " + privateDetail, "docker_port_bind"), + (network + "failed to listen on TCP socket: " + privateDetail, "docker_port_listen"), + (network + "failed to start userland proxy for port mapping " + privateDetail, "docker_port_proxy"), + (network + "failed to bind host port for " + privateDetail + + ": failed to listen on TCP socket: " + privateDetail, "docker_port_listen"), + (network + privateDetail, "docker_network_error"), + ("failed to create task for container: " + privateDetail, "docker_runtime_task"), + }; + foreach (var (message, reason) in daemonMessages) + { + var body = Body(message); + var exception = new Docker.DotNet.DockerApiException(HttpStatusCode.InternalServerError, body); + exception.ResponseBody.Should().Contain(canary, "the control must contain private daemon text"); + KeycloakStartupFailure.Classify(exception).Should().Be(reason).And.NotContain(canary); + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.BadRequest, body)) + .Should().Be("docker_bad_request", "body parsing is confined to HTTP 500"); + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.BadGateway, body)) + .Should().Be("docker_api_error", "body parsing is confined to HTTP 500"); + } + + var bounded = Body(network + privateDetail); + bounded = bounded.PadRight(16384); + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.InternalServerError, bounded)) + .Should().Be("docker_network_error", "the exact body limit remains valid JSON"); + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.InternalServerError, null!)) + .Should().Be("docker_server_error"); + var unclassifiedBodies = new[] + { + "", canary, network + "failed to bind host port " + privateDetail, + "{}", "null", "[]", "{\"message\":null}", "{\"message\":1}", "{\"message\":{}}", + "{\"message\":[]}", "{\"Message\":\"" + network + "\"}", + "{\"error\":{\"message\":\"" + network + "\"}}", + "{\"message\":\"" + network + "\",\"extra\":[]}", + "{\"message\":\"" + network + "\",\"extra\":1}", + "{\"extra\":null,\"message\":\"" + network + "\"}", + "{\"message\":\"" + network + "\",\"message\":\"" + network + "\"}", + "{\"message\":\"" + network + "\",\"message\":\"different\"}", + "{\"message\":\"" + network + "\",\"messa\\u0067e\":\"" + network + "\"}", + "{\"message\":\"" + network + "\",}", "/* comment */" + Body(network), + "{\"message\":\"\\uD800\"}", "{\"messa\\uD800ge\":\"" + network + "\"}", + "{\"message\":\"\uD800\"}", "{\"message\":\"\uDC00\"}", + "{\"messa\uD800ge\":\"" + network + "\"}", "{\"messa\uDC00ge\":\"" + network + "\"}", + Body(network) + "{}", "{\"message\":\"unterminated", bounded + " ", + "{\"nested\":" + new string('[', 64) + "0" + new string(']', 64) + "}", + Body("failed: port is already allocated " + privateDetail), + Body("failed to bind host port " + privateDetail), + Body("failed to listen on TCP socket: " + privateDetail), + Body("failed to start userland proxy for port mapping " + privateDetail), + Body(privateDetail + " " + network), Body(" " + network), + Body("Failed to set up container networking: " + privateDetail), + Body(privateDetail + " failed to create task for container:"), + Body(network + privateDetail + "; " + network + "failed to bind host port " + privateDetail), + Body("failed to create task for container: " + privateDetail + "; failed to create task for container: " + privateDetail), + Body("permission denied /sys/fs/cgroup " + privateDetail), + Body("no space left on device " + privateDetail), + Body("docker_port_bind " + privateDetail), + }; + foreach (var body in unclassifiedBodies) + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.InternalServerError, body)) + .Should().Be("docker_server_error").And.NotContain(canary); + foreach (var message in new[] + { + network + "port is already allocated " + privateDetail, + network + "failed to bind host ports " + privateDetail, + network + "failed to listen on TCP socket " + privateDetail, + network + "failed to start userland proxy " + privateDetail, + network + "permission denied /sys/fs/cgroup " + privateDetail, + }) + KeycloakStartupFailure.Classify(new Docker.DotNet.DockerApiException(HttpStatusCode.InternalServerError, Body(message))) + .Should().Be("docker_network_error", "only the verified outer wrapper is recognized"); + + KeycloakStartupFailure.Classify(new DotNet.Testcontainers.Containers.ResourceReaperException(canary)) + .Should().Be("resource_reaper"); + KeycloakStartupFailure.Classify(new UnauthorizedAccessException(canary)) + .Should().Be("filesystem_access_denied"); + KeycloakStartupFailure.Classify(new IOException(canary)).Should().Be("filesystem_io"); + KeycloakStartupFailure.Classify(new System.Net.Sockets.SocketException()) + .Should().Be("socket_error"); + foreach (var message in new[] { canary, "docker_conflict", "resource_reaper", "deadline" }) + KeycloakStartupFailure.Classify(new FormatException(message)).Should().Be("provider_error", + "unclassified provider text cannot select a diagnostic reason"); + } + + [Fact] + public async Task Caller_cancellation_reaches_inflight_transport_and_retains_cancellation_identity() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var client = new HttpClient(new Handler(async (_, token) => + { + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return Json(); + })) + { BaseAddress = new Uri("https://fixture.invalid/") }; + using var cancellation = new CancellationTokenSource(); + var pending = KeycloakHttp.SendAsync(client, HttpMethod.Get, "metadata", cancellationToken: cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + var act = async () => { using var response = await pending.WaitAsync(TimeSpan.FromSeconds(5)); }; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Transport_failure_is_closed_and_compressed_response_is_refused() + { + using var client = new HttpClient(new Handler((_, _) => throw new HttpRequestException("credential-url-canary"))) + { BaseAddress = new Uri("https://fixture.invalid/") }; + var act = async () => { using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Get, "metadata"); }; + var failure = await act.Should().ThrowAsync(); + failure.Which.ToString().Should().NotContain("credential-url-canary"); + using var compressed = new HttpClient(new Handler((_, _) => + { + var response = Json(); + response.Content.Headers.ContentEncoding.Add("gzip"); + return Task.FromResult(response); + })) + { BaseAddress = new Uri("https://fixture.invalid/") }; + var encoded = async () => { using var response = await KeycloakHttp.SendAsync(compressed, HttpMethod.Get, "metadata"); }; + await encoded.Should().ThrowAsync(); + } + + [Fact] + public async Task Administrator_refresh_precedes_expiry_and_never_replays_a_refused_mutation() + { + var clock = new Clock(); + var credentials = 0; + var sent = new List(); + using var http = new HttpClient(new Handler((request, _) => + { + sent.Add(request.Headers.Authorization?.Parameter); + return Task.FromResult(request.Method == HttpMethod.Put + ? new HttpResponseMessage(HttpStatusCode.Unauthorized) : Json()); + })) + { BaseAddress = new Uri("https://fixture.invalid/") }; + using var admin = new KeycloakAdminClient(http, _ => Task.FromResult(new KeycloakAdminCredential( + "credential-" + ++credentials, TimeSpan.FromSeconds(60))), clock); + await admin.GetAsync(); + clock.Advance(TimeSpan.FromSeconds(54)); + await admin.GetAsync(); + clock.Advance(TimeSpan.FromSeconds(2)); + (await admin.PutAsync("realm", new { value = 1 })).Should().Be(HttpStatusCode.Unauthorized); + credentials.Should().Be(2, "freshness is checked before the first request which reaches the expiry margin"); + sent.Should().Equal("credential-1", "credential-1", "credential-2"); + } + + private static HttpResponseMessage Json() => new(HttpStatusCode.OK) + { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + + private sealed class Handler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + send(request, cancellationToken); + } + + private sealed class Clock : TimeProvider + { + private long _ticks; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => _ticks; + internal void Advance(TimeSpan interval) => _ticks += interval.Ticks; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTestGroup.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTestGroup.cs new file mode 100644 index 00000000..a57b9975 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTestGroup.cs @@ -0,0 +1,12 @@ +using LearnStack.Tests.Integration.Database; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +// Production API/Serilog ownership is separate from the shared read-only +// provider collection. Only this collection also pays for application PostgreSQL. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class TenantRealmApiTestGroup : ICollectionFixture, ICollectionFixture +{ + public const string Name = "tenant-keycloak-production-api"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Assertions.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Assertions.cs new file mode 100644 index 00000000..8fca2ff3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Assertions.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Api.Tenancy; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Npgsql; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class TenantRealmApiTests +{ + private static async Task AccountAsync(HttpResponseMessage response, SeedIdentity identity, + TenantRealmApiObservation observation) + { + response.StatusCode.Should().Be(HttpStatusCode.OK, + "a real provider access token must reach the existing production AccountSite route"); + TenantAuthenticationHttpTests.NoStore(response); + var account = await response.Content.ReadFromJsonAsync(); + account.EnumerateObject().Select(property => property.Name).Should().BeEquivalentTo( + ["actorId", "tenantId", "organizationId", "site"]); + account.GetProperty("actorId").GetGuid().Should().Be(identity.UserId.Value); + account.GetProperty("tenantId").GetGuid().Should().Be(identity.Tenant.TenantId.Value); + var organization = Organization(identity.Tenant); + if (organization is { } expected) account.GetProperty("organizationId").GetGuid().Should().Be(expected); + else account.GetProperty("organizationId").ValueKind.Should().Be(JsonValueKind.Null); + AssertSite(account.GetProperty("site"), identity.Tenant); + observation.Reads.Should().NotBeEmpty("the real configuration reader must have executed"); + AssertContext(observation.Reads.Last(), identity.Tenant, identity.UserId.Value, TenantContextOrigin.HostAndClaim); + } + + private static async Task PublicSiteAsync(HttpResponseMessage response, SeedTenant tenant) + { + response.StatusCode.Should().Be(HttpStatusCode.OK); + TenantAuthenticationHttpTests.NoStore(response); + AssertSite(await response.Content.ReadFromJsonAsync(), tenant); + } + + private static void AssertSite(JsonElement site, SeedTenant tenant) + { + site.GetProperty("displayName").GetString().Should().Be(tenant.DisplayName); + site.GetProperty("defaultLocale").GetString().Should().Be( + tenant.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale); + site.GetProperty("enabledLocales").EnumerateArray().Select(locale => locale.GetString()).Should().Equal( + tenant.Curriculum!.Locales.Where(locale => locale.IsEnabled).OrderBy(locale => locale.Sort).Select(locale => locale.Locale)); + using var expectedTheme = JsonDocument.Parse(tenant.Curriculum!.Theme.Value); + JsonElement.DeepEquals(site.GetProperty("theme"), expectedTheme.RootElement).Should().BeTrue( + "the actual tenant theme must agree with typed SeedData rather than another tenant's configuration"); + site.GetProperty("showPlatformAttribution").GetBoolean().Should().BeFalse(); + } + + private static Guid? Organization(SeedTenant tenant) => tenant.MapHostToDefaultOrganization + ? tenant.DefaultOrganization.OrganizationId.Value : null; + + private static void AssertContext(TenantRealmReadContext context, SeedTenant tenant, Guid? actor, TenantContextOrigin origin) + { + context.Tenant.Should().Be(tenant.TenantId.Value); + context.Organization.Should().Be(Organization(tenant)); + context.Actor.Should().Be(actor); + context.Origin.Should().Be(origin); + context.HostTenant.Should().Be(tenant.TenantId.Value); + context.HostOrganization.Should().Be(Organization(tenant)); + } + + private static async Task UnauthorizedAsync(HttpResponseMessage response) + { + await TenantAuthenticationHttpTests.Problem(response, HttpStatusCode.Unauthorized, "unauthorized"); + response.Headers.WwwAuthenticate.Should().ContainSingle().Which.ToString().Should().Be("Bearer"); + } + + private static bool HasAudience(JsonElement payload, string expected) + { + if (!payload.TryGetProperty("aud", out var audience)) return false; + return audience.ValueKind == JsonValueKind.String ? audience.GetString() == expected + : audience.ValueKind == JsonValueKind.Array + && audience.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && value.GetString() == expected); + } + + private async Task AssertRejectionAsync(HttpResponseMessage response, SeedTenant defended, SeedIdentity identity, + string assertedKey, Guid asserted) + { + var correlation = TenantAuthenticationReconciliationTests.Correlation(response); + var rows = await AccountHttpAudit.Read(database, defended, correlation); + var row = rows.Should().ContainSingle().Subject; + row.Tenant.Should().Be(defended.TenantId.Value); + row.Organization.Should().BeNull("rejected scope does not narrow security-audit visibility"); + row.Actor.Should().Be(identity.UserId.Value); + row.Operation.Should().Be(AuditingTenantAssertionRecorder.RejectOperation); + row.Outcome.Should().Be("denied"); + row.Correlation.Should().Be(correlation); + using var metadata = JsonDocument.Parse(row.Metadata); + metadata.RootElement.GetProperty("assertionSource").GetString().Should().Be("jwt-claim"); + metadata.RootElement.GetProperty(assertedKey).GetGuid().Should().Be(asserted); + if (Organization(defended) is { } trusted) + metadata.RootElement.GetProperty("trustedOrganizationId").GetGuid().Should().Be(trusted); + else metadata.RootElement.GetProperty("trustedOrganizationId").ValueKind.Should().Be(JsonValueKind.Null); + var foreign = defended == SeedData.English ? SeedData.Yoga : SeedData.English; + (await AccountHttpAudit.Read(database, foreign, correlation)).Should().BeEmpty( + "the event is visible only under the defended tenant, never token-selected scope"); + } + + private async Task AuditCountsAsync() + { + var counts = new List(); + foreach (var tenant in SeedData.All) + { + await using var connection = await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, tenant.TenantId.Value); + await SchemaQueries.AssertApplicationRoleAsync(connection, transaction); + await using var count = new NpgsqlCommand("SELECT count(*) FROM audit_log WHERE tenant_id=@tenant", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + count.Parameters.AddWithValue("tenant", tenant.TenantId.Value); + counts.Add((long)(await count.ExecuteScalarAsync())!); + } + return counts.ToArray(); + } + + private async Task AssertSingleAdditionalAuditAsync(long[] before, SeedTenant defended) + { + var expected = SeedData.All.Select((tenant, index) => before[index] + (tenant == defended ? 1 : 0)); + (await AuditCountsAsync()).Should().Equal(expected, + "one real MUST rejection audit is durable under exactly the defended tenant"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Host.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Host.cs new file mode 100644 index 00000000..3cad767e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.Host.cs @@ -0,0 +1,193 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using FluentAssertions; +using LearnStack.Api.Authentication; +using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class TenantRealmApiTests +{ + private HttpClient Client(SeedTenant tenant, out TenantRealmApiObservation observation, + X509Certificate2? authority = null, string? issuer = null) + { + var observed = new TenantRealmApiObservation(); + observation = observed; + return database.ClientFor(tenant.Host, services => + { + // No alternate retriever, configuration manager, JWT handler or claims source. + services.Replace(ServiceDescriptor.Singleton(_ => Backchannel(authority ?? provider.RootCertificate))); + // Retain the fixture's physical ReadOnly/app-role observer inside this outer context observer. + services.AddScoped(); + services.AddScoped(serviceProvider => new TenantRealmContextReader( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), observed)); + }, builder => builder.UseSetting(TenantAuthenticationOptions.IssuerConfigurationKey, issuer ?? provider.Issuer)); + } + + private static TenantAuthenticationBackchannel Backchannel(X509Certificate2 authority) + { + var handler = KeycloakHttp.CreateHandler(authority); + // An injected handler cannot relax the production transport bounds. + handler.AllowAutoRedirect = true; + handler.UseCookies = true; + handler.ConnectTimeout = TimeSpan.FromMinutes(1); + var backchannel = new TenantAuthenticationBackchannel(handler); + handler.AllowAutoRedirect.Should().BeFalse(); + handler.UseCookies.Should().BeFalse(); + handler.ConnectTimeout.Should().Be(TenantAuthenticationBackchannel.Timeout); + handler.SslOptions.RemoteCertificateValidationCallback.Should().BeNull(); + backchannel.Client.Timeout.Should().Be(TenantAuthenticationBackchannel.Timeout); + backchannel.Client.MaxResponseContentBufferSize.Should().Be(TenantAuthenticationBackchannel.MaximumDocumentBytes); + return backchannel; + } + + private Task TokensAsync(SeedIdentity identity) => + ExchangeAsync(identity.Username, Callback(identity.Tenant)); + + private async Task ExchangeAsync(string username, string callback, + string realm = "learnstack", string clientId = "learnstack-web", string? secret = KeycloakFixture.WebClientSecret) + { + using var browser = provider.Browser(realm); + var authorization = await browser.AuthorizeAsync(username, KeycloakFixture.DemoPassword, callback, + KeycloakPkce.S256, clientId); + authorization.Authorized.Should().BeTrue("the actual provider must complete the code/S256 flow"); + var tokens = await browser.RedeemAsync(authorization, secret); + tokens.StatusCode.Should().Be(HttpStatusCode.OK); + string.IsNullOrEmpty(tokens.AccessToken).Should().BeFalse("the provider must issue an access credential"); + string.IsNullOrEmpty(tokens.IdToken).Should().BeFalse("the provider must issue an identity credential"); + return tokens; + } + + private static string Callback(SeedTenant tenant) => "https://" + tenant.Host + ":3000/api/auth/callback/keycloak"; + + private static Task Send(HttpClient client, SeedTenant tenant, string? token = null, + string path = TenantAuthenticationHttpRequest.AccountPath) => + TenantAuthenticationHttpRequest.Send(client, tenant.Host, path, token); + + private static Task CreateUserAsync(KeycloakAdminClient admin, string username, + IReadOnlyDictionary? attributes = null) => admin.PostAsync("users", new + { + username, + email = username, + emailVerified = true, + enabled = true, + firstName = "API", + lastName = "Fixture", + requiredActions = Array.Empty(), + attributes = attributes ?? new Dictionary(), + credentials = new[] { new { type = "password", value = KeycloakFixture.DemoPassword, temporary = false } }, + }); + + private static string UserPath(System.Text.Json.JsonElement user) + { + var id = user.GetProperty("id").GetString(); + Guid.TryParse(id, out _).Should().BeTrue("the provider must return one concrete account identifier"); + return "users/" + id; + } + + // A supplied attribute map replaces profile values in Keycloak 26.6.2. + private static object UserAttributeUpdate(System.Text.Json.JsonElement user, object attributes) => new + { + username = user.GetProperty("username"), + email = user.GetProperty("email"), + firstName = user.GetProperty("firstName"), + lastName = user.GetProperty("lastName"), + attributes, + }; + + private static async Task DeleteOwnedUserAsync(KeycloakAdminClient admin, string username) + { + var owned = await admin.UserAsync(username); + (owned.GetProperty("username").GetString() == username).Should().BeTrue( + "cleanup may delete only the exact unrelated account created by this case"); + (await admin.DeleteAsync(UserPath(owned))).Should().Be(HttpStatusCode.NoContent); + } +} + +internal sealed class TenantRealmApiObservation +{ + internal ConcurrentQueue Reads { get; } = new(); +} + +internal sealed record TenantRealmReadContext(Guid Tenant, Guid? Organization, Guid? Actor, + TenantContextOrigin? Origin, Guid HostTenant, Guid? HostOrganization); + +/// Observes existing context and GUCs without supplying an identity, host or persistence answer. +internal sealed class TenantRealmContextReader(ObservedPublicConfigurationReader reader, ITenantContext context, + IUnitOfWork unit, TenantRealmApiObservation observation) : IPublicTenantConfigurationReader +{ + public async Task> ReadAsync(CancellationToken cancellationToken) + { + context.IsResolved.Should().BeTrue(); + context.HostScope.Should().NotBeNull(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection, unit.Transaction); + await using (var command = new NpgsqlCommand(""" + SELECT current_setting('app.tenant_id', true), current_setting('app.organization_id', true) + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction)) + await using (var settings = await command.ExecuteReaderAsync(cancellationToken)) + { + (await settings.ReadAsync(cancellationToken)).Should().BeTrue(); + settings.GetString(0).Should().Be(context.TenantId.Value.ToString("D")); + settings.GetString(1).Should().Be(context.OrganizationId?.Value.ToString("D") ?? ""); + } + var snapshot = new TenantRealmReadContext(context.TenantId.Value, context.OrganizationId?.Value, + context.UserId?.Value, context.Origin, context.HostScope!.TenantId.Value, context.HostScope.OrganizationId?.Value); + var result = await reader.ReadAsync(cancellationToken); + observation.Reads.Enqueue(snapshot); + return result; + } +} + +/// Owned loopback transport failure with a connection witness; it never stops a shared provider. +internal sealed class UnavailableTenantIssuer : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _owner; + private int _connections; + + internal UnavailableTenantIssuer() + { + _listener.Start(); + Issuer = "https://127.0.0.1:" + ((IPEndPoint)_listener.LocalEndpoint).Port + "/realms/learnstack"; + _owner = RefuseAsync(); + } + + internal string Issuer { get; } + internal int Connections => Volatile.Read(ref _connections); + + private async Task RefuseAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + using var connection = await _listener.AcceptTcpClientAsync(_stop.Token); + Interlocked.Increment(ref _connections); + connection.LingerState = new LingerOption(true, 0); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + catch (SocketException) when (_stop.IsCancellationRequested) { } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _listener.Stop(); + await _owner; + _stop.Dispose(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs new file mode 100644 index 00000000..b47cbb2c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs @@ -0,0 +1,778 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class TenantRealmApiTests +{ + private static readonly string[] WarmProtectedNames = ["user_id", "tenant_id", "organization_id"]; + private static readonly string[] WarmAccountRoles = ["manage-account", "manage-account-links"]; + private static readonly string[] WarmUnrelatedRealmRoles = ["offline_access", "tenant-instructor"]; + private static readonly string[] WarmOfflineRole = ["offline_access"]; + + [Fact] + public async Task Historical_automatic_upgrade_preserves_live_password_and_MFA_then_serves_signed_API_and_console_users() + { + var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(KeycloakFixture.RealmPath("learnstack"))!, "..", "..", "..")); + var historical = JsonNode.Parse(await TenantRealmStockBaselineTests.HistoricalImportAsync(root))!.AsObject(); + var imported = historical["users"]!.AsArray(); + var passwords = imported.ToDictionary(user => user!["username"]!.GetValue(), + user => user!["credentials"]![0]!["value"]!.GetValue(), StringComparer.Ordinal); + var legacy = SeedData.IdentityUsers[0]; + var mfaIdentity = SeedData.IdentityUsers[1]; + var otp = new WarmTotp(); + imported.Single(user => user!["username"]!.GetValue() == mfaIdentity.Username)!["credentials"]!.AsArray() + .Add(otp.Credential()); + const string unrelated = "warm-eligible@provider.test"; + imported.Add(JsonSerializer.SerializeToNode(new + { + username = unrelated, + email = unrelated, + emailVerified = true, + enabled = true, + firstName = "Preserved", + lastName = "Unrelated", + credentials = new[] { new { type = "password", value = KeycloakFixture.DemoPassword, temporary = false } }, + realmRoles = WarmUnrelatedRealmRoles, + clientRoles = new Dictionary { ["account"] = ["manage-account"] }, + })); + await using var warm = await KeycloakFixture.StartAsync(historical.ToJsonString()); + var resources = await warm.StockProviderResourcesAsync(); + var journey = Stopwatch.StartNew(); + try + { + (await warm.StockProviderVersionAsync()).Should().Be("26.6.2"); + using var admin = await warm.CreateAdminClientAsync(); + using var hub = await warm.CreateAdminClientAsync("learnstack-hub"); + var profile = await admin.GetAsync("users/profile"); + profile.GetProperty("attributes").EnumerateArray().Any(definition => + WarmProtectedNames.Contains(definition.GetProperty("name").GetString(), StringComparer.Ordinal)).Should().BeFalse(); + profile.TryGetProperty("unmanagedAttributePolicy", out _).Should().BeFalse( + "the genuine historical stock visibility boundary is retained"); + output.WriteLine("Historical journey stage: stock profile observed."); + var expectedProfile = await WarmAppendedProfileAsync(profile); + var changedPassword = Guid.NewGuid().ToString("N"); + var originalLegacyPassword = passwords[legacy.Username]; + var legacyPath = UserPath(await admin.UserAsync(legacy.Username)); + (await admin.PutAsync(legacyPath + "/reset-password", new { type = "password", value = changedPassword, temporary = false })) + .Should().Be(HttpStatusCode.NoContent); + await WarmPasswordRefusedAsync(warm, legacy.Username, passwords[legacy.Username]); + passwords[legacy.Username] = changedPassword; + await WarmMfaRefusalAsync(warm, mfaIdentity.Username, passwords[mfaIdentity.Username], otp); + await WarmConsoleTokensAsync(warm, mfaIdentity.Username, passwords[mfaIdentity.Username], false, 1800, otp: otp); + output.WriteLine("Historical journey stage: changed password and actual MFA challenge controls passed."); + + var legacyBefore = await WarmConsoleTokensAsync(warm, legacy.Username, changedPassword, false, 1800); + var unrelatedBefore = await WarmConsoleTokensAsync(warm, unrelated, KeycloakFixture.DemoPassword, true, 1800); + var offlineBefore = await WarmConsoleScopeEffectsAsync(warm, unrelated, unrelatedBefore, beforeUpgrade: true); + var beforeLegacyStatus = await WarmAccountStatusAsync(warm, legacyBefore.Exchange); + var beforeUnrelatedStatus = await WarmAccountStatusAsync(warm, unrelatedBefore.Exchange); + output.WriteLine("Before console Account REST status: manifest={0}; eligible nonmanifest={1}.", + (int)beforeLegacyStatus, (int)beforeUnrelatedStatus); + beforeLegacyStatus.Should().Be(HttpStatusCode.Unauthorized, "the observed historical token has no account audience"); + beforeUnrelatedStatus.Should().Be(HttpStatusCode.OK); + output.WriteLine("Historical journey stage: signed stock console and optional-scope controls passed."); + + // Real logins can update credential metadata. These snapshots bracket only + // reconciliation, before any post-upgrade login can legitimately change it. + var usernames = new[] { legacy.Username, mfaIdentity.Username, unrelated }; + var accountsBefore = await WarmAccountsAsync(admin, usernames); + var graphBefore = await WarmGraphAsync(admin); + var hubBefore = await WarmRealmStateAsync(hub); + var first = await KeycloakReconciliationDriver.RunAsync(warm); + first.Success.Should().BeTrue("closed historical reconciliation outcome: {0}/{1}", first.Stage, first.Reason); + first.PreparationDispatched.Should().BeTrue(); + first.Mutations.Should().Be(17, "one preparation and sixteen ordinary writes; the eligible unrelated account needs no grant"); + output.WriteLine("Historical journey stage: automatic reconciliation completed."); + var accountsAfter = await WarmAccountsAsync(admin, usernames); + WarmSame(expectedProfile, WarmNode(await admin.GetAsync("users/profile"))).Should().BeTrue( + "only the three canonical definitions are appended to the entire historical profile"); + await WarmAssertPreservationAsync(admin, accountsBefore, accountsAfter, unrelated); + await WarmAssertGraphAsync(admin, graphBefore); + WarmSame(hubBefore, await WarmRealmStateAsync(hub)).Should().BeTrue("the complete observed Hub state is preserved"); + WarmAssertPass(first, preparation: true); + var second = await KeycloakReconciliationDriver.RunAsync(warm); + second.Success.Should().BeTrue("closed explicit rerun outcome: {0}/{1}", second.Stage, second.Reason); + second.Mutations.Should().Be(0); + second.PreparationDispatched.Should().BeFalse(); + WarmAssertPass(second, preparation: false); + var accountsRerun = await WarmAccountsAsync(admin, usernames); + WarmSame(accountsAfter, accountsRerun).Should().BeTrue("an explicit zero-write pass preserves all captured account state"); + await WarmAssertGraphAsync(admin, graphBefore); + output.WriteLine("Historical journey stage: exact preservation and zero-write rerun passed."); + + var tokenFlow = Stopwatch.StartNew(); + await WarmPasswordRefusedAsync(warm, legacy.Username, originalLegacyPassword); + (await admin.GetAsync("attack-detection/brute-force/" + legacyPath)).GetProperty("disabled").GetBoolean() + .Should().BeFalse("the old-password control must not lock the account before its positive login"); + await WarmMfaRefusalAsync(warm, mfaIdentity.Username, passwords[mfaIdentity.Username], otp); + var auditCounts = await AuditCountsAsync(); + var reads = database.Observation.Reads; + foreach (var identity in SeedData.IdentityUsers) + { + var password = passwords.GetValueOrDefault(identity.Username) ?? KeycloakFixture.DemoPassword; + var tokens = await WarmWebTokensAsync(warm, identity, password, + identity.Username == mfaIdentity.Username ? otp : null); + using var client = Client(identity.Tenant, out var observation, warm.RootCertificate, warm.Issuer); + using var response = await Send(client, identity.Tenant, tokens.AccessToken); + await AccountAsync(response, identity, observation); + observation.Reads.Should().HaveCount(1); + } + database.Observation.Reads.Should().Be(reads + 4, + "each genuine warm-provider persona reaches the physical ReadOnly learnstack_app transaction"); + (await AuditCountsAsync()).Should().Equal(auditCounts); + output.WriteLine("Historical journey stage: four signed API personas passed physical ReadOnly application-role reads."); + + var legacyAfter = await WarmConsoleTokensAsync(warm, legacy.Username, changedPassword, true, 900); + var unrelatedAfter = await WarmConsoleTokensAsync(warm, unrelated, KeycloakFixture.DemoPassword, true, 900); + WarmAssertConsoleDelta(legacyBefore, legacyAfter, addedCapability: true); + WarmAssertConsoleDelta(unrelatedBefore, unrelatedAfter, addedCapability: false); + await WarmConsoleScopeEffectsAsync(warm, unrelated, unrelatedAfter, beforeUpgrade: false); + await WarmExistingOfflineAsync(warm, unrelated, offlineBefore!); + await WarmProfileActionsAsync(warm, admin, legacy.Username, legacyAfter.Exchange, legacy); + await WarmProfileActionsAsync(warm, admin, unrelated, unrelatedAfter.Exchange, identity: null); + await WarmWebTokensAsync(warm, legacy, changedPassword); + await WarmNonmanifestWebTokensAsync(warm, unrelated); + var unrelatedFresh = await WarmConsoleTokensAsync(warm, unrelated, KeycloakFixture.DemoPassword, true, 900); + WarmSame(WarmClaims(unrelatedAfter.Access, ordinaryNameChanged: true), WarmClaims(unrelatedFresh.Access, ordinaryNameChanged: true)) + .Should().BeTrue("ordinary profile editing cannot add identity authority or other unexpected signed claims"); + WarmSame(WarmClaims(unrelatedAfter.Id, ordinaryNameChanged: true), WarmClaims(unrelatedFresh.Id, ordinaryNameChanged: true)) + .Should().BeTrue("fresh independently verified ID claims retain the same nonmanifest identity boundary"); + WarmSame(accountsAfter[unrelated]!["/role-mappings"], + WarmNode(await admin.GetAsync(UserPath(await admin.UserAsync(unrelated)) + "/role-mappings"))) + .Should().BeTrue("nonmanifest grants remain unchanged after self-service too"); + var edited = await WarmAccountsAsync(admin, usernames); + var afterSelfService = await KeycloakReconciliationDriver.RunAsync(warm); + afterSelfService.Success.Should().BeTrue("closed post-self-service pass: {0}/{1}", afterSelfService.Stage, afterSelfService.Reason); + afterSelfService.Mutations.Should().Be(0); + afterSelfService.PreparationDispatched.Should().BeFalse(); + WarmAssertPass(afterSelfService, preparation: false); + WarmSame(edited, await WarmAccountsAsync(admin, usernames)).Should().BeTrue( + "the final explicit pass preserves ordinary edits, credentials, identity attributes and grants without login between snapshots"); + foreach (var username in new[] { legacy.Username, unrelated }) + (edited[username]![""]!["firstName"]!.GetValue() == "Ordinary warm edit").Should().BeTrue(); + output.WriteLine("Historical live journey: personas=4; account POST/readback=2; protected fields=3 per subject; MFA before/after=true; wrong OTP before/after=true."); + output.WriteLine("Costs seconds: fixture={0:F3}; token/API/profile={1:F3}; journey={2:F3}. Small owned fixture only; no worst-case capacity claim.", + warm.StartupElapsed.TotalSeconds, tokenFlow.Elapsed.TotalSeconds, journey.Elapsed.TotalSeconds); + } + finally + { + await warm.DisposeAsync(); + await KeycloakFixture.AssertStockResourcesAbsentAsync(resources); + output.WriteLine("Joined cleanup: two owned provider containers and one owned network independently absent=true."); + } + } + + private void WarmAssertPass(ReconciliationOutcome outcome, bool preparation) + { + outcome.Elapsed.Should().BeGreaterThan(TimeSpan.Zero).And.BeLessThan(TimeSpan.FromSeconds(120)); + outcome.Requests.Should().NotBeNull(); + var requests = outcome.Requests!; + requests["discovery"].Should().Be(1); + requests["version"].Should().Be(1); + requests["authentication"].Should().BeInRange(1, 3); + requests["initialPlan"].Should().BeGreaterThan(0); + requests["prepare"].Should().Be(preparation ? 2 : 0); + if (preparation) requests["freshPlan"].Should().BeGreaterThan(0); + else requests["freshPlan"].Should().Be(0); + requests["readback"].Should().BeGreaterThan(0); + output.WriteLine("Reconciler finite observation: mutations={0}; preparation={1}; elapsed={2:F3}s; total requests={3}; phases={4}.", + outcome.Mutations, outcome.PreparationDispatched, outcome.Elapsed.TotalSeconds, requests.Values.Sum(), + string.Join(",", requests.Select(value => value.Key + "=" + value.Value.ToString(CultureInfo.InvariantCulture)))); + } + + private static async Task WarmAccountsAsync(KeycloakAdminClient admin, IEnumerable usernames) + { + var result = new JsonObject(); + foreach (var username in usernames) + { + var user = await admin.UserAsync(username); + var values = new JsonObject(); + foreach (var suffix in new[] { "", "/credentials", "/role-mappings", "/groups" }) + values[suffix] = WarmNode(await admin.GetAsync(UserPath(user) + suffix)); + result[username] = values; + } + return result; + } + + private static async Task WarmAssertPreservationAsync(KeycloakAdminClient admin, JsonObject before, JsonObject after, string unrelated) + { + foreach (var (username, previous) in before) + { + var current = after[username]!; + (previous![""]!["id"]!.GetValue() == current[""]!["id"]!.GetValue()).Should().BeTrue(); + foreach (var suffix in new[] { "/credentials", "/groups" }) + WarmSame(previous[suffix], current[suffix]).Should().BeTrue("credential DTOs and membership survive without login between captures"); + var oldUser = previous[""]!.DeepClone().AsObject(); + var newUser = current[""]!.DeepClone().AsObject(); + if (username != unrelated) + { + foreach (var user in new[] { oldUser, newUser }) + if (user["attributes"] is JsonObject attributes) + { + foreach (var name in WarmProtectedNames) attributes.Remove(name); + if (attributes.Count == 0) user.Remove("attributes"); + } + } + WarmSame(oldUser, newUser).Should().BeTrue("all unrelated account fields are preserved"); + var grants = current["/role-mappings"]!.DeepClone().AsObject(); + if (username != unrelated) + { + var added = grants["clientMappings"]!["account"]!["mappings"]!.AsArray(); + added.Count.Should().Be(1); + (added[0]!["name"]!.GetValue() == "manage-account").Should().BeTrue(); + var role = await admin.GetAsync("clients/" + WarmId(await admin.ClientAsync("account")) + "/roles/manage-account"); + (added[0]!["id"]!.GetValue() == WarmId(role)).Should().BeTrue(); + grants["clientMappings"]!.AsObject().Remove("account"); + if (grants["clientMappings"]!.AsObject().Count == 0) grants.Remove("clientMappings"); + } + WarmSame(previous["/role-mappings"], grants).Should().BeTrue("only the declared manifest account capability may be added"); + } + var mfa = after[SeedData.IdentityUsers[1].Username]!["/credentials"]!.AsArray(); + mfa.Count(value => value!["type"]!.GetValue() == "otp").Should().Be(1); + mfa.Count(value => value!["type"]!.GetValue() == "password").Should().Be(1); + } + + private static async Task WarmAppendedProfileAsync(JsonElement before) + { + var expected = WarmNode(before).AsObject(); + var desired = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!; + var definitions = JsonNode.Parse(desired["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]! + ["config"]!["kc.user.profile.config"]![0]!.GetValue())!["attributes"]!.AsArray(); + foreach (var name in WarmProtectedNames) + expected["attributes"]!.AsArray().Add(definitions.Single(value => value!["name"]!.GetValue() == name)!.DeepClone()); + return expected; + } + + private static async Task> WarmGraphAsync(KeycloakAdminClient admin) + { + var result = new Dictionary(StringComparer.Ordinal); + async Task Read(string path) + { + if (result.TryGetValue(path, out var cached)) return cached; + var value = await admin.GetAsync(path); + result.Add(path, value); + return value; + } + var clients = await Read("clients"); + var scopes = await Read("client-scopes"); + foreach (var scope in scopes.EnumerateArray()) + foreach (var suffix in new[] { "", "/protocol-mappers/models", "/scope-mappings" }) + await Read("client-scopes/" + WarmId(scope) + suffix); + foreach (var client in clients.EnumerateArray()) + foreach (var category in new[] { "default", "optional" }) + await Read("clients/" + WarmId(client) + "/" + category + "-client-scopes"); + var account = await admin.ClientAsync("account"); + await Read("clients/" + WarmId(account)); + var consolePath = "clients/" + WarmId(await admin.ClientAsync("account-console")); + foreach (var suffix in new[] { "", "/protocol-mappers/models", "/scope-mappings" }) await Read(consolePath + suffix); + foreach (var (category, names) in new[] + { + ("default", new[] { "acr", "basic", "email", "profile", "roles", "web-origins" }), + ("optional", new[] { "address", "microprofile-jwt", "offline_access", "organization", "phone" }), + }) result[consolePath + "/" + category + "-client-scopes"].EnumerateArray() + .Select(value => value.GetProperty("name").GetString()).Order(StringComparer.Ordinal).Should().Equal(names); + var roles = await Read("roles"); + var accountRoles = await Read("clients/" + WarmId(account) + "/roles"); + var pending = new Queue(roles.EnumerateArray().Concat(accountRoles.EnumerateArray())); + var seen = new HashSet(StringComparer.Ordinal); + while (pending.TryDequeue(out var role)) + { + if (!seen.Add(WarmId(role))) continue; + seen.Count.Should().BeLessThanOrEqualTo(200); + var path = "roles-by-id/" + WarmId(role); + await Read(path); + foreach (var child in (await Read(path + "/composites")).EnumerateArray()) pending.Enqueue(child); + } + await Read("groups"); + await Read("clients/" + WarmId(await admin.ClientAsync("learnstack-api")) + "/client-secret"); + return result; + } + + private static async Task WarmAssertGraphAsync(KeycloakAdminClient admin, Dictionary before) + { + var console = await admin.ClientAsync("account-console"); + var consolePath = "clients/" + WarmId(console); + var desired = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!; + var currentScopes = await admin.GetAsync("client-scopes"); + foreach (var (path, prior) in before) + { + var current = await admin.GetAsync(path); + if (path == consolePath + "/protocol-mappers/models") + { + var afterMappers = WarmNode(current).AsArray(); + var owned = afterMappers.Single(mapper => mapper!["name"]!.GetValue() == "learnstack-account-self-service-roles")!; + var declared = desired["clients"]!.AsArray().Single(client => client!["clientId"]!.GetValue() == "account-console")! + ["protocolMappers"]!.AsArray().Single(mapper => mapper!["name"]!.GetValue() == "learnstack-account-self-service-roles")!; + var expectedConfig = declared["config"]!.DeepClone().AsObject(); + expectedConfig.Remove("usermodel.clientRoleMapping.rolePrefix"); + WarmSame(expectedConfig, owned["config"]).Should().BeTrue("the sole new mapper is exactly the approved fixed-account emitter"); + (owned["protocol"]!.GetValue() == "openid-connect" + && owned["protocolMapper"]!.GetValue() == "oidc-usermodel-client-role-mapper").Should().BeTrue(); + afterMappers.Remove(owned); + WarmSame(WarmNode(prior), afterMappers).Should().BeTrue("every preexisting direct console mapper survives unchanged"); + continue; + } + if (path == consolePath) + { + var expectedClient = WarmNode(prior).AsObject(); + var actualClient = WarmNode(current).AsObject(); + foreach (var client in new[] { expectedClient, actualClient }) client.Remove("protocolMappers"); + expectedClient["defaultClientScopes"] = new JsonArray(expectedClient["defaultClientScopes"]!.AsArray() + .Where(scope => scope!.GetValue() != "roles").Select(scope => scope!.DeepClone()).ToArray()); + expectedClient["optionalClientScopes"] = new JsonArray(expectedClient["optionalClientScopes"]!.AsArray() + .Where(scope => scope!.GetValue() is not ("microprofile-jwt" or "offline_access")).Select(scope => scope!.DeepClone()).ToArray()); + WarmSame(expectedClient, actualClient).Should().BeTrue("all console settings retain their exact preexisting values beyond the three edge removals"); + continue; + } + if (path is "clients" or "client-scopes") + { + var oldIds = prior.EnumerateArray().Select(WarmId).ToHashSet(StringComparer.Ordinal); + var currentIds = current.EnumerateArray().Select(WarmId).ToHashSet(StringComparer.Ordinal); + oldIds.IsSubsetOf(currentIds).Should().BeTrue("no preexisting client or scope is deleted or recreated"); + if (path == "clients") currentIds.SetEquals(oldIds).Should().BeTrue(); + else current.EnumerateArray().Where(scope => !oldIds.Contains(WarmId(scope))) + .Select(scope => scope.GetProperty("name").GetString()).Order(StringComparer.Ordinal) + .Should().Equal(desired["clientScopes"]!.AsArray().Select(scope => scope!["name"]!.GetValue()) + .Except(prior.EnumerateArray().Select(scope => scope.GetProperty("name").GetString()), StringComparer.Ordinal) + .Order(StringComparer.Ordinal)); + continue; + } + if (path.EndsWith("-client-scopes", StringComparison.Ordinal)) + { + var owner = before["clients"].EnumerateArray().Single(client => path.StartsWith("clients/" + WarmId(client) + "/", StringComparison.Ordinal)); + var expected = prior.EnumerateArray().Where(scope => path == consolePath + "/default-client-scopes" + ? scope.GetProperty("name").GetString() != "roles" + : path != consolePath + "/optional-client-scopes" || scope.GetProperty("name").GetString() is not ("microprofile-jwt" or "offline_access")) + .Select(scope => (Id: WarmId(scope), Name: scope.GetProperty("name").GetString())).ToHashSet(); + var clientId = owner.GetProperty("clientId").GetString(); + if (clientId is "learnstack-web" or "learnstack-api") + { + var declared = desired["clients"]!.AsArray().Single(client => client!["clientId"]!.GetValue() == clientId)!; + var category = path.EndsWith("/default-client-scopes", StringComparison.Ordinal) ? "defaultClientScopes" : "optionalClientScopes"; + foreach (var name in declared[category]!.AsArray().Select(value => value!.GetValue())) + { + var scope = currentScopes.EnumerateArray().Single(value => value.GetProperty("name").GetString() == name); + expected.Add((WarmId(scope), name)); + } + } + var actual = current.EnumerateArray().Select(scope => (Id: WarmId(scope), Name: scope.GetProperty("name").GetString())).ToArray(); + (actual.Length == expected.Count && expected.SetEquals(actual)).Should().BeTrue( + "exact edge delta: three console removals and approved web/API additions; every other assignment is preserved"); + continue; + } + WarmSame(WarmNode(prior), WarmNode(current)).Should().BeTrue("global definitions, role graphs, account client, groups and API secret are preserved"); + } + foreach (var (category, count) in new[] { ("default", 5), ("optional", 3) }) + (await admin.GetAsync(consolePath + "/" + category + "-client-scopes")).GetArrayLength().Should().Be(count); + } + + private static async Task WarmRealmStateAsync(KeycloakAdminClient admin) + { + var state = new JsonObject(); + foreach (var path in new[] { "", "users/profile", "clients", "client-scopes", "roles", "groups", "users?max=100&briefRepresentation=false" }) + state[path] = WarmNode(await admin.GetAsync(path)); + foreach (var user in state["users?max=100&briefRepresentation=false"]!.AsArray()) + foreach (var suffix in new[] { "", "/credentials", "/role-mappings", "/groups" }) + { + var path = "users/" + user!["id"]!.GetValue() + suffix; + state[path] = WarmNode(await admin.GetAsync(path)); + } + foreach (var client in state["clients"]!.AsArray()) + foreach (var suffix in new[] { "", "/protocol-mappers/models", "/scope-mappings", "/default-client-scopes", "/optional-client-scopes" }) + { + var path = "clients/" + client!["id"]!.GetValue() + suffix; + state[path] = WarmNode(await admin.GetAsync(path)); + } + return state; + } + + private static async Task WarmPasswordRefusedAsync(KeycloakFixture fixture, string username, string password) + { + using var browser = fixture.Browser(); + var refusal = await browser.AuthorizeAsync(username, password, WarmConsoleCallback(fixture), clientId: "account-console", scope: "openid"); + refusal.Authorized.Should().BeFalse(); + refusal.Error.Should().Be("invalid_credentials"); + } + + private static async Task WarmMfaRefusalAsync(KeycloakFixture fixture, string username, string password, WarmTotp otp) + { + using (var browser = fixture.Browser()) + { + var missing = await browser.AuthorizeAsync(username, password, WarmConsoleCallback(fixture), clientId: "account-console", scope: "openid"); + missing.Authorized.Should().BeFalse(); + missing.MfaChallenged.Should().BeTrue(); + missing.Error.Should().Be("mfa_challenge_required"); + } + using var wrong = fixture.Browser(); + var refused = await wrong.AuthorizeAsync(username, password, WarmConsoleCallback(fixture), clientId: "account-console", scope: "openid", + otpCode: () => Task.FromResult(otp.WrongCode())); + refused.Authorized.Should().BeFalse(); + refused.MfaChallenged.Should().BeTrue(); + refused.Error.Should().Be("invalid_otp"); + } + + private static async Task WarmWebTokensAsync(KeycloakFixture fixture, SeedIdentity identity, string password, WarmTotp? otp = null) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, password, Callback(identity.Tenant), otpCode: otp is null ? null : otp.NextAsync); + authorization.Authorized.Should().BeTrue("warm code/S256 authorization must complete for persona {0}: {1}", + SeedData.IdentityUsers.IndexOf(identity), authorization.Error); + authorization.MfaChallenged.Should().Be(otp is not null); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var access = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + var id = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.IdToken, "learnstack-web"); + WarmAccessHashMatches(id, exchange.AccessToken).Should().BeTrue("the signed ID-token hash binds the same independently verified access token"); + using var admin = await fixture.CreateAdminClientAsync(); + var account = await admin.UserAsync(identity.Username); + foreach (var payload in new[] { access, id }) + { + (payload.GetProperty("sub").GetString() == WarmId(account)).Should().BeTrue(); + WarmIdentityMatches(payload, identity).Should().BeTrue("fresh signed user/tenant/organization values agree exactly with typed SeedData"); + exchange.Window.AcceptsFresh(payload, 900).Should().BeTrue(); + } + HasAudience(id, "learnstack-api").Should().BeFalse(); + return exchange; + } + + private static async Task WarmConsoleTokensAsync(KeycloakFixture fixture, string username, string password, + bool accountAudience, int lifespan, string scope = "openid", WarmTotp? otp = null) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(username, password, WarmConsoleCallback(fixture), clientId: "account-console", scope: scope, + otpCode: otp is null ? null : otp.NextAsync); + authorization.Authorized.Should().BeTrue("console code/S256 authorization must complete: {0}", authorization.Error); + authorization.MfaChallenged.Should().Be(otp is not null); + var exchange = await browser.RedeemAsync(authorization, secret: null); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var access = await KeycloakTokenVerifier.ValidateConsoleAsync(fixture, exchange.AccessToken, idToken: false, accountAudience: accountAudience); + var id = await KeycloakTokenVerifier.ValidateConsoleAsync(fixture, exchange.IdToken, idToken: true); + WarmAccessHashMatches(id, exchange.AccessToken).Should().BeTrue("the console ID-token hash binds this independently verified access token"); + foreach (var payload in new[] { access, id }) + { + exchange.Window.AcceptsFresh(payload, lifespan).Should().BeTrue(); + WarmProtectedNames.All(name => !payload.TryGetProperty(name, out _)).Should().BeTrue( + "the console mapper never invents LearnStack authority, including for nonmanifest users"); + } + if (accountAudience) + { + WarmAudiences(access).Should().Equal("account"); + access.GetProperty("resource_access").EnumerateObject().Select(value => value.Name).Should().Equal("account"); + access.GetProperty("resource_access").GetProperty("account").GetProperty("roles").EnumerateArray() + .Select(value => value.GetString()).Order(StringComparer.Ordinal).Should().Equal(WarmAccountRoles); + } + else + { + access.TryGetProperty("aud", out _).Should().BeFalse(); + access.TryGetProperty("resource_access", out _).Should().BeFalse(); + } + id.TryGetProperty("resource_access", out _).Should().BeFalse(); + WarmAudiences(id).Should().Equal("account-console"); + return new WarmConsoleTokens(exchange, access, id); + } + + private static async Task WarmNonmanifestWebTokensAsync(KeycloakFixture fixture, string username) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(username, KeycloakFixture.DemoPassword, Callback(SeedData.English)); + authorization.Authorized.Should().BeTrue(); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var access = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + var id = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.IdToken, "learnstack-web"); + WarmAccessHashMatches(id, exchange.AccessToken).Should().BeTrue(); + using var admin = await fixture.CreateAdminClientAsync(); + var user = await admin.UserAsync(username); + foreach (var token in new[] { access, id }) + { + (token.GetProperty("sub").GetString() == WarmId(user)).Should().BeTrue(); + exchange.Window.AcceptsFresh(token, 900).Should().BeTrue(); + WarmProtectedNames.All(name => !token.TryGetProperty(name, out _)).Should().BeTrue( + "even the web client's real identity mappers do not invent authority for an eligible nonmanifest account"); + } + } + + private static void WarmAssertConsoleDelta(WarmConsoleTokens before, WarmConsoleTokens after, bool addedCapability) + { + var expected = WarmClaims(before.Access); + if (addedCapability) + { + expected["aud"] = "account"; + expected["resource_access"] = JsonSerializer.SerializeToNode(new + { account = new { roles = WarmAccountRoles } }); + } + WarmSame(expected, WarmClaims(after.Access)).Should().BeTrue( + "the full signed console access claim set changes only by the approved account capability"); + WarmSame(WarmClaims(before.Id), WarmClaims(after.Id)).Should().BeTrue( + "the full signed console ID claim set remains unchanged apart from fresh issuance/session fields"); + } + + private static async Task WarmConsoleScopeEffectsAsync(KeycloakFixture fixture, string username, + WarmConsoleTokens ordinary, bool beforeUpgrade) + { + KeycloakTokenExchange? offline = null; + foreach (var scope in new[] { "microprofile-jwt", "offline_access" }) + { + if (!beforeUpgrade) + { + using var browser = fixture.Browser(); + var refused = await browser.AuthorizeAsync(username, KeycloakFixture.DemoPassword, WarmConsoleCallback(fixture), + clientId: "account-console", scope: "openid " + scope); + refused.Authorized.Should().BeFalse("the detached optional scope cannot be requested through the console"); + refused.StatusCode.Should().Be(HttpStatusCode.Found); + refused.Error.Should().Be("invalid_scope", "the registered callback receives the closed OAuth scope refusal with its state and issuer checked"); + continue; + } + var selected = await WarmConsoleTokensAsync(fixture, username, KeycloakFixture.DemoPassword, true, 1800, "openid " + scope); + var expected = WarmClaims(ordinary.Access); + var expectedId = WarmClaims(ordinary.Id); + var actual = WarmClaims(selected.Access); + if (scope == "microprofile-jwt") + { + (selected.Access.GetProperty("upn").GetString() == username).Should().BeTrue(); + expected["upn"] = username; + expectedId["upn"] = username; + } + else + { + expected["realm_access"] = JsonSerializer.SerializeToNode(new { roles = WarmOfflineRole }); + // The token endpoint's refresh credential is checked only in memory. + // Offline support is observed by an actual refresh exchange, not decoded without validation. + string.IsNullOrEmpty(selected.Exchange.RefreshToken).Should().BeFalse(); + using var browser = fixture.Browser(); + var refreshed = await browser.RequestTokenAsync(new Dictionary + { ["grant_type"] = "refresh_token", ["client_id"] = "account-console", ["refresh_token"] = selected.Exchange.RefreshToken }); + refreshed.StatusCode.Should().Be(HttpStatusCode.OK); + string.IsNullOrEmpty(refreshed.RefreshToken).Should().BeFalse( + "the later continuity observation must submit the nonempty credential returned by this successful refresh"); + offline = refreshed; + var signed = await KeycloakTokenVerifier.ValidateConsoleAsync(fixture, refreshed.AccessToken, idToken: false); + signed.GetProperty("scope").GetString()!.Split(' ').Should().Contain("offline_access"); + using var admin = await fixture.CreateAdminClientAsync(); + var user = await admin.UserAsync(username); + var console = await admin.ClientAsync("account-console"); + (await admin.GetAsync(UserPath(user) + "/offline-sessions/" + WarmId(console))).GetArrayLength().Should().Be(1, + "the explicit scope really creates an owned offline session before its assignment is detached"); + } + var expectedScopes = ordinary.Access.GetProperty("scope").GetString()!.Split(' ').Append(scope).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal); + selected.Access.GetProperty("scope").GetString()!.Split(' ').Order(StringComparer.Ordinal).Should().Equal(expectedScopes); + expected.Remove("scope"); + actual.Remove("scope"); + WarmSame(expected, actual).Should().BeTrue("explicit optional selection has only its measured full signed claim delta"); + WarmSame(expectedId, WarmClaims(selected.Id)).Should().BeTrue( + "the complete ID claim set observes the optional mapper's own ID-token setting"); + } + return offline; + } + + private async Task WarmExistingOfflineAsync(KeycloakFixture fixture, string username, KeycloakTokenExchange prior) + { + using var admin = await fixture.CreateAdminClientAsync(); + var user = await admin.UserAsync(username); + var console = await admin.ClientAsync("account-console"); + var path = UserPath(user) + "/offline-sessions/" + WarmId(console); + (await admin.GetAsync(path)).GetArrayLength().Should().Be(1, + "detaching an optional scope does not authorize deleting the preexisting offline session"); + using var browser = fixture.Browser(); + var refreshed = await browser.RequestTokenAsync(new Dictionary + { ["grant_type"] = "refresh_token", ["client_id"] = "account-console", ["refresh_token"] = prior.RefreshToken }); + // Pin this fixture's observed provider behavior, without claiming that + // scope detachment universally revokes previously issued credentials. + refreshed.StatusCode.Should().Be(HttpStatusCode.BadRequest); + refreshed.Error.Should().Be("invalid_grant"); + string.IsNullOrEmpty(refreshed.AccessToken).Should().BeTrue(); + string.IsNullOrEmpty(refreshed.IdToken).Should().BeTrue(); + (await admin.GetAsync(path)).GetArrayLength().Should().Be(1); + output.WriteLine("Existing offline observation: session retained=true; refresh=invalid_grant; signed token not issued."); + } + + private static async Task WarmAccountStatusAsync(KeycloakFixture fixture, KeycloakTokenExchange exchange) + { + using var account = WarmAccountClient(fixture, exchange); + using var response = await KeycloakHttp.SendAsync(account, HttpMethod.Get, "?userProfileMetadata=true"); + return response.StatusCode; + } + + private static async Task WarmProfileActionsAsync(KeycloakFixture fixture, KeycloakAdminClient admin, string username, + KeycloakTokenExchange exchange, SeedIdentity? identity) + { + using var account = WarmAccountClient(fixture, exchange); + var original = await WarmAccountReadAsync(account); + WarmHidden(original).Should().BeTrue(); + var update = WarmAccountUpdate(original); + update["firstName"] = "Ordinary warm edit"; + (await WarmAccountWriteAsync(account, update)).Should().Be(HttpStatusCode.NoContent); + var stored = await admin.GetAsync(UserPath(await admin.UserAsync(username))); + (stored.GetProperty("firstName").GetString() == "Ordinary warm edit").Should().BeTrue( + "a separate authenticated admin GET proves the ordinary Account REST POST reached storage"); + WarmStoredIdentityMatches(stored, identity).Should().BeTrue(); + foreach (var name in WarmProtectedNames) + { + var payload = WarmAccountUpdate(await WarmAccountReadAsync(account)); + payload["attributes"]![name] = new JsonArray(Guid.NewGuid().ToString("D")); + (await WarmAccountWriteAsync(account, payload)).Should().BeOneOf(HttpStatusCode.NoContent, HttpStatusCode.BadRequest, HttpStatusCode.Forbidden); + var after = await admin.GetAsync(UserPath(await admin.UserAsync(username))); + WarmSame(WarmNode(stored), WarmNode(after)).Should().BeTrue( + "protected replacements cannot persist for either manifest or nonmanifest accounts; HTTP status alone is insufficient"); + WarmHidden(await WarmAccountReadAsync(account)).Should().BeTrue(); + } + } + + private static HttpClient WarmAccountClient(KeycloakFixture fixture, KeycloakTokenExchange exchange) + { + var client = KeycloakHttp.Create(new Uri(fixture.BaseAddress, "realms/learnstack/account/"), fixture.RootCertificate); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", exchange.AccessToken); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + private static async Task WarmAccountReadAsync(HttpClient account) + { + using var response = await KeycloakHttp.SendAsync(account, HttpMethod.Get, "?userProfileMetadata=true"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (response.Content.Headers.ContentType?.MediaType).Should().Be("application/json"); + using var json = await KeycloakHttp.ReadJsonAsync(response); + return json.RootElement.Clone(); + } + + private static async Task WarmAccountWriteAsync(HttpClient account, JsonObject body) + { + using var response = await KeycloakHttp.SendAsync(account, HttpMethod.Post, "", JsonContent.Create(body)); + return response.StatusCode; + } + + private static JsonObject WarmAccountUpdate(JsonElement account) + { + var result = WarmNode(account).AsObject(); + result.Remove("id"); + result.Remove("userProfileMetadata"); + result["attributes"] ??= new JsonObject(); + return result; + } + + private static bool WarmHidden(JsonElement account) => + (!account.TryGetProperty("attributes", out var attributes) || WarmProtectedNames.All(name => !attributes.TryGetProperty(name, out _))) + && account.TryGetProperty("userProfileMetadata", out var metadata) + && metadata.TryGetProperty("attributes", out var definitions) + && !definitions.EnumerateArray().Any(value => WarmProtectedNames.Contains(value.GetProperty("name").GetString(), StringComparer.Ordinal)); + + private static bool WarmStoredIdentityMatches(JsonElement user, SeedIdentity? identity) + { + if (identity is null) return !user.TryGetProperty("attributes", out var absent) + || WarmProtectedNames.All(name => !absent.TryGetProperty(name, out _)); + if (!user.TryGetProperty("attributes", out var attributes)) return false; + bool One(string name, string expected) => attributes.TryGetProperty(name, out var values) + && values.ValueKind == JsonValueKind.Array && values.GetArrayLength() == 1 && values[0].GetString() == expected; + return One("user_id", identity.UserId.Value.ToString("D")) && One("tenant_id", identity.Tenant.TenantId.Value.ToString("D")) + && (identity.Tenant.MapHostToDefaultOrganization ? One("organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + : !attributes.TryGetProperty("organization_id", out _)); + } + + private static bool WarmIdentityMatches(JsonElement payload, SeedIdentity identity) + { + bool Scalar(string name, string expected) => payload.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String && value.GetString() == expected; + return Scalar("user_id", identity.UserId.Value.ToString("D")) && Scalar("tenant_id", identity.Tenant.TenantId.Value.ToString("D")) + && (identity.Tenant.MapHostToDefaultOrganization ? Scalar("organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + : !payload.TryGetProperty("organization_id", out _)); + } + + private static JsonObject WarmClaims(JsonElement payload, bool ordinaryNameChanged = false) + { + var claims = WarmNode(payload).AsObject(); + foreach (var name in new[] { "exp", "iat", "jti", "sid", "nonce", "auth_time", "at_hash" }) claims.Remove(name); + if (ordinaryNameChanged) foreach (var name in new[] { "name", "given_name" }) claims.Remove(name); + if (claims["scope"] is JsonValue scope) + claims["scope"] = string.Join(' ', scope.GetValue().Split(' ').Order(StringComparer.Ordinal)); + if (claims["resource_access"] is JsonObject clients) + foreach (var (_, client) in clients) + if (client?["roles"] is JsonArray roles) + client["roles"] = new JsonArray(roles.Select(role => role!.GetValue()).Order(StringComparer.Ordinal) + .Select(role => (JsonNode?)JsonValue.Create(role)).ToArray()); + return claims; + } + + private static string[] WarmAudiences(JsonElement token) => !token.TryGetProperty("aud", out var audience) ? [] + : audience.ValueKind == JsonValueKind.String ? [audience.GetString()!] + : audience.EnumerateArray().Select(value => value.GetString()!).Order(StringComparer.Ordinal).ToArray(); + private static bool WarmAccessHashMatches(JsonElement id, string accessToken) + { + var hash = SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)); + var expected = Convert.ToBase64String(hash.AsSpan(0, hash.Length / 2)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return id.TryGetProperty("at_hash", out var actual) && actual.ValueKind == JsonValueKind.String && actual.GetString() == expected; + } + private static string WarmConsoleCallback(KeycloakFixture fixture) => new Uri(fixture.BaseAddress, "realms/learnstack/account/").AbsoluteUri; + private static JsonNode WarmNode(JsonElement value) => JsonNode.Parse(value.GetRawText())!; + private static string WarmId(JsonElement value) => value.GetProperty("id").GetString()!; + private static bool WarmSame(JsonNode? first, JsonNode? second) => JsonNode.DeepEquals(first, second); + + private sealed class WarmConsoleTokens(KeycloakTokenExchange exchange, JsonElement access, JsonElement id) + { + internal KeycloakTokenExchange Exchange { get; } = exchange; + internal JsonElement Access { get; } = access; + internal JsonElement Id { get; } = id; + public override string ToString() => "Signed console observations (credentials omitted)"; + } + + private sealed class WarmTotp + { + private readonly string _secret = Guid.NewGuid().ToString("N"); + private long _lastIssued = -1; + internal JsonNode Credential() => JsonSerializer.SerializeToNode(new + { + type = "otp", + userLabel = "Owned warm proof", + secretData = JsonSerializer.Serialize(new { value = _secret }), + credentialData = JsonSerializer.Serialize(new { subType = "totp", digits = 6, counter = 0, period = 30, algorithm = "HmacSHA1" }), + })!; + + internal async Task NextAsync() + { + var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + if (counter <= _lastIssued) + { + var wait = DateTimeOffset.FromUnixTimeSeconds((_lastIssued + 1) * 30).AddMilliseconds(50) - DateTimeOffset.UtcNow; + if (wait < TimeSpan.Zero || wait > TimeSpan.FromSeconds(31)) + throw new InvalidOperationException("Owned OTP clock exceeded its one-period bound."); + await Task.Delay(wait); + counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + if (counter <= _lastIssued) throw new InvalidOperationException("Owned OTP clock did not advance."); + } + _lastIssued = counter; + return Code(counter); + } + + internal string WrongCode() + { + var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + var valid = Enumerable.Range(-2, 5).Select(offset => Code(counter + offset)).ToHashSet(StringComparer.Ordinal); + return Enumerable.Range(0, 10).Select(value => value.ToString("D6", CultureInfo.InvariantCulture)).First(value => !valid.Contains(value)); + } + + private string Code(long counter) + { + Span input = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(input, counter); + // HMAC-SHA1 is the pinned provider's TOTP algorithm, not a new password/signature choice. +#pragma warning disable CA5350 // Exercise the unchanged stock Keycloak TOTP protocol with an invocation-owned credential. + var hash = HMACSHA1.HashData(Encoding.UTF8.GetBytes(_secret), input); +#pragma warning restore CA5350 + var offset = hash[^1] & 15; + var number = BinaryPrimitives.ReadInt32BigEndian(hash.AsSpan(offset, 4)) & 0x7fffffff; + return (number % 1000000).ToString("D6", CultureInfo.InvariantCulture); + } + public override string ToString() => "Owned OTP credential (secret omitted)"; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.cs new file mode 100644 index 00000000..162997bf --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.cs @@ -0,0 +1,321 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Committed provider issuance through Program, stock bearer validation and real application-role reads. +[Collection(TenantRealmApiTestGroup.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class TenantRealmApiTests(PublicReadFixture database, KeycloakFixture provider, Xunit.Abstractions.ITestOutputHelper output) +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Every_seed_persona_reads_its_actual_account_site_under_the_matching_host_scope(int index) + { + SeedData.IdentityUsers.Length.Should().Be(4); + var identity = SeedData.IdentityUsers[index]; + var tokens = await TokensAsync(identity); + using var client = Client(identity.Tenant, out var observation); + var audits = await AuditCountsAsync(); + var reads = database.Observation.Reads; + + using var response = await Send(client, identity.Tenant, tokens.AccessToken); + await AccountAsync(response, identity, observation); + + observation.Reads.Should().HaveCount(1); + database.Observation.Reads.Should().Be(reads + 1, + "the existing observer must witness the physical ReadOnly learnstack_app transaction"); + (await AuditCountsAsync()).Should().Equal(audits, "successful account reads create no query audit"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Real_cross_tenant_tokens_refuse_before_configuration_and_audit_the_defended_host(bool yogaActor) + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant == (yogaActor ? SeedData.Yoga : SeedData.English)); + var defended = yogaActor ? SeedData.English : SeedData.Yoga; + var tokens = await TokensAsync(identity); + using var client = Client(identity.Tenant, out var observation); + using (var positive = await Send(client, identity.Tenant, tokens.AccessToken)) + await AccountAsync(positive, identity, observation); + var reads = database.Observation.Reads; + var audits = await AuditCountsAsync(); + + using var refusal = await Send(client, defended, tokens.AccessToken); + await TenantAuthenticationHttpTests.Problem(refusal, HttpStatusCode.NotFound, "tenant_mismatch"); + + database.Observation.Reads.Should().Be(reads); + observation.Reads.Should().HaveCount(1); + await AssertRejectionAsync(refusal, defended, identity, "assertedTenantId", identity.Tenant.TenantId.Value); + await AssertSingleAdditionalAuditAsync(audits, defended); + } + + [Fact] + public async Task A_real_provider_organization_disagreement_refuses_and_records_the_trusted_host_organization() + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant == SeedData.Yoga); + using var admin = await provider.CreateAdminClientAsync(); + var user = await admin.UserAsync(identity.Username); + var userPath = UserPath(user); + var originalDetails = await admin.GetAsync(userPath); + var original = user.GetProperty("attributes").Clone(); + var attributes = original.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.Clone()); + attributes["organization_id"] = JsonSerializer.SerializeToElement( + new[] { SeedData.English.DefaultOrganization.OrganizationId.Value.ToString("D") }); + using var client = Client(identity.Tenant, out var observation); + var originalTokens = await TokensAsync(identity); + using (var positive = await Send(client, identity.Tenant, originalTokens.AccessToken)) + await AccountAsync(positive, identity, observation); + var audits = await AuditCountsAsync(); + var reads = database.Observation.Reads; + + try + { + (await admin.PutAsync(userPath, UserAttributeUpdate(user, attributes))).Should().Be(HttpStatusCode.NoContent); + var changedTokens = await TokensAsync(identity); + using var refusal = await Send(client, identity.Tenant, changedTokens.AccessToken); + await TenantAuthenticationHttpTests.Problem(refusal, HttpStatusCode.NotFound, "tenant_mismatch"); + + database.Observation.Reads.Should().Be(reads); + observation.Reads.Should().HaveCount(1); + await AssertRejectionAsync(refusal, identity.Tenant, identity, "assertedOrganizationId", + SeedData.English.DefaultOrganization.OrganizationId.Value); + await AssertSingleAdditionalAuditAsync(audits, identity.Tenant); + } + finally + { + (await admin.PutAsync(userPath, UserAttributeUpdate(user, original))).Should().Be(HttpStatusCode.NoContent); + var restored = await admin.GetAsync(userPath); + JsonElement.DeepEquals(restored.GetProperty("attributes"), original).Should().BeTrue( + "only the owned provider mutation is restored, including all original attributes"); + JsonElement.DeepEquals(restored, originalDetails).Should().BeTrue( + "restoring scope attributes cannot silently change the account's unrelated state"); + } + + var restoredTokens = await TokensAsync(identity); + using var restoredResponse = await Send(client, identity.Tenant, restoredTokens.AccessToken); + await AccountAsync(restoredResponse, identity, observation); + observation.Reads.Should().HaveCount(2); + } + + [Fact] + public async Task A_real_claimless_actor_retains_HostOnly_attribution_without_protected_read_authority() + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant == SeedData.Yoga); + var positiveTokens = await TokensAsync(identity); + using var client = Client(identity.Tenant, out var observation); + using (var positive = await Send(client, identity.Tenant, positiveTokens.AccessToken)) + await AccountAsync(positive, identity, observation); + using var admin = await provider.CreateAdminClientAsync(); + var username = "api-claimless-" + Guid.NewGuid().ToString("N") + "@example.test"; + var actor = Guid.NewGuid(); + var created = false; + var audits = await AuditCountsAsync(); + try + { + var status = await CreateUserAsync(admin, username, new Dictionary + { + ["user_id"] = [actor.ToString("D")], + }); + created = status == HttpStatusCode.Created; + status.Should().Be(HttpStatusCode.Created); + var user = await admin.UserAsync(username); + user.GetProperty("attributes").EnumerateObject().Select(property => property.Name) + .Should().Equal("user_id"); + var tokens = await ExchangeAsync(username, Callback(identity.Tenant)); + var payload = await KeycloakTokenVerifier.ValidateAsync(provider, tokens.AccessToken, "learnstack-api"); + payload.TryGetProperty("tenant_id", out _).Should().BeFalse(); + payload.TryGetProperty("organization_id", out _).Should().BeFalse(); + payload.GetProperty("user_id").GetGuid().Should().Be(actor); + + var reads = database.Observation.Reads; + using var protectedResponse = await Send(client, identity.Tenant, tokens.AccessToken); + await TenantAuthenticationHttpTests.Problem(protectedResponse, HttpStatusCode.NotFound, "not_found"); + database.Observation.Reads.Should().Be(reads); + observation.Reads.Should().HaveCount(1); + + using var publicResponse = await Send(client, identity.Tenant, tokens.AccessToken, + TenantAuthenticationHttpRequest.PublicPath); + await PublicSiteAsync(publicResponse, identity.Tenant); + observation.Reads.Should().HaveCount(2); + AssertContext(observation.Reads.Last(), identity.Tenant, actor, TenantContextOrigin.HostOnly); + database.Observation.Reads.Should().Be(reads + 1); + (await AuditCountsAsync()).Should().Equal(audits); + } + finally + { + if (created) await DeleteOwnedUserAsync(admin, username); + } + } + + [Fact] + public async Task A_genuine_ID_token_without_API_audience_is_401_on_account_and_public_routes() + { + var identity = SeedData.IdentityUsers.First(); + var tokens = await TokensAsync(identity); + var id = await KeycloakTokenVerifier.ValidateAsync(provider, tokens.IdToken, "learnstack-web"); + HasAudience(id, "learnstack-api").Should().BeFalse(); + using var client = Client(identity.Tenant, out var observation); + using (var positive = await Send(client, identity.Tenant, tokens.AccessToken)) + await AccountAsync(positive, identity, observation); + var reads = database.Observation.Reads; + var audits = await AuditCountsAsync(); + + foreach (var path in new[] { TenantAuthenticationHttpRequest.AccountPath, TenantAuthenticationHttpRequest.PublicPath }) + { + using var response = await Send(client, identity.Tenant, tokens.IdToken, path); + await UnauthorizedAsync(response); + } + // Keep purpose valid while independently removing only the API audience. + using var admin = await provider.CreateAdminClientAsync(); + var scope = (await admin.GetAsync("client-scopes")).EnumerateArray() + .Single(value => value.GetProperty("name").GetString() == "learnstack-tenant-api"); + var mapperBase = "client-scopes/" + scope.GetProperty("id").GetString() + "/protocol-mappers/models"; + var mapper = (await admin.GetAsync(mapperBase)).EnumerateArray() + .Single(value => value.GetProperty("name").GetString() == "learnstack-api-audience"); + var mapperPath = mapperBase + "/" + mapper.GetProperty("id").GetString(); + var differentAudience = System.Text.Json.Nodes.JsonNode.Parse(mapper.GetRawText())!.AsObject(); + differentAudience["config"]!["included.client.audience"] = "different-fixture-resource"; + try + { + (await admin.PutAsync(mapperPath, differentAudience)).Should().Be(HttpStatusCode.NoContent); + var wrongAudience = await TokensAsync(identity); + var access = await KeycloakTokenVerifier.ValidateAsync(provider, wrongAudience.AccessToken, "account"); + HasAudience(access, "learnstack-api").Should().BeFalse(); + (access.GetProperty("typ").GetString() == "Bearer" + && access.GetProperty("azp").GetString() == "learnstack-web").Should().BeTrue(); + foreach (var path in new[] { TenantAuthenticationHttpRequest.AccountPath, TenantAuthenticationHttpRequest.PublicPath }) + { + using var response = await Send(client, identity.Tenant, wrongAudience.AccessToken, path); + await UnauthorizedAsync(response); + } + } + finally { (await admin.PutAsync(mapperPath, mapper)).Should().Be(HttpStatusCode.NoContent); } + database.Observation.Reads.Should().Be(reads); + observation.Reads.Should().HaveCount(1); + (await AuditCountsAsync()).Should().Equal(audits); + } + + [Fact] + public async Task A_genuine_Hub_issuer_token_is_rejected_without_changing_the_operator_or_Hub_client() + { + var identity = SeedData.IdentityUsers.First(); + var positiveTokens = await TokensAsync(identity); + using var client = Client(identity.Tenant, out var observation); + using (var positive = await Send(client, identity.Tenant, positiveTokens.AccessToken)) + await AccountAsync(positive, identity, observation); + using var admin = await provider.CreateAdminClientAsync("learnstack-hub"); + var originalOperator = await admin.UserAsync("demo-operator@learnstack.test"); + var originalClient = await admin.ClientAsync("learnstack-hub-web"); + var username = "api-hub-issuer-" + Guid.NewGuid().ToString("N") + "@example.test"; + var created = false; + var reads = database.Observation.Reads; + var audits = await AuditCountsAsync(); + try + { + var status = await CreateUserAsync(admin, username); + created = status == HttpStatusCode.Created; + status.Should().Be(HttpStatusCode.Created); + // This owned unrelated account qualifies a genuine issuer negative, not an operator/MFA journey. + var tokens = await ExchangeAsync(username, "http://localhost:3100/api/auth/callback/keycloak", + realm: "learnstack-hub", clientId: "learnstack-hub-web", secret: null); + var payload = await KeycloakTokenVerifier.ValidateAsync(provider, tokens.AccessToken, + "account", "learnstack-hub"); + payload.GetProperty("iss").GetString().Should().Be(provider.IssuerFor("learnstack-hub")); + foreach (var path in new[] { TenantAuthenticationHttpRequest.AccountPath, TenantAuthenticationHttpRequest.PublicPath }) + { + using var refusal = await Send(client, identity.Tenant, tokens.AccessToken, path); + await UnauthorizedAsync(refusal); + } + database.Observation.Reads.Should().Be(reads); + observation.Reads.Should().HaveCount(1); + (await AuditCountsAsync()).Should().Equal(audits); + } + finally + { + if (created) await DeleteOwnedUserAsync(admin, username); + JsonElement.DeepEquals(await admin.UserAsync("demo-operator@learnstack.test"), originalOperator) + .Should().BeTrue("the original operator's MFA, credentials and required actions are never changed"); + JsonElement.DeepEquals(await admin.ClientAsync("learnstack-hub-web"), originalClient) + .Should().BeTrue("the registered Hub client remains unchanged"); + } + } + + [Fact] + public async Task The_real_backchannel_accepts_only_the_owned_CA_and_preserves_all_transport_bounds() + { + var identity = SeedData.IdentityUsers.First(); + var tokens = await TokensAsync(identity); + using (var client = Client(identity.Tenant, out var observation)) + { + using var positive = await Send(client, identity.Tenant, tokens.AccessToken); + await AccountAsync(positive, identity, observation); + } + using var wrongAuthority = new KeycloakTls(); + using (var backchannel = Backchannel(wrongAuthority.RootCertificate)) + { + var metadata = async () => { using var response = await backchannel.Client.GetAsync(provider.Issuer + "/.well-known/openid-configuration"); }; + var tls = await metadata.Should().ThrowAsync(); + tls.Which.InnerException.Should().BeOfType( + "the actual production backchannel must fail certificate authentication with this CA"); + } + using var wrongClient = Client(identity.Tenant, out var wrongObservation, wrongAuthority.RootCertificate); + var reads = database.Observation.Reads; + var audits = await AuditCountsAsync(); + + using var refusal = await Send(wrongClient, identity.Tenant, tokens.AccessToken); + await UnauthorizedAsync(refusal); + + wrongObservation.Reads.Should().BeEmpty(); + database.Observation.Reads.Should().Be(reads); + (await AuditCountsAsync()).Should().Equal(audits); + } + + [Fact] + public async Task Anonymous_public_reads_and_masked_account_refusals_do_not_contact_an_unavailable_identity_endpoint() + { + var identity = SeedData.IdentityUsers.First(); + var tokens = await TokensAsync(identity); + using (var positiveClient = Client(identity.Tenant, out var positiveObservation)) + { + using var positive = await Send(positiveClient, identity.Tenant, tokens.AccessToken); + await AccountAsync(positive, identity, positiveObservation); + } + await using var unavailable = new UnavailableTenantIssuer(); + using var client = Client(identity.Tenant, out var observation, issuer: unavailable.Issuer); + var audits = await AuditCountsAsync(); + var reads = database.Observation.Reads; + using (var account = await Send(client, identity.Tenant)) + { + await TenantAuthenticationHttpTests.Problem(account, HttpStatusCode.NotFound, "not_found"); + account.Headers.WwwAuthenticate.Should().BeEmpty(); + } + database.Observation.Reads.Should().Be(reads); + using (var anonymous = await Send(client, identity.Tenant, path: TenantAuthenticationHttpRequest.PublicPath)) + await PublicSiteAsync(anonymous, identity.Tenant); + unavailable.Connections.Should().Be(0, "absent credentials never attempt metadata transport"); + observation.Reads.Should().HaveCount(1); + AssertContext(observation.Reads.Last(), identity.Tenant, null, TenantContextOrigin.HostOnly); + + using (var rejected = await Send(client, identity.Tenant, tokens.AccessToken, + TenantAuthenticationHttpRequest.PublicPath)) + await UnauthorizedAsync(rejected); + unavailable.Connections.Should().BeGreaterThan(0, "the endpoint really fails a stock metadata TLS connection"); + observation.Reads.Should().HaveCount(1, "a real credential must not fall back to anonymous after transport failure"); + using (var anonymousAgain = await Send(client, identity.Tenant, path: TenantAuthenticationHttpRequest.PublicPath)) + await PublicSiteAsync(anonymousAgain, identity.Tenant); + observation.Reads.Should().HaveCount(2); + AssertContext(observation.Reads.Last(), identity.Tenant, null, TenantContextOrigin.HostOnly); + database.Observation.Reads.Should().Be(reads + 2); + (await AuditCountsAsync()).Should().Equal(audits); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs new file mode 100644 index 00000000..a31142ec --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs @@ -0,0 +1,447 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Offline drift controls; live provider behavior belongs to the Docker issuance proofs. +public sealed partial class TenantRealmExportTests +{ + [Fact] + public void Identity_manifest_rejects_reassignment_legacy_expansion_and_nonstable_actor_ids() + { + var project = typeof(SeedIdentityManifest).GetMethod("Project", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; + var users = SeedData.IdentityUsers; + project.Invoke(null, [users]).Should().NotBeNull(); + var nonlegacy = users[2]; + nonlegacy.LegacyTenantAttribute.Should().BeNull("actor-format controls must not trip legacy identity binding"); + var variants = new[] + { + users.SetItem(2, users[2] with { LegacyTenantAttribute = "tenant-a" }), + users.SetItem(0, users[0] with { LegacyTenantAttribute = "another-legacy-tenant" }), + users.SetItem(0, users[0] with { Username = "unowned@tenant-a.test" }), + // Preserve both legacy identities so only username uniqueness rejects this variant. + users.SetItem(3, users[3] with { Username = users[2].Username }), + users.SetItem(0, users[0] with { Tenant = SeedData.Yoga }), + // A valid replacement v7 actor violates only this legacy identity's binding. + users.SetItem(0, users[0] with { UserId = LearnStack.SharedKernel.Identifiers.UserId.From( + Guid.Parse("01930000-0000-7000-8000-00000000a003")) }), + // Nonlegacy actors isolate version, reserved-actor, uniqueness and scope collisions. + users.SetItem(2, nonlegacy with { UserId = LearnStack.SharedKernel.Identifiers.UserId.From( + Guid.Parse("01930000-0000-4000-8000-00000000b003")) }), + users.SetItem(2, nonlegacy with { UserId = LearnStack.SharedKernel.Identifiers.UserId.SystemActor }), + users.SetItem(2, nonlegacy with { UserId = users[3].UserId }), + users.SetItem(2, nonlegacy with { UserId = LearnStack.SharedKernel.Identifiers.UserId.From(SeedData.English.TenantId.Value) }), + users.SetItem(2, nonlegacy with { UserId = LearnStack.SharedKernel.Identifiers.UserId.From( + SeedData.Yoga.DefaultOrganization.OrganizationId.Value) }), + }; + foreach (var variant in variants) + { + var act = () => project.Invoke(null, [variant]); + act.Should().Throw() + .Which.InnerException.Should().BeOfType(); + } + project.Invoke(null, [users]).Should().NotBeNull("rejected declarations cannot alter the committed declaration"); + } + + private static readonly string[] ReservedClaims = + ["iss", "sub", "aud", "azp", "typ", "scope", "iat", "exp", "nbf"]; + private static readonly string[] StockAccountRoles = + ["view-profile", "manage-account", "manage-account-links", "view-applications", "view-consent", "manage-consent", "delete-account", "view-groups"]; + private static readonly string[] StockDefaultAccountRoles = ["view-profile", "manage-account"]; + private static readonly string[] StockConsoleScopeRoles = ["manage-account", "view-groups"]; + private static readonly string[] UnsafeOptionalDestinations = + [ + "sub", "aud", "azp", "iss", "iat", "exp", "nbf", "typ", "scope", + "user_id.nested", "tenant_id.nested", "organization_id.nested", @"user\_id", @"tenant\_id.nested", + ]; + private static readonly string[] KnownMapperTypes = + [ + "oidc-acr-mapper", "oidc-address-mapper", "oidc-allowed-origins-mapper", + "oidc-audience-mapper", "oidc-audience-resolve-mapper", "oidc-full-name-mapper", + "oidc-organization-membership-mapper", "oidc-sub-mapper", "oidc-usermodel-attribute-mapper", + "oidc-usermodel-client-role-mapper", "oidc-usermodel-property-mapper", + "oidc-usermodel-realm-role-mapper", "oidc-usersessionmodel-note-mapper", + ]; + [Fact] + public void The_committed_realm_matches_typed_identity_and_security_declarations() + { + Violations(ReadRealm()).Should().BeEmpty(); + var manifest = SeedIdentityManifest.Create(); + manifest.Users.Should().HaveCount(4); + manifest.Users.Select(user => user.UserId).Distinct().Should().HaveCount(4); + foreach (var user in manifest.Users) + { + var tenant = SeedData.All.Single(value => value.TenantId.Value == user.TenantId); + user.Host.Should().Be(tenant.Host); + user.OrganizationId.Should().Be(tenant.MapHostToDefaultOrganization + ? tenant.DefaultOrganization.OrganizationId.Value : null); + } + } + + [Fact] + public void Export_controls_reject_unsafe_mapper_profile_client_and_identity_variants() + { + var original = ReadRealm(); + var mutations = new List<(string Name, Action Apply)> + { + ("wrong realm TTL", realm => realm["accessTokenLifespan"] = 1800), + ("unmanaged profile enabled", realm => SetProfilePolicy(realm, "ENABLED")), + ("malformed profile configuration", realm => ProfileValue(realm)[0] = "{"), + ("default application role composite", realm => realm["roles"]!["realm"]!.AsArray() + .Single(role => Text(role?["name"]) == "default-roles-learnstack")!["composites"]!["realm"] = new JsonArray("tenant-admin")), + ("missing account client", realm => realm["clients"]!.AsArray().Remove(Client(realm, "account"))), + ("console full scope", realm => Client(realm, "account-console")["fullScopeAllowed"] = true), + ("console default broad roles", realm => Client(realm, "account-console")["defaultClientScopes"]!.AsArray().Add("roles")), + ("console optional broad roles", realm => Client(realm, "account-console")["optionalClientScopes"]!.AsArray().Add("roles")), + ("console mapper missing", realm => Client(realm, "account-console")["protocolMappers"]!.AsArray().RemoveAt(1)), + ("console dynamic role mapper", realm => Client(realm, "account-console")["protocolMappers"]![1]!["config"]! + .AsObject().Remove("usermodel.clientRoleMapping.clientId")), + ("console ID token roles", realm => Client(realm, "account-console")["protocolMappers"]![1]!["config"]!["id.token.claim"] = "true"), + ("missing account grant", realm => realm["users"]![0]!.AsObject().Remove("clientRoles")), + ("wider account grant", realm => realm["users"]![0]!["clientRoles"]!["account"]!.AsArray().Add("view-profile")), + ("default realm grant", realm => realm["users"]![0]!["realmRoles"]!.AsArray().Add("default-roles-learnstack")), + ("wider account composite", realm => realm["roles"]!["client"]!["account"]!.AsArray() + .Single(role => Text(role?["name"]) == "manage-account")!["composites"]!["client"]!["account"]!.AsArray().Add("view-profile")), + ("missing stock default role", realm => realm.Remove("defaultRole")), + ("client TTL override", realm => Client(realm, "learnstack-web")["attributes"]!["access.token.lifespan"] = "900"), + ("public client", realm => Client(realm, "learnstack-web")["publicClient"] = true), + ("changed API service secret", realm => Client(realm, "learnstack-api")["secret"] = "different-service-secret"), + ("disabled web client", realm => Client(realm, "learnstack-web")["enabled"] = false), + ("web client consent", realm => Client(realm, "learnstack-web")["consentRequired"] = true), + ("disabled persona", realm => realm["users"]![0]!["enabled"] = false), + ("unverified persona", realm => realm["users"]![0]!["emailVerified"] = false), + ("temporary persona credential", realm => realm["users"]![0]!["credentials"]![0]!["temporary"] = true), + ("literal secret", realm => Client(realm, "learnstack-web")["secret"] = "unowned-source"), + ("password grant", realm => Client(realm, "learnstack-web")["directAccessGrantsEnabled"] = true), + ("API browser flow", realm => Client(realm, "learnstack-api")["standardFlowEnabled"] = true), + ("wildcard callback", realm => Client(realm, "learnstack-web")["redirectUris"]![0] = "https://*.invalid/*"), + ("missing attached scope", realm => + { + var scopes = Client(realm, "learnstack-web")["defaultClientScopes"]!.AsArray(); + scopes.Remove(scopes.Single(scope => Text(scope) == "learnstack-tenant-api")); + }), + ("missing basic scope", realm => Scopes(realm).Remove(Scope(realm, "basic"))), + ("missing sub mapper", realm => Scope(realm, "basic")["protocolMappers"] = new JsonArray()), + ("missing actor mapper", realm => Mappers(realm).RemoveAt(0)), + ("array identity", realm => Mapper(realm, "user_id")["config"]!["multivalued"] = "true"), + ("nonstring identity", realm => Mapper(realm, "tenant_id")["config"]!["jsonType.label"] = "long"), + ("ID token API audience", realm => Mapper(realm, "learnstack-api-audience")["config"]!["id.token.claim"] = "true"), + ("missing access audience", realm => Mapper(realm, "learnstack-api-audience")["config"]!["access.token.claim"] = "false"), + ("duplicate identity mapper", realm => Mappers(realm).Add(Mapper(realm, "user_id").DeepClone())), + ("direct client identity collision", realm => Client(realm, "learnstack-web")["protocolMappers"] = + new JsonArray(Mapper(realm, "user_id").DeepClone())), + ("optional scope identity collision", realm => Scope(realm, "phone")["protocolMappers"]!.AsArray() + .Add(Mapper(realm, "tenant_id").DeepClone())), + ("wrong actor UUID", realm => realm["users"]![0]!["attributes"]!["user_id"]![0] = Guid.Empty.ToString("D")), + ("array tenant identity", realm => realm["users"]![0]!["attributes"]!["tenant_id"]!.AsArray().Add("other")), + ("missing user", realm => realm["users"]!.AsArray().RemoveAt(3)), + ("writable actor", realm => ChangeProfile(realm, "user_id", "edit")), + ("visible tenant", realm => ChangeProfile(realm, "tenant_id", "view")), + ("writable organization", realm => ChangeProfile(realm, "organization_id", "edit")), + ("optional implicit subject", realm => Scope(realm, "phone")["protocolMappers"]!.AsArray().Add( + Scope(realm, "basic")["protocolMappers"]!.AsArray().Single(mapper => + Text(mapper?["protocolMapper"]) == "oidc-sub-mapper")!.DeepClone())), + ("optional implicit API audience", realm => Scope(realm, "phone")["protocolMappers"]!.AsArray() + .Add(Mapper(realm, "learnstack-api-audience").DeepClone())), + ("optional audience resolver", realm => Scope(realm, "phone")["protocolMappers"]!.AsArray().Add( + Scope(realm, "roles")["protocolMappers"]!.AsArray().Single(mapper => + Text(mapper?["protocolMapper"]) == "oidc-audience-resolve-mapper")!.DeepClone())), + ("role audience in ID token", realm => Scope(realm, "roles")["protocolMappers"]!.AsArray().Single(mapper => + Text(mapper?["protocolMapper"]) == "oidc-audience-resolve-mapper")!["config"]!["id.token.claim"] = "true"), + ("unknown implicit mapper", realm => Scope(realm, "phone")["protocolMappers"]!.AsArray().Add( + new JsonObject { ["name"] = "unqualified", ["protocolMapper"] = "unqualified-provider-mapper" })), + }; + foreach (var destination in UnsafeOptionalDestinations) + mutations.Add(("optional reserved destination " + destination, realm => AddOptionalClaim(realm, destination))); + foreach (var clientId in new[] { "user_id", "sub", "aud" }) + mutations.Add(("interpolated client-role destination " + clientId, realm => + { + realm["clients"]!.AsArray().Add(new JsonObject { ["clientId"] = clientId }); + AddOptionalClientRoleClaim(realm, "${client_id}", clientId); + } + )); + Violations(original).Should().BeEmpty("the control input is the real export"); + foreach (var (name, apply) in mutations) + { + var mutant = original.DeepClone().AsObject(); + apply(mutant); + Violations(mutant).Should().NotBeEmpty(name); + } + Violations(original).Should().BeEmpty("mutants cannot contaminate the control"); + var escapedLiteral = original.DeepClone().AsObject(); + AddOptionalClaim(escapedLiteral, @"user_id\.nested"); + Violations(escapedLiteral).Should().BeEmpty("an escaped dot produces a separate literal key, not an identity object"); + var nestedClientRoles = original.DeepClone().AsObject(); + AddOptionalClientRoleClaim(nestedClientRoles, "resource_access.${client_id}.roles", "learnstack-api"); + Violations(nestedClientRoles).Should().BeEmpty("the pinned built-in client-role path has a fixed safe root"); + } + + internal static IReadOnlyList Violations(JsonObject realm) + { + var failures = new List(); + void Require(bool value, string reason) { if (!value) failures.Add(reason); } + try + { + Require(Text(realm["realm"]) == "learnstack", "tenant realm"); + Require(realm["accessTokenLifespan"]?.GetValue() == 900, "realm lifespan"); + ValidateAccountDeclaration(realm, Require); + var scopeNames = Scopes(realm).Select(scope => Text(scope?["name"])).ToArray(); + Require(scopeNames.Distinct(StringComparer.Ordinal).Count() == scopeNames.Length, "unique scope names"); + var identityNames = new[] { "user_id", "tenant_id", "organization_id" }; + foreach (var clientId in new[] { "learnstack-web", "learnstack-api" }) + { + var client = Client(realm, clientId); + Require(client["publicClient"]?.GetValue() == false, "confidential client"); + Require(client["implicitFlowEnabled"]?.GetValue() == false, "implicit flow disabled"); + Require(client["directAccessGrantsEnabled"]?.GetValue() == false, "password grant disabled"); + Require(client["attributes"]?["access.token.lifespan"] is null, "no client TTL override"); + Require(Strings(client["webOrigins"]).Length == 0, "server-only client"); + var defaults = Strings(client["defaultClientScopes"]); + Require(defaults.Contains("basic", StringComparer.Ordinal) + && defaults.Contains("learnstack-tenant-api", StringComparer.Ordinal), "required default scopes"); + var effectiveScopes = defaults.Concat(Strings(client["optionalClientScopes"])).ToArray(); + Require(effectiveScopes.All(scopeNames.Contains), "all attached scopes actually declared"); + var effectiveMappers = effectiveScopes.SelectMany(name => + Scope(realm, name)["protocolMappers"]?.AsArray() ?? []).Concat( + client["protocolMappers"]?.AsArray() ?? []).ToArray(); + foreach (var name in identityNames) + Require(effectiveMappers.Count(mapper => ClaimRoot(Text(mapper?["config"]?["claim.name"])) == name) == 1, + "one effective identity mapper"); + Require(effectiveMappers.All(mapper => KnownMapperTypes.Contains(Text(mapper?["protocolMapper"]), StringComparer.Ordinal)), + "every effective mapper type is qualified against the pinned provider"); + Require(effectiveMappers.All(mapper => !ReservedClaims.Contains( + ClaimRoot(Text(mapper?["config"]?["claim.name"])), StringComparer.Ordinal)), + "no configurable mapper overrides a reserved token property"); + Require(effectiveMappers.All(mapper => + Text(mapper?["protocolMapper"]) != "oidc-usermodel-client-role-mapper" + || !ClaimRoot(Text(mapper?["config"]?["claim.name"])).Contains("${client_id}", StringComparison.Ordinal)), + "client-role interpolation cannot select a dynamic top-level claim"); + foreach (var type in new[] { "oidc-sub-mapper", "oidc-audience-mapper", "oidc-audience-resolve-mapper" }) + Require(effectiveMappers.Count(mapper => Text(mapper?["protocolMapper"]) == type) == 1, + "one qualified implicit subject/audience writer"); + var resolver = effectiveMappers.Single(mapper => Text(mapper?["protocolMapper"]) == "oidc-audience-resolve-mapper"); + Require(Text(resolver?["config"]?["access.token.claim"]) == "true" + && Text(resolver?["config"]?["id.token.claim"]) != "true", "role audience resolution is access-only"); + if (clientId == "learnstack-web") + { + Require(client["enabled"]?.GetValue() == true + && client["consentRequired"]?.GetValue() != true, "available nonconsent web login"); + Require(client["standardFlowEnabled"]?.GetValue() == true, "web code flow"); + Require(client["serviceAccountsEnabled"]?.GetValue() == false, "no web service account"); + Require(Text(client["secret"]) == "${KEYCLOAK_WEB_CLIENT_SECRET}", "private secret placeholder"); + Require(Text(client["attributes"]?["pkce.code.challenge.method"]) == "S256", "S256 required"); + Require(Strings(client["redirectUris"]).Order().SequenceEqual(SeedData.All.Select(tenant => + "https://" + tenant.Host + ":3000/api/auth/callback/keycloak").Order()), "exact callbacks"); + Require(Text(client["attributes"]?["post.logout.redirect.uris"]).Split("##").Order() + .SequenceEqual(SeedData.All.Select(tenant => "https://" + tenant.Host + ":3000/").Order()), + "exact logout roots"); + } + else + { + Require(Text(client["secret"]) == "learnstack-api-dev-secret", "retained development service credential"); + Require(client["standardFlowEnabled"]?.GetValue() == false, "no API code flow"); + Require(Strings(client["redirectUris"]).Length == 0, "no API callbacks"); + Require(client["serviceAccountsEnabled"]?.GetValue() == true, "retain legacy service account"); + } + } + Require(Scope(realm, "basic")["protocolMappers"]!.AsArray().Any(mapper => + Text(mapper?["protocolMapper"]) == "oidc-sub-mapper" + && Text(mapper?["config"]?["access.token.claim"]) == "true"), "actual subject mapper"); + Require(Mappers(realm).Count == 4, "closed managed scope"); + foreach (var name in identityNames) + { + var mapper = Mapper(realm, name); + Require(Text(mapper["protocolMapper"]) == "oidc-usermodel-attribute-mapper", "attribute mapper type"); + var config = mapper["config"]!; + Require(Text(config["user.attribute"]) == name && Text(config["claim.name"]) == name, + "identity source and claim"); + Require(Text(config["jsonType.label"]) == "String" && Text(config["multivalued"]) == "false", + "scalar string mapper"); + Require(Text(config["access.token.claim"]) == "true" && Text(config["id.token.claim"]) == "true" + && Text(config["userinfo.token.claim"]) == "false", "identity token destinations"); + } + var audience = Mapper(realm, "learnstack-api-audience"); + Require(Text(audience["protocolMapper"]) == "oidc-audience-mapper" + && Text(audience["config"]?["included.client.audience"]) == "learnstack-api" + && Text(audience["config"]?["access.token.claim"]) == "true" + && Text(audience["config"]?["id.token.claim"]) == "false", "access-only API audience"); + var profile = Profile(realm); + Require(profile["unmanagedAttributePolicy"] is null, "disabled unmanaged profile policy"); + foreach (var name in identityNames) + { + var attribute = profile["attributes"]!.AsArray().Single(value => Text(value?["name"]) == name)!; + Require(attribute["multivalued"]?.GetValue() == false, "single-valued profile identity"); + foreach (var permission in new[] { "view", "edit" }) + Require(Strings(attribute["permissions"]?[permission]).SequenceEqual(["admin"]), "admin-only identity"); + } + var users = realm["users"]!.AsArray(); + Require(users.Count == SeedData.IdentityUsers.Length, "exact seed population"); + foreach (var identity in SeedIdentityManifest.Create().Users) + { + var user = users.Single(value => Text(value?["username"]) == identity.Username)!; + Require(Text(user["email"]) == identity.Email, "declared email"); + Require(user["enabled"]?.GetValue() == true && user["emailVerified"]?.GetValue() == true, + "enabled verified development persona"); + var credentials = user["credentials"]!.AsArray(); + Require(credentials.Count == 1 && Text(credentials[0]?["type"]) == "password" + && Text(credentials[0]?["value"]) == "demo-dev-secret" + && credentials[0]?["temporary"]?.GetValue() == false, "one create-only development password"); + Require(identity.AccountCapability == new SeedAccountCapability("account", "manage-account") + && Strings(user["clientRoles"]?[identity.AccountCapability.ClientId]).SequenceEqual([identity.AccountCapability.Role]) + && user["clientRoles"]!.AsObject().Count == 1, "exact declared self-service capability"); + Require(Strings(user["realmRoles"]).SequenceEqual([identity.RealmRole]), "no implicit default or application role grant"); + Require(user["id"] is null, "provider owns account ID"); + var attributes = user["attributes"]!; + Require(Strings(attributes["user_id"]).SequenceEqual([identity.UserId.ToString("D")]), "fixed actor"); + Require(Strings(attributes["tenant_id"]).SequenceEqual([identity.TenantId.ToString("D")]), "tenant identity"); + Require(identity.OrganizationId is { } organization + ? Strings(attributes["organization_id"]).SequenceEqual([organization.ToString("D")]) + : attributes["organization_id"] is null, "organization identity"); + } + } + catch (Exception exception) when (exception is InvalidOperationException or KeyNotFoundException + or FormatException or ArgumentException or NullReferenceException or System.Text.Json.JsonException) + { + failures.Add("invalid or ambiguous export shape"); + } + return failures; + } + + private static void ValidateAccountDeclaration(JsonObject realm, Action require) + { + foreach (var name in new[] { "account", "account-console" }) + { + var client = Client(realm, name); + require(client["publicClient"]!.GetValue() && client["enabled"]!.GetValue() + && client["standardFlowEnabled"]!.GetValue() && !client["fullScopeAllowed"]!.GetValue() + && !client["directAccessGrantsEnabled"]!.GetValue() && !client["implicitFlowEnabled"]!.GetValue() + && !client["serviceAccountsEnabled"]!.GetValue(), "stock account client modes"); + require(Text(client["rootUrl"]) == "${authBaseUrl}" && Text(client["baseUrl"]) == "/realms/learnstack/account/" + && Strings(client["redirectUris"]).SequenceEqual(["/realms/learnstack/account/*"]) + && Strings(client["webOrigins"]).Length == 0 + && Text(client["attributes"]?["post.logout.redirect.uris"]) == "+", "stock account callbacks"); + require(Strings(client["defaultClientScopes"]).Length == 0 && Strings(client["optionalClientScopes"]).Length == 0, + "no broad account role scopes"); + } + var console = Client(realm, "account-console"); + require(Text(console["attributes"]?["pkce.code.challenge.method"]) == "S256", "account S256"); + var mappers = console["protocolMappers"]!.AsArray(); + require(mappers.Count == 2 && mappers.Count(value => Text(value?["protocolMapper"]) == "oidc-audience-resolve-mapper") == 1, + "stock account resolver and one fixed emitter"); + var mapper = mappers.Single(value => Text(value?["name"]) == "learnstack-account-self-service-roles")!; + var expected = new Dictionary + { + ["usermodel.clientRoleMapping.clientId"] = "account", + ["usermodel.clientRoleMapping.rolePrefix"] = "", + ["claim.name"] = "resource_access.account.roles", + ["jsonType.label"] = "String", + ["multivalued"] = "true", + ["access.token.claim"] = "true", + ["introspection.token.claim"] = "true", + ["id.token.claim"] = "false", + ["userinfo.token.claim"] = "false", + ["lightweight.claim"] = "false", + }; + require(Text(mapper["protocolMapper"]) == "oidc-usermodel-client-role-mapper" + && mapper["config"]!.AsObject().Count == expected.Count + && expected.All(entry => Text(mapper["config"]?[entry.Key]) == entry.Value), "closed fixed-account emitter"); + var roles = realm["roles"]!["client"]!["account"]!.AsArray(); + require(roles.Select(role => Text(role?["name"])).Order().SequenceEqual(StockAccountRoles.Order()), + "eight stock account roles"); + foreach (var role in roles) + { + var name = Text(role?["name"]); + var child = name switch { "manage-account" => "manage-account-links", "manage-consent" => "view-consent", _ => null }; + require(role!["clientRole"]!.GetValue() && role["composite"]!.GetValue() == (child is not null) + && Text(role["description"]) == "${role_" + name + "}", "stock account role definitions"); + require(child is null ? role["composites"] is null : + role["composites"]!.AsObject().Count == 1 && role["composites"]!["client"]!.AsObject().Count == 1 + && Strings(role["composites"]!["client"]!["account"]).SequenceEqual([child]), "bounded stock account composites"); + } + require(Text(realm["defaultRole"]?["name"]) == "default-roles-learnstack", "stock default role identity"); + var defaults = realm["roles"]!["realm"]!.AsArray().Single(role => Text(role?["name"]) == "default-roles-learnstack")!; + require(Strings(defaults["composites"]?["client"]?["account"]).Order().SequenceEqual(StockDefaultAccountRoles.Order()), + "stock registration default account roles"); + require(Strings(defaults["composites"]?["realm"]).Length == 0 + && defaults["composites"]?["client"]?.AsObject().Count == 1, "no default application composites"); + var allowance = realm["clientScopeMappings"]!["account"]!.AsArray(); + require(allowance.Count == 1 && Text(allowance[0]?["client"]) == "account-console" + && Strings(allowance[0]?["roles"]).Order().SequenceEqual(StockConsoleScopeRoles.Order()), + "stock console scope allowance"); + } + + private static string Text(JsonNode? node) => node?.GetValue() ?? ""; + private static string[] Strings(JsonNode? node) => node?.AsArray().Select(Text).ToArray() ?? []; + private static JsonArray Scopes(JsonObject realm) => realm["clientScopes"]!.AsArray(); + private static JsonObject Scope(JsonObject realm, string name) => + Scopes(realm).Single(scope => Text(scope?["name"]) == name)!.AsObject(); + private static JsonObject Client(JsonObject realm, string name) => realm["clients"]!.AsArray() + .Single(client => Text(client?["clientId"]) == name)!.AsObject(); + private static JsonArray Mappers(JsonObject realm) => Scope(realm, "learnstack-tenant-api")["protocolMappers"]!.AsArray(); + private static JsonObject Mapper(JsonObject realm, string name) => + Mappers(realm).Single(mapper => Text(mapper?["name"]) == name)!.AsObject(); + private static void AddOptionalClaim(JsonObject realm, string destination) + { + var mapper = Mapper(realm, "user_id").DeepClone().AsObject(); + mapper["name"] = "additional-optional-claim"; + mapper["config"]!["claim.name"] = destination; + Scope(realm, "phone")["protocolMappers"]!.AsArray().Add(mapper); + } + + private static void AddOptionalClientRoleClaim(JsonObject realm, string destination, string clientId) + { + var mapper = Scope(realm, "roles")["protocolMappers"]!.AsArray().Single(value => + Text(value?["protocolMapper"]) == "oidc-usermodel-client-role-mapper")!.DeepClone().AsObject(); + mapper["name"] = "additional-optional-client-role"; + mapper["config"]!["claim.name"] = destination; + mapper["config"]!["usermodel.clientRoleMapping.clientId"] = clientId; + Scope(realm, "phone")["protocolMappers"]!.AsArray().Add(mapper); + } + + // Keycloak 26.6.2 JsonUtils.splitClaimPath: backslash escapes any character, + // and an unescaped dot terminates a nonempty component. Leading dots stay literal. + private static string ClaimRoot(string claim) + { + var component = ClaimComponent().Match(claim); + return BackslashedCharacter().Replace(component.Success ? component.Groups[1].Value : claim, "$1"); + } + + [GeneratedRegex(@"^((\\.|[^\\.])+?)\.", RegexOptions.CultureInvariant, 1000)] + private static partial Regex ClaimComponent(); + + [GeneratedRegex(@"\\(.)", RegexOptions.CultureInvariant, 1000)] + private static partial Regex BackslashedCharacter(); + private static JsonNode ProfileValue(JsonObject realm) => realm["components"]! + ["org.keycloak.userprofile.UserProfileProvider"]![0]!["config"]!["kc.user.profile.config"]!; + private static JsonObject Profile(JsonObject realm) => JsonNode.Parse(Text(ProfileValue(realm)[0]))!.AsObject(); + private static void SetProfilePolicy(JsonObject realm, string policy) + { + var profile = Profile(realm); + profile["unmanagedAttributePolicy"] = policy; + ProfileValue(realm)[0] = profile.ToJsonString(); + } + + private static void ChangeProfile(JsonObject realm, string name, string permission) + { + var profile = Profile(realm); + profile["attributes"]!.AsArray().Single(value => Text(value?["name"]) == name)! + ["permissions"]![permission] = new JsonArray("admin", "user"); + ProfileValue(realm)[0] = profile.ToJsonString(); + } + + private static JsonObject ReadRealm() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, "infra", "keycloak", "realms", "learnstack.json"); + if (File.Exists(path)) return JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + directory = directory.Parent; + } + throw new InvalidOperationException("Repository realm export is missing."); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmIssuanceTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmIssuanceTests.cs new file mode 100644 index 00000000..e56fea00 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmIssuanceTests.cs @@ -0,0 +1,564 @@ +using System.Net; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Nodes; +using Docker.DotNet.Models; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Xunit; +using Xunit.Abstractions; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Committed realm import and real confidential code/S256 issuance, before any BFF implementation. +[Collection(SharedKeycloakRealm.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantRealmIssuanceTests(KeycloakFixture fixture, ITestOutputHelper output) +{ + [Fact] + public async Task Fixture_HTTPS_requires_its_owned_certificate_authority() + { + output.WriteLine("Owned provider startup wall time: {0:F3} seconds", fixture.StartupElapsed.TotalSeconds); + await fixture.AssertLoopbackBindingsAsync(); + var inspection = new ContainerInspectResponse + { + HostConfig = new HostConfig + { + PortBindings = new Dictionary> + { ["8443/tcp"] = [new() { HostIP = "127.0.0.1", HostPort = "" }] }, + }, + NetworkSettings = new NetworkSettings + { + Ports = new Dictionary> + { ["8443/tcp"] = [new() { HostIP = "127.0.0.1", HostPort = "34443" }] }, + }, + }; + ContainerLoopbackPorts.IsExclusive(inspection, "8443/tcp").Should().BeTrue(); + foreach (var address in new[] { "", "0.0.0.0", "::", "192.0.2.1" }) + { + inspection.NetworkSettings.Ports["8443/tcp"][0].HostIP = address; + ContainerLoopbackPorts.IsExclusive(inspection, "8443/tcp").Should().BeFalse( + "client-loopback URI checks cannot substitute for the actual Docker publication"); + } + using var trusted = KeycloakHttp.Create(fixture.BaseAddress, fixture.RootCertificate); + using (var positive = await KeycloakHttp.SendAsync(trusted, HttpMethod.Get, + "realms/learnstack/.well-known/openid-configuration")) + positive.StatusCode.Should().Be(HttpStatusCode.OK); + using var otherAuthority = new KeycloakTls(); + using var untrusted = KeycloakHttp.Create(fixture.BaseAddress, otherAuthority.RootCertificate); + var rejectedByTls = false; + try + { + using var response = await untrusted.GetAsync("realms/learnstack/.well-known/openid-configuration"); + } + catch (HttpRequestException exception) when (exception.InnerException is AuthenticationException) + { + rejectedByTls = true; + } + catch (Exception) + { + throw new InvalidOperationException("Keycloak fixture TLS negative control failed outside certificate authentication."); + } + rejectedByTls.Should().BeTrue("a different owned CA cannot authenticate the same provider certificate"); + using var stillTrusted = await KeycloakHttp.SendAsync(trusted, HttpMethod.Get, + "realms/learnstack/.well-known/openid-configuration"); + stillTrusted.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Fresh_import_issues_the_API_profile_for_all_four_declared_identities() + { + using var admin = await fixture.CreateAdminClientAsync(); + var subjects = new HashSet(StringComparer.Ordinal); + foreach (var identity in SeedData.IdentityUsers) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + AssertAuthorized(authorization); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + (exchange.AccessToken.Length > 0 && exchange.IdToken.Length > 0 && exchange.RefreshToken.Length > 0) + .Should().BeTrue("the confidential code exchange returns all three credentials"); + var access = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + var account = await admin.UserAsync(identity.Username); + var subject = access.GetProperty("sub").GetString(); + (subject == account.GetProperty("id").GetString()).Should().BeTrue("sub remains the real provider account ID"); + subjects.Add(subject ?? throw new InvalidOperationException("Provider subject is missing.")); + AssertIdentity(access, identity); + AssertScalar(access, "typ", "Bearer"); + AssertScalar(access, "azp", "learnstack-web"); + (access.GetProperty("iss").GetString() == fixture.Issuer).Should().BeTrue("the issuer uses the mapped loopback endpoint"); + HasAudience(access, "learnstack-api").Should().BeTrue(); + var header = KeycloakTokenVerifier.Header(exchange.AccessToken); + header.EnumerateObject().Select(property => property.Name).Should().BeEquivalentTo(["alg", "typ", "kid"]); + AssertScalar(header, "alg", "RS256"); + AssertScalar(header, "typ", "JWT"); + string.IsNullOrEmpty(header.GetProperty("kid").GetString()).Should().BeFalse(); + exchange.Window.AcceptsFresh(access, 900).Should().BeTrue("signed expiration fits the observed UTC/monotonic exchange window"); + AssertExpirationControls(access, exchange.Window); + + var idToken = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.IdToken, "learnstack-web"); + AssertIdentity(idToken, identity); + exchange.Window.AcceptsFresh(idToken, 900).Should().BeTrue("signed ID-token expiration fits the same observed issuance window"); + (idToken.GetProperty("exp").GetInt64() == access.GetProperty("exp").GetInt64()).Should().BeTrue( + "the pinned provider copies access-token expiration into the ID token from the same exchange"); + AssertExpirationControls(idToken, exchange.Window); + FreshProfileMatches(access, idToken, identity, subject, exchange.Window).Should().BeTrue(); + foreach (var defect in new[] { "missing_subject", "wrong_subject", "wrong_party", "wrong_expiration" }) + { + var mutant = JsonNode.Parse(idToken.GetRawText())!.AsObject(); + if (defect == "missing_subject") mutant.Remove("sub"); + else if (defect == "wrong_subject") mutant["sub"] = "another-provider-subject"; + else if (defect == "wrong_party") mutant["azp"] = "another-client"; + else + { + // Keep this mismatch inside the fresh window so equality is the only violated expiration condition. + var expiration = exchange.Window.StartedUtc.ToUnixTimeSeconds() + 900; + mutant["exp"] = expiration == access.GetProperty("exp").GetInt64() ? expiration + 1 : expiration; + } + using var invalid = JsonDocument.Parse(mutant.ToJsonString()); + if (defect == "wrong_expiration") + { + exchange.Window.AcceptsFresh(access, 900).Should().BeTrue("the unchanged access token remains fresh"); + exchange.Window.AcceptsFresh(invalid.RootElement, 900).Should().BeTrue( + "the planted ID-token expiration differs while still fitting the fresh issuance window"); + } + FreshProfileMatches(access, invalid.RootElement, identity, subject, exchange.Window).Should().BeFalse( + "the same profile predicate must detect an ID-token subject, authorized-party or expiration defect"); + } + AssertScalar(idToken, "typ", "ID"); + HasAudience(idToken, "learnstack-api").Should().BeFalse("the explicit API audience belongs only in access tokens"); + } + subjects.Count.Should().Be(SeedData.IdentityUsers.Length); + } + + [Fact] + public async Task Live_import_has_exact_lifespan_callbacks_and_confidential_PKCE_configuration() + { + using var admin = await fixture.CreateAdminClientAsync(); + var realm = await admin.GetAsync(); + realm.GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + var web = await admin.ClientAsync("learnstack-web"); + web.GetProperty("publicClient").GetBoolean().Should().BeFalse(); + web.GetProperty("standardFlowEnabled").GetBoolean().Should().BeTrue(); + web.GetProperty("implicitFlowEnabled").GetBoolean().Should().BeFalse(); + web.GetProperty("directAccessGrantsEnabled").GetBoolean().Should().BeFalse(); + web.GetProperty("serviceAccountsEnabled").GetBoolean().Should().BeFalse(); + AssertScalar(web.GetProperty("attributes"), "pkce.code.challenge.method", "S256"); + var redirects = web.GetProperty("redirectUris").EnumerateArray().Select(value => value.GetString()).ToArray(); + redirects.Should().BeEquivalentTo(SeedData.All.Select(tenant => + "https://" + tenant.Host + ":3000/api/auth/callback/keycloak")); + var logout = web.GetProperty("attributes").GetProperty("post.logout.redirect.uris").GetString(); + (logout ?? throw new InvalidOperationException("Provider logout registrations are missing.")) + .Split("##", StringSplitOptions.None).Should().BeEquivalentTo( + SeedData.All.Select(tenant => "https://" + tenant.Host + ":3000/")); + var secret = await admin.GetAsync("clients/" + RequiredId(web) + "/client-secret"); + (secret.GetProperty("value").GetString() == KeycloakFixture.WebClientSecret) + .Should().BeTrue("the committed import substitutes the private fixture source"); + + var api = await admin.ClientAsync("learnstack-api"); + api.GetProperty("standardFlowEnabled").GetBoolean().Should().BeFalse(); + api.GetProperty("redirectUris").GetArrayLength().Should().Be(0); + foreach (var client in new[] { web, api }) + { + client.GetProperty("attributes").TryGetProperty("access.token.lifespan", out _).Should().BeFalse(); + var defaults = await admin.GetAsync("clients/" + RequiredId(client) + "/default-client-scopes"); + var scopes = defaults.EnumerateArray().ToArray(); + scopes.Select(scope => scope.GetProperty("name").GetString()).Should().Contain("learnstack-tenant-api"); + var basic = scopes.Single(scope => scope.GetProperty("name").GetString() == "basic"); + var mappers = await admin.GetAsync("client-scopes/" + RequiredId(basic) + "/protocol-mappers/models"); + mappers.EnumerateArray().Any(mapper => mapper.GetProperty("protocolMapper").GetString() == "oidc-sub-mapper") + .Should().BeTrue("actual basic scope must retain its subject mapper"); + } + } + + [Theory] + [InlineData("missing")] + [InlineData("plain")] + public async Task Authorization_refuses_a_missing_or_plain_challenge(string challenge) + { + var identity = SeedData.IdentityUsers[0]; + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, + Callback(identity), challenge == "plain" ? KeycloakPkce.Plain : KeycloakPkce.Missing); + authorization.Authorized.Should().BeFalse(); + authorization.Error.Should().Be("invalid_request"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Code_exchange_refuses_a_missing_or_wrong_client_secret(bool missing) + { + var identity = SeedData.IdentityUsers[0]; + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + AssertAuthorized(authorization); + var exchange = await browser.RedeemAsync(authorization, missing ? null : "unmatched-fixture-secret"); + exchange.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + exchange.Error.Should().Be("unauthorized_client"); + (exchange.AccessToken.Length == 0).Should().BeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Code_exchange_refuses_a_missing_or_wrong_verifier(bool missing) + { + var identity = SeedData.IdentityUsers[0]; + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + AssertAuthorized(authorization); + var exchange = await browser.RedeemAsync(authorization, verifier: new string('v', 43), omitVerifier: missing); + exchange.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exchange.Error.Should().Be("invalid_grant"); + (exchange.AccessToken.Length == 0).Should().BeTrue(); + } + + [Fact] + public async Task Authorization_refuses_an_unregistered_callback() + { + var identity = SeedData.IdentityUsers[0]; + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, + "https://unregistered.learnstack.local:3000/api/auth/callback/keycloak"); + authorization.Authorized.Should().BeFalse(); + authorization.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Tenant_password_grant_stays_disabled() + { + using var browser = fixture.Browser(); + var refusal = await browser.RequestTokenAsync(new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "learnstack-web", + ["client_secret"] = KeycloakFixture.WebClientSecret, + ["username"] = SeedData.IdentityUsers[0].Username, + ["password"] = KeycloakFixture.DemoPassword, + }); + refusal.StatusCode.Should().Be(HttpStatusCode.BadRequest); + refusal.Error.Should().Be("unauthorized_client"); + (refusal.AccessToken.Length == 0).Should().BeTrue(); + } + + [Fact] + public async Task Actual_signed_tokens_detect_live_mapper_scope_audience_scalar_and_lifespan_mutants() + { + // Mutation controls own their complete provider database. Shared import users/configuration stay pristine. + await using var owned = await KeycloakFixture.StartAsync(); + using var admin = await owned.CreateAdminClientAsync(); + var identity = SeedData.IdentityUsers.First(user => user.Tenant.MapHostToDefaultOrganization); + var subject = RequiredId(await admin.UserAsync(identity.Username)); + var scopes = await admin.GetAsync("client-scopes"); + var tenantScope = scopes.EnumerateArray().Single(scope => scope.GetProperty("name").GetString() == "learnstack-tenant-api"); + var basicScope = scopes.EnumerateArray().Single(scope => scope.GetProperty("name").GetString() == "basic"); + var tenantId = RequiredId(tenantScope); + var basicId = RequiredId(basicScope); + var web = await admin.ClientAsync("learnstack-web"); + var webId = RequiredId(web); + var unusedName = "unattached-proof-" + Guid.NewGuid().ToString("N"); + (await admin.PostAsync("client-scopes", new { name = unusedName, protocol = "openid-connect" })) + .Should().Be(HttpStatusCode.Created); + scopes = await admin.GetAsync("client-scopes"); + var unusedId = RequiredId(scopes.EnumerateArray().Single(scope => scope.GetProperty("name").GetString() == unusedName)); + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue(); + + // Deleted mappers and identical declarations attached to an unrequested scope both lose real claims. + foreach (var target in new[] { (tenantId, "user_id"), (tenantId, "learnstack-api-audience"), (basicId, "sub") }) + foreach (var misattached in new[] { false, true }) + { + var original = await MapperAsync(admin, target.Item1, target.Item2); + try + { + (await admin.DeleteAsync(MapperPath(target.Item1, RequiredId(original)))).Should().Be(HttpStatusCode.NoContent); + if (misattached) + (await admin.PostAsync(MapperPath(unusedId), WithoutId(original))).Should().Be(HttpStatusCode.Created); + (await ProbeFreshProfileAsync(owned, identity, subject, "missing_" + target.Item2)).Should().BeFalse( + "real signed issuance must detect a deleted or misattached identity/audience/basic mapper"); + } + finally + { + if (misattached) + { + var moved = await MapperAsync(admin, unusedId, target.Item2); + (await admin.DeleteAsync(MapperPath(unusedId, RequiredId(moved)))).Should().Be(HttpStatusCode.NoContent); + } + (await admin.PostAsync(MapperPath(target.Item1), WithoutId(original))).Should().Be(HttpStatusCode.Created); + } + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue("restored provider issuance is the recovery control"); + } + + foreach (var scopeId in new[] { tenantId, basicId }) + { + try + { + (await admin.DeleteAsync("clients/" + webId + "/default-client-scopes/" + scopeId)).Should().Be(HttpStatusCode.NoContent); + (await admin.PutAsync("clients/" + webId + "/optional-client-scopes/" + scopeId, new { })).Should().Be(HttpStatusCode.NoContent); + (await ProbeFreshProfileAsync(owned, identity, subject, scopeId == tenantId ? "missing_identity_scope" : "missing_sub")).Should().BeFalse( + "an optional scope omitted from the actual request is not an attached default scope"); + } + finally + { + (await admin.DeleteAsync("clients/" + webId + "/optional-client-scopes/" + scopeId)).Should().Be(HttpStatusCode.NoContent); + (await admin.PutAsync("clients/" + webId + "/default-client-scopes/" + scopeId, new { })).Should().Be(HttpStatusCode.NoContent); + } + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue(); + } + + foreach (var mutation in new[] { "array", "not_uuid", "wrong_uuid", "duplicate_conflict", "id_audience" }) + { + var name = mutation == "id_audience" ? "learnstack-api-audience" : "user_id"; + var original = await MapperAsync(admin, tenantId, name); + var dirty = JsonNode.Parse(original.GetRawText())!.AsObject(); + var config = dirty["config"]!.AsObject(); + switch (mutation) + { + case "array": config["multivalued"] = "true"; break; + case "not_uuid": + dirty["protocolMapper"] = "oidc-usermodel-property-mapper"; + config["user.attribute"] = "email"; + break; + case "wrong_uuid": config["user.attribute"] = "tenant_id"; break; + case "duplicate_conflict": config["multivalued"] = "true"; break; + case "id_audience": config["id.token.claim"] = "true"; break; + } + string? duplicateId = null; + try + { + (await admin.PutAsync(MapperPath(tenantId, RequiredId(original)), dirty)).Should().Be(HttpStatusCode.NoContent); + if (mutation == "duplicate_conflict") + { + // Both emitters deliberately merge into an array, independent of mapper iteration order. + var duplicate = dirty.DeepClone().AsObject(); + duplicate.Remove("id"); + duplicate["name"] = "conflicting-user-id-proof"; + duplicate["config"]!["user.attribute"] = "tenant_id"; + (await admin.PostAsync(MapperPath(tenantId), duplicate)).Should().Be(HttpStatusCode.Created); + duplicateId = RequiredId(await MapperAsync(admin, tenantId, "conflicting-user-id-proof")); + } + (await ProbeFreshProfileAsync(owned, identity, subject, mutation)).Should().BeFalse( + "actual JWT scalar/UUID/duplicate and ID-audience mutations must break the clean predicate"); + } + finally + { + if (duplicateId is not null) + (await admin.DeleteAsync(MapperPath(tenantId, duplicateId))).Should().Be(HttpStatusCode.NoContent); + (await admin.PutAsync(MapperPath(tenantId, RequiredId(original)), original)).Should().Be(HttpStatusCode.NoContent); + } + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue(); + } + + var realm = await admin.GetAsync(); + realm.GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + foreach (var client in new[] { web, await admin.ClientAsync("learnstack-api") }) + client.GetProperty("attributes").TryGetProperty("access.token.lifespan", out _).Should().BeFalse(); + try + { + var dirty = JsonNode.Parse(web.GetRawText())!.AsObject(); + dirty["attributes"]!["access.token.lifespan"] = "60"; + (await admin.PutAsync("clients/" + webId, dirty)).Should().Be(HttpStatusCode.NoContent); + (await admin.ClientAsync("learnstack-web")).GetProperty("attributes").GetProperty("access.token.lifespan") + .GetString().Should().Be("60"); + (await ProbeFreshProfileAsync(owned, identity, subject, "client_ttl")).Should().BeFalse("a live client override changes signed expiration"); + } + finally + { + // Client updates merge supplied attribute keys; omission does not delete an existing override. + var restore = JsonNode.Parse(web.GetRawText())!.AsObject(); + restore["attributes"]!["access.token.lifespan"] = null; + (await admin.PutAsync("clients/" + webId, restore)).Should().Be(HttpStatusCode.NoContent); + (await admin.ClientAsync("learnstack-web")).GetProperty("attributes") + .TryGetProperty("access.token.lifespan", out _).Should().BeFalse(); + } + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue(); + try + { + (await admin.PutAsync("", new { accessTokenLifespan = 1200 })).Should().Be(HttpStatusCode.NoContent); + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(1200); + (await ProbeFreshProfileAsync(owned, identity, subject, "realm_ttl")).Should().BeFalse("live realm drift changes signed expiration"); + } + finally { (await admin.PutAsync("", new { accessTokenLifespan = 900 })).Should().Be(HttpStatusCode.NoContent); } + (await ProbeFreshProfileAsync(owned, identity, subject)).Should().BeTrue(); + } + + [Fact] + public async Task Late_session_refresh_is_clamped_while_the_configured_access_lifespan_remains_exactly_900() + { + await using var owned = await KeycloakFixture.StartAsync(); + using var admin = await owned.CreateAdminClientAsync(); + var identity = SeedData.IdentityUsers[0]; + var realm = await admin.GetAsync(); + realm.GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + try + { + (await admin.PutAsync("", new { ssoSessionMaxLifespan = 30 })).Should().Be(HttpStatusCode.NoContent); + using var browser = owned.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + AssertAuthorized(authorization); + var initial = await browser.RedeemAsync(authorization); + initial.StatusCode.Should().Be(HttpStatusCode.OK); + var first = await KeycloakTokenVerifier.ValidateAsync(owned, initial.AccessToken, "learnstack-api"); + AssertIdentity(first, identity); + var firstId = await KeycloakTokenVerifier.ValidateAsync(owned, initial.IdToken, "learnstack-web"); + (firstId.GetProperty("exp").GetInt64() == first.GetProperty("exp").GetInt64()).Should().BeTrue( + "initial session-clamped access and ID tokens share expiration"); + await Task.Delay(TimeSpan.FromSeconds(2)); + var late = await browser.RequestTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = "learnstack-web", + ["client_secret"] = KeycloakFixture.WebClientSecret, + ["refresh_token"] = initial.RefreshToken, + }); + late.StatusCode.Should().Be(HttpStatusCode.OK); + var refreshed = await KeycloakTokenVerifier.ValidateAsync(owned, late.AccessToken, "learnstack-api"); + AssertIdentity(refreshed, identity); + var refreshedId = await KeycloakTokenVerifier.ValidateAsync(owned, late.IdToken, "learnstack-web"); + (refreshedId.GetProperty("exp").GetInt64() == refreshed.GetProperty("exp").GetInt64()).Should().BeTrue( + "the ID token from the same refresh shares the access token's session-clamped expiration"); + (refreshed.GetProperty("iat").GetInt64() > first.GetProperty("iat").GetInt64()).Should().BeTrue(); + (refreshed.GetProperty("auth_time").GetInt64() == first.GetProperty("auth_time").GetInt64()).Should().BeTrue( + "the refresh belongs to the same earlier authenticated session"); + var expiration = refreshed.GetProperty("exp").GetInt64(); + (expiration <= first.GetProperty("exp").GetInt64() + 2 + && expiration <= initial.Window.CompletedUtc.ToUnixTimeSeconds() + 30 + 2 + && expiration > late.Window.CompletedUtc.ToUnixTimeSeconds()).Should().BeTrue( + "the live late-session expiration stays within its short session ceiling"); + late.Window.AcceptsFresh(refreshed, 900).Should().BeFalse("a shorter session clamp is separate from a fresh 900-second issuance proof"); + late.Window.AcceptsFresh(refreshedId, 900).Should().BeFalse("the shared shorter expiration also clamps the refreshed ID token"); + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + foreach (var name in new[] { "learnstack-web", "learnstack-api" }) + (await admin.ClientAsync(name)).GetProperty("attributes").TryGetProperty("access.token.lifespan", out _).Should().BeFalse(); + } + finally + { + (await admin.PutAsync("", new { ssoSessionMaxLifespan = realm.GetProperty("ssoSessionMaxLifespan").GetInt32() })) + .Should().Be(HttpStatusCode.NoContent); + } + } + + private static async Task ProbeFreshProfileAsync(KeycloakFixture provider, SeedIdentity identity, string subject, + string? defect = null) + { + using var browser = provider.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + AssertAuthorized(authorization); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + // The production role declaration and stock roles audience resolver retain this independent audience. + // This checks signature/issuer/lifetime even when the deliberately dirty API audience is absent. + var access = await KeycloakTokenVerifier.ValidateAsync(provider, exchange.AccessToken, "account", + allowMissingSubject: defect == "missing_sub"); + var id = await KeycloakTokenVerifier.ValidateAsync(provider, exchange.IdToken, "learnstack-web", + allowMissingSubject: defect == "missing_sub"); + var reachedDefect = defect switch + { + null => true, + "missing_user_id" => !access.TryGetProperty("user_id", out _), + "missing_learnstack-api-audience" => !HasAudience(access, "learnstack-api"), + "missing_sub" => !access.TryGetProperty("sub", out _), + "missing_identity_scope" => !access.TryGetProperty("user_id", out _) && !access.TryGetProperty("tenant_id", out _), + "array" => access.GetProperty("user_id").ValueKind == JsonValueKind.Array, + "not_uuid" => access.GetProperty("user_id").ValueKind == JsonValueKind.String + && !Guid.TryParse(access.GetProperty("user_id").GetString(), out _), + "wrong_uuid" => ScalarEquals(access, "user_id", identity.Tenant.TenantId.Value.ToString("D")), + "duplicate_conflict" => access.GetProperty("user_id").ValueKind == JsonValueKind.Array + && access.GetProperty("user_id").EnumerateArray().Any(value => value.GetString() == identity.UserId.Value.ToString("D")) + && access.GetProperty("user_id").EnumerateArray().Any(value => value.GetString() == identity.Tenant.TenantId.Value.ToString("D")), + "id_audience" => HasAudience(id, "learnstack-api"), + "client_ttl" => exchange.Window.AcceptsFresh(access, 60), + "realm_ttl" => exchange.Window.AcceptsFresh(access, 1200), + _ => false, + }; + reachedDefect.Should().BeTrue("a dirty provider must reach the specific signed-token defect being exercised"); + return FreshProfileMatches(access, id, identity, subject, exchange.Window); + } + + private static bool FreshProfileMatches(JsonElement access, JsonElement id, SeedIdentity identity, + string? subject, KeycloakIssuanceWindow window) => MatchesIdentity(access, identity) && MatchesIdentity(id, identity) + && ScalarEquals(access, "sub", subject) && ScalarEquals(access, "typ", "Bearer") + && ScalarEquals(access, "azp", "learnstack-web") && HasAudience(access, "learnstack-api") + && ScalarEquals(id, "sub", subject) && ScalarEquals(id, "azp", "learnstack-web") + && ScalarEquals(id, "typ", "ID") && !HasAudience(id, "learnstack-api") + && window.AcceptsFresh(access, 900) && window.AcceptsFresh(id, 900) + && access.GetProperty("exp").GetInt64() == id.GetProperty("exp").GetInt64(); + + private static bool MatchesIdentity(JsonElement payload, SeedIdentity identity) => + ScalarEquals(payload, "user_id", identity.UserId.Value.ToString("D")) + && ScalarEquals(payload, "tenant_id", identity.Tenant.TenantId.Value.ToString("D")) + && (identity.Tenant.MapHostToDefaultOrganization + ? ScalarEquals(payload, "organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + : !payload.TryGetProperty("organization_id", out _)); + + private static bool ScalarEquals(JsonElement payload, string name, string? expected) => + payload.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String && value.GetString() == expected; + + private static async Task MapperAsync(KeycloakAdminClient admin, string scopeId, string name) + { + var mappers = await admin.GetAsync(MapperPath(scopeId)); + var matches = mappers.EnumerateArray().Where(mapper => mapper.GetProperty("name").GetString() == name).ToArray(); + if (matches.Length != 1) throw new InvalidOperationException("Keycloak fixture expected exactly one named mapper."); + return matches[0]; + } + + private static JsonObject WithoutId(JsonElement value) + { + var clone = JsonNode.Parse(value.GetRawText())!.AsObject(); + clone.Remove("id"); + return clone; + } + + private static string MapperPath(string scopeId, string? mapperId = null) => + "client-scopes/" + scopeId + "/protocol-mappers/models" + (mapperId is null ? "" : "/" + mapperId); + + private static string Callback(SeedIdentity identity) => + "https://" + identity.Tenant.Host + ":3000/api/auth/callback/keycloak"; + + private static void AssertAuthorized(KeycloakAuthorization authorization) => authorization.Authorized.Should().BeTrue( + "ordinary code/S256 login must complete (closed outcome: {0})", authorization.Error ?? "no_error_code"); + + private static string RequiredId(JsonElement entity) => entity.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Keycloak fixture entity ID is missing."); + + private static void AssertIdentity(JsonElement payload, SeedIdentity identity) + { + AssertScalar(payload, "user_id", identity.UserId.Value.ToString("D")); + AssertScalar(payload, "tenant_id", identity.Tenant.TenantId.Value.ToString("D")); + if (identity.Tenant.MapHostToDefaultOrganization) + AssertScalar(payload, "organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")); + else payload.TryGetProperty("organization_id", out _).Should().BeFalse(); + } + + private static void AssertScalar(JsonElement payload, string name, string value) + { + var actual = payload.GetProperty(name); + actual.ValueKind.Should().Be(JsonValueKind.String, name + " is a scalar JSON string"); + (actual.GetString() == value).Should().BeTrue(name + " agrees with the declared profile"); + } + + private static bool HasAudience(JsonElement payload, string audience) + { + if (!payload.TryGetProperty("aud", out var value)) return false; + return value.ValueKind == JsonValueKind.String ? value.GetString() == audience + : value.ValueKind == JsonValueKind.Array && value.EnumerateArray().Any(item => + item.ValueKind == JsonValueKind.String && item.GetString() == audience); + } + + private static void AssertExpirationControls(JsonElement valid, KeycloakIssuanceWindow window) + { + foreach (var offset in new[] { -60, 60 }) + { + var mutant = JsonNode.Parse(valid.GetRawText())?.AsObject() + ?? throw new InvalidOperationException("Expected a token payload object."); + mutant["exp"] = valid.GetProperty("exp").GetInt64() + offset; + using var json = JsonDocument.Parse(mutant.ToJsonString()); + window.AcceptsFresh(json.RootElement, 900).Should().BeFalse("an independently wrong expiration cannot fit the measured issuance interval"); + } + (window with { CompletedUtc = window.StartedUtc.AddSeconds(-1) }).AcceptsFresh(valid, 900).Should().BeFalse(); + (window with { Elapsed = TimeSpan.FromMinutes(1) }).AcceptsFresh(valid, 900).Should().BeFalse(); + (window with { CompletedUtc = window.CompletedUtc.AddSeconds(10) }).AcceptsFresh(valid, 900).Should().BeFalse(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmProfileProtectionTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmProfileProtectionTests.cs new file mode 100644 index 00000000..57df7a3d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmProfileProtectionTests.cs @@ -0,0 +1,325 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.WebUtilities; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Real provider account and registration protection; no application-database isolation claim. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantRealmProfileProtectionTests(KeycloakFixture fixture) : IClassFixture +{ + private static readonly string[] ProtectedAttributes = ["user_id", "tenant_id", "organization_id"]; + private static readonly string[] ProfilePermissions = ["view", "edit"]; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Same_seed_account_can_edit_ordinary_profile_but_cannot_see_write_or_delete_authority(bool yoga) + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant.MapHostToDefaultOrganization == yoga); + using var admin = await fixture.CreateAdminClientAsync(); + SeedAttributesMatch(await admin.UserAsync(identity.Username), identity).Should().BeTrue(); + var profile = await admin.GetAsync("users/profile"); + AdminOnlyDefinitions(profile).Should().BeTrue("the provider's independent profile configuration is admin-only"); + using var account = await AccountClientAsync(identity); + var original = await AccountAsync(account); + HiddenFromAccount(original).Should().BeTrue("both user data and user-profile metadata hide every authority attribute"); + var ordinary = AccountUpdate(original); + var originalName = original.GetProperty("firstName").GetString(); + ordinary["firstName"] = "Profile proof"; + try + { + (await UpdateAsync(account, ordinary)).Should().Be(HttpStatusCode.NoContent); + var updated = await admin.UserAsync(identity.Username); + (updated.GetProperty("firstName").GetString() == "Profile proof").Should().BeTrue( + "independent admin readback proves this same user's ordinary write reached storage"); + SeedAttributesMatch(updated, identity).Should().BeTrue(); + foreach (var attribute in ProtectedAttributes) + foreach (var attempt in new[] { "replace", "empty", "multiple", "delete", "omit" }) + { + var payload = AccountUpdate(await AccountAsync(account)); + var attributes = payload["attributes"]?.AsObject() ?? new JsonObject(); + if (payload["attributes"] is null) payload["attributes"] = attributes; + switch (attempt) + { + case "replace": attributes[attribute] = new JsonArray(Guid.NewGuid().ToString("D")); break; + case "empty": attributes[attribute] = new JsonArray(""); break; + case "multiple": attributes[attribute] = new JsonArray(Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D")); break; + case "delete": attributes[attribute] = new JsonArray(); break; + case "omit": attributes.Remove(attribute); break; + } + (await UpdateAsync(account, payload)).Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, HttpStatusCode.NoContent); + SeedAttributesMatch(await admin.UserAsync(identity.Username), identity).Should().BeTrue( + "a 204 can mean a protected omission was ignored; the stored typed values decide protection"); + HiddenFromAccount(await AccountAsync(account)).Should().BeTrue(); + } + await AssertFreshIdentityAsync(identity); + } + finally + { + // Restore only the ordinary field through the same owner's real account API. + var restore = AccountUpdate(await AccountAsync(account)); + restore["firstName"] = originalName; + (await UpdateAsync(account, restore)).Should().Be(HttpStatusCode.NoContent); + var restored = await admin.UserAsync(identity.Username); + (restored.GetProperty("firstName").GetString() == originalName).Should().BeTrue(); + SeedAttributesMatch(restored, identity).Should().BeTrue(); + } + } + + [Fact] + public async Task Ordinary_registration_works_and_injected_authority_never_becomes_new_account_data() + { + using var admin = await fixture.CreateAdminClientAsync(); + var owned = new List(); + var callback = Callback(SeedData.IdentityUsers[0]); + try + { + var ordinary = OwnedRegistrationName(); + owned.Add(ordinary); + using (var browser = fixture.Browser()) + { + var result = await browser.RegisterAsync(ordinary, KeycloakFixture.DemoPassword, callback); + result.Authorized.Should().BeTrue("the ordinary submitted form must genuinely create an account and complete authorization"); + RegistrationFieldsHidden(result).Should().BeTrue(); + } + var created = await FindOwnedRegistrationAsync(admin, ordinary); + created.HasValue.Should().BeTrue(); + NoAuthorityAttributes(created!.Value).Should().BeTrue(); + foreach (var attribute in ProtectedAttributes) + foreach (var variant in new[] { "replace", "empty", "multiple" }) + { + var username = OwnedRegistrationName(); + owned.Add(username); + using var browser = fixture.Browser(); + var injected = new List> + { new(attribute, variant == "empty" ? "" : Guid.NewGuid().ToString("D")) }; + if (variant == "multiple") injected.Add(new(attribute, Guid.NewGuid().ToString("D"))); + var result = await browser.RegisterAsync(username, KeycloakFixture.DemoPassword, callback, injected); + RegistrationFieldsHidden(result).Should().BeTrue(); + result.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Found, + HttpStatusCode.SeeOther, HttpStatusCode.BadRequest); + var attempted = await FindOwnedRegistrationAsync(admin, username); + if (attempted.HasValue) + NoAuthorityAttributes(attempted.Value).Should().BeTrue( + "an accepted registration cannot persist an injected authority attribute"); + else result.Authorized.Should().BeFalse("a refused injection must not claim completed registration"); + } + } + finally + { + foreach (var username in owned) + { + var user = await FindOwnedRegistrationAsync(admin, username); + if (user.HasValue) + (await admin.DeleteAsync("users/" + RequiredId(user.Value))).Should().Be(HttpStatusCode.NoContent); + } + } + } + + [Theory] + [InlineData("user_id")] + [InlineData("tenant_id")] + [InlineData("organization_id")] + public async Task User_editable_profile_counterfactual_breaks_the_same_protection_predicate(string attribute) + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant.MapHostToDefaultOrganization); + using var admin = await fixture.CreateAdminClientAsync(); + var originalProfile = await admin.GetAsync("users/profile"); + AdminOnlyDefinitions(originalProfile).Should().BeTrue(); + SeedAttributesMatch(await admin.UserAsync(identity.Username), identity).Should().BeTrue(); + var dirtyProfile = JsonNode.Parse(originalProfile.GetRawText())!.AsObject(); + var definition = dirtyProfile["attributes"]!.AsArray().Single(node => node!["name"]!.GetValue() == attribute)!; + definition["permissions"] = new JsonObject + { + ["view"] = new JsonArray("admin", "user"), + ["edit"] = new JsonArray("admin", "user"), + }; + HttpClient? account = null; + JsonObject? restore = null; + string? registered = null; + try + { + (await admin.PutAsync("users/profile", dirtyProfile)).Should().Be(HttpStatusCode.OK); + AdminOnlyDefinitions(await admin.GetAsync("users/profile")).Should().BeFalse(); + account = await AccountClientAsync(identity); + var visible = await AccountAsync(account); + HiddenFromAccount(visible).Should().BeFalse("the same visibility predicate detects a user-visible authority definition"); + restore = AccountUpdate(visible); + var payload = restore.DeepClone().AsObject(); + var replacement = Guid.NewGuid().ToString("D"); + payload["attributes"]!.AsObject()[attribute] = new JsonArray(replacement); + (await UpdateAsync(account, payload)).Should().Be(HttpStatusCode.NoContent); + var changed = await admin.UserAsync(identity.Username); + StoredScalar(changed, attribute, replacement).Should().BeTrue( + "this counterfactual must positively reach the protected field, rather than receive a generic denial"); + SeedAttributesMatch(changed, identity).Should().BeFalse("the exact predicate used by the clean proof must detect the actual stored mutation"); + registered = OwnedRegistrationName(); + using var registration = fixture.Browser(); + var dirtyRegistration = await registration.RegisterAsync(registered, KeycloakFixture.DemoPassword, Callback(identity), + new Dictionary { [attribute] = replacement }); + dirtyRegistration.Authorized.Should().BeTrue("a writable registration definition must positively reach account creation"); + RegistrationFieldsHidden(dirtyRegistration).Should().BeFalse(); + var registeredUser = await FindOwnedRegistrationAsync(admin, registered); + registeredUser.HasValue.Should().BeTrue(); + StoredScalar(registeredUser!.Value, attribute, replacement).Should().BeTrue( + "the injected field name must be proven effective in the actual registration parser"); + NoAuthorityAttributes(registeredUser.Value).Should().BeFalse( + "the same new-account protection predicate detects the user-editable registration variant"); + } + finally + { + try + { + try + { + if (account is not null && restore is not null) + (await UpdateAsync(account, restore)).Should().Be(HttpStatusCode.NoContent); + } + finally + { + if (registered is not null) + { + var registeredUser = await FindOwnedRegistrationAsync(admin, registered); + if (registeredUser.HasValue) + (await admin.DeleteAsync("users/" + RequiredId(registeredUser.Value))).Should().Be(HttpStatusCode.NoContent); + } + } + } + finally + { + account?.Dispose(); + (await admin.PutAsync("users/profile", originalProfile)).Should().Be(HttpStatusCode.OK); + } + AdminOnlyDefinitions(await admin.GetAsync("users/profile")).Should().BeTrue(); + SeedAttributesMatch(await admin.UserAsync(identity.Username), identity).Should().BeTrue(); + } + using var restoredAccount = await AccountClientAsync(identity); + HiddenFromAccount(await AccountAsync(restoredAccount)).Should().BeTrue(); + await AssertFreshIdentityAsync(identity); + } + + private async Task AccountClientAsync(SeedIdentity identity) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, + new Uri(fixture.BaseAddress, "realms/learnstack/account/").AbsoluteUri, + clientId: "account-console", scope: "openid"); + authorization.Authorized.Should().BeTrue("the delivered account-console code/S256 configuration must work"); + var exchange = await browser.RedeemAsync(authorization, secret: null); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + (exchange.AccessToken.Length > 0).Should().BeTrue(); + var client = KeycloakHttp.Create(fixture.BaseAddress, fixture.RootCertificate); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", exchange.AccessToken); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + private async Task AssertFreshIdentityAsync(SeedIdentity identity) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + authorization.Authorized.Should().BeTrue(); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var token = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + TokenIdentityMatches(token, identity).Should().BeTrue("fresh BFF issuance still uses exactly the typed seed authority"); + } + + private static async Task AccountAsync(HttpClient client) + { + using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Get, + "realms/learnstack/account/?userProfileMetadata=true"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (response.Content.Headers.ContentType?.MediaType == "application/json").Should().BeTrue( + "the account request must reach Account REST rather than the HTML console shell"); + using var json = await KeycloakHttp.ReadJsonAsync(response); + return json.RootElement.Clone(); + } + + private static async Task UpdateAsync(HttpClient client, JsonObject payload) + { + using var response = await KeycloakHttp.SendAsync(client, HttpMethod.Post, "realms/learnstack/account/", + JsonContent.Create(payload)); + return response.StatusCode; + } + + private static JsonObject AccountUpdate(JsonElement account) + { + var payload = JsonNode.Parse(account.GetRawText())!.AsObject(); + payload.Remove("userProfileMetadata"); + payload.Remove("id"); + if (payload["attributes"] is null) payload["attributes"] = new JsonObject(); + return payload; + } + + private static bool HiddenFromAccount(JsonElement account) => NoAuthorityAttributes(account) + && account.TryGetProperty("userProfileMetadata", out var metadata) + && metadata.TryGetProperty("attributes", out var attributes) && attributes.ValueKind == JsonValueKind.Array + && !attributes.EnumerateArray().Any(definition => ProtectedAttributes.Contains(definition.GetProperty("name").GetString())); + + private static bool AdminOnlyDefinitions(JsonElement profile) => ProtectedAttributes.All(name => + { + var matches = profile.GetProperty("attributes").EnumerateArray() + .Where(definition => definition.GetProperty("name").GetString() == name).ToArray(); + return matches.Length == 1 && ProfilePermissions.All(permission => + { + var values = matches[0].GetProperty("permissions").GetProperty(permission); + return values.GetArrayLength() == 1 && values[0].GetString() == "admin"; + }); + }); + + private static bool NoAuthorityAttributes(JsonElement user) => !user.TryGetProperty("attributes", out var attributes) + || ProtectedAttributes.All(name => !attributes.TryGetProperty(name, out _)); + + private static bool SeedAttributesMatch(JsonElement user, SeedIdentity identity) => + StoredScalar(user, "user_id", identity.UserId.Value.ToString("D")) + && StoredScalar(user, "tenant_id", identity.Tenant.TenantId.Value.ToString("D")) + && (identity.Tenant.MapHostToDefaultOrganization + ? StoredScalar(user, "organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + : !user.GetProperty("attributes").TryGetProperty("organization_id", out _)); + + private static bool StoredScalar(JsonElement user, string name, string expected) => + user.TryGetProperty("attributes", out var attributes) && attributes.TryGetProperty(name, out var values) + && values.ValueKind == JsonValueKind.Array && values.GetArrayLength() == 1 + && values[0].ValueKind == JsonValueKind.String && values[0].GetString() == expected; + + private static bool TokenIdentityMatches(JsonElement token, SeedIdentity identity) => + Scalar(token, "user_id", identity.UserId.Value.ToString("D")) + && Scalar(token, "tenant_id", identity.Tenant.TenantId.Value.ToString("D")) + && (identity.Tenant.MapHostToDefaultOrganization + ? Scalar(token, "organization_id", identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + : !token.TryGetProperty("organization_id", out _)); + + private static bool Scalar(JsonElement payload, string name, string expected) => payload.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String && value.GetString() == expected; + + private static bool RegistrationFieldsHidden(KeycloakRegistration result) => ProtectedAttributes.All(name => + !result.Fields.Contains(name) && !result.Fields.Contains("user.attributes." + name)); + + private static async Task FindOwnedRegistrationAsync(KeycloakAdminClient admin, string username) + { + var users = await admin.GetAsync(QueryHelpers.AddQueryString("users", new Dictionary + { + ["username"] = username, + ["exact"] = "true", + })); + if (users.GetArrayLength() == 0) return null; + (users.GetArrayLength() == 1 && users[0].GetProperty("username").GetString() == username + && users[0].GetProperty("email").GetString() == username).Should().BeTrue( + "cleanup and readback select only the exact invocation-owned registration"); + return users[0]; + } + + private static string OwnedRegistrationName() => "profile-proof-" + Guid.NewGuid().ToString("N") + "@provider.test"; + private static string Callback(SeedIdentity identity) => "https://" + identity.Tenant.Host + ":3000/api/auth/callback/keycloak"; + private static string RequiredId(JsonElement entity) => entity.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Keycloak fixture entity ID is missing."); +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs new file mode 100644 index 00000000..df4389f4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs @@ -0,0 +1,327 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class TenantRealmReconciliationTests +{ + private static readonly string[] ProtectedPreparationNames = ["user_id", "tenant_id", "organization_id"]; + + [Fact] + public Task Historical_stock_upgrade_automatically_prepares_once_then_converges_with_seventeen_writes() => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var profileBefore = await admin.GetAsync("users/profile"); + var expectedProfile = await AppendedPreparationProfileAsync(profileBefore); + var originalUsers = new Dictionary(StringComparer.Ordinal); + var credentials = new Dictionary(StringComparer.Ordinal); + var grants = new Dictionary(StringComparer.Ordinal); + foreach (var identity in SeedData.IdentityUsers.Take(2)) + { + var user = await UserDetailsAsync(admin, identity.Username); + originalUsers.Add(identity.Username, user); + credentials.Add(Id(user), await CredentialHashAsync(admin, Id(user))); + grants.Add(Id(user), await admin.GetAsync("users/" + Id(user) + "/role-mappings")); + } + var clientsBefore = await admin.GetAsync("clients"); + var scopesBefore = await PreparationScopeStateAsync(admin); + var api = await admin.ClientAsync("learnstack-api"); + var apiSecret = await admin.GetAsync("clients/" + Id(api) + "/client-secret"); + using var hub = await fixture.CreateAdminClientAsync("learnstack-hub"); + var hubBefore = await StateAsync(hub); + + var first = await KeycloakReconciliationDriver.RunAsync(fixture); + first.Success.Should().BeTrue("the automatic historical path reports {0}/{1}", first.Stage, first.Reason); + first.PreparationDispatched.Should().BeTrue(); + first.Mutations.Should().Be(17, "one metadata preparation precedes sixteen ordinary stock-upgrade writes"); + AssertPreparationProfile(expectedProfile, await admin.GetAsync("users/profile")); + foreach (var identity in SeedData.IdentityUsers) await AssertStoredIdentityAsync(admin, identity); + foreach (var (username, original) in originalUsers) + { + var after = await UserDetailsAsync(admin, username); + (Id(after) == Id(original)).Should().BeTrue("historical accounts retain their provider IDs"); + AssertPreparationPreservedUser(original, after); + (await CredentialHashAsync(admin, Id(after)) == credentials[Id(original)]).Should().BeTrue( + "the actual historical password credential DTO remains unchanged"); + await AssertOnlyAccountGrantAddedAsync(admin, Id(after), grants[Id(original)]); + } + (await admin.GetAsync("clients")).EnumerateArray().Select(Id).Order(StringComparer.Ordinal).Should().Equal( + clientsBefore.EnumerateArray().Select(Id).Order(StringComparer.Ordinal), "all existing clients retain their provider IDs"); + foreach (var (path, before) in scopesBefore) + AssertSame(before, await admin.GetAsync(path), "preparation and normalization preserve each preexisting global scope definition"); + AssertSame(apiSecret, await admin.GetAsync("clients/" + Id(api) + "/client-secret"), "the API credential survives the automatic path"); + (await StateAsync(hub) == hubBefore).Should().BeTrue("the separate Hub realm remains untouched"); + await AssertPreparedConsoleAssignmentsAsync(admin); + var afterFirst = await StateAsync(admin); + var second = await KeycloakReconciliationDriver.RunAsync(fixture); + second.Success.Should().BeTrue("the explicit subsequent pass reports {0}/{1}", second.Stage, second.Reason); + second.Mutations.Should().Be(0); + second.PreparationDispatched.Should().BeFalse(); + (await StateAsync(admin) == afterFirst).Should().BeTrue("zero writes preserves independently read provider state"); + }); + + [Fact] + public Task Hidden_historical_actor_conflict_is_revealed_after_preparation_without_any_ordinary_write() => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var identity = SeedData.IdentityUsers[0]; + var stockProfile = await admin.GetAsync("users/profile"); + var visibleProfile = Object(stockProfile); + visibleProfile["unmanagedAttributePolicy"] = "ADMIN_EDIT"; + (await admin.PutAsync("users/profile", visibleProfile)).Should().Be(HttpStatusCode.OK); + var user = await UserDetailsAsync(admin, identity.Username); + var conflictingActor = Guid.NewGuid().ToString("D"); + var changed = Object(user); + changed["attributes"]!["user_id"] = new JsonArray(conflictingActor); + (await admin.PutAsync("users/" + Id(user), changed)).Should().Be(HttpStatusCode.NoContent); + var visibleUser = await UserDetailsAsync(admin, identity.Username); + (visibleUser.GetProperty("attributes").GetProperty("user_id")[0].GetString() == conflictingActor).Should().BeTrue( + "the planted actor exists in actual provider storage before it is hidden"); + (await admin.PutAsync("users/profile", Object(stockProfile))).Should().Be(HttpStatusCode.OK); + AssertSame(stockProfile, await admin.GetAsync("users/profile"), "test-owned visibility is restored before invoking the real reconciler"); + var hidden = await UserDetailsAsync(admin, identity.Username); + (hidden.TryGetProperty("attributes", out var attributes) && attributes.TryGetProperty("user_id", out _)) + .Should().BeFalse("the stock provider actually conceals the conflicting actor"); + var before = await PreparationOrdinaryStateAsync(admin); + var credentialHash = await CredentialHashAsync(admin, Id(user)); + + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + AssertFailure(outcome, "plan", "identity_conflict"); + outcome.Mutations.Should().Be(1, "only the admitted metadata preparation may precede the fresh-plan refusal"); + outcome.PreparationDispatched.Should().BeTrue(); + AssertPreparationProfile(await AppendedPreparationProfileAsync(stockProfile), await admin.GetAsync("users/profile")); + var revealed = await UserDetailsAsync(admin, identity.Username); + AssertSame(visibleUser, revealed, "the fresh plan refuses the actual actor without rewriting the account"); + (Id(revealed) == Id(user)).Should().BeTrue(); + (await CredentialHashAsync(admin, Id(user)) == credentialHash).Should().BeTrue(); + (await PreparationOrdinaryStateAsync(admin) == before).Should().BeTrue("no realm, account, credential, grant, mapper or assignment mutation follows preparation"); + }); + + [Fact] + public Task Missing_protected_definitions_do_not_allow_preparation_before_an_independent_secret_conflict() => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var web = Object(await admin.ClientAsync("learnstack-web")); + web["publicClient"] = false; + web["secret"] = Guid.NewGuid().ToString("N"); + (await admin.PutAsync("clients/" + web["id"]!.GetValue(), web)).Should().Be(HttpStatusCode.NoContent); + await AssertPreparationRefusesWithoutWriteAsync(fixture, admin, "secret_conflict"); + }); + + [Fact] + public Task Missing_protected_definitions_do_not_allow_preparation_for_a_customized_stock_scope_lookalike() => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var roles = Object(await ScopeAsync(admin, "roles")); + roles["attributes"]!["fixture.customized"] = "true"; + (await admin.PutAsync("client-scopes/" + roles["id"]!.GetValue(), roles)).Should().Be(HttpStatusCode.NoContent); + (await ScopeAsync(admin, "roles")).GetProperty("attributes").GetProperty("fixture.customized").GetString().Should().Be("true"); + await AssertPreparationRefusesWithoutWriteAsync(fixture, admin, "unsupported"); + }); + + [Theory] + [InlineData("lost_acknowledgement", "transport")] + [InlineData("partial_cancel", "cancelled")] + public Task Interrupted_historical_preparation_counts_the_dispatched_write_and_requires_an_explicit_resume(string scenario, string reason) => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var profileBefore = await admin.GetAsync("users/profile"); + var before = await PreparationOrdinaryStateAsync(admin); + var interrupted = await KeycloakReconciliationDriver.RunAsync(fixture, scenario); + AssertFailure(interrupted, "prepare", reason); + interrupted.Mutations.Should().Be(1, "a real provider commit with a lost response or cancellation cannot report zero writes"); + interrupted.PreparationDispatched.Should().BeTrue(); + AssertPreparationProfile(await AppendedPreparationProfileAsync(profileBefore), await admin.GetAsync("users/profile")); + (await PreparationOrdinaryStateAsync(admin) == before).Should().BeTrue("the interrupted preparation dispatches no ordinary mutation"); + + var resumed = await KeycloakReconciliationDriver.RunAsync(fixture); + resumed.Success.Should().BeTrue("an explicit fresh invocation reports {0}/{1}", resumed.Stage, resumed.Reason); + resumed.Mutations.Should().Be(16, "the durable preparation is reused without a second metadata preparation"); + resumed.PreparationDispatched.Should().BeFalse(); + var completed = await KeycloakReconciliationDriver.RunAsync(fixture); + completed.Success.Should().BeTrue(); + completed.Mutations.Should().Be(0); + }); + + [Fact] + public Task Partial_admin_view_only_definition_is_preserved_by_preparation_then_normalized_by_a_distinct_ordinary_write() => + WithHistoricalPreparationAsync(async (fixture, admin) => + { + var profile = Object(await admin.GetAsync("users/profile")); + var canonical = await CanonicalPreparationDefinitionsAsync(); + var readable = canonical.Single(value => value["name"]!.GetValue() == "user_id").DeepClone(); + readable["permissions"]!["edit"] = new JsonArray(); + profile["attributes"]!.AsArray().Add(readable); + (await admin.PutAsync("users/profile", profile)).Should().Be(HttpStatusCode.OK); + var before = await admin.GetAsync("users/profile"); + AssertPreparationProfile(profile, before); + var ordinaryBefore = await PreparationOrdinaryStateAsync(admin); + + var interrupted = await KeycloakReconciliationDriver.RunAsync(fixture, "partial_cancel"); + AssertFailure(interrupted, "prepare", "cancelled"); + interrupted.Mutations.Should().Be(1); + interrupted.PreparationDispatched.Should().BeTrue(); + var prepared = await admin.GetAsync("users/profile"); + var expectedPreparation = await AppendedPreparationProfileAsync(before); + AssertPreparationProfile(expectedPreparation, prepared); + (await PreparationOrdinaryStateAsync(admin) == ordinaryBefore).Should().BeTrue(); + var actualReadable = prepared.GetProperty("attributes").EnumerateArray().Single(value => value.GetProperty("name").GetString() == "user_id"); + JsonNode.DeepEquals(readable, JsonNode.Parse(actualReadable.GetRawText())).Should().BeTrue( + "preparation preserves the existing admin-view-only definition exactly, including its empty edit list"); + + var resumed = await KeycloakReconciliationDriver.RunAsync(fixture); + resumed.Success.Should().BeTrue("the separate ordinary plan reports {0}/{1}", resumed.Stage, resumed.Reason); + resumed.PreparationDispatched.Should().BeFalse(); + resumed.Mutations.Should().Be(17, "sixteen stock-upgrade writes plus the separate approved profile-normalization PUT"); + var expectedFinal = expectedPreparation.DeepClone(); + var definitions = expectedFinal["attributes"]!.AsArray(); + var index = definitions.IndexOf(definitions.Single(value => value!["name"]!.GetValue() == "user_id")); + definitions[index] = canonical.Single(value => value["name"]!.GetValue() == "user_id").DeepClone(); + AssertPreparationProfile(expectedFinal, await admin.GetAsync("users/profile")); + var completed = await KeycloakReconciliationDriver.RunAsync(fixture); + completed.Success.Should().BeTrue(); + completed.Mutations.Should().Be(0); + }); + + private static async Task WithHistoricalPreparationAsync(Func proof) + { + var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(KeycloakFixture.RealmPath("learnstack"))!, "..", "..", "..")); + var historical = await TenantRealmStockBaselineTests.HistoricalImportAsync(root); + var fixture = await KeycloakFixture.StartAsync(historical); + StockProviderResources? resources = null; + try + { + resources = await fixture.StockProviderResourcesAsync(); + (await fixture.StockProviderVersionAsync()).Should().Be("26.6.2"); + using var admin = await fixture.CreateAdminClientAsync(); + await proof(fixture, admin); + } + finally + { + try { await fixture.DisposeAsync(); } + finally + { + if (resources is not null) await KeycloakFixture.AssertStockResourcesAbsentAsync(resources); + } + } + } + + private static async Task AssertPreparationRefusesWithoutWriteAsync(KeycloakFixture fixture, KeycloakAdminClient admin, string reason) + { + var before = await StateAsync(admin); + var profile = await admin.GetAsync("users/profile"); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + AssertFailure(outcome, "plan", reason); + outcome.Mutations.Should().Be(0, "the independent conflict must refuse before the exceptional metadata write"); + outcome.PreparationDispatched.Should().BeFalse(); + AssertSame(profile, await admin.GetAsync("users/profile"), "the entire stock profile remains unchanged"); + (await StateAsync(admin) == before).Should().BeTrue("independent real-provider snapshots prove zero mutations"); + } + + private static async Task CanonicalPreparationDefinitionsAsync() + { + var desired = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!; + var profile = JsonNode.Parse(desired["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]! + ["config"]!["kc.user.profile.config"]![0]!.GetValue())!; + return ProtectedPreparationNames.Select(name => profile["attributes"]!.AsArray() + .Single(value => value!["name"]!.GetValue() == name)!.DeepClone()).ToArray(); + } + + private static async Task AppendedPreparationProfileAsync(JsonElement before) + { + var expected = Object(before); + var attributes = expected["attributes"]!.AsArray(); + var appended = 0; + foreach (var definition in await CanonicalPreparationDefinitionsAsync()) + { + if (attributes.Any(value => value!["name"]!.GetValue() == definition["name"]!.GetValue())) continue; + attributes.Add(definition); + appended++; + } + appended.Should().BeGreaterThan(0, "this provider control must reach the missing-definition preparation path"); + return expected; + } + + private static void AssertPreparationProfile(JsonNode expected, JsonElement actual) => + JsonNode.DeepEquals(expected, JsonNode.Parse(actual.GetRawText())).Should().BeTrue( + "a separate provider GET proves the exact complete profile, including ordered attributes, groups, permissions and the unchanged unmanaged policy"); + + private static async Task CredentialHashAsync(KeycloakAdminClient admin, string userId) + { + var credentials = await admin.GetAsync("users/" + userId + "/credentials"); + credentials.EnumerateArray().Any(value => value.GetProperty("type").GetString() == "password").Should().BeTrue( + "the historical preservation control contains an actual password credential"); + return Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(credentials))); + } + + private static void AssertPreparationPreservedUser(JsonElement before, JsonElement after) + { + var prior = Object(before); + var current = Object(after); + foreach (var user in new[] { prior, current }) + { + if (user["attributes"] is not JsonObject attributes) continue; + foreach (var name in ProtectedPreparationNames) attributes.Remove(name); + if (attributes.Count == 0) user.Remove("attributes"); + } + JsonNode.DeepEquals(prior, current).Should().BeTrue( + "all preexisting account fields survive, allowing only the declared protected identity attributes to change"); + } + + private static async Task> PreparationScopeStateAsync(KeycloakAdminClient admin) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var scope in (await admin.GetAsync("client-scopes")).EnumerateArray()) + foreach (var suffix in new[] { "", "/protocol-mappers/models", "/scope-mappings" }) + { + var path = "client-scopes/" + Id(scope) + suffix; + result.Add(path, await admin.GetAsync(path)); + } + return result; + } + + private static async Task AssertPreparedConsoleAssignmentsAsync(KeycloakAdminClient admin) + { + var console = await admin.ClientAsync("account-console"); + foreach (var (category, expected) in new[] + { + ("default", new[] { "acr", "basic", "email", "profile", "web-origins" }), + ("optional", new[] { "address", "organization", "phone" }), + }) + { + var assigned = await admin.GetAsync("clients/" + Id(console) + "/" + category + "-client-scopes"); + assigned.EnumerateArray().Select(value => value.GetProperty("name").GetString()).Order(StringComparer.Ordinal) + .Should().Equal(expected, "only the three incompatible console assignment edges are detached"); + } + } + + private static async Task PreparationOrdinaryStateAsync(KeycloakAdminClient admin) + { + var state = JsonNode.Parse(await StateAsync(admin))!.AsObject(); + state.Remove("users/profile"); + // Profile metadata and newly visible identity attributes are the read projection + // changed by preparation. Account values receive separate exact assertions. + void RemoveVisibilityProjection(JsonNode? node) + { + if (node is JsonObject value) + { + value.Remove("userProfileMetadata"); + if (value["attributes"] is JsonObject attributes) + { + foreach (var name in ProtectedPreparationNames) attributes.Remove(name); + if (attributes.Count == 0) value.Remove("attributes"); + } + foreach (var child in value.ToArray()) RemoveVisibilityProjection(child.Value); + } + else if (node is JsonArray array) + foreach (var child in array) RemoveVisibilityProjection(child); + } + RemoveVisibilityProjection(state); + return Canonical(state).ToJsonString(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.cs new file mode 100644 index 00000000..edb0501a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.cs @@ -0,0 +1,912 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Actual shared tooling against owned HTTPS Keycloak, including a controlled pre-mapper import. +[Collection(SharedKeycloakRealm.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class TenantRealmReconciliationTests(KeycloakFixture fresh) +{ + private const string ChangedPassword = "preserved-fixture-password"; + private const string ExtraUsername = "unmanaged-account@fixture.test"; + private static readonly string[] AccountSelfServiceRoles = ["manage-account", "manage-account-links"]; + + [Fact] + public async Task Committed_fresh_import_is_already_converged_and_repeated_runs_have_zero_writes() + { + using var admin = await fresh.CreateAdminClientAsync(); + (await admin.GetAsync()).GetProperty("registrationEmailAsUsername").GetBoolean().Should().BeTrue(); + var before = await StateAsync(admin); + for (var pass = 0; pass < 3; pass++) + { + var result = await KeycloakReconciliationDriver.RunAsync(fresh); + result.Success.Should().BeTrue("the real committed import satisfies the shared planner (closed outcome: {0}/{1})", result.Stage, result.Reason); + result.Mutations.Should().Be(0); + } + (await StateAsync(admin) == before).Should().BeTrue("even zero-write reconciliation preserves provider state"); + await AssertLoginAsync(fresh, SeedData.IdentityUsers[0], KeycloakFixture.DemoPassword); + } + + [Fact] + public async Task Legacy_upgrade_resumes_durable_partial_writes_and_preserves_accounts_and_unrelated_state() + { + await using var fixture = await KeycloakFixture.StartAsync(await LegacyRealmAsync()); + using var admin = await fixture.CreateAdminClientAsync(); + using var hub = await fixture.CreateAdminClientAsync("learnstack-hub"); + (await admin.GetAsync()).GetProperty("registrationEmailAsUsername").GetBoolean().Should().BeFalse( + "the warm realm permits preserving an email distinct from its stable login username"); + var firstIdentity = SeedData.IdentityUsers[0]; + var secondIdentity = SeedData.IdentityUsers[1]; + var beforeAdmin = await UserDetailsAsync(admin, firstIdentity.Username); + var beforeLearner = await UserDetailsAsync(admin, secondIdentity.Username); + var beforeExtra = await UserDetailsAsync(admin, ExtraUsername); + var adminId = Id(beforeAdmin); + var learnerId = Id(beforeLearner); + var extraId = Id(beforeExtra); + var adminRoles = await admin.GetAsync("users/" + adminId + "/role-mappings"); + var learnerRoles = await admin.GetAsync("users/" + learnerId + "/role-mappings"); + var learnerCredentials = await admin.GetAsync("users/" + learnerId + "/credentials"); + learnerCredentials.EnumerateArray().Any(value => value.GetProperty("type").GetString() == "otp") + .Should().BeTrue("the preservation control contains an actual imported MFA credential"); + beforeLearner.GetProperty("enabled").GetBoolean().Should().BeFalse(); + beforeLearner.GetProperty("requiredActions").EnumerateArray().Any(value => value.GetString() == "UPDATE_PASSWORD") + .Should().BeTrue(); + var extraCredentials = await admin.GetAsync("users/" + extraId + "/credentials"); + var extraRoles = await admin.GetAsync("users/" + extraId + "/role-mappings"); + var hubBefore = await StateAsync(hub); + var profileBefore = await admin.GetAsync("users/profile"); + var apiBefore = await admin.ClientAsync("learnstack-api"); + var apiSecretBefore = await admin.GetAsync("clients/" + Id(apiBefore) + "/client-secret"); + apiBefore.GetProperty("authorizationServicesEnabled").GetBoolean().Should().BeTrue(); + var resourceServerPath = "clients/" + Id(apiBefore) + "/authz/resource-server/settings"; + var resourceServerBefore = await admin.GetAsync(resourceServerPath); + resourceServerBefore.GetProperty("allowRemoteResourceManagement").GetBoolean().Should().BeFalse(); + (resourceServerBefore.GetProperty("policyEnforcementMode").GetString() == "PERMISSIVE").Should().BeTrue(); + (resourceServerBefore.GetProperty("decisionStrategy").GetString() == "AFFIRMATIVE").Should().BeTrue(); + var serviceAccountPath = "clients/" + Id(apiBefore) + "/service-account-user"; + var serviceAccountBefore = await admin.GetAsync("users/" + Id(await admin.GetAsync(serviceAccountPath))); + var serviceAccountCredentials = await admin.GetAsync("users/" + Id(serviceAccountBefore) + "/credentials"); + var apiDefaultScopesPath = "clients/" + Id(apiBefore) + "/default-client-scopes"; + var apiDefaults = await admin.GetAsync(apiDefaultScopesPath); + var serviceAccountScope = apiDefaults.EnumerateArray().Single(scope => scope.GetProperty("name").GetString() == "service_account"); + (await admin.DeleteAsync(apiDefaultScopesPath + "/" + Id(serviceAccountScope))).Should().Be(HttpStatusCode.NoContent); + (await admin.GetAsync(apiDefaultScopesPath)).EnumerateArray().Any(scope => scope.GetProperty("name").GetString() == "service_account") + .Should().BeFalse("the warm service-account scope customization is deliberate"); + var customScopeBefore = await ScopeAsync(admin, "unrelated-fixture-scope"); + + // Positive password proof before the conversion uses the old public client's exact legacy callback. + using (var browser = fixture.Browser()) + { + var authorization = await browser.AuthorizeAsync(firstIdentity.Username, ChangedPassword, + "http://localhost:3000/legacy-callback"); + authorization.Authorized.Should().BeTrue("the pre-upgrade account accepts its changed password"); + var exchange = await browser.RedeemAsync(authorization, secret: null); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + } + var adminCredentials = await admin.GetAsync("users/" + adminId + "/credentials"); + + var original = await StateAsync(admin); + var uncertain = await KeycloakReconciliationDriver.RunAsync(fixture, "lost_acknowledgement"); + AssertFailure(uncertain, "apply", "transport"); + uncertain.Mutations.Should().Be(1, "acknowledgement loss is injected after one real committed response closes"); + (await StateAsync(admin) != original).Should().BeTrue("the failed run left a durable partial mutation"); + var partial = await KeycloakReconciliationDriver.RunAsync(fixture, "partial_cancel"); + AssertFailure(partial, "apply", "cancelled"); + partial.Mutations.Should().Be(1); + var resumed = await KeycloakReconciliationDriver.RunAsync(fixture); + resumed.Success.Should().BeTrue("closed outcome: {0}/{1} after {2} mutating calls", resumed.Stage, resumed.Reason, resumed.Mutations); + resumed.Mutations.Should().BeGreaterThan(0); + await AssertOwnedScopeAndMapperRepairAsync(admin, fixture); + var second = await KeycloakReconciliationDriver.RunAsync(fixture); + var third = await KeycloakReconciliationDriver.RunAsync(fixture); + second.Success.Should().BeTrue("closed outcome: {0}/{1}", second.Stage, second.Reason); + third.Success.Should().BeTrue("closed outcome: {0}/{1}", third.Stage, third.Reason); + second.Mutations.Should().Be(0); + third.Mutations.Should().Be(0, "a completed third pass performs no mutating Admin REST call"); + + // Independent typed declarations also cover the disabled learner, whose login cannot prove migration. + foreach (var identity in SeedData.IdentityUsers) await AssertStoredIdentityAsync(admin, identity); + AssertSamePreservedUser(beforeAdmin, await UserDetailsAsync(admin, firstIdentity.Username)); + AssertSamePreservedUser(beforeLearner, await UserDetailsAsync(admin, secondIdentity.Username)); + AssertSame(beforeExtra, await UserDetailsAsync(admin, ExtraUsername), "nonmanifest accounts are never filled in or rewritten"); + AssertSame(adminCredentials, await admin.GetAsync("users/" + adminId + "/credentials"), "the existing password credential remains unchanged"); + AssertSame(learnerCredentials, await admin.GetAsync("users/" + learnerId + "/credentials"), "password and MFA credentials remain unchanged"); + AssertSame(extraCredentials, await admin.GetAsync("users/" + extraId + "/credentials"), "nonmanifest credentials remain unchanged"); + await AssertOnlyAccountGrantAddedAsync(admin, adminId, adminRoles); + await AssertOnlyAccountGrantAddedAsync(admin, learnerId, learnerRoles); + AssertSame(extraRoles, await admin.GetAsync("users/" + extraId + "/role-mappings"), "nonmanifest roles remain unchanged"); + (await StateAsync(hub) == hubBefore).Should().BeTrue("the separate Hub realm and operator account are untouched"); + AssertSame(apiSecretBefore, await admin.GetAsync("clients/" + Id(apiBefore) + "/client-secret"), "the API service credential is retained"); + (await admin.ClientAsync("learnstack-api")).GetProperty("authorizationServicesEnabled").GetBoolean().Should().BeTrue(); + AssertSame(resourceServerBefore, await admin.GetAsync(resourceServerPath), "unrelated authorization resource-server settings survive client convergence"); + (Id(await admin.GetAsync(serviceAccountPath)) == Id(serviceAccountBefore)).Should().BeTrue("the API retains its existing service account"); + AssertSame(serviceAccountBefore, await admin.GetAsync("users/" + Id(serviceAccountBefore)), "the existing API service account state is retained"); + AssertSame(serviceAccountCredentials, await admin.GetAsync("users/" + Id(serviceAccountBefore) + "/credentials"), "the service-account credential state is retained"); + (await admin.GetAsync(apiDefaultScopesPath)).EnumerateArray().Any(scope => scope.GetProperty("name").GetString() == "service_account") + .Should().BeFalse("unchanged service-account enablement must not reattach a deliberately removed scope"); + AssertSame(customScopeBefore, await ScopeAsync(admin, "unrelated-fixture-scope"), "unrelated client scope definitions are retained"); + await AssertUnrelatedConfigurationAsync(admin, profileBefore); + await AssertManagedConfigurationAsync(admin); + await AssertLoginAsync(fixture, firstIdentity, ChangedPassword, adminId); + foreach (var identity in SeedData.IdentityUsers.Where(value => value.Tenant != firstIdentity.Tenant)) + await AssertLoginAsync(fixture, identity, KeycloakFixture.DemoPassword); + using (var extraBrowser = fixture.Browser()) + { + var authorization = await extraBrowser.AuthorizeAsync(ExtraUsername, ChangedPassword, Callback(firstIdentity)); + authorization.Authorized.Should().BeTrue("an unrelated account's password remains usable after upgrade"); + var exchange = await extraBrowser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + (payload.GetProperty("sub").GetString() == extraId).Should().BeTrue(); + payload.TryGetProperty("user_id", out _).Should().BeFalse("reconciliation never invents an actor for nonmanifest accounts"); + } + + // The old create-only password cannot replace an existing account's changed credential. + using var rejectedBrowser = fixture.Browser(); + var rejected = await rejectedBrowser.AuthorizeAsync(firstIdentity.Username, KeycloakFixture.DemoPassword, Callback(firstIdentity)); + rejected.Authorized.Should().BeFalse(); + rejected.Error.Should().Be("invalid_credentials"); + } + + [Fact] + public async Task Uninterrupted_legacy_upgrade_preserves_nonmanifest_actor_and_account_state() + { + var unrelatedActor = Guid.NewGuid().ToString("D"); + SeedData.IdentityUsers.All(identity => identity.UserId.Value.ToString("D") != unrelatedActor).Should().BeTrue(); + var realm = JsonNode.Parse(await LegacyRealmAsync())!.AsObject(); + var importedExtra = realm["users"]!.AsArray().Single(value => value!["username"]!.GetValue() == ExtraUsername)!; + importedExtra["attributes"]!["user_id"] = new JsonArray(unrelatedActor); + await using var fixture = await KeycloakFixture.StartAsync(realm.ToJsonString()); + using var admin = await fixture.CreateAdminClientAsync(); + var defaultGroupId = await CreateGroupAsync(admin, "fixture-create-only-default", + new Dictionary { ["fixture_note"] = ["preserved-default-group"] }); + (await admin.PutAsync("default-groups/" + defaultGroupId, new { })).Should().Be(HttpStatusCode.NoContent); + var defaultsBefore = await admin.GetAsync("default-groups"); + var defaultGroupBefore = await admin.GetAsync("groups/" + defaultGroupId); + var before = await UserDetailsAsync(admin, ExtraUsername); + var stableId = Id(before); + (before.GetProperty("attributes").GetProperty("user_id")[0].GetString() == unrelatedActor).Should().BeTrue(); + var credentialsBefore = await admin.GetAsync("users/" + stableId + "/credentials"); + var rolesBefore = await admin.GetAsync("users/" + stableId + "/role-mappings"); + + var first = await KeycloakReconciliationDriver.RunAsync(fixture); + var after = await UserDetailsAsync(admin, ExtraUsername); + var credentialsAfter = await admin.GetAsync("users/" + stableId + "/credentials"); + var rolesAfter = await admin.GetAsync("users/" + stableId + "/role-mappings"); + static bool Same(JsonElement left, JsonElement right) => + JsonNode.DeepEquals(JsonNode.Parse(left.GetRawText()), JsonNode.Parse(right.GetRawText())); + var accountPreserved = Same(before, after); + var attributesPreserved = Same(before.GetProperty("attributes"), after.GetProperty("attributes")); + var actorPreserved = after.GetProperty("attributes").GetProperty("user_id")[0].GetString() == unrelatedActor; + var credentialsPreserved = Same(credentialsBefore, credentialsAfter); + var rolesPreserved = Same(rolesBefore, rolesAfter); + var explicitRetry = await KeycloakReconciliationDriver.RunAsync(fixture); + explicitRetry.Success.Should().BeTrue("the explicit new pass reports {0}/{1}", explicitRetry.Stage, explicitRetry.Reason); + explicitRetry.Mutations.Should().Be(0, "the uninterrupted first pass already committed its managed changes"); + + first.Success.Should().BeTrue("closed first outcome: {0}/{1} after {2} mutating calls; account/attributes/actor/credentials/roles preserved: {3}/{4}/{5}/{6}/{7}; explicit retry made zero writes", + first.Stage, first.Reason, first.Mutations, accountPreserved, attributesPreserved, actorPreserved, credentialsPreserved, rolesPreserved); + first.Mutations.Should().BeGreaterThan(0); + accountPreserved.Should().BeTrue(); + attributesPreserved.Should().BeTrue(); + actorPreserved.Should().BeTrue(); + credentialsPreserved.Should().BeTrue(); + rolesPreserved.Should().BeTrue(); + (Id(after) == stableId).Should().BeTrue(); + AssertSame(defaultsBefore, await admin.GetAsync("default-groups"), "create-only import preserves configured realm defaults"); + AssertSame(defaultGroupBefore, await admin.GetAsync("groups/" + defaultGroupId), "the configured default group is not changed"); + foreach (var identity in SeedData.IdentityUsers.Where(identity => identity.Tenant != SeedData.IdentityUsers[0].Tenant)) + { + var created = await UserDetailsAsync(admin, identity.Username); + (await admin.GetAsync("users/" + Id(created) + "/groups?briefRepresentation=true")).GetArrayLength().Should().Be(0, + "atomic create-only import suppresses automatic default-group membership"); + } + var third = await KeycloakReconciliationDriver.RunAsync(fixture); + third.Success.Should().BeTrue("the completed third pass reports {0}/{1}", third.Stage, third.Reason); + third.Mutations.Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Group_carried_identity_requires_a_no_write_refusal(bool aggregateActor) + { + await using var fixture = await KeycloakFixture.StartAsync(); + using var admin = await fixture.CreateAdminClientAsync(); + var identity = SeedData.IdentityUsers[0]; + var user = await UserDetailsAsync(admin, identity.Username); + var userId = Id(user); + const string groupActor = "10000000-0000-7000-8000-000000000001"; + var groupOrganization = Guid.NewGuid().ToString("D"); + var groupName = aggregateActor ? "fixture-aggregate-identity" : "fixture-fallback-identity"; + var groupAttributes = aggregateActor + ? new Dictionary { ["user_id"] = [groupActor] } + : new Dictionary { ["organization_id"] = [groupOrganization] }; + var groupId = await CreateGroupAsync(admin, groupName, groupAttributes); + (await admin.PutAsync("users/" + userId + "/groups/" + groupId, new { })).Should().Be(HttpStatusCode.NoContent); + var scopes = await admin.GetAsync("client-scopes"); + var scopeId = Id(scopes.EnumerateArray().Single(value => value.GetProperty("name").GetString() == "learnstack-tenant-api")); + var mapperPath = "client-scopes/" + scopeId + "/protocol-mappers/models"; + var mappers = await admin.GetAsync(mapperPath); + var claim = aggregateActor ? "user_id" : "organization_id"; + var mapper = Object(mappers.EnumerateArray().Single(value => value.GetProperty("config").TryGetProperty("claim.name", out var claimName) + && claimName.GetString() == claim)); + mapper["config"]!["aggregate.attrs"] = aggregateActor ? "true" : "false"; + (await admin.PutAsync(mapperPath + "/" + mapper["id"]!.GetValue(), mapper)).Should().Be(HttpStatusCode.NoContent); + var groupBefore = await admin.GetAsync("groups/" + groupId); + var membershipsBefore = await admin.GetAsync("users/" + userId + "/groups?briefRepresentation=false&max=100"); + var before = await StateAsync(admin); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + var unchanged = await StateAsync(admin) == before; + AssertSame(groupBefore, await admin.GetAsync("groups/" + groupId), "reconciliation never changes group attributes"); + AssertSame(membershipsBefore, await admin.GetAsync("users/" + userId + "/groups?briefRepresentation=false&max=100"), "reconciliation never changes group membership"); + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + authorization.Authorized.Should().BeTrue("the isolated group control completes actual code/S256 authorization"); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var token = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + var actorWrong = token.GetProperty("user_id").GetString() != identity.UserId.Value.ToString("D"); + var actorFromGroup = token.GetProperty("user_id").GetString() == groupActor; + var unexpectedOrganization = token.TryGetProperty("organization_id", out var organization); + var organizationFromGroup = unexpectedOrganization && organization.GetString() == groupOrganization; + actorWrong.Should().Be(aggregateActor, "the signed token demonstrates the pinned mapper's group resolution"); + actorFromGroup.Should().Be(aggregateActor); + unexpectedOrganization.Should().Be(!aggregateActor); + organizationFromGroup.Should().Be(!aggregateActor); + outcome.Success.Should().BeFalse("closed outcome: {0}/{1} after {2} mutating calls; signed actor wrong/from group: {3}/{4}; organization unexpected/from group: {5}/{6}", + outcome.Stage ?? "success", outcome.Reason ?? "none", outcome.Mutations, actorWrong, actorFromGroup, unexpectedOrganization, organizationFromGroup); + outcome.Stage.Should().Be("plan"); + outcome.Reason.Should().Be(aggregateActor ? "mapper_conflict" : "identity_conflict"); + outcome.Mutations.Should().Be(0); + unchanged.Should().BeTrue(); + } + + [Fact] + public async Task Unrelated_nested_groups_converge_and_preserve_membership_and_strict_token_identity() + { + await using var fixture = await KeycloakFixture.StartAsync(); + using var admin = await fixture.CreateAdminClientAsync(); + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var parentId = await CreateGroupAsync(admin, "fixture-unrelated-parent", + new Dictionary { ["fixture_note"] = ["preserved-parent"] }); + var childId = await CreateGroupAsync(admin, "fixture-unrelated-child", + new Dictionary { ["fixture_note"] = ["preserved-child"] }, parentId); + var identity = SeedData.IdentityUsers[0]; + var userBefore = await UserDetailsAsync(admin, identity.Username); + var userId = Id(userBefore); + (await admin.PutAsync("users/" + userId + "/groups/" + childId, new { })).Should().Be(HttpStatusCode.NoContent); + userBefore = await UserDetailsAsync(admin, identity.Username); + var parentBefore = await admin.GetAsync("groups/" + parentId); + var childBefore = await admin.GetAsync("groups/" + childId); + var membershipsBefore = await admin.GetAsync("users/" + userId + "/groups?briefRepresentation=true"); + var credentialsBefore = await admin.GetAsync("users/" + userId + "/credentials"); + var rolesBefore = await admin.GetAsync("users/" + userId + "/role-mappings"); + var first = await KeycloakReconciliationDriver.RunAsync(fixture); + first.Success.Should().BeTrue("closed outcome: {0}/{1} after {2} mutating calls", first.Stage, first.Reason, first.Mutations); + first.Mutations.Should().Be(1); + var second = await KeycloakReconciliationDriver.RunAsync(fixture); + second.Success.Should().BeTrue("closed outcome: {0}/{1}", second.Stage, second.Reason); + second.Mutations.Should().Be(0); + AssertSame(parentBefore, await admin.GetAsync("groups/" + parentId), "the unrelated parent group is preserved"); + AssertSame(childBefore, await admin.GetAsync("groups/" + childId), "the unrelated nested group is preserved"); + AssertSame(membershipsBefore, await admin.GetAsync("users/" + userId + "/groups?briefRepresentation=true"), "nested membership is preserved"); + AssertSame(userBefore, await UserDetailsAsync(admin, identity.Username), "the member's stored account is preserved"); + AssertSame(credentialsBefore, await admin.GetAsync("users/" + userId + "/credentials"), "the member's credentials are preserved"); + AssertSame(rolesBefore, await admin.GetAsync("users/" + userId + "/role-mappings"), "the member's role assignments are preserved"); + await AssertLoginAsync(fixture, identity, KeycloakFixture.DemoPassword, userId); + } + + [Fact] + public async Task Retained_cached_organization_groups_do_not_override_strict_user_identity() + { + await using var fixture = await KeycloakFixture.StartAsync(); + using var admin = await fixture.CreateAdminClientAsync(); + var identity = SeedData.IdentityUsers[0]; + var user = await UserDetailsAsync(admin, identity.Username); + var userId = Id(user); + using var establishedBrowser = fixture.Browser(); + var authorization = await establishedBrowser.AuthorizeAsync(identity.Username, KeycloakFixture.DemoPassword, Callback(identity)); + authorization.Authorized.Should().BeTrue("the ordinary code/S256 session is established before enabling Organizations"); + var session = await establishedBrowser.RedeemAsync(authorization); + session.StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.PutAsync("", new { organizationsEnabled = true })).Should().Be(HttpStatusCode.NoContent); + const string organizationName = "Fixture retained organization"; + var canaries = new Dictionary + { + ["user_id"] = ["10000000-0000-7000-8000-000000000001"], + ["tenant_id"] = [Guid.NewGuid().ToString("D")], + ["organization_id"] = [Guid.NewGuid().ToString("D")], + }; + (await admin.PostAsync("organizations", new { name = organizationName, alias = "fixture-retained-organization", attributes = canaries })) + .Should().Be(HttpStatusCode.Created); + var organizations = await admin.GetAsync("organizations?max=100"); + var organizationId = Id(organizations.EnumerateArray().Single(value => value.GetProperty("name").GetString() == organizationName)); + (await admin.PostAsync("organizations/" + organizationId + "/members", userId)).Should().Be(HttpStatusCode.Created); + var verifiedMember = await admin.GetAsync("organizations/" + organizationId + "/members/" + userId); + (Id(verifiedMember) == userId).Should().BeTrue("the enabled organization API verifies the exact member"); + var organizationBefore = await admin.GetAsync("organizations/" + organizationId); + foreach (var canary in canaries) + (organizationBefore.GetProperty("attributes").GetProperty(canary.Key)[0].GetString() == canary.Value[0]).Should().BeTrue(); + var membershipPath = "users/" + userId + "/groups?briefRepresentation=true"; + (await admin.GetAsync(membershipPath)).GetArrayLength().Should().Be(0, "the pinned cached-user stream excludes organization groups"); + var enabledExchange = await establishedBrowser.RequestTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = "learnstack-web", + ["client_secret"] = KeycloakFixture.WebClientSecret, + ["refresh_token"] = session.RefreshToken, + }); + enabledExchange.StatusCode.Should().Be(HttpStatusCode.OK); + var enabledToken = await KeycloakTokenVerifier.ValidateAsync(fixture, enabledExchange.AccessToken, "learnstack-api"); + (enabledToken.GetProperty("user_id").GetString() == identity.UserId.Value.ToString("D")).Should().BeTrue(); + (enabledToken.GetProperty("tenant_id").GetString() == identity.Tenant.TenantId.Value.ToString("D")).Should().BeTrue(); + enabledToken.TryGetProperty("organization_id", out _).Should().BeFalse(); + (enabledToken.GetProperty("sub").GetString() == userId).Should().BeTrue(); + enabledExchange.Window.AcceptsFresh(enabledToken, 900).Should().BeTrue(); + (await admin.PutAsync("", new { organizationsEnabled = false })).Should().Be(HttpStatusCode.NoContent); + (await admin.GetAsync()).GetProperty("organizationsEnabled").GetBoolean().Should().BeFalse(); + (await admin.GetAsync(membershipPath)).GetArrayLength().Should().Be(0, "disabling the feature retains the same cached-user group resolution"); + await AssertLoginAsync(fixture, identity, KeycloakFixture.DemoPassword, userId); + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var memberBefore = await UserDetailsAsync(admin, identity.Username); + var credentialsBefore = await admin.GetAsync("users/" + userId + "/credentials"); + var rolesBefore = await admin.GetAsync("users/" + userId + "/role-mappings"); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + outcome.Success.Should().BeTrue("closed outcome: {0}/{1} after {2} mutating calls", outcome.Stage, outcome.Reason, outcome.Mutations); + outcome.Mutations.Should().Be(1); + var repeat = await KeycloakReconciliationDriver.RunAsync(fixture); + repeat.Success.Should().BeTrue("closed outcome: {0}/{1}", repeat.Stage, repeat.Reason); + repeat.Mutations.Should().Be(0); + (await admin.GetAsync()).GetProperty("organizationsEnabled").GetBoolean().Should().BeFalse(); + (await admin.GetAsync(membershipPath)).GetArrayLength().Should().Be(0); + AssertSame(memberBefore, await UserDetailsAsync(admin, identity.Username), "the member's stored account is preserved"); + AssertSame(credentialsBefore, await admin.GetAsync("users/" + userId + "/credentials"), "the member's credentials are preserved"); + AssertSame(rolesBefore, await admin.GetAsync("users/" + userId + "/role-mappings"), "the member's role mappings are preserved"); + // Test-owned visibility restoration occurs after the reconciliation results and preservation checks. + (await admin.PutAsync("", new { organizationsEnabled = true })).Should().Be(HttpStatusCode.NoContent); + AssertSame(organizationBefore, await admin.GetAsync("organizations/" + organizationId), "stored organization canaries are retained"); + AssertSame(verifiedMember, await admin.GetAsync("organizations/" + organizationId + "/members/" + userId), "the organization membership is retained"); + (await admin.PutAsync("", new { organizationsEnabled = false })).Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Identity_mapper_secret_and_duplicate_lookup_conflicts_refuse_before_the_first_write() + { + var identity = SeedData.IdentityUsers[0]; + await using (var fixture = await KeycloakFixture.StartAsync()) + { + using var admin = await fixture.CreateAdminClientAsync(); + // A legitimate owned change is pending, so zero writes proves complete planning precedes apply. + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var user = await admin.UserAsync(identity.Username); + var changedUser = Object(user); + changedUser["attributes"]!["user_id"] = new JsonArray(Guid.NewGuid().ToString("D")); + (await admin.PutAsync("users/" + Id(user), changedUser)).Should().Be(HttpStatusCode.NoContent); + await AssertRefusesWithoutWriteAsync(fixture, admin, "identity_conflict"); + (await admin.PutAsync("users/" + Id(user), Object(user))).Should().Be(HttpStatusCode.NoContent); + + var web = await admin.ClientAsync("learnstack-web"); + var directMapper = new JsonObject + { + ["name"] = "unowned-identity-collision", + ["protocol"] = "openid-connect", + ["protocolMapper"] = "oidc-usermodel-attribute-mapper", + ["config"] = new JsonObject + { + ["user.attribute"] = "other-identity", + ["claim.name"] = "user_id", + ["jsonType.label"] = "String", + ["access.token.claim"] = "true", + }, + }; + var mapperPath = "clients/" + Id(web) + "/protocol-mappers/models"; + (await admin.PostAsync(mapperPath, directMapper)).Should().Be(HttpStatusCode.Created); + await AssertRefusesWithoutWriteAsync(fixture, admin, "mapper_conflict"); + var mappers = await admin.GetAsync(mapperPath); + var collision = mappers.EnumerateArray().Single(value => value.GetProperty("name").GetString() == "unowned-identity-collision"); + (await admin.DeleteAsync(mapperPath + "/" + Id(collision))).Should().Be(HttpStatusCode.NoContent); + + var changedWeb = Object(web); + changedWeb["secret"] = "different-private-fixture-source"; + (await admin.PutAsync("clients/" + Id(web), changedWeb)).Should().Be(HttpStatusCode.NoContent); + await AssertRefusesWithoutWriteAsync(fixture, admin, "secret_conflict"); + changedWeb["secret"] = KeycloakFixture.WebClientSecret; + (await admin.PutAsync("clients/" + Id(web), changedWeb)).Should().Be(HttpStatusCode.NoContent); + await AssertRefusesWithoutWriteAsync(fixture, admin, "identity_conflict", "duplicate_lookup"); + + var clean = await KeycloakReconciliationDriver.RunAsync(fixture); + clean.Success.Should().BeTrue("removing each planted conflict restores convergence (closed outcome: {0}/{1})", clean.Stage, clean.Reason); + clean.Mutations.Should().BeGreaterThan(0); + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + await AssertLoginAsync(fixture, identity, KeycloakFixture.DemoPassword); + } + + // Under email-as-username, the imported distinct email produces a conflicting + // provider username projection. Preserve that account and refuse before writes. + await using var inconsistent = await KeycloakFixture.StartAsync(await LegacyRealmAsync(registrationEmailAsUsername: true)); + using var inconsistentAdmin = await inconsistent.CreateAdminClientAsync(); + (await inconsistentAdmin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(1800, + "the negative control has a legitimate pending owned change"); + var existing = await inconsistentAdmin.UserAsync(identity.Username); + (existing.GetProperty("username").GetString() == identity.Username).Should().BeFalse( + "the negative control exercises an actual provider username disagreement"); + var stableId = Id(existing); + await AssertRefusesWithoutWriteAsync(inconsistent, inconsistentAdmin, "identity_conflict"); + (Id(await inconsistentAdmin.UserAsync(identity.Username)) == stableId).Should().BeTrue(); + (await inconsistentAdmin.GetAsync()).GetProperty("registrationEmailAsUsername").GetBoolean().Should().BeTrue(); + } + + [Theory] + [InlineData("unmanaged_disabled")] + [InlineData("managed_admin_hidden")] + public Task Hidden_stored_identity_refuses_before_ordinary_writes_and_preserves_the_conflicting_actor(string visibility) => + AssertUnsafeIdentityProfileRefusesAsync(visibility, hiddenConflict: true); + + [Theory] + [InlineData("managed_selector")] + [InlineData("managed_default")] + public Task Projected_identity_profile_configuration_refuses_before_any_pending_write(string visibility) => + AssertUnsafeIdentityProfileRefusesAsync(visibility, hiddenConflict: false); + + private static async Task AssertUnsafeIdentityProfileRefusesAsync(string visibility, bool hiddenConflict) + { + var identity = SeedData.IdentityUsers[0]; + var storedActor = hiddenConflict ? Guid.NewGuid().ToString("D") : identity.UserId.Value.ToString("D"); + var realm = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!.AsObject(); + realm["accessTokenLifespan"] = 1800; + var profileValue = realm["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]!["config"]!["kc.user.profile.config"]!; + var profile = JsonNode.Parse(profileValue[0]!.GetValue())!.AsObject(); + var definitions = profile["attributes"]!.AsArray(); + var identityDefinitions = definitions.Where(value => new[] { "user_id", "tenant_id", "organization_id" } + .Contains(value!["name"]!.GetValue(), StringComparer.Ordinal)).Select(value => value!.DeepClone()).ToArray(); + if (visibility == "unmanaged_disabled") + { + foreach (var definition in identityDefinitions) + definitions.Remove(definitions.Single(value => value!["name"]!.GetValue() == definition["name"]!.GetValue())); + profile["unmanagedAttributePolicy"] = null; + } + else if (visibility == "managed_admin_hidden") + { + foreach (var definition in definitions.Where(value => identityDefinitions.Any(identityDefinition => + identityDefinition["name"]!.GetValue() == value!["name"]!.GetValue()))) + definition!["permissions"] = new JsonObject + { + ["view"] = new JsonArray("user"), + ["edit"] = new JsonArray("user"), + }; + profile["unmanagedAttributePolicy"] = "ENABLED"; + } + else + { + var actorDefinition = definitions.Single(value => value!["name"]!.GetValue() == "user_id")!; + if (visibility == "managed_selector") + actorDefinition["selector"] = new JsonObject { ["scopes"] = new JsonArray("profile") }; + else actorDefinition["defaultValue"] = storedActor; + } + profileValue[0] = profile.ToJsonString(); + var importedUser = realm["users"]!.AsArray().Single(value => value!["username"]!.GetValue() == identity.Username)!; + importedUser["attributes"]!["user_id"] = new JsonArray(storedActor); + + await using var fixture = await KeycloakFixture.StartAsync(realm.ToJsonString()); + using var admin = await fixture.CreateAdminClientAsync(); + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(1800, + "a legitimate owned change remains pending behind the identity visibility guard"); + var hidden = await UserDetailsAsync(admin, identity.Username); + var stableId = Id(hidden); + var actorVisible = hidden.TryGetProperty("attributes", out var hiddenAttributes) && hiddenAttributes.TryGetProperty("user_id", out _); + if (hiddenConflict) actorVisible.Should().BeFalse("the pinned provider actually hides the stored conflicting identity"); + else + { + actorVisible.Should().BeTrue("a correct visible actor isolates the unsupported identity profile configuration"); + (hiddenAttributes.GetProperty("user_id")[0].GetString() == storedActor).Should().BeTrue(); + } + var preparationExpected = visibility == "unmanaged_disabled"; + var profileBefore = await admin.GetAsync("users/profile"); + var ordinaryBefore = preparationExpected ? await PreparationOrdinaryStateAsync(admin) : null; + var before = await StateAsync(admin); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + var after = await StateAsync(admin); + if (preparationExpected) + { + AssertPreparationProfile(await AppendedPreparationProfileAsync(profileBefore), await admin.GetAsync("users/profile")); + (await PreparationOrdinaryStateAsync(admin) == ordinaryBefore).Should().BeTrue( + "the fresh-plan actor conflict allows only the exceptional metadata preparation"); + } + + // Test-owned inspection starts only after the reconciler outcome and its state snapshot. + var visibleProfile = Object(await admin.GetAsync("users/profile")); + var visibleDefinitions = visibleProfile["attributes"]!.AsArray(); + foreach (var definition in identityDefinitions) + { + var existing = visibleDefinitions.SingleOrDefault(value => value!["name"]!.GetValue() == definition["name"]!.GetValue()); + if (existing is not null) visibleDefinitions.Remove(existing); + visibleDefinitions.Add(definition.DeepClone()); + } + (await admin.PutAsync("users/profile", visibleProfile)).Should().Be(HttpStatusCode.OK); + var revealed = await admin.GetAsync("users/" + stableId); + var storedActorPreserved = revealed.GetProperty("attributes").GetProperty("user_id")[0].GetString() == storedActor; + outcome.Success.Should().BeFalse("closed outcome: {0}/{1} after {2} mutating calls; stored actor preserved: {3}", + outcome.Stage ?? "success", outcome.Reason ?? "none", outcome.Mutations, storedActorPreserved); + outcome.Mutations.Should().Be(preparationExpected ? 1 : 0, "closed outcome: {0}/{1}; stored actor preserved: {2}", + outcome.Stage ?? "success", outcome.Reason ?? "none", storedActorPreserved); + outcome.PreparationDispatched.Should().Be(preparationExpected); + outcome.Stage.Should().Be("plan"); + outcome.Reason.Should().Be("identity_conflict"); + if (!preparationExpected) + (after == before).Should().BeTrue("the independent snapshot precedes the test-owned visibility change"); + storedActorPreserved.Should().BeTrue("the stored actor is never overwritten"); + (Id(revealed) == stableId).Should().BeTrue(); + } + + [Fact] + public async Task Readback_failure_is_nonzero_and_a_new_explicit_pass_verifies_the_committed_result() + { + await using var fixture = await KeycloakFixture.StartAsync(); + using var admin = await fixture.CreateAdminClientAsync(); + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var failed = await KeycloakReconciliationDriver.RunAsync(fixture, "readback_failure"); + AssertFailure(failed, "readback", "transport"); + failed.Mutations.Should().BeGreaterThan(0); + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + var explicitRetry = await KeycloakReconciliationDriver.RunAsync(fixture); + explicitRetry.Success.Should().BeTrue("closed outcome: {0}/{1}", explicitRetry.Stage, explicitRetry.Reason); + explicitRetry.Mutations.Should().Be(0); + } + + private static async Task AssertRefusesWithoutWriteAsync(KeycloakFixture fixture, KeycloakAdminClient admin, + string reason, string scenario = "normal") + { + var before = await StateAsync(admin); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture, scenario); + AssertFailure(outcome, "plan", reason); + outcome.Mutations.Should().Be(0, "the complete bounded plan refuses conflicting state before any write"); + (await StateAsync(admin) == before).Should().BeTrue("independent real-provider snapshots witness zero changes"); + } + + private static void AssertFailure(ReconciliationOutcome outcome, string stage, string reason) + { + outcome.Success.Should().BeFalse(); + outcome.Stage.Should().Be(stage, "the closed failure reason is {0}", outcome.Reason); + outcome.Reason.Should().Be(reason); + } + + private static async Task AssertStoredIdentityAsync(KeycloakAdminClient admin, SeedIdentity identity) + { + var user = await admin.UserAsync(identity.Username); + var attributes = user.GetProperty("attributes"); + var expected = new Dictionary + { + ["user_id"] = identity.UserId.Value.ToString("D"), + ["tenant_id"] = identity.Tenant.TenantId.Value.ToString("D"), + }; + if (identity.Tenant.MapHostToDefaultOrganization) + expected["organization_id"] = identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D"); + foreach (var (name, value) in expected) + { + var stored = attributes.GetProperty(name); + (stored.ValueKind == JsonValueKind.Array && stored.GetArrayLength() == 1 + && stored[0].ValueKind == JsonValueKind.String && stored[0].GetString() == value).Should().BeTrue( + "each migrated persona stores its independently declared typed authority"); + } + if (!expected.ContainsKey("organization_id")) attributes.TryGetProperty("organization_id", out _).Should().BeFalse(); + } + + private static async Task AssertOwnedScopeAndMapperRepairAsync(KeycloakAdminClient admin, KeycloakFixture fixture) + { + var scope = await ScopeAsync(admin, "learnstack-tenant-api"); + var path = "client-scopes/" + Id(scope); + var changedScope = Object(scope); + changedScope["attributes"]!["include.in.token.scope"] = "false"; + (await admin.PutAsync(path, changedScope)).Should().Be(HttpStatusCode.NoContent); + var mapper = (await admin.GetAsync(path + "/protocol-mappers/models")).EnumerateArray() + .Single(value => value.GetProperty("name").GetString() == "user_id"); + var changedMapper = Object(mapper); + changedMapper["config"]!["id.token.claim"] = "false"; + var mapperPath = path + "/protocol-mappers/models/" + Id(mapper); + (await admin.PutAsync(mapperPath, changedMapper)).Should().Be(HttpStatusCode.NoContent); + var result = await KeycloakReconciliationDriver.RunAsync(fixture); + result.Success.Should().BeTrue("closed owned-repair outcome: {0}/{1}", result.Stage, result.Reason); + result.Mutations.Should().Be(2, "one real update_scope and one real update_mapper repair are necessary"); + AssertSame(scope, await admin.GetAsync(path), "the owned scope repair restores the original complete scope representation"); + AssertSame(mapper, await admin.GetAsync(mapperPath), "the owned mapper repair restores the original complete mapper representation"); + } + + private static async Task AssertLoginAsync(KeycloakFixture fixture, SeedIdentity identity, string password, string? subject = null) + { + using var browser = fixture.Browser(); + var authorization = await browser.AuthorizeAsync(identity.Username, password, Callback(identity)); + authorization.Authorized.Should().BeTrue("the preserved/new account completes real code/S256 authorization (closed outcome: {0})", + authorization.Error ?? "no_error_code"); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var access = await KeycloakTokenVerifier.ValidateAsync(fixture, exchange.AccessToken, "learnstack-api"); + (access.GetProperty("user_id").GetString() == identity.UserId.Value.ToString("D")).Should().BeTrue(); + (access.GetProperty("tenant_id").GetString() == identity.Tenant.TenantId.Value.ToString("D")).Should().BeTrue(); + if (subject is not null) (access.GetProperty("sub").GetString() == subject).Should().BeTrue("the existing provider subject is preserved"); + if (identity.Tenant.MapHostToDefaultOrganization) + (access.GetProperty("organization_id").GetString() == identity.Tenant.DefaultOrganization.OrganizationId.Value.ToString("D")) + .Should().BeTrue(); + else access.TryGetProperty("organization_id", out _).Should().BeFalse(); + exchange.Window.AcceptsFresh(access, 900).Should().BeTrue(); + } + + private static async Task AssertManagedConfigurationAsync(KeycloakAdminClient admin) + { + (await admin.GetAsync()).GetProperty("accessTokenLifespan").GetInt32().Should().Be(900); + foreach (var clientId in new[] { "learnstack-web", "learnstack-api" }) + { + var client = await admin.ClientAsync(clientId); + client.GetProperty("publicClient").GetBoolean().Should().BeFalse(); + (client.GetProperty("clientAuthenticatorType").GetString() == "client-secret").Should().BeTrue(); + client.GetProperty("standardFlowEnabled").GetBoolean().Should().Be(clientId == "learnstack-web"); + var expectedRedirects = clientId == "learnstack-web" + ? SeedData.All.Select(tenant => "https://" + tenant.Host + ":3000/api/auth/callback/keycloak") : []; + client.GetProperty("redirectUris").EnumerateArray().Select(value => value.GetString()) + .Should().BeEquivalentTo(expectedRedirects); + if (clientId == "learnstack-web") + client.GetProperty("attributes").GetProperty("post.logout.redirect.uris").GetString()!.Split("##") + .Should().BeEquivalentTo(SeedData.All.Select(tenant => "https://" + tenant.Host + ":3000/")); + client.GetProperty("webOrigins").GetArrayLength().Should().Be(0, + "the declared empty origin list survives redirect changes without provider-derived origins"); + client.GetProperty("attributes").TryGetProperty("access.token.lifespan", out _).Should().BeFalse(); + var defaults = await admin.GetAsync("clients/" + Id(client) + "/default-client-scopes"); + defaults.EnumerateArray().Select(value => value.GetProperty("name").GetString()) + .Should().Contain(["basic", "learnstack-tenant-api", "unrelated-fixture-scope"]); + } + var profile = await admin.GetAsync("users/profile"); + foreach (var name in new[] { "user_id", "tenant_id", "organization_id" }) + { + var definition = profile.GetProperty("attributes").EnumerateArray().Single(value => value.GetProperty("name").GetString() == name); + definition.GetProperty("multivalued").GetBoolean().Should().BeFalse(); + foreach (var permission in new[] { "view", "edit" }) + definition.GetProperty("permissions").GetProperty(permission).EnumerateArray().Select(value => value.GetString()) + .Should().Equal("admin"); + } + } + + private static async Task AssertUnrelatedConfigurationAsync(KeycloakAdminClient admin, JsonElement profileBefore) + { + var realm = await admin.GetAsync(); + realm.GetProperty("registrationEmailAsUsername").GetBoolean().Should().BeFalse( + "reconciliation preserves the unrelated warm realm username policy"); + (realm.GetProperty("displayName").GetString() == "Preserved warm-realm name").Should().BeTrue(); + realm.GetProperty("ssoSessionMaxLifespan").GetInt32().Should().Be(43200); + var profile = await admin.GetAsync("users/profile"); + AssertSame(profileBefore.GetProperty("groups"), profile.GetProperty("groups"), "unrelated profile groups survive merge"); + (profile.GetProperty("unmanagedAttributePolicy").GetString() == profileBefore.GetProperty("unmanagedAttributePolicy").GetString()) + .Should().BeTrue(); + var oldAttribute = profileBefore.GetProperty("attributes").EnumerateArray().Single(value => value.GetProperty("name").GetString() == "legacy_note"); + var attribute = profile.GetProperty("attributes").EnumerateArray().Single(value => value.GetProperty("name").GetString() == "legacy_note"); + AssertSame(oldAttribute, attribute, "unrelated profile definitions survive merge"); + foreach (var clientId in new[] { "learnstack-web", "learnstack-api" }) + { + var client = await admin.ClientAsync(clientId); + (client.GetProperty("attributes").GetProperty("unrelated.fixture.key").GetString() == "preserved").Should().BeTrue(); + (client.GetProperty("name").GetString() == "Preserved " + clientId).Should().BeTrue(); + } + } + + private static async Task AssertOnlyAccountGrantAddedAsync(KeycloakAdminClient admin, string userId, JsonElement before) + { + var account = await admin.ClientAsync("account"); + var basePath = "users/" + userId + "/role-mappings/clients/" + Id(account); + var direct = await admin.GetAsync(basePath); + direct.EnumerateArray().Select(role => role.GetProperty("name").GetString()).Should().Equal("manage-account"); + var role = await admin.GetAsync("clients/" + Id(account) + "/roles/manage-account"); + (Id(direct[0]) == Id(role)).Should().BeTrue("only the qualified built-in capability is added"); + var effective = await admin.GetAsync(basePath + "/composite"); + effective.EnumerateArray().Select(value => value.GetProperty("name").GetString()).Should() + .BeEquivalentTo(AccountSelfServiceRoles); + var after = Object(await admin.GetAsync("users/" + userId + "/role-mappings")); + after["clientMappings"]!.AsObject().Remove("account"); + if (after["clientMappings"]!.AsObject().Count == 0) after.Remove("clientMappings"); + JsonNode.DeepEquals(JsonNode.Parse(before.GetRawText()), after).Should().BeTrue( + "every prior realm and client mapping survives the single approved addition"); + } + + private static void AssertSamePreservedUser(JsonElement before, JsonElement after) + { + var old = Object(before); + var current = Object(after); + foreach (var user in new[] { old, current }) + foreach (var name in new[] { "user_id", "tenant_id", "organization_id" }) user["attributes"]?.AsObject().Remove(name); + JsonNode.DeepEquals(old, current).Should().BeTrue("only the three declared identity attributes may change on an existing account"); + } + + private static async Task CreateGroupAsync(KeycloakAdminClient admin, string name, + IReadOnlyDictionary attributes, string? parentId = null) + { + var path = parentId is null ? "groups" : "groups/" + parentId + "/children"; + (await admin.PostAsync(path, new { name, attributes })).Should().Be(HttpStatusCode.Created); + var groups = await admin.GetAsync(path + "?briefRepresentation=false&max=100"); + return Id(groups.EnumerateArray().Single(value => value.GetProperty("name").GetString() == name)); + } + + // Lookup responses include realm-wide profile metadata; detail responses hold the account state being preserved. + private static async Task UserDetailsAsync(KeycloakAdminClient admin, string username) => + await admin.GetAsync("users/" + Id(await admin.UserAsync(username))); + + private static void AssertSame(JsonElement before, JsonElement after, string because) => + JsonNode.DeepEquals(JsonNode.Parse(before.GetRawText()), JsonNode.Parse(after.GetRawText())).Should().BeTrue(because); + + private static async Task StateAsync(KeycloakAdminClient admin) + { + var state = new JsonObject { ["realm"] = JsonNode.Parse((await admin.GetAsync()).GetRawText()) }; + foreach (var path in new[] { "users?max=100&briefRepresentation=false", "clients", "client-scopes", "users/profile", "roles", + "groups?max=100&briefRepresentation=false", "default-groups" }) + state[path] = JsonNode.Parse((await admin.GetAsync(path)).GetRawText()); + foreach (var user in state["users?max=100&briefRepresentation=false"]!.AsArray()) + { + var id = user!["id"]!.GetValue(); + state["user/" + id] = JsonNode.Parse((await admin.GetAsync("users/" + id)).GetRawText()); + state["groups/" + id] = JsonNode.Parse((await admin.GetAsync("users/" + id + "/groups?max=100&briefRepresentation=false")).GetRawText()); + state["credentials/" + id] = JsonNode.Parse((await admin.GetAsync("users/" + id + "/credentials")).GetRawText()); + state["roles/" + id] = JsonNode.Parse((await admin.GetAsync("users/" + id + "/role-mappings")).GetRawText()); + } + foreach (var client in state["clients"]!.AsArray()) + { + var id = client!["id"]!.GetValue(); + foreach (var path in new[] { "default-client-scopes", "optional-client-scopes", "protocol-mappers/models" }) + state["clients/" + id + "/" + path] = JsonNode.Parse((await admin.GetAsync("clients/" + id + "/" + path)).GetRawText()); + if (client["clientId"]!.GetValue() is "learnstack-web" or "learnstack-api") + state["clients/" + id + "/client-secret"] = JsonNode.Parse((await admin.GetAsync("clients/" + id + "/client-secret")).GetRawText()); + } + foreach (var scope in state["client-scopes"]!.AsArray()) + { + var id = scope!["id"]!.GetValue(); + state["mappers/" + id] = JsonNode.Parse((await admin.GetAsync("client-scopes/" + id + "/protocol-mappers/models")).GetRawText()); + } + return Canonical(state).ToJsonString(); + } + + private static JsonNode Canonical(JsonNode? node, bool preserveArrayOrder = false) => node switch + { + JsonObject value => new JsonObject(value.OrderBy(property => property.Key, StringComparer.Ordinal) + .Select(property => new KeyValuePair(property.Key, property.Value is null ? null : + Canonical(property.Value, preserveArrayOrder || property.Key is "attributes" or "requiredActions")))), + JsonArray value => new JsonArray((preserveArrayOrder + ? value.Select(item => item is null ? null : Canonical(item, true)) + : value.Select(item => item is null ? null : Canonical(item)) + .OrderBy(item => item?.ToJsonString(), StringComparer.Ordinal)).ToArray()), + _ => node!.DeepClone(), + }; + + private static async Task ScopeAsync(KeycloakAdminClient admin, string name) + { + var scope = (await admin.GetAsync("client-scopes")).EnumerateArray().Single(value => value.GetProperty("name").GetString() == name); + return await admin.GetAsync("client-scopes/" + Id(scope)); + } + + private static string Id(JsonElement value) => value.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Keycloak fixture expected a resource ID."); + private static JsonObject Object(JsonElement value) => JsonNode.Parse(value.GetRawText())!.AsObject(); + private static string Callback(SeedIdentity identity) => "https://" + identity.Tenant.Host + ":3000/api/auth/callback/keycloak"; + + // Controlled legacy import is derived from the committed desired state, without a runtime git dependency. + private static async Task LegacyRealmAsync(bool registrationEmailAsUsername = false) + { + var realm = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!.AsObject(); + realm["accessTokenLifespan"] = 1800; + realm["registrationEmailAsUsername"] = registrationEmailAsUsername; + realm["displayName"] = "Preserved warm-realm name"; + realm["ssoSessionMaxLifespan"] = 43200; + var scopes = realm["clientScopes"]!.AsArray(); + scopes.Remove(scopes.Single(value => value!["name"]!.GetValue() == "learnstack-tenant-api")); + scopes.Add(new JsonObject + { + ["name"] = "unrelated-fixture-scope", + ["protocol"] = "openid-connect", + ["description"] = "Preserved unrelated scope", + ["attributes"] = new JsonObject { ["include.in.token.scope"] = "true" }, + ["protocolMappers"] = new JsonArray(new JsonObject + { + ["name"] = "preserved-note", + ["protocol"] = "openid-connect", + ["protocolMapper"] = "oidc-usermodel-attribute-mapper", + ["config"] = new JsonObject + { + ["claim.name"] = "fixture_note", + ["user.attribute"] = "legacy_note", + ["jsonType.label"] = "String", + ["access.token.claim"] = "true", + ["multivalued"] = "false" + }, + }) + }); + realm["roles"]!["realm"]!.AsArray().Add(new JsonObject { ["name"] = "fixture-retained-role" }); + realm["roles"]!["client"]!["learnstack-web"] = + new JsonArray(new JsonObject { ["name"] = "fixture-client-role", ["clientRole"] = true }); + foreach (var client in realm["clients"]!.AsArray()) + { + var name = client!["clientId"]!.GetValue(); + if (name is not ("learnstack-web" or "learnstack-api")) continue; + client["name"] = "Preserved " + name; + client["attributes"]!["access.token.lifespan"] = "1800"; + client["attributes"]!["unrelated.fixture.key"] = "preserved"; + var defaults = client["defaultClientScopes"]!.AsArray(); + defaults.Remove(defaults.Single(value => value!.GetValue() == "learnstack-tenant-api")); + defaults.Add("unrelated-fixture-scope"); + if (name == "learnstack-web") + { + client["publicClient"] = true; + client.AsObject().Remove("secret"); + client["redirectUris"] = new JsonArray("http://localhost:3000/*"); + } + else + { + defaults.Add("service_account"); + client["authorizationServicesEnabled"] = true; + client["authorizationSettings"] = new JsonObject + { + ["allowRemoteResourceManagement"] = false, + ["policyEnforcementMode"] = "PERMISSIVE", + ["decisionStrategy"] = "AFFIRMATIVE", + ["resources"] = new JsonArray(), + ["scopes"] = new JsonArray(), + ["policies"] = new JsonArray(), + }; + client["standardFlowEnabled"] = true; + client["redirectUris"] = new JsonArray("http://localhost:5000/*"); + } + } + var console = realm["clients"]!.AsArray().Single(value => value!["clientId"]!.GetValue() == "account-console")!; + var consoleMappers = console["protocolMappers"]!.AsArray(); + consoleMappers.Remove(consoleMappers.Single(value => value!["name"]!.GetValue() == "learnstack-account-self-service-roles")); + var users = realm["users"]!.AsArray(); + foreach (var user in users) user!.AsObject().Remove("clientRoles"); + while (users.Count > 2) users.RemoveAt(users.Count - 1); + foreach (var user in users) + user!["attributes"] = new JsonObject { ["tenant_id"] = new JsonArray("tenant-a"), ["legacy_note"] = new JsonArray("retain-me") }; + users[0]!["firstName"] = "Preserved"; + users[0]!["lastName"] = "Identity"; + users[0]!["email"] = "changed-account-email@fixture.test"; + users[0]!["credentials"]![0]!["value"] = ChangedPassword; + users[0]!["realmRoles"]!.AsArray().Add("fixture-retained-role"); + users[0]!["clientRoles"] = new JsonObject { ["learnstack-web"] = new JsonArray("fixture-client-role") }; + users[1]!["enabled"] = false; + users[1]!["requiredActions"] = new JsonArray("UPDATE_PASSWORD"); + users[1]!["credentials"]!.AsArray().Add(new JsonObject + { + ["type"] = "otp", + ["userLabel"] = "Preserved fixture MFA", + ["secretData"] = "{\"value\":\"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\"}", + ["credentialData"] = "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}", + }); + users.Add(new JsonObject + { + ["username"] = ExtraUsername, + ["email"] = ExtraUsername, + ["emailVerified"] = true, + ["enabled"] = true, + ["firstName"] = "Unmanaged", + ["lastName"] = "Account", + ["realmRoles"] = new JsonArray("tenant-instructor"), + ["attributes"] = new JsonObject { ["legacy_note"] = new JsonArray("extra-account-retained") }, + ["credentials"] = new JsonArray(new JsonObject { ["type"] = "password", ["value"] = ChangedPassword, ["temporary"] = false }), + }); + var profileValue = realm["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]!["config"]!["kc.user.profile.config"]!; + var profile = JsonNode.Parse(profileValue[0]!.GetValue())!.AsObject(); + var attributes = profile["attributes"]!.AsArray(); + foreach (var value in attributes.Where(value => new[] { "user_id", "tenant_id", "organization_id" } + .Contains(value!["name"]!.GetValue(), StringComparer.Ordinal)).ToArray()) attributes.Remove(value); + profile["unmanagedAttributePolicy"] = "ENABLED"; + profile["groups"]!.AsArray().Add(new JsonObject { ["name"] = "unrelated-fixture-group", ["displayHeader"] = "Preserved metadata" }); + attributes.Add(new JsonObject + { + ["name"] = "legacy_note", + ["multivalued"] = true, + ["group"] = "unrelated-fixture-group", + ["permissions"] = new JsonObject + { ["view"] = new JsonArray("admin"), ["edit"] = new JsonArray("admin") } + }); + profileValue[0] = profile.ToJsonString(); + return realm.ToJsonString(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmSelfServiceTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmSelfServiceTests.cs new file mode 100644 index 00000000..cbda7e6e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmSelfServiceTests.cs @@ -0,0 +1,392 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Actual stock bootstrap parity and the bounded shared account-console capability. +[Collection(SharedKeycloakRealm.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantRealmSelfServiceTests(KeycloakFixture fresh) +{ + private const string MapperName = "learnstack-account-self-service-roles"; + + [Fact] + public async Task Explicit_fresh_builtins_preserve_stock_bootstrap_and_add_only_the_approved_mapper_and_four_grants() + { + // Reconstruct the previous automatic bootstrap by removing only the new + // built-in declarations and grants, leaving the same realm/profile/scopes. + var stock = await RealmAsync(); + foreach (var client in stock["clients"]!.AsArray().Where(value => + Text(value?["clientId"]) is "account" or "account-console").ToArray()) stock["clients"]!.AsArray().Remove(client); + stock["roles"]!["client"]!.AsObject().Remove("account"); + stock["roles"]!["realm"]!.AsArray().Remove(stock["roles"]!["realm"]!.AsArray() + .Single(role => Text(role?["name"]) == "default-roles-learnstack")); + stock.Remove("defaultRole"); + stock.Remove("clientScopeMappings"); + foreach (var user in stock["users"]!.AsArray()) user!.AsObject().Remove("clientRoles"); + await using var baseline = await KeycloakFixture.StartAsync(stock.ToJsonString()); + using var oldAdmin = await baseline.CreateAdminClientAsync(); + using var newAdmin = await fresh.CreateAdminClientAsync(); + var oldState = await BuiltinsAsync(oldAdmin); + var newState = await BuiltinsAsync(newAdmin); + await AssertNormalizedAccountMapperAsync(newAdmin); + var newMappers = newState["account-console"]!["protocolMappers"]!.AsArray(); + newMappers.Remove(newMappers.Single(mapper => Text(mapper?["name"]) == MapperName)); + (Normalize(oldState).ToJsonString() == Normalize(newState).ToJsonString()).Should().BeTrue( + "both clients, all eight roles/composites, stock defaults and scope allowances match automatic 26.6.2 bootstrap exactly"); + foreach (var identity in SeedData.IdentityUsers) + { + await AssertCapabilityAsync(newAdmin, identity.Username, true); + await AssertCapabilityAsync(oldAdmin, identity.Username, false); + } + var identityUser = SeedData.IdentityUsers[0]; + using (var refused = await AccountClientAsync(baseline, identityUser.Username)) + using (var response = await KeycloakHttp.SendAsync(refused, HttpMethod.Get, "")) + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "the original stock bootstrap with inert-only seed roles cannot satisfy the account positive control"); + var result = await KeycloakReconciliationDriver.RunAsync(baseline); + result.Success.Should().BeTrue("closed reconciliation outcome: {0}/{1}", result.Stage, result.Reason); + result.Mutations.Should().Be(5, "one console emitter plus exactly four missing account grants"); + await AssertNormalizedAccountMapperAsync(oldAdmin); + foreach (var identity in SeedData.IdentityUsers) await AssertCapabilityAsync(oldAdmin, identity.Username, true); + using (var admitted = await AccountClientAsync(baseline, identityUser.Username)) + using (var response = await KeycloakHttp.SendAsync(admitted, HttpMethod.Get, "")) + await AssertAccountJsonAsync(response, identityUser.Username); + var rerun = await KeycloakReconciliationDriver.RunAsync(baseline); + rerun.Success.Should().BeTrue("closed rerun outcome: {0}/{1}", rerun.Stage, rerun.Reason); + rerun.Mutations.Should().Be(0); + } + + [Fact] + public async Task Shared_mapper_restores_an_already_eligible_nonmanifest_account_without_changing_its_data_or_grants() + { + const string username = "existing-self-service@fixture.test"; + var realm = await RealmAsync(); + RemoveMapper(realm); + var user = realm["users"]![0]!.DeepClone().AsObject(); + user["username"] = username; + user["email"] = username; + user["firstName"] = "Existing"; + user["lastName"] = "Preserved family"; + user["attributes"] = new JsonObject { ["fixture_note"] = new JsonArray("preserved ordinary data") }; + var profileComponent = realm["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]!; + var profile = JsonNode.Parse(Text(profileComponent["config"]!["kc.user.profile.config"]![0]))!.AsObject(); + profile["attributes"]!.AsArray().Add(new JsonObject + { + ["name"] = "fixture_note", + ["multivalued"] = false, + ["permissions"] = new JsonObject + { + ["view"] = new JsonArray("admin", "user"), + ["edit"] = new JsonArray("admin", "user"), + }, + }); + profileComponent["config"]!["kc.user.profile.config"]![0] = profile.ToJsonString(); + realm["users"]!.AsArray().Add(user); + await using var fixture = await KeycloakFixture.StartAsync(realm.ToJsonString()); + using var admin = await fixture.CreateAdminClientAsync(); + using var hub = await fixture.CreateAdminClientAsync("learnstack-hub"); + var userId = Id(await admin.UserAsync(username)); + var before = await AccountStateAsync(admin, userId); + var hubBefore = (await hub.GetAsync()).GetRawText(); + using (var refused = await AccountClientAsync(fixture, username)) + using (var response = await KeycloakHttp.SendAsync(refused, HttpMethod.Get, "")) + response.StatusCode.Should().Be(HttpStatusCode.Forbidden, + "the eligible role alone cannot replace the console claim emitter"); + var result = await KeycloakReconciliationDriver.RunAsync(fixture); + result.Success.Should().BeTrue("closed reconciliation outcome: {0}/{1}", result.Stage, result.Reason); + result.Mutations.Should().Be(1, "only the shared mapper is missing; no persona or nonmanifest grant changes"); + await AssertNormalizedAccountMapperAsync(admin); + (await AccountStateAsync(admin, userId) == before).Should().BeTrue(); + ((await hub.GetAsync()).GetRawText() == hubBefore).Should().BeTrue(); + var beforeEdit = await admin.GetAsync("users/" + userId); + using (var admitted = await AccountClientAsync(fixture, username)) + { + using var response = await KeycloakHttp.SendAsync(admitted, HttpMethod.Get, ""); + await AssertAccountJsonAsync(response, username); + using var accountResponse = await KeycloakHttp.SendAsync(admitted, HttpMethod.Get, ""); + using var accountJson = await KeycloakHttp.ReadJsonAsync(accountResponse); + var update = Node(accountJson.RootElement).AsObject(); + update.Remove("id"); + update["firstName"] = "Ordinary edit"; + using var edit = await KeycloakHttp.SendAsync(admitted, HttpMethod.Post, "", + JsonContent.Create(update)); + edit.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + var updated = await admin.GetAsync("users/" + userId); + var expectedEdit = Node(beforeEdit).AsObject(); + expectedEdit["firstName"] = "Ordinary edit"; + JsonNode.DeepEquals(expectedEdit, Node(updated)).Should().BeTrue( + "the successful real profile update changes only its requested ordinary field"); + (updated.GetProperty("firstName").GetString() == "Ordinary edit").Should().BeTrue(); + (updated.GetProperty("lastName").GetString() == "Preserved family" + && updated.GetProperty("email").GetString() == username + && updated.GetProperty("attributes").GetProperty("fixture_note")[0].GetString() == "preserved ordinary data") + .Should().BeTrue("the real ordinary account update preserves unrelated profile fields and attributes"); + foreach (var name in new[] { "user_id", "tenant_id", "organization_id" }) + (updated.TryGetProperty("attributes", out var attributes) && attributes.TryGetProperty(name, out _)).Should().BeFalse( + "the shared mapper does not provision actor or scope attributes for this nonmanifest account"); + var rerun = await KeycloakReconciliationDriver.RunAsync(fixture); + rerun.Success.Should().BeTrue("closed rerun outcome: {0}/{1}", rerun.Stage, rerun.Reason); + rerun.Mutations.Should().Be(0); + } + + [Theory] + [InlineData("group")] + [InlineData("composite")] + public async Task Effective_inherited_capability_and_a_compatible_existing_mapper_need_no_redundant_additions(string source) + { + var realm = await RealmAsync(); + var user = realm["users"]![0]!.AsObject(); + user.Remove("clientRoles"); + if (source == "group") + { + realm["groups"] = new JsonArray(new JsonObject + { + ["name"] = "existing-account-access", + ["clientRoles"] = new JsonObject { ["account"] = new JsonArray("manage-account") } + }); + user["groups"] = new JsonArray("/existing-account-access"); + } + else + { + realm["roles"]!["realm"]!.AsArray().Add(new JsonObject + { + ["name"] = "existing-account-access", + ["composite"] = true, + ["composites"] = new JsonObject { ["client"] = new JsonObject { ["account"] = new JsonArray("manage-account") } } + }); + user["realmRoles"]!.AsArray().Add("existing-account-access"); + } + var console = realm["clients"]!.AsArray().Single(value => Text(value?["clientId"]) == "account-console")!; + console["protocolMappers"]!.AsArray().Single(mapper => Text(mapper?["name"]) == MapperName)!["name"] = "compatible-existing-emitter"; + await using var fixture = await KeycloakFixture.StartAsync(realm.ToJsonString()); + using var admin = await fixture.CreateAdminClientAsync(); + var userId = Id(await admin.UserAsync(SeedData.IdentityUsers[0].Username)); + var before = await AccountStateAsync(admin, userId); + var clientsBefore = (await BuiltinsAsync(admin)).ToJsonString(); + var result = await KeycloakReconciliationDriver.RunAsync(fixture); + result.Success.Should().BeTrue("closed reconciliation outcome: {0}/{1}", result.Stage, result.Reason); + result.Mutations.Should().Be(0); + (await AccountStateAsync(admin, userId) == before).Should().BeTrue(); + ((await BuiltinsAsync(admin)).ToJsonString() == clientsBefore).Should().BeTrue(); + var account = await admin.ClientAsync("account"); + (await admin.GetAsync("users/" + userId + "/role-mappings/clients/" + Id(account))).GetArrayLength().Should().Be(0, + "the existing inherited capability never becomes a new direct grant"); + using var admitted = await AccountClientAsync(fixture, SeedData.IdentityUsers[0].Username); + using var response = await KeycloakHttp.SendAsync(admitted, HttpMethod.Get, ""); + await AssertAccountJsonAsync(response, SeedData.IdentityUsers[0].Username); + } + + [Theory] + [InlineData("composite")] + [InlineData("default_scope")] + [InlineData("optional_scope")] + [InlineData("direct_mapper")] + [InlineData("implicit_mapper")] + public async Task Unsafe_account_customization_refuses_before_any_pending_write_and_preserves_the_customization(string variant) + { + await using var fixture = await KeycloakFixture.StartAsync(); + using var admin = await fixture.CreateAdminClientAsync(); + // A clean twin proves the same pending owned mutation is otherwise executable. + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var clean = await KeycloakReconciliationDriver.RunAsync(fixture); + clean.Success.Should().BeTrue("closed clean outcome: {0}/{1}", clean.Stage, clean.Reason); + clean.Mutations.Should().Be(1); + (await admin.PutAsync("", new { accessTokenLifespan = 1800 })).Should().Be(HttpStatusCode.NoContent); + var account = await admin.ClientAsync("account"); + var console = await admin.ClientAsync("account-console"); + if (variant == "composite") + { + var role = await admin.GetAsync("clients/" + Id(account) + "/roles/manage-account"); + var realmRole = await admin.GetAsync("roles/tenant-admin"); + (await admin.PostAsync("roles-by-id/" + Id(role) + "/composites", new[] { realmRole })).Should().Be(HttpStatusCode.NoContent); + } + else if (variant is "default_scope" or "optional_scope") + { + var scopes = await admin.GetAsync("client-scopes"); + var scope = scopes.EnumerateArray().Single(value => value.GetProperty("name").GetString() == "phone"); + var wider = await admin.GetAsync("clients/" + Id(account) + "/roles/view-profile"); + (await admin.PostAsync("client-scopes/" + Id(scope) + "/scope-mappings/clients/" + Id(account), new[] { wider })) + .Should().Be(HttpStatusCode.NoContent); + (await admin.PutAsync("clients/" + Id(console) + "/" + (variant == "default_scope" ? "default" : "optional") + + "-client-scopes/" + Id(scope), new { })).Should().Be(HttpStatusCode.NoContent); + } + else + { + var mapper = variant == "implicit_mapper" + ? new JsonObject + { + ["name"] = "unqualified role transformer", + ["protocol"] = "openid-connect", + ["protocolMapper"] = "oidc-hardcoded-role-mapper", + ["config"] = new JsonObject { ["role"] = "account.view-profile" } + } + : new JsonObject + { + ["name"] = "unqualified realm emitter", + ["protocol"] = "openid-connect", + ["protocolMapper"] = "oidc-usermodel-realm-role-mapper", + ["config"] = new JsonObject + { ["claim.name"] = "realm_access.roles", ["jsonType.label"] = "String", ["multivalued"] = "true", ["access.token.claim"] = "true" } + }; + (await admin.PostAsync("clients/" + Id(console) + "/protocol-mappers/models", mapper)).Should().Be(HttpStatusCode.Created); + } + var before = await RefusalStateAsync(admin); + var refused = await KeycloakReconciliationDriver.RunAsync(fixture); + refused.Success.Should().BeFalse(); + refused.Stage.Should().Be("plan"); + refused.Reason.Should().Be(variant is "direct_mapper" or "implicit_mapper" ? "mapper_conflict" : "unsupported"); + refused.Mutations.Should().Be(0); + (await RefusalStateAsync(admin) == before).Should().BeTrue("the pending TTL and incompatible provider customization remain untouched"); + } + + private static async Task AssertCapabilityAsync(KeycloakAdminClient admin, string username, bool present) + { + var user = await admin.UserAsync(username); + var account = await admin.ClientAsync("account"); + var path = "users/" + Id(user) + "/role-mappings/clients/" + Id(account); + var direct = await admin.GetAsync(path); + var effective = await admin.GetAsync(path + "/composite"); + direct.EnumerateArray().Select(role => role.GetProperty("name").GetString()) + .Should().BeEquivalentTo(present ? ["manage-account"] : Array.Empty()); + effective.EnumerateArray().Select(role => role.GetProperty("name").GetString()) + .Should().BeEquivalentTo(present ? ["manage-account", "manage-account-links"] : Array.Empty()); + var mappings = await admin.GetAsync("users/" + Id(user) + "/role-mappings"); + mappings.GetProperty("realmMappings").EnumerateArray().Select(role => role.GetProperty("name").GetString()).Should() + .Equal(SeedData.IdentityUsers.Single(identity => identity.Username == username).RealmRole); + } + + private static async Task AccountClientAsync(KeycloakFixture fixture, string username) + { + using var browser = fixture.Browser(); + var callback = new Uri(fixture.BaseAddress, "realms/learnstack/account/").AbsoluteUri; + var authorization = await browser.AuthorizeAsync(username, KeycloakFixture.DemoPassword, callback, + clientId: "account-console", scope: "openid"); + authorization.Authorized.Should().BeTrue("closed account authorization outcome: {0}", authorization.Error); + var exchange = await browser.RedeemAsync(authorization, secret: null); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var client = KeycloakHttp.Create(new Uri(fixture.BaseAddress, "realms/learnstack/account/"), fixture.RootCertificate); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", exchange.AccessToken); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + private static async Task AssertAccountJsonAsync(HttpResponseMessage response, string username) + { + response.StatusCode.Should().Be(HttpStatusCode.OK); + (response.Content.Headers.ContentType?.MediaType).Should().Be("application/json", + "the positive control requires the account REST JSON representation"); + using var json = await KeycloakHttp.ReadJsonAsync(response); + json.RootElement.ValueKind.Should().Be(JsonValueKind.Object); + (json.RootElement.GetProperty("username").GetString() == username).Should().BeTrue(); + json.RootElement.GetProperty("firstName").ValueKind.Should().Be(JsonValueKind.String); + json.RootElement.GetProperty("email").ValueKind.Should().Be(JsonValueKind.String); + } + + private static async Task AssertNormalizedAccountMapperAsync(KeycloakAdminClient admin) + { + var console = await admin.ClientAsync("account-console"); + var mappers = await admin.GetAsync("clients/" + Id(console) + "/protocol-mappers/models"); + var mapper = mappers.EnumerateArray().Single(value => value.GetProperty("name").GetString() == MapperName); + (mapper.GetProperty("protocolMapper").GetString() == "oidc-usermodel-client-role-mapper").Should().BeTrue(); + var config = mapper.GetProperty("config"); + config.TryGetProperty("usermodel.clientRoleMapping.rolePrefix", out _).Should().BeFalse( + "the pinned provider removes the declared empty prefix during import and Admin REST creation"); + var expected = new Dictionary + { + ["usermodel.clientRoleMapping.clientId"] = "account", + ["claim.name"] = "resource_access.account.roles", + ["jsonType.label"] = "String", + ["multivalued"] = "true", + ["access.token.claim"] = "true", + ["introspection.token.claim"] = "true", + ["id.token.claim"] = "false", + ["userinfo.token.claim"] = "false", + ["lightweight.claim"] = "false", + }; + config.EnumerateObject().Count().Should().Be(expected.Count); + expected.All(entry => config.TryGetProperty(entry.Key, out var value) && value.GetString() == entry.Value) + .Should().BeTrue("every required mapper setting survives the provider's empty-string normalization"); + } + + private static async Task BuiltinsAsync(KeycloakAdminClient admin) + { + var state = new JsonObject(); + var account = await admin.ClientAsync("account"); + foreach (var name in new[] { "account", "account-console" }) + { + var client = await admin.ClientAsync(name); + var value = Node(await admin.GetAsync("clients/" + Id(client))).AsObject(); + value["protocolMappers"] = Node(await admin.GetAsync("clients/" + Id(client) + "/protocol-mappers/models")); + value["scopeMappings"] = Node(await admin.GetAsync("clients/" + Id(client) + "/scope-mappings")); + value["defaultScopes"] = Node(await admin.GetAsync("clients/" + Id(client) + "/default-client-scopes")); + value["optionalScopes"] = Node(await admin.GetAsync("clients/" + Id(client) + "/optional-client-scopes")); + state[name] = value; + } + var roles = await admin.GetAsync("clients/" + Id(account) + "/roles?briefRepresentation=false"); + roles.GetArrayLength().Should().Be(8); + var definitions = new JsonArray(); + foreach (var role in roles.EnumerateArray()) + { + var definition = Node(role).AsObject(); + definition["children"] = Node(await admin.GetAsync("roles-by-id/" + Id(role) + "/composites")); + definitions.Add(definition); + } + state["accountRoles"] = definitions; + var defaultRole = await admin.GetAsync("roles/default-roles-learnstack"); + state["defaultRole"] = Node(defaultRole); + state["defaultComposites"] = Node(await admin.GetAsync("roles-by-id/" + Id(defaultRole) + "/composites")); + return state; + } + + private static async Task AccountStateAsync(KeycloakAdminClient admin, string id) + { + var state = new JsonObject(); + foreach (var suffix in new[] { "", "/credentials", "/role-mappings", "/groups" }) + state[suffix] = Node(await admin.GetAsync("users/" + id + suffix)); + return state.ToJsonString(); + } + + private static async Task RefusalStateAsync(KeycloakAdminClient admin) + { + var state = await BuiltinsAsync(admin); + state["realm"] = Node(await admin.GetAsync()); + var scopes = await admin.GetAsync("client-scopes"); + foreach (var scope in scopes.EnumerateArray()) + state["scope/" + Id(scope)] = Node(await admin.GetAsync("client-scopes/" + Id(scope) + "/scope-mappings")); + foreach (var identity in SeedData.IdentityUsers) + state[identity.Username] = await AccountStateAsync(admin, Id(await admin.UserAsync(identity.Username))); + return state.ToJsonString(); + } + + // Provider resource IDs and secret creation timestamps are generated independently + // for the two imports. Every other stock field and collection is compared. + private static JsonNode Normalize(JsonNode node) => node switch + { + JsonObject value => new JsonObject(value.Where(property => property.Key is not ("id" or "containerId" or "client.secret.creation.time")) + .OrderBy(property => property.Key, StringComparer.Ordinal).Select(property => + new KeyValuePair(property.Key, property.Value is null ? null : Normalize(property.Value)))), + JsonArray value => new JsonArray(value.Select(item => item is null ? null : Normalize(item)) + .OrderBy(item => item?.ToJsonString(), StringComparer.Ordinal).ToArray()), + _ => node.DeepClone(), + }; + + private static void RemoveMapper(JsonObject realm) + { + var mappers = realm["clients"]!.AsArray().Single(client => Text(client?["clientId"]) == "account-console")!["protocolMappers"]!.AsArray(); + mappers.Remove(mappers.Single(mapper => Text(mapper?["name"]) == MapperName)); + } + private static async Task RealmAsync() => + JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!.AsObject(); + private static JsonNode Node(JsonElement value) => JsonNode.Parse(value.GetRawText())!; + private static string Id(JsonElement value) => value.GetProperty("id").GetString()!; + private static string Text(JsonNode? value) => value?.GetValue() ?? ""; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs new file mode 100644 index 00000000..12b9ddd7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs @@ -0,0 +1,435 @@ +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using Xunit; +using Xunit.Abstractions; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Reproduce the immutable historical console graph without changing the observation or expectation. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantRealmStockBaselineTests(ITestOutputHelper output) +{ + private const string ExportSha256 = "39659a4e505c8ad3c1b2963e69e58dadb725e4fb37b408dcf87214a2bb0d2d07"; + private const string RedactedExportSha256 = "ba9a6d6783d8849387e1a194f650a853f9596ade032a5b48eb3d19695ade7b1d"; + private const string CanonicalSha256 = "fe38c56fdd99f2073a901e4b7261ca9f0f4d9f24c45e1effea1e69261781a476"; + private static readonly string[] DefaultScopes = ["acr", "basic", "email", "profile", "roles", "web-origins"]; + private static readonly string[] OptionalScopes = ["address", "microprofile-jwt", "offline_access", "organization", "phone"]; + private static readonly JsonSerializerOptions ReceiptOptions = new() { WriteIndented = true }; + + [Fact] + public async Task Historical_24705a7a_bootstrap_matches_the_fixed_26_6_2_scope_role_and_mapper_baseline() + { + var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(KeycloakFixture.RealmPath("learnstack"))!, "..", "..", "..")); + var historical = await HistoricalImportAsync(root); + var directory = Directory.CreateTempSubdirectory("learnstack-stock-proof-").FullName; + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + KeycloakFixture? fixture = null; + StockProviderResources? resources = null; + var watch = Stopwatch.StartNew(); + try + { + fixture = await KeycloakFixture.StartAsync(historical); + resources = await fixture.StockProviderResourcesAsync(); + (await fixture.StockProviderVersionAsync()).Should().Be("26.6.2"); + using var admin = await fixture.CreateAdminClientAsync(); + var graph = await CaptureAsync(admin); + var capture = JsonSerializer.SerializeToUtf8Bytes(graph); + var capturePath = Path.Combine(directory, "private-stock-provider-graph.json"); + await WritePrivateAsync(capturePath, capture); + await AssertExtractorAsync(root, capturePath); + var receipt = new + { + historicalCommit = "24705a7a", + historicalExportSha256 = ExportSha256, + redactedHistoricalExportSha256 = RedactedExportSha256, + privateImportSha256 = Hash(System.Text.Encoding.UTF8.GetBytes(historical)), + credentialTransformation = "Only the two historical user password values and learnstack-api secret are redacted in the committed fixture; private test values replace those placeholders in memory before import.", + providerImage = KeycloakFixture.Image, + authenticatedProviderVersion = "26.6.2", + resources.ImageId, + resources.RepoDigests, + resources.ProviderContainerId, + resources.DatabaseContainerId, + resources.NetworkId, + resources.OperatingSystem, + resources.Architecture, + resources.ImageDescriptorDigest, + resources.ImageDescriptorMediaType, + resources.PlatformManifestDigest, + captureSha256 = Hash(capture), + canonicalSha256 = CanonicalSha256, + extractorSha256 = Hash(await File.ReadAllBytesAsync(Path.Combine(root, "scripts", "extract-development-realm-stock-console.mjs"))), + classifierSha256 = Hash(await File.ReadAllBytesAsync(Path.Combine(root, "scripts", "development-realm-stock-console.mjs"))), + endpointCount = graph.Count, + stockScopeCount = DefaultScopes.Length + OptionalScopes.Length, + referencedRoleCount = 4, + elapsedSeconds = watch.Elapsed.TotalSeconds, + limitations = "Read-only historical bootstrap qualification only; no automatic preparation, detachment, token or profile-write proof.", + }; + var receiptBytes = JsonSerializer.SerializeToUtf8Bytes(receipt, ReceiptOptions); + await WritePrivateAsync(Path.Combine(directory, "receipt.json"), receiptBytes); + // Explicit opt-in retains private DTOs in a caller-owned private temporary directory. + // The ordinary test leaves no raw provider response on disk. + var retain = Environment.GetEnvironmentVariable("LEARNSTACK_STOCK_PROOF_PRIVATE_OUTPUT"); + if (retain is not null) + { + if (!Path.IsPathFullyQualified(retain) || !Directory.Exists(retain)) + throw new InvalidOperationException("Stock proof private output directory is invalid."); + if (!OperatingSystem.IsWindows() + && (File.GetUnixFileMode(retain) & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) != 0) + throw new InvalidOperationException("Stock proof private output directory is not private."); + await WritePrivateAsync(Path.Combine(retain, "private-stock-provider-graph.json"), capture); + await WritePrivateAsync(Path.Combine(retain, "receipt.json"), receiptBytes); + } + output.WriteLine("Stock historical baseline matched: {0}; endpoints={1}; scopes=11; referenced roles=4.", CanonicalSha256, graph.Count); + output.WriteLine("Owned provider image: {0}; digests: {1}.", resources.ImageId, string.Join(",", resources.RepoDigests)); + output.WriteLine("Historical source: {0}; redacted fixture: {1}; capture: {2}.", ExportSha256, RedactedExportSha256, Hash(capture)); + } + finally + { + try + { + if (fixture is not null) await fixture.DisposeAsync(); + } + finally + { + try + { + if (resources is not null) + { + await KeycloakFixture.AssertStockResourcesAbsentAsync(resources); + output.WriteLine("Joined cleanup absent: containers={0},{1}; network={2}.", + resources.ProviderContainerId, resources.DatabaseContainerId, resources.NetworkId); + } + } + finally { Directory.Delete(directory, recursive: true); } + } + } + } + + [Fact] + public async Task Historical_stock_ordinary_reconciliation_detaches_only_three_console_edges_and_then_writes_zero() + { + var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(KeycloakFixture.RealmPath("learnstack"))!, "..", "..", "..")); + var historical = await HistoricalImportAsync(root); + await using var fixture = await KeycloakFixture.StartAsync(historical); + var resources = await fixture.StockProviderResourcesAsync(); + var watch = Stopwatch.StartNew(); + try + { + (await fixture.StockProviderVersionAsync()).Should().Be("26.6.2"); + using var admin = await fixture.CreateAdminClientAsync(); + var before = await CaptureAsync(admin); + var assignmentsBefore = await AssignmentsAsync(admin, before["clients"]); + var allUserIdsBefore = (await admin.GetAsync("users?max=100&briefRepresentation=false")) + .EnumerateArray().Select(Id).ToArray(); + var account = Named(before["clients"], "clientId", "account"); + var console = Named(before["clients"], "clientId", "account-console"); + var accountPath = "clients/" + Id(account); + var consolePath = "clients/" + Id(console); + var userIds = new Dictionary(StringComparer.Ordinal); + var credentials = new Dictionary(StringComparer.Ordinal); + using var original = JsonDocument.Parse(historical); + foreach (var imported in original.RootElement.GetProperty("users").EnumerateArray()) + { + var username = Text(imported, "username"); + var user = await admin.UserAsync(username); + var id = Id(user); + userIds.Add(username, id); + var credential = await admin.GetAsync("users/" + id + "/credentials"); + credential.EnumerateArray().Any(value => Text(value, "type") == "password").Should().BeTrue( + "the preservation control starts with a real imported password credential"); + credentials.Add(username, Hash(JsonSerializer.SerializeToUtf8Bytes(credential))); + } + var api = Named(before["clients"], "clientId", "learnstack-api"); + var apiSecret = await admin.GetAsync("clients/" + Id(api) + "/client-secret"); + + // This is test-owned setup for Step 1's ordinary path. It is deliberately + // separate from the unimplemented production preparation protocol. + var profile = JsonNode.Parse(before["users/profile"].GetRawText())!.AsObject(); + var definitions = profile["attributes"]!.AsArray(); + var desired = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!; + var declared = JsonNode.Parse(desired["components"]!["org.keycloak.userprofile.UserProfileProvider"]![0]! + ["config"]!["kc.user.profile.config"]![0]!.GetValue())!["attributes"]!.AsArray(); + foreach (var name in new[] { "user_id", "tenant_id", "organization_id" }) + { + definitions.Any(value => value!["name"]!.GetValue() == name).Should().BeFalse(); + definitions.Add(declared.Single(value => value!["name"]!.GetValue() == name)!.DeepClone()); + } + (await admin.PutAsync("users/profile", profile)).Should().Be(HttpStatusCode.OK); + JsonNode.DeepEquals(profile, JsonNode.Parse((await admin.GetAsync("users/profile")).GetRawText())).Should().BeTrue( + "an independent GET must prove the entire test-owned metadata append, including ordered arrays"); + + var first = await KeycloakReconciliationDriver.RunAsync(fixture); + first.Success.Should().BeTrue("closed ordinary reconciliation outcome: {0}/{1} after {2} writes", + first.Stage, first.Reason, first.Mutations); + first.Mutations.Should().Be(16, "thirteen ordinary approved mutations plus exactly three console assignment DELETEs"); + var after = await CaptureAsync(admin, historicalAssignments: false); + Names(after[consolePath + "/default-client-scopes"]).Should().Equal(DefaultScopes.Where(value => value != "roles")); + Names(after[consolePath + "/optional-client-scopes"]).Should().Equal(OptionalScopes.Where(value => value is not ("microprofile-jwt" or "offline_access"))); + Same(before[accountPath], after[accountPath]).Should().BeTrue("the account client is unchanged"); + Same(before[accountPath + "/roles"], after[accountPath + "/roles"]).Should().BeTrue("all account role definitions and composites are retained"); + foreach (var scopeName in DefaultScopes.Concat(OptionalScopes)) + { + var scope = Named(before["client-scopes"], "name", scopeName); + var path = "client-scopes/" + Id(scope); + foreach (var suffix in new[] { "", "/protocol-mappers/models", "/scope-mappings" }) + Same(before[path + suffix], after[path + suffix]).Should().BeTrue( + "every stock global scope definition, effective mapper and allowance is unchanged"); + } + var assignmentsAfter = await AssignmentsAsync(admin, after["clients"]); + foreach (var (path, prior) in assignmentsBefore) + { + var expected = prior.EnumerateArray().Where(value => path == consolePath + "/default-client-scopes" + ? Text(value, "name") != "roles" + : path != consolePath + "/optional-client-scopes" || Text(value, "name") is not ("microprofile-jwt" or "offline_access")) + .ToList(); + var owner = before["clients"].EnumerateArray().Single(client => path.StartsWith("clients/" + Id(client) + "/", StringComparison.Ordinal)); + if (Text(owner, "clientId") is "learnstack-web" or "learnstack-api") + { + var declaredClient = desired["clients"]!.AsArray().Single(client => client!["clientId"]!.GetValue() == Text(owner, "clientId"))!; + var category = path.EndsWith("/default-client-scopes", StringComparison.Ordinal) ? "defaultClientScopes" : "optionalClientScopes"; + foreach (var declaredName in declaredClient[category]!.AsArray().Select(value => value!.GetValue())) + { + if (expected.Any(value => Text(value, "name") == declaredName)) continue; + var scope = Named(after["client-scopes"], "name", declaredName); + expected.Add(JsonSerializer.SerializeToElement(new { id = Text(scope, "id"), name = declaredName })); + } + } + SameSet(expected, assignmentsAfter[path]).Should().BeTrue( + "the exact edge delta is three console removals plus declared web/API scope additions; every other edge is preserved"); + } + before["clients"].EnumerateArray().Select(Id).Order(StringComparer.Ordinal).Should().Equal( + after["clients"].EnumerateArray().Select(Id).Order(StringComparer.Ordinal), "no client is deleted or recreated"); + var oldScopeIds = before["client-scopes"].EnumerateArray().Select(Id).ToHashSet(StringComparer.Ordinal); + var newScopes = after["client-scopes"].EnumerateArray().Where(scope => !oldScopeIds.Contains(Id(scope))).ToArray(); + oldScopeIds.Except(after["client-scopes"].EnumerateArray().Select(Id), StringComparer.Ordinal).Should().BeEmpty( + "no global client scope is deleted"); + var approvedMissingScopes = desired["clientScopes"]!.AsArray().Select(scope => scope!["name"]!.GetValue()) + .Except(Names(before["client-scopes"]), StringComparer.Ordinal).Order(StringComparer.Ordinal); + newScopes.Select(scope => Text(scope, "name")).Order(StringComparer.Ordinal).Should().Equal(approvedMissingScopes, + "only missing scopes from the approved declaration are created"); + Same(before["roles"], after["roles"]).Should().BeTrue("realm roles are retained"); + Same(before["groups"], after["groups"]).Should().BeTrue("groups are retained"); + foreach (var (path, role) in before.Where(value => value.Key.StartsWith("roles-by-id/", StringComparison.Ordinal))) + Same(role, after[path]).Should().BeTrue("all existing role DTOs and composite edges are retained"); + var allUserIdsAfter = (await admin.GetAsync("users?max=100&briefRepresentation=false")) + .EnumerateArray().Select(Id).ToArray(); + allUserIdsBefore.Except(allUserIdsAfter, StringComparer.Ordinal).Should().BeEmpty("no preexisting user is deleted"); + foreach (var (username, id) in userIds) + { + (Id(await admin.UserAsync(username)) == id).Should().BeTrue("original accounts retain their provider IDs"); + (Hash(JsonSerializer.SerializeToUtf8Bytes(await admin.GetAsync("users/" + id + "/credentials"))) == credentials[username]) + .Should().BeTrue("original credential DTO hashes remain unchanged"); + } + Same(apiSecret, await admin.GetAsync("clients/" + Id(api) + "/client-secret")).Should().BeTrue( + "the imported private API secret is retained"); + var rerun = await KeycloakReconciliationDriver.RunAsync(fixture); + rerun.Success.Should().BeTrue("closed rerun outcome: {0}/{1}", rerun.Stage, rerun.Reason); + rerun.Mutations.Should().Be(0); + output.WriteLine("Historical ordinary stock path: 16 mutations, then zero; retained five default/three optional edges; elapsed={0:F3}s.", watch.Elapsed.TotalSeconds); + } + finally + { + await fixture.DisposeAsync(); + await KeycloakFixture.AssertStockResourcesAbsentAsync(resources); + output.WriteLine("Joined cleanup absent: containers={0},{1}; network={2}.", + resources.ProviderContainerId, resources.DatabaseContainerId, resources.NetworkId); + } + } + + private static async Task> AssignmentsAsync(KeycloakAdminClient admin, JsonElement clients) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var client in clients.EnumerateArray()) + foreach (var category in new[] { "default", "optional" }) + { + var path = "clients/" + Id(client) + "/" + category + "-client-scopes"; + result.Add(path, await admin.GetAsync(path)); + } + return result; + } + + private static async Task> CaptureAsync(KeycloakAdminClient admin, bool historicalAssignments = true) + { + var capture = new Dictionary(StringComparer.Ordinal); + async Task ReadAsync(string path) + { + if (capture.TryGetValue(path, out var prior)) return prior; + var value = await admin.GetAsync(path); + capture.Add(path, value); + return value; + } + + await ReadAsync(""); + await ReadAsync("users/profile"); + var clients = await ReadAsync("clients"); + var scopes = await ReadAsync("client-scopes"); + var realmRoles = await ReadAsync("roles"); + await ReadAsync("groups"); + var accountSummary = Named(clients, "clientId", "account"); + var consoleSummary = Named(clients, "clientId", "account-console"); + var accountPath = "clients/" + Id(accountSummary); + var consolePath = "clients/" + Id(consoleSummary); + var account = await ReadAsync(accountPath); + var console = await ReadAsync(consolePath); + Bind(accountSummary, account, "clientId"); + Bind(consoleSummary, console, "clientId"); + foreach (var path in new[] { accountPath, consolePath }) + { + await ReadAsync(path + "/default-client-scopes"); + await ReadAsync(path + "/optional-client-scopes"); + } + if (historicalAssignments) + { + Names(capture[consolePath + "/default-client-scopes"]).Should().Equal(DefaultScopes); + Names(capture[consolePath + "/optional-client-scopes"]).Should().Equal(OptionalScopes); + } + await ReadAsync(consolePath + "/protocol-mappers/models"); + await ReadAsync(consolePath + "/scope-mappings"); + var accountRoles = await ReadAsync(accountPath + "/roles"); + foreach (var summary in scopes.EnumerateArray()) + { + var path = "client-scopes/" + Id(summary); + var full = await ReadAsync(path); + Bind(summary, full, "name"); + await ReadAsync(path + "/protocol-mappers/models"); + await ReadAsync(path + "/scope-mappings"); + } + foreach (var category in new[] { "default", "optional" }) + foreach (var assignment in capture[consolePath + "/" + category + "-client-scopes"].EnumerateArray()) + Bind(assignment, Named(scopes, "name", Text(assignment, "name")), "name"); + await ReadAsync("roles/offline_access"); + foreach (var role in new[] { "manage-account", "manage-account-links", "view-groups" }) + await ReadAsync(accountPath + "/roles/" + role); + var pending = new Queue(realmRoles.EnumerateArray().Concat(accountRoles.EnumerateArray())); + var readRoles = new HashSet(StringComparer.Ordinal); + while (pending.TryDequeue(out var summary)) + { + if (!readRoles.Add(Id(summary))) continue; + if (readRoles.Count > 200) throw new InvalidOperationException("Stock proof role graph exceeded its fixed bound."); + var path = "roles-by-id/" + Id(summary); + var role = await ReadAsync(path); + Bind(summary, role, "name"); + var composites = await ReadAsync(path + "/composites"); + foreach (var child in composites.EnumerateArray()) pending.Enqueue(child); + } + return capture; + } + + private static async Task AssertExtractorAsync(string root, string capturePath) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var start = new ProcessStartInfo("node") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add(Path.Combine(root, "scripts", "extract-development-realm-stock-console.mjs")); + start.ArgumentList.Add("--check"); + start.ArgumentList.Add(capturePath); + var executablePath = Environment.GetEnvironmentVariable("PATH"); + start.Environment.Clear(); + if (executablePath is not null) start.Environment["PATH"] = executablePath; + using var child = new Process { StartInfo = start }; + Task? stdout = null; + Task? stderr = null; + var started = false; + try + { + started = child.Start(); + if (!started) throw new InvalidOperationException("Stock proof extractor did not start."); + stdout = ReadBoundedAsync(child.StandardOutput, deadline.Token); + stderr = ReadBoundedAsync(child.StandardError, deadline.Token); + await Task.WhenAll(child.WaitForExitAsync(deadline.Token), stdout, stderr); + child.ExitCode.Should().Be(0, "the observed graph must match the independently committed fixed expectation"); + (await stderr).Length.Should().Be(0, "the extractor must emit only its closed success receipt"); + (await stdout == "stock-console: fixed expectation matched; sha256=" + CanonicalSha256 + "\n").Should().BeTrue( + "the extractor must match the fixed canonical hash without publishing provider data"); + } + finally + { + if (started) + { + if (!child.HasExited) + try { child.Kill(entireProcessTree: true); } catch (InvalidOperationException) when (child.HasExited) { } + await child.WaitForExitAsync(CancellationToken.None); + if (stdout is not null) try { await stdout; } catch (Exception) { } + if (stderr is not null) try { await stderr; } catch (Exception) { } + } + } + } + + private static async Task ReadBoundedAsync(StreamReader reader, CancellationToken cancellationToken) + { + var buffer = new char[1025]; + var count = 0; + int read; + while ((read = await reader.ReadAsync(buffer.AsMemory(count), cancellationToken)) > 0) + { + count += read; + if (count > 1024) throw new InvalidOperationException("Stock proof extractor output exceeded its fixed bound."); + } + return new string(buffer, 0, count); + } + + private static async Task WritePrivateAsync(string path, byte[] bytes) + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using var stream = new FileStream(path, options); + await stream.WriteAsync(bytes); + } + + private static void Bind(JsonElement summary, JsonElement full, string property) + { + (Id(summary) == Id(full) && Text(summary, property) == Text(full, property)).Should().BeTrue( + "independently read IDs and names must bind before any generated ID is canonicalized"); + } + + private static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + private static bool Same(JsonElement left, JsonElement right) => JsonNode.DeepEquals( + JsonNode.Parse(left.GetRawText()), JsonNode.Parse(right.GetRawText())); + private static bool SameSet(List expected, JsonElement actual) => actual.GetArrayLength() == expected.Count + && expected.All(value => actual.EnumerateArray().Count(candidate => Same(value, candidate)) == 1); + private static string Text(JsonElement value, string property) => value.GetProperty(property).GetString() + ?? throw new InvalidOperationException("Stock proof required field is absent."); + private static string Id(JsonElement value) => Uri.EscapeDataString(Text(value, "id")); + private static JsonElement Named(JsonElement values, string property, string name) => values.EnumerateArray() + .Single(value => Text(value, property) == name); + private static string[] Names(JsonElement values) => values.EnumerateArray().Select(value => Text(value, "name")) + .Order(StringComparer.Ordinal).ToArray(); + + internal static async Task HistoricalImportAsync(string root) + { + var path = Path.Combine(root, "backend", "tests", "LearnStack.Tests.Integration", "TenancyIdentity", + "Fixtures", "learnstack-24705a7a.json"); + var bytes = await File.ReadAllBytesAsync(path); + Hash(bytes).Should().Be(RedactedExportSha256, "only the reviewed credential-redacted historical fixture is imported"); + var realm = JsonNode.Parse(bytes)!.AsObject(); + var users = realm["users"]!.AsArray(); + users.Count.Should().Be(2); + foreach (var user in users) + { + (user!["credentials"]![0]!["value"]!.GetValue() == "__HISTORICAL_TEST_PASSWORD__").Should().BeTrue(); + user["credentials"]![0]!["value"] = Guid.NewGuid().ToString("N"); + } + var api = realm["clients"]!.AsArray().Single(client => client!["clientId"]!.GetValue() == "learnstack-api")!; + (api["secret"]!.GetValue() == "__HISTORICAL_TEST_API_SECRET__").Should().BeTrue(); + api["secret"] = Guid.NewGuid().ToString("N"); + return realm.ToJsonString(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/reconciliation-driver.mjs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/reconciliation-driver.mjs new file mode 100644 index 00000000..544abb83 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/reconciliation-driver.mjs @@ -0,0 +1,78 @@ +// Test-only transport controls; the production launcher accepts no fixture endpoint or scenario. +import { reconcileRealm } from '../../../../scripts/development-realm-core.mjs'; +import { realmRequest } from '../../../../scripts/development-realm-http.mjs'; +import { RealmError } from '../../../../scripts/development-realm-errors.mjs'; +let writes = 0; +let input; +let passStarted; +let elapsedMilliseconds = 0; +let prepared = false; +const requests = Object.fromEntries(['discovery', 'authentication', 'version', 'initialPlan', 'prepare', 'freshPlan', 'apply', 'readback'] + .map(stage => [stage, 0])); +try { + if (Number(process.versions.node.split('.')[0]) < 22) throw new RealmError('preflight', 'unsupported'); + const chunks = []; + let size = 0; + for await (const chunk of process.stdin) { + size += chunk.length; + if (size > 256 * 1024) throw new RealmError('preflight', 'body_limit'); + chunks.push(chunk); + } + input = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const scenarios = new Set(['normal', 'lost_acknowledgement', 'partial_cancel', 'readback_failure', 'duplicate_lookup']); + if (!scenarios.has(input.scenario)) throw new RealmError('preflight', 'configuration'); + const abort = new AbortController(); + let injected = false; + const transport = async (url, options) => { + const phase = options.stage === 'plan' ? prepared ? 'freshPlan' : 'initialPlan' : options.stage; + if (!Object.hasOwn(requests, phase) || requests[phase] >= 65536) throw new RealmError('preflight', 'configuration'); + requests[phase]++; + const mutation = url.pathname.startsWith('/admin/realms/learnstack/') + && options.method !== undefined && options.method !== 'GET'; + if (mutation) writes++; + if (mutation && options.stage === 'prepare') prepared = true; + const response = await realmRequest(url, options); + if (mutation) { + if (!injected && response.status >= 200 && response.status < 300) { + if (input.scenario === 'lost_acknowledgement') { + injected = true; + // Acknowledgement loss is injected only after the real provider committed and closed its response. + throw new RealmError(options.stage, 'transport'); + } + if (input.scenario === 'partial_cancel') { + injected = true; + abort.abort(); + } + } + } + if (!injected && input.scenario === 'readback_failure' && options.stage === 'readback') { + injected = true; + throw new RealmError('readback', 'transport'); + } + if (!injected && input.scenario === 'duplicate_lookup' + && options.stage === 'plan' && /\/users$/.test(url.pathname)) { + const users = JSON.parse(response.bytes.toString('utf8')); + if (Array.isArray(users) && users.length > 0) { + injected = true; + return { ...response, bytes: Buffer.from(JSON.stringify([...users, users[0]])) }; + } + } + return response; + }; + passStarted = performance.now(); + let result; + try { result = await reconcileRealm({ ...input, signal: abort.signal, transport }); } + finally { elapsedMilliseconds = performance.now() - passStarted; } + if (writes !== result.mutations || (input.scenario !== 'normal' && !injected)) + throw new RealmError('readback', 'response', result.preparationDispatched === true); + process.stdout.write(JSON.stringify({ outcome: 'success', mutations: writes, requests, elapsedMilliseconds, + ...(result.preparationDispatched === true ? { preparationDispatched: true } : {}) }) + '\n'); +} catch (error) { + const closed = error instanceof RealmError ? error : new RealmError('preflight', 'configuration'); + process.stdout.write(JSON.stringify({ outcome: 'failure', mutations: writes, requests, elapsedMilliseconds, + stage: closed.stage, reason: closed.reason, + ...(closed.preparationDispatched === true ? { preparationDispatched: true } : {}) }) + '\n'); + process.exitCode = 1; +} finally { + input = undefined; +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationConfigurationTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationConfigurationTests.cs index f13f986f..902465de 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationConfigurationTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationConfigurationTests.cs @@ -15,6 +15,15 @@ namespace LearnStack.Tests.Integration; public sealed class TenantAuthenticationConfigurationTests { + [Fact] + public void Backchannel_rejects_a_supplied_certificate_validation_bypass() + { + using var handler = new SocketsHttpHandler(); + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, errors) => errors == System.Net.Security.SslPolicyErrors.None; + var act = () => new TenantAuthenticationBackchannel(handler); + act.Should().Throw(); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/docs/architecture/13-identity-and-auth.md b/docs/architecture/13-identity-and-auth.md index 71881253..0de7fd39 100644 --- a/docs/architecture/13-identity-and-auth.md +++ b/docs/architecture/13-identity-and-auth.md @@ -4,24 +4,42 @@ LearnStack delegates user authentication to a self-hosted identity provider rath This document covers what lives where: in Keycloak vs in LearnStack, how multi-tenant identity works, how tokens flow, and how SSO/OIDC integrations land later. -**Implementation boundary — 2026-10-12.** The flows and application identity -model below are target architecture. The dev realms exist, but API JWT validation, -tenant claim mappers, a BFF session and mirrored users/memberships are not shipped. -Current public sites use host-based anonymous admission without forwarding cookies -or credentials. [Phase 02b's entry verification](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) -records the baseline; G10–G13/G20 settle token shape, resolution, session custody -and lifetimes before their first consumers. Phase 03 owns the identity domain. +**Historical implementation boundary — as of 2026-10-12.** The dev realms +existed, but API JWT validation, tenant claim mappers, a BFF session and mirrored +users/memberships had not shipped. Public sites used host-based anonymous +admission without forwarding cookies or credentials. +[Phase 02b's entry verification](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +records that baseline. The P02b-5/P02b-6 delivery records below supersede its API +validation and realm-issuance status. BFF sessions remain P02b-7; Phase 03 owns +the application identity domain. Undelivered flows below remain target architecture. **P02b-5 decision Accepted — 2026-10-19.** [ADR-0064](../decisions/0064-tenant-api-authentication-boundary.md) and its [decision package](../roadmap/phase-02b-events-auth.md#p02b-5-decision-package-2026-10-19) authorize scalar identity claims, one closed authenticated read and additional subject accounting. All five implementation steps and their two independent -review rounds are locally complete; the -[Step 5 closeout](../roadmap/phase-02b-events-auth.md#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) -owns delivery and PR qualification. Authentication and authorization registration -activate together with the closed surface in Step 4. -Realm reconciliation and BFF sessions remain P02b-6/P02b-7. +review rounds are complete and merged through PR #33 on 2026-10-20; the +[merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) +owns delivery, correction reviews and final-source qualification. Authentication +and authorization registration activate together with the closed surface in Step 4. +P02b-6 supplies realm reconciliation as recorded below; BFF sessions remain P02b-7. + +**P02b-6 decision Accepted — 2026-10-21.** The +[accepted package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +authorizes four implementation steps for real issuance, protected seed attributes, +account-preserving development reconciliation, exact callbacks and issuance TTL. +Steps 1–3 supply the committed fresh-import scopes/profile/client definition, +typed identity manifest, bounded warm reconciliation and shared seed/demo entry. +Their local reviews and actual lifecycle qualification are complete. Step 4 +delivers the approved account capability and complete provider/API security +proofs after both independent review rounds and verified corrections; its +[record](../roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns local implementation, review closure and source-bound qualification receipts. +The correction record below owns exact hosted assignments; merge is separate. +G13's BFF session part +stays Open for P02b-7. The +[development realm guide](../../infra/keycloak/README.md) owns exact callbacks, +private sources and current upgrade status. **Assertion diagnostics Accepted — 2026-10-20.** [ADR-0065](../decisions/0065-bounded-tenant-assertion-diagnostics.md) admits 600 @@ -53,8 +71,9 @@ owns current implementation and proof status. that strict bearer boundary and exposes exact `GET /api/v1/account/site`. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-5-step-4--atomic-http-activation-and-contracts-2026-10-19) owns validation and review status. This updates the API-validation part of the -2026-10-12 baseline above; realm mappers, BFF sessions and the application identity -model remain with their named packets/phases. Public transport stays anonymous. +2026-10-12 baseline above; P02b-6 owns the realm's issuance changes, P02b-7 the +BFF session and Phase 03 the application identity model. Public transport stays +anonymous. ## Why Not Build It Ourselves @@ -104,8 +123,8 @@ Two realm strategies were considered: is the required issuer for the ADR-0064 profile: opaque `sub`, stable UUID `user_id`, optional scalar UUID `tenant_id` and optional scalar UUID `organization_id`. It carries no authoritative membership array or application-role grant. P02b-6 supplies - admin-controlled attributes/mappers and real issuance proof; the current realm - export does not yet issue that profile. Phase 03 owns live memberships. + admin-controlled attributes/mappers and real issuance proof; the committed realm + export issues that profile. Phase 03 owns live memberships. - **Realm-per-tenant**. Cleaner isolation, but a user with memberships in multiple tenants needs separate accounts; user discovery and SSO get more complex. The single tenant realm matches the global-user, tenant-scoped membership model. @@ -143,8 +162,11 @@ Key details: - **PKCE only.** No implicit flow, no client secrets in the browser. - **Cookies, not localStorage.** Tokens never enter JavaScript; the BFF holds them. -- **Short access token TTL.** P02b-6's realm export/reconciler owns actual - issuance lifetime. ADR-0064 bounds API acceptance; P02b-7 owns BFF refresh. +- **Short access token TTL.** [Security Standards](../standards/11-security.md#authentication) + owns the ongoing issuance rule under + [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md); + P02b-6's realm export/reconciler owns its executable value and enforcement. + ADR-0064 bounds API acceptance; P02b-7 owns BFF refresh and session lifetimes. - **JWKS rotation.** API validates tokens against Keycloak's JWKS endpoint, with key caching and rotation handling built into the .NET OIDC middleware. ## Tenant Resolution Across Identity @@ -247,25 +269,39 @@ Mirrored events (e.g. `user.created`) are propagated via Keycloak webhooks into Local Keycloak runs in Docker Compose, in the default profile of [`infra/compose/dev.yml`](../../infra/compose/dev.yml), and imports the `learnstack` and `learnstack-hub` realms from `infra/keycloak/realms/` at first start. The imports carry -the demo users [`infra/keycloak/README.md`](../../infra/keycloak/README.md) lists: a -tenant admin and a tenant learner in `learnstack`, whose `tenant_id` attribute is the -slug `tenant-a` and names neither seed tenant, and an operator in `learnstack-hub`. No +the four tenant personas that the +[realm guide](../../infra/keycloak/README.md) lists, with stable actor/tenant UUIDs +and host-appropriate optional organization UUIDs, and the separate operator in +`learnstack-hub`. No platform-admin user belongs in `learnstack`, because operators are the `learnstack-hub` population ([ADR-0004 Amendment 1](../decisions/0004-authentication-strategy.md#amendment-1--learnstack-hub-realm-for-operators-2026-05-18)). -`make seed` checks that both realms answer and writes nothing to Keycloak. -`LearnStack.Tools.Seeder` provisions the two seed tenants, `demo-english` and -`demo-yoga`, through the request path +`make seed` checks both realm readiness gates, converges application seed data, +then awaits the same bounded tenant-realm reconciliation used by `make demo`. +The direct `LearnStack.Tools.Seeder` entry remains application-data-only. It +provisions `demo-english` and `demo-yoga` through the request path ([Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-7)), and a re-run is idempotent. -> **Open in Phase 02b.** How seed users, mappers and the client scope reach a Keycloak -> database that has already consumed the import is G12 in -> [Phase 02b's decision register](../roadmap/phase-02b-events-auth.md#the-decision-register); -> G10 is Accepted: one token carries at most one active tenant. A user in both tenants -> also needs `Membership`, which [Phase 03](../roadmap/phase-03-identity-admin.md) -> delivers. The pass that closes each gate edits this section with its answer. +G12's [accepted package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +owns the account-preserving warm upgrade and its named account-console adjustment. +[ADR-0068](../decisions/0068-development-warm-realm-qualification.md), Accepted — +2026-10-22, adds bounded profile preparation and three qualified stock-console +assignment detachments. This deletes named assignment edges, while preserving +accounts and global definitions. Its [implementation record](../roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22) +tracks all three locally completed steps and their independent review rounds. +The preparation owner reuses identity-independent qualifiers, verifies the exact +profile and discards its projections before complete planning. A later identity +conflict retains a fixed metadata-progress notice and permits no ordinary write. +The +[third correction step](../roadmap/phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +owns actual historical lifecycle, usable credential/MFA, signed API and stored +self-service proof, with source-bound hosted assignments and evidence limits. +The realm guide owns the exact local procedure and refusal boundaries. G10 is +Accepted: one token carries at most one active tenant. A user in both tenants +also needs `Membership`, which [Phase 03](../roadmap/phase-03-identity-admin.md) +delivers. BFF custody and sessions remain P02b-7. ## Risks diff --git a/docs/architecture/30-api-gateway.md b/docs/architecture/30-api-gateway.md index a66f318d..1fa0f679 100644 --- a/docs/architecture/30-api-gateway.md +++ b/docs/architecture/30-api-gateway.md @@ -61,7 +61,8 @@ membership refusal has no audit row and only Debug visibility today. owns reachable issuer/DNS/TLS drills, operational failure signals, rotation, readiness, alerts and exercised runbooks before production exposure. P02b-6 owns real realm issuance; P02b-7 owns session custody and recovery. These are separate -from P02b-5's locally implemented API boundary. +from P02b-5's +[merged API boundary](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20). ## 1. Topology @@ -445,8 +446,10 @@ current metadata; disabling last-known-good fallback does not promise either an outage grace period or immediate refusal of every warm-cache request. The [Phase 11 authentication activation gate](../roadmap/phase-11-production-hardening.md#tenant-authentication-production-activation) owns topology, cold/warm-cache and key-rotation drills, diagnostic/readiness decisions -and operational runbooks before production use. P02b-6/7 still own actual realm -issuance and browser-session integration. +and operational runbooks before production use. P02b-6 delivers actual realm +issuance and safe development convergence; its +[Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns qualification. Browser-session integration remains P02b-7. ## 6. Rate limit policy diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 3e0fbd40..26389184 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -61,9 +61,9 @@ expired-only sweeps inspect at most 128 entries per call, and no timer or dispos owner is added. ADR-0065 owns the illustrative nominal 864,000 rows/24h/tenant/instance steady-saturation rate (not a calendar-day hard cap) and assumed storage costs; Phase 11 owns measured capacity and distributed admission. -Implementation and local proofs are delivered; the -[P02b-5 remediation record](../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) -owns source-bound qualification. Production capacity remains unqualified. +Implementation and local proofs are delivered and merged; the +[P02b-5 merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) +owns final-source qualification. Production capacity remains unqualified. **A granted read-sensitive query rides the first row, not the second.** It used to sit in the standalone class, on the reasoning that a read opens no transaction. Packet 6 diff --git a/docs/decisions/0067-development-tenant-access-token-issuance.md b/docs/decisions/0067-development-tenant-access-token-issuance.md new file mode 100644 index 00000000..e3439c70 --- /dev/null +++ b/docs/decisions/0067-development-tenant-access-token-issuance.md @@ -0,0 +1,219 @@ +# ADR-0067: Development Tenant Access-Token Issuance + +## Status + +Accepted — 2026-10-21. The maintainer explicitly approved the revised dedicated +record after its review corrections and two fresh independent review rounds. +The earlier P02b-6 approval of 900s development issuance remains recorded in its +[decision package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20); +this separate acceptance does not backdate ADR authority. + +**Date:** 2026-10-21 +**Deciders:** @cemil + +This record formalizes the already approved issuance choice. It introduces no new +runtime behavior, provider boundary or supersession. ADR-0004 retains provider +ownership; ADR-0064 retains API token acceptance. + +## Decision Drivers + +- The configured issuance lifespan is a security-sensitive choice carried by + Security 11 and an approved phase package. Documentation Standards 13 requires + an ADR for that choice and an + [authority header](../standards/13-documentation.md#anchored-standards) for the + Active standard; a phase package is not an authority type for that header. + ADR-0064 deliberately delegates actual issuance. +- The selected 900s development value reduces the ordinary stale-scope window + relative to the previous 1800s configuration, while increasing renewal demand. + The phase package does not claim measured production capacity. +- Provider timestamps, API admission and browser session lifetimes have different + owners. An issuance decision must not silently change any of those contracts. +- Fresh import and preserved-account warm reconciliation must use one executable + value, with independent evidence that a client override cannot conceal drift. + +## Considered Options + +1. **Dedicated ADR for the approved 900s development configuration** (chosen). + Supply a durable decision carrier and rationale; Security 11 keeps + the ongoing rule and the committed realm keeps its executable value. +2. **Retain only the phase package and Security 11** (rejected). They preserve the + maintainer's actual approval but leave the Active issuance rule without its + dedicated header authority and invite mistaken attribution to ADR-0064. +3. **Retain the earlier 1800s development configuration** (rejected in the approved + phase package). Less frequent renewal comes with a longer stale-scope window; + it does not match the explicitly approved development default. +4. **Use the API's one-hour ceiling as the issuance default** (rejected). A maximum + accepted token lifetime does not select how long the provider issues tokens. + +## Decision + +LearnStack configures the development `learnstack` realm's access-token lifespan +as **900 seconds**, inherited by `learnstack-web` and `learnstack-api` without an +`access.token.lifespan` client override. Security 11 owns the ongoing issuance +rule, `infra/keycloak/realms/learnstack.json` owns the executable value, and the +existing shared development reconciler enforces it. The configured lifespan is +distinct from observed token timestamps, ADR-0064's API acceptance bound and BFF +session lifetimes. The pinned provider copies the resulting access-token +expiration into generated ID tokens. This decision retains that existing +coupling; it selects no independent ID-token or BFF session lifetime. + +## Context + +### Earlier approval and this follow-up + +The maintainer approved the P02b-6 package at `6d75e532` on 2026-10-21, including +900s development issuance and its four implementation steps. Commit `285db56b` +records that acceptance before implementation. The package closes G13's +issuance-TTL part while leaving its session part Open; G12's development +reconciliation and callback choices have their own approved phase vehicle. + +The package originally states that no new ADR is proposed. This follow-up +supplies the missing dedicated issuance carrier under +[Documentation Standards 13](../standards/13-documentation.md#adrs), without +reopening the approved value or transferring G12 into this ADR. The maintainer +separately accepted the revised record on 2026-10-21. This does not backdate ADR +acceptance or change the approval provenance of existing implementation. + +[ADR-0004](0004-authentication-strategy.md) delegates authentication and issuance +to Keycloak. [ADR-0064](0064-tenant-api-authentication-boundary.md#access-token-profile) +defines the resource server's at-most-one-hour `exp - iat` acceptance bound and +30-second clock skew, and expressly leaves actual issuance to P02b-6. Neither +record selects the 900s configured value; both retain their Accepted decisions. + +### Configured lifespan and observed issuance + +Keycloak 26.6.2 reads the issuance and expiration clocks separately. A second +rollover can yield `exp - iat == 901` with a 900s configuration; a scheduling +pause can widen that difference. Provider session limits can also shorten +late-session issuance. Neither exact `exp - iat == 900` nor a fixed 901s ceiling +expresses this decision. The pinned upstream +[issuance timestamp call](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L1081), +[later expiration clock and session clamp](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L1099-L1153), +[issuance timestamp](https://github.com/keycloak/keycloak/blob/26.6.2/core/src/main/java/org/keycloak/representations/JsonWebToken.java#L150-L151) +and [clock implementation](https://github.com/keycloak/keycloak/blob/26.6.2/common/src/main/java/org/keycloak/common/util/Time.java#L33-L34) +support the phase package's distinction. + +The proof contract checks the exact realm value and absence of an +`access.token.lifespan` override on either named client independently. Fresh +signed expiration must fit the observed UTC/monotonic code-exchange window plus +the configured lifespan, within its finite HTTP deadline. Any explicit +fixture/provider clock tolerance is at most the API's existing 30-second skew; +an indeterminate clock/window fails the proof. The current fixture uses two +seconds. This is qualification evidence, not a new API acceptance rule. Separate +late-session controls allow shorter issuance; wrong-configuration and +wrong-expiration controls prevent tolerance hiding drift. + +Keycloak also +[copies access-token expiration into the ID token](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L1375). +Its ID-token issuance timestamp is read separately; this does not promise an +exact ID-token `exp - iat` either. The proof checks expiration equality for the +same signed exchange and retains shorter session-clamped issuance. ID tokens +remain identity credentials rejected by the tenant API. P02b-7 owns their BFF +validation/use and session policy; this provider consequence sets none of those +lifetimes and does not enable refresh-token rotation. + +Changing the realm does not revoke earlier 1800s tokens. Their validity remains +subject to ADR-0064's acceptance bound and skew. The shorter configured default +does not supply immediate revocation or live membership checks. + +### Scope retained by other decisions + +- P02b-7 owns BFF idle/absolute lifetime, refresh custody and serialization, CSRF, + callback implementation and logout; G13's session part stays Open. +- Phase 03 owns durable identity, membership, permission and tenant-switching + decisions. This record creates no application identity-provider adapter. +- Phase 11 owns production realm provisioning, refresh-token rotation and + deployment hardening. The 900s development choice is no production readiness + or capacity claim. +- The separate `learnstack-hub` realm and Hub contract are outside this decision. + +### What would change this choice + +Revisit the development setting if provider issuance/expiration semantics change, +measured security or renewal costs justify a different lifespan, or changes to +the API acceptance bound, P02b-7 session design or Phase 11 production policy +make it unsuitable. Those other owners do not silently change this development +decision; its 900s value needs an explicit replacement decision to change. + +## Consequences + +### Positive + +- Issuance has a dedicated decision carrier without attributing its value to the + API acceptance ADR or concealing the earlier explicit phase approval. +- One configured default converges on fresh and existing development realms. +- Timestamp evidence remains compatible with the pinned provider and preserves + independent configuration and session-clamping controls. + +### Negative + +- A shorter default requires more frequent renewal than 1800s; production + throughput and session design still require their own qualification. Consumers + that renew through refresh tokens use those credentials more frequently; + P02b-7 owns custody and Phase 11 owns rotation hardening. +- The shorter configured lifespan also affects generated ID-token expiration; + it is not an independently configured browser session lifetime. +- Accepted-token staleness remains possible until expiry; shortening the + configured lifespan does not immediately invalidate already issued tokens. + +### Neutral + +- This follow-up introduces no runtime change, new catalogue rule, supersession, + BFF session decision or additional G12 scope. +- Delivery and hosted qualification remain source-bound in the P02b-6 records; + an ADR approval does not qualify a later PR head or authorize merge. + +## Implementation notes + +The existing implementation is the subject of this record, not a new work plan: + +- [Tenant realm export](../../infra/keycloak/realms/learnstack.json) supplies 900s. +- [Development realm planner](../../scripts/development-realm-plan.mjs) validates + that declaration and defines the mutation actions and readback predicate. + [Reconciliation core](../../scripts/development-realm-core.mjs) applies the + actions, invokes that verification and requires an empty second plan. + [Shared development entry](../../scripts/reconcile-development-realm.mjs) + owns target qualification, cancellation, locking and process cleanup. +- [Security 11](../standards/11-security.md#authentication) keeps the ongoing rule; + [Keycloak setup](../../infra/keycloak/README.md) links to it for local operation. + +### Acceptance carriers + +Explicit maintainer acceptance authorizes the following lifecycle/cross-link +updates. The phase record retains the earlier Proposed-state reviews and this +separate acceptance. No runtime change is required by this decision. + +| Carrier | Acceptance update | +|---|---| +| This ADR § Status and decisions README | Record the actual acceptance date and approval; move the single Proposed index row to Active. Retain the earlier phase approval provenance. | +| Security 11 § Authentication and authority header | Derive issuance from this ADR; replace draft/pending notices with its Accepted authority and cite it beside the phase history. Retain ADR-0064 for API acceptance and the inherited ID-token expiration consequence without selecting a BFF session lifetime. | +| P02b-6 decision package's vehicle, G13 answer, test-source and handoff passages | Add dated disclosures of this later dedicated carrier for issuance only. Preserve the original package acceptance, G12 vehicle, implementation receipts and Open G13 session portion. | +| Standards 21 issuance obligation's source references | Add this ADR's issuance authority beside the existing API/profile sources; retain the canonical rule, test owners and implementation status. | +| Architecture 13 and Keycloak README issuance pointers | Keep Security 11 as the ongoing-rule pointer; distinguish this dedicated issuance authority from the phase package's reconciliation/approval provenance. | + +## Architecture tests + +No new canonical obligation is introduced. Existing controls remain required: + +- [`Tenant_Realm_Issuance_Matches_The_Api_Profile`](../standards/21-architecture-tests-catalogue.md#tenant_realm_issuance_matches_the_api_profile) + owns real signed issuance/API compatibility. `TenantRealmIssuanceTests` checks + live realm/no-override state, bounded fresh access/ID expiration and their + equality, wrong TTL/override and independent ID-expiration controls, and shorter + late-session issuance. `TenantRealmExportTests` + independently checks the committed declaration and drift controls. +- [`Development_Realm_Reconciliation_Preserves_Accounts`](../standards/21-architecture-tests-catalogue.md#development_realm_reconciliation_preserves_accounts) + owns warm convergence/preservation through `TenantRealmReconciliationTests` + and the existing Node tooling controls, including TTL restoration/readback and + zero-write completed reruns. + +The phase delivery records own historical results; this record claims no new +runtime or current-head hosted test result. + +## References + +- [ADR-0004 — Authentication Strategy](0004-authentication-strategy.md). +- [ADR-0064 — Tenant API Authentication Boundary](0064-tenant-api-authentication-boundary.md). +- [P02b-6 decision package and approval](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20). +- [Security 11 — Authentication](../standards/11-security.md#authentication). +- [Documentation 13 — ADRs](../standards/13-documentation.md#adrs). +- [Identity and authentication architecture](../architecture/13-identity-and-auth.md). diff --git a/docs/decisions/0068-development-warm-realm-qualification.md b/docs/decisions/0068-development-warm-realm-qualification.md new file mode 100644 index 00000000..13de76cb --- /dev/null +++ b/docs/decisions/0068-development-warm-realm-qualification.md @@ -0,0 +1,401 @@ +# ADR-0068: Development Warm-Realm Qualification + +## Status + +Accepted — 2026-10-22. +Existing guards remain active until the bounded implementation is qualified. + +**Date:** 2026-10-22 +**Deciders:** @cemil + +Implementation and final-source automatic-path proof remain pending. +This decision replaces bounded parts of two Accepted P02b-6 clauses; it does not +supersede ADR-0004, ADR-0064 or ADR-0067, +correct their history or backdate new mutation authority. + +## Decision Drivers + +- The Accepted package requires migration of the two legacy `tenant-a` values. + The historical export delegates profile/bootstrap to Keycloak; its stock + profile hides those stored values, so the current guard blocks that migration. + Stock console assignments also conflict with the approved narrow console policy. +- Treating hidden values as absent can overwrite an actor. Treating scope names + or assignment summaries as complete definitions can misclassify customization. +- Warm upgrade must retain provider IDs, credentials and unrelated state while + converging the intended development console configuration. +- Profile preparation precedes complete identity qualification. Scope detachment + changes a shared client. Both costs need explicit authority and independent proof. + +## Considered Options + +1. **Bounded preparation and qualified stock-console normalization** (chosen). + Reveal only missing protected definitions, then validate a complete + fresh plan before detaching only the three incompatible stock console edges. +2. **Manual preparation and operator normalization** (viable alternative). Retain + existing refusals; an administrator appends the definitions and reconciles the + console assignments before rerunning. This preserves current tooling authority + but makes this developer-only stock upgrade an explicit manual prerequisite. + Profile preparation is already documented; deliberate console normalization + would need the same full graph qualification, not silent name-based removal. +3. **Preserve the broad stock console assignments** (rejected). Retains broader + scope allowances and role emitters that violate the accepted narrow capability. +4. **Global or temporary unmanaged visibility** (rejected). `ADMIN_VIEW` changes + visibility without granting edit permission, but exposes unrelated attributes. + `ADMIN_EDIT`/`ENABLED` also expand editability. Temporary opening/restoration + adds writes and uncertain recovery; restoring cannot undo a disclosed value. +5. **Replay/reset the realm or drop the guards** (rejected). Reset risks account + preservation; ignoring visibility or customization restores unsafe adoption. +6. **Explicit fixed opt-in** (viable alternative, not chosen). A dedicated target + could authorize these same two mutations separately. Automatic shared seed/demo + convergence is chosen to fulfill the approved warm-legacy migration without + another prerequisite; every pass still qualifies the same narrow authority. +7. **Automate only preparation or only normalization** (viable alternatives). + Each leaves the other observed stock obstacle as a named operator prerequisite. + Choosing both is an upgrade workflow decision, not an indivisible technical need. +8. **Read full scope representations without mutation** (required corrective work). + ID-bound enrichment restores the already Accepted compatible-scope promise and + ships independently. It grants no preparation or DELETE authority. Permitting + incompatible stock emitters without detachment would instead change the + approved refusal policy; this decision keeps that policy. + +## Decision + +1. **Identity visibility preparation.** LearnStack permits at most one preparation + `PUT /admin/realms/learnstack/users/profile` per pass, solely to append absent + canonical admin-only definitions for `user_id`, `tenant_id` and + `organization_id` when needed for qualified reads. It preserves all existing + profile state, verifies the exact resulting profile, then discards earlier + projections and obtains a fresh complete bounded snapshot and plan before any + ordinary reconciliation mutation. All identity-independent refusal/capacity + checks must pass before preparation. A fresh-plan conflict can leave this + preparation durable but permits no other Admin REST mutation. Here and below, + **ordinary mutation** means every Admin REST write except that preparation PUT, + including the separately authorized profile update, mapper/grant changes and + the named assignment DELETEs. +2. **Stock console assignment normalization.** In that complete ordinary plan, + LearnStack detaches only independently qualified pinned-stock `roles` default + and `microprofile-jwt`/`offline_access` optional edges from `account-console`. + It preserves the other eight compatible stock edges, all global definitions + and other clients' assignments. The remaining graph must satisfy the accepted + compatible allowance/emitter policy. Warm state may differ from fresh import; + zero console assignments is not a target. Normal complete-plan, readback and + failure rules apply; detachment is never a second preparation exception. + +Full-representation scope reads are an independent correction to existing behavior. +The two new mutation permissions share the existing target, owner and budgets. +A definitive or uncertain failure stops without automatic retry or rollback. +A subsequent explicit invocation reads the durable current state anew. + +## Context + +### Two bounded replacements of Accepted phase clauses + +The Accepted package's +[Reconciler ownership, failure and security boundaries](../roadmap/phase-02b-events-auth.md#reconciler-ownership-failure-and-security-boundaries) +requires: “Read and validate the complete bounded change plan before the first +write.” Decision 1 replaces that ordering only for its single metadata write: +qualify bounded preparation, write and verify it, then build the full fresh plan +before all other mutations. The remaining ownership and preservation rules survive. + +The Accepted +[Account-console self-service capability](../roadmap/phase-02b-events-auth.md#account-console-self-service-capability--proposal-2026-10-21) +requires: + +> Refuse a wider/customized role allowance or +> an effective conflicting emitter/role-transforming mapper, without changing it. + +Its following sentence preserves compatible prior scopes. Decision 2 permits +removing only the three fully qualified stock edges named above, eliminating their +broader emitters/allowances from the console's assigned graph. Customized state +still refuses; direct console mappers, role definitions and composites receive +no broader repair authority. The existing approved fixed-account mapper and +missing four-persona grant additions retain their separate authority. + +Removing compatible stock edges too would lose the reason to continue once the +last incompatible edge disappeared. The three-edge decision needs no durable +marker: its final deletion naturally hands later passes to compatible preservation. + +The [dated acceptance record](../roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22) +names both replacements beside their phase clauses and retains the original +package and review chronology. The +[Step 2 review](../roadmap/phase-02b-events-auth.md#step-2-review-round-1--identity-visibility-and-plan-feasibility) +demonstrated an actual hidden-actor overwrite before the visibility guard. That +guard is protective, and the old warm fixture enabled unmanaged attributes. +The current bootstrap-parity fixture derives from today's desired export; it +cannot establish the historical export's provider-generated scope assignments. +The dated review's no-profile-write result remains historical; the adjacent +acceptance note records the changed remedy without rewriting that evidence. + +### Pinned provider behavior and manual feasibility evidence + +Keycloak 26.6.2's +[stock profile](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json) +lacks these protected definitions and an unmanaged policy. The public +[unmanaged-attributes read](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java#L1242-L1260) +uses USER_API; its [collection](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java#L399-L462) +is gated by a [policy treating null as disabled](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java#L500-L517). +It supplies no raw-storage escape hatch. The +[profile PUT](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java#L106-L140) +sets the whole configuration, requiring preservation and independent GET verification. + +The console assignment endpoints return +[only ID/name summaries](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java#L391-L410), +using a [minimal representation](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java#L908-L913). +An omitted summary protocol does not establish SAML or unsafe protocol. Complete +scope representations include +[protocol, attributes and mappers](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java#L828-L842). + +A private disposable-provider probe imported exactly the `24705a7a` tenant export +into 26.6.2. Missing definitions first caused `plan/identity_conflict`, zero writes. +Test-owned append of the three definitions preserved the complete profile by +independent deep-equal GET and exposed the legacy `tenant-a` value. The unchanged +reconciler then refused `plan/unsupported` at the account assignment-summary check. +Full by-ID reads found all eleven scopes were `openid-connect`, not SAML: + +| Assignment | Exact historical names | +|---|---| +| Default (6) | `web-origins`, `acr`, `roles`, `profile`, `basic`, `email` | +| Optional (5) | `address`, `phone`, `organization`, `offline_access`, `microprofile-jwt` | + +The `roles` scope contained realm/all-client role emitters; `microprofile-jwt` +contained a realm-role emitter; `offline_access` carried a realm-role allowance. +Test-owned DELETEs removed only those eleven console assignment edges. The +unchanged reconciler then succeeded with 13 writes, retained the old provider ID +and credential hash, and the next pass performed zero writes. That one-case probe +passed in 29.52s, qualified four strict signed personas and console Account REST +HTTP 200, and verified its two owned containers and network absent. A separate +hidden actor probe still refused after exposure without reconciler mutations. + +The three-edge follow-up used test-owned definition append and only the three +proposed DELETEs, plus a private copied account module with full scope-by-ID reads +and strict ID/name binding. It retained five default/three optional assignments; +ordinary reconciliation wrote 13, preserved the old ID/hash, then wrote zero on +rerun. Signed code/S256 identity/scope checks passed for all four personas, as did +console code/S256 and Account REST GET 200. This was a private variant, not the +unchanged reconciler. Automatic preparation, canonical stock qualification and +planned DELETEs remain unimplemented/unproved. The phase record owns both source +hashes, receipts and limits. The follow-up passed in 28.95s; its two exact +fixture containers and network are independently confirmed absent. + + +### Observed console impact and limits + +A separate private pinned-provider measurement records actual code/S256 access +and ID tokens. Both stock clients report `fullScopeAllowed=false`. For the two +legacy users, stock ordinary `openid` access has 17 claim keys, no audience and +no realm/client roles; detaching the three edges leaves those claims unchanged. +Explicit `microprofile-jwt` adds only `upn` before detachment; afterward that +scope request is refused. Thus these users lose no observed role claim merely +from detachment. The purpose is to make the Accepted narrow console policy +satisfiable on the stock graph, not to assert a measured security improvement +relative to the separately approved BFF capability. + +After the ordinary thirteen-write reconciliation, all four personas' console +access tokens add `aud=account` and the two approved account roles. ID-token +claims remain unchanged. A subsequent pass writes zero. The private Fact then +fails its overly strict role-equality harness check on those intended grant +additions, before a profile POST; no successful Fact or actual POST proof is +claimed for that experiment. Full before/after preservation and POST/readback +remain mandatory committed implementation evidence. The phase records source, +receipt and graph hashes, exclusions and exact owned cleanup. + + +## Consequences + +### Positive + +- The combined automatic path can support the required legacy migration while + preserving accounts and the narrow console policy, once its end-to-end provider + proof passes. Preparation alone establishes visibility, not complete upgrade. +- Unknown/customized state remains a refusal, with no name-only stock exception. + +### Negative + +- A later conflict can leave appended profile metadata durable. A failed pass + need not mean zero Admin REST mutations; a fresh-plan conflict still means + zero ordinary mutations, including console detachment and account updates. +- The same three assignment removals affect every console user. Actual role/claim + or offline-token effects depend on scope selection, full scope and granted roles; + no universal role-loss claim or security gain relative to the BFF is inferred. + Actual before/after tokens and self-service actions must qualify the effect. + Compatible profile/email/acr/basic edges, global definitions and grants remain. +- A whole-profile PUT turns an implicit default into a stored explicit + configuration when the definitions change; it is not automatically reverted + after a later conflict. The pinned [configuration setter](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java#L197-L239) + creates/persists the component for a non-null configuration. +- Metadata grows repeated user-profile responses. Preparation and DELETEs can + commit despite a lost response; partial progress is durable, with no rollback. + +### Neutral + +- Approved fixed-account emission retains account self-service; no new grant is + authorized by normalization. Existing approved missing grants remain separate. +- Global scopes and other client edges, users, passwords, MFA and prior grants + are preserved. Current BFF full scope, API credentials and Hub remain unchanged. +- Exact username ownership and legacy discrimination remain. This chooses no + alternate-email adoption, email-renaming policy or new issuance/session lifetime. + +## Implementation Notes + +### 1. Bounded metadata preparation + +- Preparation triggers only when at least one protected definition is absent and + `unmanagedAttributePolicy` is outside `ENABLED`, `ADMIN_EDIT`, `ADMIN_VIEW`. + If unmanaged values are already admin-readable, keep the existing ordinary path. + Existing protected definitions must be plain and admin-readable; admin-view-only + definitions remain admissible and are preserved exactly during preparation. + Hidden/defaulted/selected/otherwise unqualified definitions refuse with + `prepare/identity_conflict`; preparation does not repair them. +- Before preparation, complete every existing refusal/capacity check independent + of hidden protected values: desired/manifest validation, realm/client/secret + continuity, service-account safety, effective mappers, compatible or qualified + stock console graph, groups/memberships, storage projection and known request/ + response bounds. Reuse those real qualifiers, not a fake identity snapshot. + Only checks requiring newly visible protected values are deferred. All state + is independently reread and revalidated afterward; external drift can still fail. +- Append only absent canonical scalar definitions from the committed export: + matching display name, admin-only view/edit, no default/selector/additional + semantics. Preserve existing definition order, all fields/groups and the exact + null/absent/explicit unmanaged policy; never widen that policy. +- Precheck complete body/profile-response size, collection counts and conservative + metadata growth in bounded user reads. A capacity census cannot treat hidden + identity absence as ownership proof. Known overflow refuses before preparation; + newly exposed payload exceeding a later limit stops without ordinary writes. +- Require expected successful status and valid profile JSON, then a separate GET + structurally equal to the complete expected profile, including ordered arrays. + Drift, malformed data, failure or uncertainty stops; no second preparation. +- At most one **preparation** PUT is permitted. A subsequent full ordinary plan + may separately include its existing profile-normalization PUT, for example when + a readable prior definition needs its approved permission/scalar normalization. + Preparation preserves that definition; ordinary apply alone can normalize it. + Both dispatched calls count independently in a successful pass's `mutations`; + zero-write completion means no mutating Admin call was dispatched. +- Add the closed `prepare` diagnostic stage. Preserve a closed preparation state + through later failures and cleanup. Once the PUT is dispatched, any failed pass + emits a fixed notice that metadata may already be durable and requires current- + state inspection before an explicit rerun. A lost acknowledgement never reports + zero writes. No values, provider bodies or arbitrary messages enter the notice. +- Discard earlier projections/actions. Read a full fresh snapshot and qualify + identity ownership, duplicate actors, secrets, mappers, group/storage sources + and projected read/write capacity before ordinary apply, including any DELETE. + +### 2. Full-plan stock assignment qualification and detachment + +- Resolve the complete graph first. If it satisfies the accepted compatible + allowance/emitter policy, preserve it, even when a compatible customized scope + uses a historical name. Only a definitively incompatible graph can enter stock + normalization. HTTP/JSON failures or arbitrary exceptions never select a fallback. +- Normalization requires a subset of the eleven historical edges containing at + least one of the three incompatible edges, each in its original category. + Every complete definition/role graph must match the baseline below. Extra/custom + edges, wrong categories or duplicates refuse. Never reattach missing edges. + If neither qualifier passes, retain the refusal before ordinary writes. +- Resolve each summary ID with a full scope GET. Independently bind current ID, + unique name, protocol and assignment references before comparing a canonical + shape. Qualify complete definition fields, attributes, mapper models/configs, + scope role allowances and every referenced role/composite edge against an + immutable historical 26.6.2 baseline. Do not trust names or summary protocol. +- Bind mapper IDs, role IDs, container IDs and composite references to independent + authoritative current reads before normalizing generated IDs for comparison. + Unknown/extra/customized fields, emitters, roles or composites and ID/name/container + mismatches refuse. Do not blanket-ignore attributes or generated-ID differences. +- Reproduce the baseline by importing the exact hashed historical export into + a disposable pinned 26.6.2 provider, capturing full scope/role/composite reads, + independently binding generated IDs and then canonicalizing only those IDs. + Commit the extraction procedure, source/image/transformation hashes and reviewed + expected shape. A later fresh historical bootstrap must match that fixed fixture; + the observation under test cannot approve or regenerate its own expectation. + Redact published example credentials if retained and record that transformation; + never use private credentials or derive the baseline from today's desired export. +- Extend the closed Admin client with assignment-only DELETE, expected status + **204**: `clients/{qualified-console-id}/default-client-scopes/{qualified-roles-id}` + and `clients/{qualified-console-id}/optional-client-scopes/{qualified-id}` for + `microprofile-jwt` or `offline_access` only, under `/admin/realms/learnstack/`. + Qualified bindings authorize these exact paths; a general delete method is not + exposed. Deleting scopes, clients, roles, users or other-client edges is forbidden. +- Plan DELETEs only for the present intersection with default `roles` and optional + `microprofile-jwt`/`offline_access`. Preserve every other edge, global definition, + other-client assignment and existing user/grant state. Separately qualify direct + console allowance/mappers; require the projected remaining graph to pass the + accepted compatible policy before apply. No wider detachment fallback is allowed. +- Readback proves exactly that edge delta and unchanged retained state; verify + existing approved mapper/grant deltas separately. Each DELETE needs its expected + status. Error or uncertainty stops without replay. An explicit rerun requalifies + while any incompatible edge remains; after the last is gone, compatible-mode + priority preserves the remaining edges and resumes any other missing accepted + acts. No marker is required. A completed pass followed by a new pass writes zero. + +### 3. Common execution and acceptance boundaries + +- Retain selected Compose target/image/private-source agreement, exact loopback + issuer, authenticated **26.6.2**, secret confinement and the same local lock + through joined cleanup. Concurrent external administration remains unsupported. +- Keep **5s** per HTTP wait, **120s** for the whole pass, **256 KiB** for every + outbound body and ordinary decoded response, all collection caps and the fixed + authenticated server-info **512 KiB** exception. No extra preparation budget. + Maximum collection/request counts do not imply worst-case completion in 120s: a + sequence of bounded 5s exchanges can exceed it and must refuse at the shared + deadline. Record exact request/phase counts and elapsed automatic-path evidence; + the private 28.95s experiment does not establish this fit. + Reject redirects; retain bounded proactive refresh, without failed-request replay. +- Definitive/uncertain failure, cancellation or readback conflict exits nonzero. + No automatic token, preparation or mutation retry; no rollback. Emit only fixed + stage/reason diagnostics, never upstream bodies, arbitrary identity values or secrets. +- Reopen stock normalization on provider/image/bootstrap changes, altered desired + identity definitions, repeated budget refusals, or after the legacy upgrade + population is retired. Requalification or removal is an explicit reviewed change; + no provider upgrade silently refreshes the baseline or broadens DELETE authority. +- The named phase clauses and dated Step 2 remedy note, Security 11, + Architecture 13's account-preservation/assignment wording, Keycloak guide, ADR + index and repository/phase front doors record these exceptions and pending + delivery. Final source, receipts and reviews precede + Implemented claims; existing Accepted ADR bodies remain unchanged. + +## Architecture Tests + +Reuse the existing canonical obligations +[`Development_Realm_Reconciliation_Preserves_Accounts`](../standards/21-architecture-tests-catalogue.md#development_realm_reconciliation_preserves_accounts) +and [`Tenant_Realm_Issuance_Matches_The_Api_Profile`](../standards/21-architecture-tests-catalogue.md#tenant_realm_issuance_matches_the_api_profile). +Retain their existing **Implemented** status and counts for shipped coverage. +The catalogue records dated **Accepted extension — pending implementation +and qualification** paragraphs with precise Asserts boundaries and owners. +Do not claim that extension implemented before final-source proofs/reviews; preserve +prior phase receipts and invent no new canonical rule. + +- `TenantRealmReconciliationTests` owns genuine historical stock upgrade, exact + metadata delta, old provider IDs/changed passwords/credential hashes/MFA and + unrelated-state preservation, hidden-conflict refusal before ordinary writes, + partial progress and explicit zero-write rerun. Retain hidden-managed/default/ + selector refusals; add partial definitions and admin-view-only preservation. + Missing definitions combined with each independent conflict must prove zero + writes; partial definitions prove one preparation plus a separate ordinary PUT. + Exercise preparation notices/counts across lost responses and later failures. +- `TenantRealmSelfServiceTests` owns immutable bootstrap qualification, exact + three-edge detachment/eight-edge preservation and console usage for manifest + and eligible nonmanifest users. Verify full issued access/ID-token claim sets + and successful console login/profile POST with independent stored-field readback, + with original grants retained. Measure actual before/after token effects rather + than inferring them from mapper names or full-scope settings. + Plant stock lookalike names with changed definitions, emitters, allowances, + composites or ID references; each must refuse before pending ordinary writes. +- `TenantRealmIssuanceTests`, `TenantRealmProfileProtectionTests` and + `TenantRealmApiTests` retain actual token/API and protected-account proof. Show + ordinary console editing succeeds while protected identity writes do not persist; + do not substitute generic refusal, HTTP 204 alone or desired-export parity. +- `development-realm-plan.test.mjs` and `development-realm-core.test.mjs` own + finite ordering, full-ID reads, independent corruptions, budget boundaries, + subset/category/extra-edge refusals, compatible-mode priority with unchanged + custom graphs, malformed/status failures without fallback and readback drift. + `development-realm-http.test.mjs`, `development-realm-owner.test.mjs` and + `reconcile-development-realm.test.mjs` retain actual transport/lock/CLI proof and + add lost acknowledgements after each of the three DELETEs, cancellation and + explicit reruns, including last-edge handoff to compatible preservation without + a marker. Node fixtures cannot replace actual-provider qualification. + +## References + +- [Accepted P02b-6 package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20). +- [Security 11 — Authentication](../standards/11-security.md#authentication). +- [Architecture 13](../architecture/13-identity-and-auth.md) and [Keycloak guide](../../infra/keycloak/README.md). +- [ADR-0004](0004-authentication-strategy.md), [ADR-0064](0064-tenant-api-authentication-boundary.md) + and [ADR-0067](0067-development-tenant-access-token-issuance.md). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 38517fa1..c6b519a4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -108,6 +108,8 @@ owns outstanding hosted CI/PR qualification. Phase 11 owns production qualificat | 0064 | [Tenant API Authentication Boundary](0064-tenant-api-authentication-boundary.md) | Accepted 2026-10-19: strict tenant bearer profile, stable actor/subject quota, closed authenticated read and bounded membership-denial audit replacement; [five-step P02b-5 package](../roadmap/phase-02b-events-auth.md#p02b-5-decision-package-2026-10-19) approved before implementation. Own dated disclosures in ADR-0036/0040/0052 preserve their Accepted bodies. All five implementation steps and both independent review rounds per step are locally complete; the [Step 5 closeout](../roadmap/phase-02b-events-auth.md#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) owns historical local/source evidence; the [2026-10-20 remediation](../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) owns subsequent corrections and PR #33 owns current-head CI. [Amendment 1](0064-tenant-api-authentication-boundary.md#amendment-1--bounded-assertion-diagnostics-2026-10-20) discloses ADR-0065's audit-admission replacement. [Amendment 2](0064-tenant-api-authentication-boundary.md#amendment-2--explicit-subject-registry-bound-and-attribution-2026-10-20) records ADR-0066 acceptance and corrects the old cap attribution. [Amendment 3](0064-tenant-api-authentication-boundary.md#amendment-3--read-mode-instrument-classification-2026-10-20) clarifies read-mode instrument classification. | | 0065 | [Bounded Tenant Assertion Diagnostics](0065-bounded-tenant-assertion-diagnostics.md) | Accepted 2026-10-20: singleton host-tenant admission of 600 authenticated audit attempts per instance/minute, closed counters and one attributed warning per occurrence; explicitly accepts lost durable actor evidence and unbounded log volume. Ratifies process-wide native outbound metric projection and dependency-attribution cost. Implementation and local proofs are delivered; the [remediation record](../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) owns implementation and proof status. [Amendment 1](0065-bounded-tenant-assertion-diagnostics.md#amendment-1--exemplar-export-enforcement-2026-10-20) clarifies exemplar privacy enforcement and its actual export controls. [Amendment 2](0065-bounded-tenant-assertion-diagnostics.md#amendment-2--subject-registry-source-attribution-2026-10-20) corrects subject-registry attribution without changing audit allowance. [Amendment 3](0065-bounded-tenant-assertion-diagnostics.md#amendment-3--delivered-implementation-and-receipt-chronology-2026-10-20) clarifies delivered implementation chronology. | | 0066 | [Bounded Tenant Subject Admission](0066-bounded-tenant-subject-admission.md) | Accepted 2026-10-20: explicit 4096 retained-subject process bound, one-disposal acquisition reclamation, sole joined cleanup owner and distinct quota/capacity/fault results and metrics. Sticky cleanup fault refuses new keys with 503; quota/capacity stay 429. ADR-0064/0065 each carry inline attribution errata and their own Amendment 2. [Amendment 1](0066-bounded-tenant-subject-admission.md#amendment-1--implementation-and-local-proof-ownership-2026-10-20) records implementation and actual proof owners; the [delivery record](../roadmap/phase-02b-events-auth.md#adr-0066-acceptance-and-implementation-2026-10-20) owns status. [Amendment 2](0066-bounded-tenant-subject-admission.md#amendment-2--acceptance-time-proof-list-and-cleanup-evidence-2026-10-20) clarifies acceptance-time proof list and cleanup evidence. | +| 0067 | [Development Tenant Access-Token Issuance](0067-development-tenant-access-token-issuance.md) | Accepted 2026-10-21 after separate maintainer approval: dedicated authority for the already approved 900s development issuance choice, inherited ID-token expiration and bounded provider timestamp/session semantics. Security 11 owns the ongoing rule; API acceptance and BFF session policy remain separate. The [acceptance record](../roadmap/phase-02b-events-auth.md#adr-0067-acceptance-and-carrier-reconciliation-2026-10-21) preserves original phase approval and source-bound proof/review status. | +| 0068 | [Development Warm-Realm Qualification](0068-development-warm-realm-qualification.md) | Accepted 2026-10-22 under conditional maintainer authority after verified review corrections: identity-independent prechecks, one bounded profile preparation and three qualified stock-console assignment detachments. Implementation and automatic-path qualification remain pending; the [acceptance record](../roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22) owns the three-step plan and proof status. | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 902b2335..728e42af 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -28,6 +28,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Role** | A named bundle of permissions. Scope: `Platform` / `Tenant` / `Organization`. Examples: `tenant-admin`, `editor`, `instructor`, `learner`, `org-admin`. | | **Permission** | A fine-grained capability `{module}.{resource}.{action}` with a scope (Platform / Tenant / Organization). Action set is closed: `read \| write \| delete \| admin`. | | **Invitation** | A pending offer for a user to accept a membership in a tenant + organization. | +| **Development realm reconciliation** | P02b-6 tooling operation that converges the named tenant-realm configuration and demo identity attributes on a fresh or existing local Keycloak database. It preserves account identity and credentials, does not provision application memberships and is not the runtime `IIdentityProvider`. The [P02b-6 decision package](roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) owns its approved field/capability boundaries; the [Step 3 record](roadmap/phase-02b-events-auth.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21) records delivered entry-point convergence and the Step 4 record owns remaining qualification. | | **Validated tenant identity** | P02b-5 request-local immutable projection of a successfully validated tenant access token: its issuer/subject, stable actor and optional scalar tenant/organization. Actor attribution is independent of tenant authority: a claimless actor can remain HostOnly and cannot enter protected reads. It proves neither live membership nor permission. Accepted [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md) owns the contract; the P02b-5 delivery record owns implementation status. | | **Claimless principal** | A validated tenant API identity without a tenant claim. It keeps the HostOnly public ceiling; it cannot acquire tenant authority merely through its actor UUID. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#access-token-profile). | | **AccountSite** | The one authenticated host-backed API read: validated actor UUID, resolved tenant/organization and the existing public site projection. It grants no membership or application permission. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#closed-authenticated-read). | diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index cd692d27..27c9f545 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -152,7 +152,7 @@ unmarked row is one a shipped command already raises. | `platform_entitlement_cache` | `tenancy.entitlement.refresh` `(planned)` `(off-path)` | **MUST** | Changes what the tenant may do; written only by `IEntitlementProvider.RefreshAsync`, a port method driven by the Hub over `/api/internal/*` ([ADR-0045 § 1](../../decisions/0045-entitlement-and-feature-flag-socket.md)) and never by a MediatR request, so there is no request type to key a catalogue entry on. **Both markers**, for the reason `tenancy.killswitch.toggle` carries both: Packet 9 ships `NullEntitlementProvider`, which answers from constants and touches no table, and the `HubEntitlementProvider` that performs a real refresh lands in [Phase 02c](../../roadmap/phase-02c-hub-foundation.md) ([ADR-0045 § 6](../../decisions/0045-entitlement-and-feature-flag-socket.md)) — so there is no writer here yet either | | `platform_host_to_tenant` | `tenancy.hostmapping.write` | **MUST** | The resolution index — the row that decides whose data an anonymous request sees | | `platform_host_to_tenant` | `tenancy.hostmapping.delete` `(planned)` | **MUST** | The same row removed. The table carries no `deleted_at`, so the delete is a row gone and a host that resolves to nobody | -| `TenantAssertion` | `tenancy.tenant_assertion.reject` `(off-path)` | **MUST** for admitted events (`security-event`) | A validated header/claim mismatch with an independently defended host tenant is recorded under that host tenant, never the asserted tenant or sentinel; row 14 is counted only. [ADR-0065](../../decisions/0065-bounded-tenant-assertion-diagnostics.md) admits 600 authenticated standalone write attempts per host tenant per API instance per first-request-anchored monotonic minute, shared across scoped recorders, actors, sources and dimensions. Failed writes consume permits; later occurrences make no audit-store call and retain their refusal, count and exactly one attributed Warning (4006 replaces normal 4001). An attacker can exhaust the allowance, so later actors lose durable evidence; aggregate log volume remains unbounded. Admitted events retain MUST failure/health behavior, tenant-wide organization scope, exact actor/correlation and trusted organization in metadata. No tenant `AuditConfig` override or independent tenant-registry cap exists. General mutation MUST durability is unchanged. Packet 9 owns the recorder/decorator path; P02b-5 Steps 3–4 implement JWT attribution and HTTP activation under ADR-0064. ADR-0065 acceptance precedes the dependent budget implementation; the [remediation record](../../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) owns its delivery status | +| `TenantAssertion` | `tenancy.tenant_assertion.reject` `(off-path)` | **MUST** for admitted events (`security-event`) | A validated header/claim mismatch with an independently defended host tenant is recorded under that host tenant, never the asserted tenant or sentinel; row 14 is counted only. [ADR-0065](../../decisions/0065-bounded-tenant-assertion-diagnostics.md) admits 600 authenticated standalone write attempts per host tenant per API instance per first-request-anchored monotonic minute, shared across scoped recorders, actors, sources and dimensions. Failed writes consume permits; later occurrences make no audit-store call and retain their refusal, count and exactly one attributed Warning (4006 replaces normal 4001). An attacker can exhaust the allowance, so later actors lose durable evidence; aggregate log volume remains unbounded. Admitted events retain MUST failure/health behavior, tenant-wide organization scope, exact actor/correlation and trusted organization in metadata. No tenant `AuditConfig` override or independent tenant-registry cap exists. General mutation MUST durability is unchanged. Packet 9 owns the recorder/decorator path; P02b-5 Steps 3–4 implement JWT attribution and HTTP activation under ADR-0064. ADR-0065 acceptance precedes the dependent budget implementation; the [merge closeout](../../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) owns its final delivery status | | `TenantAssertion` | `tenancy.tenant_assertion.anonymous_burst` `(off-path)` | **MUST** (`security-event`) | The anonymous half of the same detector, written once per `(resolved tenant, dimension, window)` when the mismatch counter crosses its threshold. An anonymous mismatch is not itself an audited operation; the burst is — the precedent the baseline's "login failure burst" already sets ([ADR-0036 Amendment 7](../../decisions/0036-tenant-resolution-trusted-inputs.md)). Same writer, and `(off-path)` for the same reason. The window is **in-process** and never `ICacheService` — a cache outage must not decide whether a MUST-class security event is recorded — so the effective threshold is per instance, which errs toward more auditing. `Tenancy:AssertionBurst` sets it; the defaults are ten occurrences in five minutes | | any | `platform.admin_scope.enter` `(off-path)` | **MUST** (`security-event`) | Cross-tenant access is the one read worth a row; [Audit Coverage](../../standards/18-audit-coverage.md) puts every platform-bypass invocation on `security-event`. [Packet 9](../../roadmap/phase-02a-kernel-tenancy.md) ships it: `PlatformAdminScope.EnterAsync` writes the row through `IAuditStore.WritePlatformScopeAsync` on the scope's own platform-role connection, in a transaction of its own that **commits before** the caller's begins — so work that later fails, throws or is abandoned cannot take the record with it — carrying `TenantId.PlatformSentinel` ([ADR-0044 § 1, § 10](../../decisions/0044-audit-write-path.md)). An entry the catalogue cannot classify, or whose row cannot be written, is **refused** — no transaction is handed out for it. The `Warning` log line stays beside the row, carrying its id, as the real-time signal the row is not. `EnterAsync` is a service method and not a MediatR request, and the catalogue is keyed by request type — so its catalogue entry is registered by slug rather than by request type. Its module segment is `platform` for the same reason: the scope belongs to no module's request path | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index abffc6a3..f3b7384b 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -95,7 +95,12 @@ not deferred to the showcase phase. The [individual exit evidence](phase-02d-exit-evidence.md) maps each exit criterion to its actual proof and completed disposition. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - — **P02b-0 through P02b-4 complete; P02b-1–4 merged through PRs #29–32**. Its + — **P02b-0 through P02b-5 complete; P02b-1–5 merged through PRs #29–33**. + P02b-6 implementation is complete locally; the correction records below own + validation and independent reviews. + [PR #34](https://github.com/HodeTech/LearnStack/pull/34) records current-head + hosted qualification and maintainer review. Merge remains separate; + P02b-7–8 are pending. Its [entry audit](phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) verifies the inherited baseline; Accepted ADR-0057 and the [producer package](phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12) @@ -140,16 +145,51 @@ not deferred to the showcase phase. owns validation and both completed reviews. Step 5 completes the authority census, carrier reconciliation and both review rounds; its [local closeout](phase-02b-events-auth.md#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) - separates exact CI receipts from live PR-head qualification and merge. All five - steps are locally complete and unmerged. After P02b-5 merge, the next packet is - P02b-6's realm/TTL decision pass. + retains its source-bound pre-merge scope. **P02b-5 is complete and merged** + through [PR #33](https://github.com/HodeTech/LearnStack/pull/33) on 2026-10-20. + Its [merge closeout](phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) + owns final-source CI, unchanged-plan A/B qualification and identical merge + trees. P02b-6's realm/TTL + [decision pass](phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) + is Accepted — 2026-10-21, including its four implementation steps. G12's + realm/reconciliation/callback choices and G13's issuance-TTL part are Accepted; + the dedicated issuance follow-up + [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) is + Accepted — 2026-10-21 after separate maintainer approval, with no runtime or + G13 session-policy change. + The [additional correction record](phase-02b-events-auth.md#p02b-6-additional-verification-and-correction-pass-2026-10-22) + owns current findings; [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) + is Accepted — 2026-10-22 after verified review corrections under conditional + maintainer authority. Its [first correction step](phase-02b-events-auth.md#adr-0068-step-1--qualified-stock-assignment-implementation-2026-10-22) + completes both review rounds locally. Its + [second correction step](phase-02b-events-auth.md#adr-0068-step-2--owned-visibility-preparation-2026-10-22) + completes owned preparation and fresh planning after both independent review + rounds. Its [third correction step](phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) + completes local historical entry, credential/MFA, signed API and self-service + proof after both review rounds, and owns source-bound hosted qualification. + Step 1 is complete locally after both review rounds; its + [record](phase-02b-events-auth.md#p02b-6-step-1--executable-realm-and-identity-declaration-2026-10-21) + owns fresh-provider qualification and implementation reviews. Step 2 is + complete locally after both review rounds and fresh fix reviews; its [record](phase-02b-events-auth.md#p02b-6-step-2--owned-warm-realm-reconciliation-2026-10-21) + owns the Accepted server-information response allowance and actual correction + qualification. Step 3 is complete locally after both review rounds and fresh + corrections; its + [record](phase-02b-events-auth.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21) + owns actual seed/demo and cancellation evidence. Step 4's + [record](phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) + owns completed local provider/API proofs, both independent review rounds and + source-bound correction/CI receipts. The correction record above owns exact + hosted assignments and evidence limits; merge remains separate. All four steps + are complete locally; + G13's session part stays Open. The [second external-review follow-up](phase-02b-events-auth.md#p02b-5-second-external-review-follow-up-2026-10-20) - owns current corrections. [ADR-0066](../decisions/0066-bounded-tenant-subject-admission.md) + retains its source-bound corrections. [ADR-0066](../decisions/0066-bounded-tenant-subject-admission.md) is Accepted — 2026-10-20; its subject-cap, cleanup and refusal refinements are implemented with local runtime/exported proofs. Its [implementation record](phase-02b-events-auth.md#adr-0066-acceptance-and-implementation-2026-10-20) - owns source-bound validation, independent reviews and final-source CI qualification. - Realm reconciliation, BFF sessions and phase-exit enforcement remain P02b-6–8. + retains source-bound validation and independent reviews; the merge closeout + above owns final-source qualification. + BFF sessions and phase-exit enforcement remain P02b-7–8. - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) - [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md) - [Phase 05: Education Catalog and Learning Content](phase-05-education-learning-content.md) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 8ab5de0d..99ee6915 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1,6 +1,6 @@ # Phase 02b: Events, Background Jobs, Identity, and Session -**Current status — 2026-10-20.** P02b-0 through P02b-4 are complete. +**Current status — 2026-10-22.** P02b-0 through P02b-5 are complete. P02b-1, P02b-2 and P02b-3 are merged through PRs #29, #30 and #31; their dated merge closeouts below retain final verification. **P02b-4 is complete and merged** through @@ -24,10 +24,27 @@ after both independent reviews. [Step 4](#p02b-5-step-4--atomic-http-activation- installs the complete HTTP boundary after both review rounds. [Step 5](#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) completes the authority census, carrier reconciliation and both independent review -rounds with verified corrections. All five steps are locally complete; the -closeout distinguishes exact source receipts from live PR-head CI and merge. -P02b-6 realm reconciliation, P02b-7 BFF sessions and P02b-8 enforcement/erasure -inventory remain pending; Phase 02b remains in progress. Earlier dated delivery +rounds with verified corrections. **P02b-5 is complete and merged** through +[PR #33](https://github.com/HodeTech/LearnStack/pull/33) on 2026-10-20. Its +[merge closeout](#p02b-5-merge-and-closeout-2026-10-20) owns final-source CI, +unchanged-plan A/B qualification and identical merge trees. +**P02b-6 decision pass is Accepted — 2026-10-21.** The maintainer approved the +[package](#p02b-6-decision-package-2026-10-20) at `6d75e532` and its four-step +implementation plan. G12's realm/reconciliation/callback choices and G13's +issuance-lifetime part are Accepted; G13's session part stays Open for P02b-7. +The dedicated issuance record, +[ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md), is +separately Accepted — 2026-10-21; it changes no runtime or session policy. +P02b-6's four implementation steps and local reviews are complete; the +[Step 4 record](#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns local qualification and correction receipts. ADR-0068's three correction +steps and both independent review rounds are complete locally; their +[qualification record](#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +owns exact hosted assignments, retained failures and evidence limits. Current CI +failure investigation and corrected-source qualification remain open. +P02b-7 BFF sessions and +P02b-8 enforcement/erasure inventory remain pending; Phase 02b remains in +progress. Earlier dated delivery and failed-run receipts keep their as-of state. **P02b-0 complete — 2026-10-12.** Phase 02d is complete and merged through PR #28; @@ -83,8 +100,8 @@ before adding BFF/session behavior; auth routes may not shadow enabled `/{locale}/courses...` paths. This handoff accepts no Phase 02b auth or BFF gate. **Review follow-up — 2026-10-20.** [P02b-5 remediation](#p02b-5-external-review-remediation-2026-10-20) -delivers the verified fixes and Accepted ADR-0065's implementation/local proofs; -its record separates final review and current PR-head CI qualification. +retains the verified fixes and Accepted ADR-0065's implementation/local proofs; +the merge closeout above owns final-source qualification. ## Goal @@ -301,8 +318,8 @@ Accepted, and its catalogue rows registered, before its first line of code. | **P02b-2** | **Complete and merged — [PR #30 closeout](#p02b-2-merge-and-closeout-2026-10-13).** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | **Complete and merged — [PR #31 closeout](#p02b-3-merge-and-closeout-2026-10-15).** Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real default subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | **Complete and merged — [PR #32 closeout](#p02b-4-merge-and-closeout-2026-10-19).** Background jobs: accepted H1 transactional/external profiles, confined storage/wakeups, tenant capacity and recurrence/fan-out; all nine implementation steps and their review gates delivered; no production job/recurrence definition | G9 Accepted 2026-10-15; P02b-3 complete | -| **P02b-5** | **[Locally complete](#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19); unmerged.** Five steps and both independent review rounds per step complete under Accepted ADR-0064. API authentication: closed JWT profile, ordered reconciliation and existing rejection recorder, additional subject quota and one physically read-only authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | -| **P02b-6** | Realm and seed: the shared client scope, the mappers, the user-profile declaration, UUID attributes agreeing with `SeedData`, the warm-realm reconciler, the Keycloak test fixture | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | +| **P02b-5** | **Complete and merged — [PR #33 closeout](#p02b-5-merge-and-closeout-2026-10-20).** Five steps and correction reviews complete under Accepted ADR-0064/0065/0066. API authentication: closed JWT profile, ordered reconciliation and attributed rejection recorder, additional subject quota and one physically read-only authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | +| **P02b-6** | **Implementation and local reviews complete — [Step 4 qualification](#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21).** Realm and seed: shared client scope/mappers, protected user-profile UUIDs agreeing with `SeedData`, safe warm reconciliation, narrow account self-service and real Keycloak/API proofs | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | | **P02b-7** | BFF session: Auth.js, the cookie session, single-flight refresh, CSRF, logout, the studio guard, the SDK's bearer attachment | G13 Accepted; P02b-6 | | **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the re-verification that the behavioural check is still required under the name it reports, and the exit checks | G16, G18 (the erasure inventory) Accepted | @@ -338,8 +355,8 @@ premise a row cites is re-verified at that pass rather than trusted. | G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; business execution uses the existing announced request owner through `ISender`, retaining the eight setters; the chosen storage/scheduler authority is separately explicit | **ADR-0047** + a dated **ADR-0003 Amendment** for any schema/role/grant-confinement extension (not merely a new tenant table under the existing policy) + a dated **ADR-0036 Amendment** disclosing the selected context-writer contract; then Standards 05/20. A decision change is not an erratum | P02b-4 | Accepted — 2026-10-15 under ADR-0047 and the nine-step P02b-4 package; bounded prior-ADR Amendments accompany acceptance; runtime and all nineteen canonical obligations implemented; [P02b-4 merge closeout](#p02b-4-merge-and-closeout-2026-10-19) records final qualification and merge | | G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | Accepted — 2026-10-19; [P02b-5 package](#p02b-5-decision-package-2026-10-19) | | G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Issue a matching organization claim for row 9. Rows 7/10/14 remain membership-dependent refusals until Phase 03; a claimless principal retains public-only HostOnly authority. Row 14 records no tenant audit without a confirmed host tenant, as bounded by ADR-0064 | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | Accepted — 2026-10-19; [P02b-5 package](#p02b-5-decision-package-2026-10-19) | -| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | Accepted — BFF-client choice, 2026-10-19; realm/reconciliation remains Open for P02b-6 | -| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | Open | +| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler shared by `make seed` and `make demo` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | Accepted — BFF-client choice, 2026-10-19; realm/reconciliation/callback choices, [2026-10-21 package](#p02b-6-decision-package-2026-10-20) | +| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) is the dedicated development-issuance authority; a new session ADR is required for a server-side store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | Accepted — issuance-TTL part, [2026-10-21 package](#p02b-6-decision-package-2026-10-20) and separate ADR-0067 acceptance on 2026-10-21; session part Open for P02b-7 | | G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 | Accepted — 2026-10-19; [P02b-5 package](#p02b-5-decision-package-2026-10-19) | | G15 | What initiates a replay in this phase? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | Accepted — internal subscriber replay implemented by P02b-3 Step 4; operator contract Phase 03 | | G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | Open | @@ -10748,11 +10765,11 @@ What this phase owes the catalogue beyond implementing those rows: already carries `public__`; it has no subject, no marker identifying a table as a read model and no catalogue row, and the first phase that ships a projection registers it with its first subject. The provider-SDK rule is the - catalogue's and is wider than the paraphrase this document carried — and because its - row is stamped 02b, the Phase-field obligation above would otherwise sweep a rule with - no subject, so `P02b-6`'s decision pass, where **G12** settles the Keycloak - reconciler, either makes that reconciler the first provider adapter and gives the rule - its first subject here, or moves its owning-phase cell to the phase that lands one. + catalogue's and is wider than the paraphrase this document carried. The + [accepted P02b-6 package](#p02b-6-decision-package-2026-10-20) introduces no + runtime SDK subject and hands its owning-phase cell to Phase 03's first runtime + identity-provider adapter, triggered by actual SDK introduction. It remains + Awaiting backfill there; direct HTTP does not qualify as SDK coverage. Two notes on what "green in CI" can mean. Several of this phase's rows are behavioural rather than structural, and CI runs those in a separate job under a Docker trait filter, @@ -13285,6 +13302,4333 @@ ADR-0063 requires one complete current-source assignment for this normal push; the earlier A/B receipts qualify the unchanged plan on their named source, not the subsequent guard commit. The live PR owns final-head qualification. +## P02b-5 merge and closeout (2026-10-20) + +**P02b-5 is complete and merged.** The maintainer merges +[PR #33](https://github.com/HodeTech/LearnStack/pull/33) at +`2026-10-20T20:18:42Z`. The verified final PR head is +`419e456260dc2023f3e6d538a46ede8b1d7e8f20`; the squash merge is +`24705a7a81ddfb7e0f5f5349370a53a55ca9e4eb`, whose parent is +`b81006c7af7d38f1587ecb3bf93dbcca16f353e0`. The PR head, tested merge checkout +`be8417c3a289d26e9062bd40bf163d51987d3e11` and actual squash merge all have tree +`7adeacfe81a15cc5e36a77cf79006da201f01e26`; their tracked contents are identical. + +| Step | Delivered scope | Review status | +|---|---|---| +| 1 | Isolated strict token foundation, bounded metadata and private diagnostics | Both independent rounds complete | +| 2 | Host-backed AccountSite query, exact physical read-only ownership and audit Off | Both independent rounds complete | +| 3 | Attributed host/claim reconciliation and stable validated-subject admission | Both independent rounds complete | +| 4 | Atomic HTTP activation, masked refusal contracts and authenticated SDK contract | Both independent rounds complete | +| 5 | Production authority census, real negative controls and corpus reconciliation | Both independent rounds complete | + +The subsequent ADR-0065 audit-budget/telemetry and ADR-0066 retained-subject/ +cleanup refinements are delivered with their recorded independent reviews. +The [systematic corrections](#p02b-5-systematic-proof-and-document-corrections-2026-10-20) +close backend proof boundaries, SDK/demo failure ownership, paired report/source +identity and measured CI weights. All three correction steps and the final +hosted-source identity guard complete two fresh independent review rounds. +Earlier delivery records remain source-bound history, including their original +unmerged, running and failed observations. + +### Final-source qualification + +[Run 39431896517, attempt 1](https://github.com/HodeTech/LearnStack/actions/runs/39431896517/attempts/1) +passes **27/27 jobs** on the final PR head. The independently downloaded, +API-bound archives verify job/artifact identities, ZIP and exported-file hashes, +complete actual case-ID unions and both aggregate results: + +- **5,847 backend cases**, zero failures/skips: 2,616 Unit, 433 Architecture, + five Contract, 287 non-Docker Integration, 2,475 ordinary Docker and 31 native. + The backend and integration aggregates count 3,341 and 2,506 respectively. + All 20 execution owners complete with joined cleanup and no forced cleanup; + native refusal evidence is empty. +- **1,699 frontend cases** across web/SDK, zero skips/todos; typecheck, lint, + production build and the ingress/cleanup proof pass. OpenAPI, secret scan + and meta pass. +- **27 JSON/HTML report pairs** verify both byte digests and source/tree/build/ + invocation/configuration identities. All 134 API completions are HTTP 200. + Nine navigations, 24 visibility controls, two TLS refusals and the timeout + control pass. The proof takes 651.490 seconds and cleanup 12.839 seconds; + all owned processes, Compose, private files, environment and lock are removed. + Hard JS/CLS/accessibility budgets pass; eight URL groups retain nonblocking + timing warnings. Browser flags and thresholds are unchanged. + +The unchanged timing plan separately qualifies both complete assignments in +[run 39424514759, A](https://github.com/HodeTech/LearnStack/actions/runs/39424514759/attempts/1) +and [B](https://github.com/HodeTech/LearnStack/actions/runs/39424514759/attempts/2) +at `b3abbce65ba60ae4165b1eb09bd44d8644e7843d`: each passes all 27 jobs with the +same complete 5,847-case population and observed toolchain, but different actual +Architecture/ordinary/SharedSchema co-membership. Their plan digest is +`85ce77743fd83d924060ad7008899595908ec4fd466597078ae6509e022db01d`. +The later guard changes no plan bytes, weights, assignment algorithm or fixture +lifetime; ADR-0063 requires the separate complete normal-push assignment above. +Historical A/B results are not reused as final-head results. The earlier failed +run `39417319998` remains failed evidence, not a partial passing receipt. + +Read-only protection verification retains all seven required context names, +their GitHub Actions app bindings and strict checking. No protection setting, +test population, timeout, audit threshold or browser budget is relaxed by this +closeout. The separate post-merge push +[run 39490885612](https://github.com/HodeTech/LearnStack/actions/runs/39490885612) +is queued at initial merge verification; it is not counted as completed +post-merge execution. The final PR-source qualification and identical-tree +comparison above are the merge evidence. + +### Remaining work and next packet + +**Next: P02b-6 realm/TTL decision pass.** Re-verify the remaining G12 realm/ +reconciler choices and G13 access-token lifetime authority before changing +Keycloak or seed users. Deliver the shared client scope and protocol mappers, +admin-only UUID user-profile attributes, exact seed identity agreement, +idempotent fresh/warm realm reconciliation and real Keycloak token/TTL proofs. +The already Accepted BFF-client choice remains the baseline; this closeout +accepts no new gate or ADR. + +P02b-7 retains BFF code exchange, token/session custody, refresh serialization, +CSRF/logout, guarded pages and SDK bearer transport. P02b-8 retains LS0002, +the retained-store erasure inventory, remaining carrier reconciliation and the +Phase 02b exit. No browser sign-in or live membership/permission service is +claimed by the merged API boundary. Phase 03 owns membership/authorization, +Phase 06 the full browser OIDC journey, and Phase 11 production identity, +readiness, distributed capacity and operational qualification. +P02b-6–8 remain unstarted; Phase 02b remains in progress. + +**Documentation-only closeout validation.** Five Release corpus consistency +cases and seven ADR lifecycle/parser controls pass, with zero failures/skips. +All 1,440 relative links/fragments in the ten changed Markdown files resolve. +Prose wrapping, whitespace and private-reference checks pass. Accepted ADRs +and the dated P02b-5 decision/delivery/review records are byte-preserved against +the final PR head. The local run covers these status/navigation edits; the +runtime, Docker/native and browser qualification remains source-bound to the +completed hosted receipts above. This closeout starts no next packet. + +## P02b-6 decision package (2026-10-20) + +**Status: Accepted — 2026-10-21.** The maintainer approved the decision package +at `6d75e532` and the four-step implementation plan on `development`. Acceptance +closes G12's realm/reconciliation/callback choices and G13's issuance-TTL part. +P02b-7's session portion of G13 stays Open independently. This acceptance record +and the two Registered catalogue obligations precede runtime implementation; +no realm, seed identity, runtime or CI implementation is delivered by acceptance. +The decision pass originally inspected `b4945705` after the P02b-5 merge; dated +inspection and review receipts below retain their as-of proposal status. + +### Problem, scope and decision vehicle + +P02b-5 validates the strict API profile, but the committed development realm does +not issue it. P02b-6 makes actual Keycloak issuance agree with that profile on +both fresh and existing development databases. It supplies protected identity +attributes, one repeatable development reconciliation path and real token/API +proofs, while preserving existing accounts and the separate operator realm. + +G12 explicitly selects a phase-document vehicle unless reconciliation becomes +ADR-0004's runtime `IIdentityProvider`. This package keeps a bounded Node.js +Admin REST client in development tooling; no application port, runtime provider +SDK, endpoint, database role/grant or schema is introduced. G13 delegates the +issuance-lifetime answer to this record and the reconciled Security standard. +A new ADR is therefore not proposed. Any implementation requiring a different +provider/runtime boundary reopens this pass before code; approval of this +package does not authorize that change. + +**Issuance-carrier follow-up — 2026-10-21.** The no-ADR statement above records +the original package's vehicle. A dedicated issuance carrier now exists as +[ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md), Proposed +pending separate maintainer acceptance. The earlier 900s phase approval remains +valid; G12's development-tooling vehicle and G13's Open session portion remain +unchanged. + +**Issuance-carrier acceptance — 2026-10-21.** The maintainer separately approved +the revised ADR-0067 after its correction reviews. It now supplies the dedicated +issuance decision authority; Security 11 retains the ongoing rule. The earlier +phase approval and Proposed-state follow-up above retain their original +chronology. G12's vehicle and G13's Open session portion are unchanged. + +The existing decisions remain authoritative: +[ADR-0004](../decisions/0004-authentication-strategy.md), +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) and +[ADR-0064](../decisions/0064-tenant-api-authentication-boundary.md). +No Accepted ADR body or acceptance metadata is rewritten. This package adds no +supersession of their authentication, tenant authority or audit contracts. + +### Reviewed premises and necessary document actions + +| Current source | Verified premise | Required disposition | +|---|---|---| +| `infra/keycloak/realms/learnstack.json` | Public `learnstack-web`, no custom client scope/mappers, slug-valued tenant attributes and no stable actor attributes | Configure the already Accepted confidential PKCE profile and real seed UUIDs; do not loosen the API to accept the old export. | +| `scripts/seed.sh`, `scripts/wait-compose.mjs` | Seed checks both realm issuers, then invokes the application Seeder; no Admin REST reconciliation | Add the shared bounded tooling call after readiness and application seed convergence; preserve the selected Compose/env identity. | +| `scripts/demo.mjs` | Demo invokes the .NET Seeder directly, then CI stops its Keycloak | Invoke the same reconciler before that stop. A seed-script-only change cannot converge this entry point. | +| `SeedData.cs`, `DemoIdentities.cs`, Seeder `Program.cs` | Tenant/org/host identities already have typed, read-only tooling projections; no identity-user declaration | Add stable actor/seed-user declarations and a credential-free projection without a new application identity table or production Seeder reference. | +| Realm `accessTokenLifespan`, API client override, Keycloak README, Security 11 | Realm/API override are 1800s; README recommends 900s for production; API permits at most one hour | Select one development issuance value, remove conflicting client overrides and distinguish issuance from API acceptance/session lifetimes. | +| This phase's former identity completion criterion: `exp - iat` equals the configured lifetime | Pinned Keycloak reads the issuance and expiration clocks separately; a second rollover can make that difference 901 for a 900s setting | Replaced at acceptance with exact realm/no-override checks and bounded observed-issuance evidence below; independent drift controls remain required. | +| Keycloak 26.6.2 profile/import source | Profile configuration is a declarative-user-profile component; runtime Admin REST uses `users/profile`. Declaring a custom `clientScopes` list can suppress automatic built-in creation | Use the supported component/config representation; include/verify actual `basic` and its `sub` mapper, not merely a scope name in a client list. | +| Keycloak account REST and profile source | Account permission is checked before attribute-edit policy; profile PUT and user-attribute PUT can replace supplied configuration/maps | Pair self-service refusals with a successful ordinary profile edit, merge unrelated definitions/attributes and verify retained state. A generic 403 proves neither protection nor preservation. | +| Standards 06, integration skills and runner instructions | Container guidance still describes Postgres as the only current integration dependency | Add the named Keycloak fixture and Docker-trait/source controls with its first tests; preserve the real app-role PostgreSQL requirement for API/database assertions. | +| Standards 21 provider-SDK backfill row and this phase's enforcement scope | The reconciler introduces no runtime provider SDK subject | Resolve the row's owning-phase handoff explicitly at acceptance, as specified below; do not claim this script implements that structural rule. | + +The review includes the full identity architecture, realm README/exports, +P02b-5 accepted profile and handoff, G12/G13, current completion/risk clauses, +Security/Testing/Infrastructure/Documentation standards, authentication and +SDK-boundary catalogue entries, Phase 03/11 ownership, seed/demo orchestration +and their existing environment/process boundaries. Historical dated delivery +receipts retain their original words; mutable current descriptions are corrected +with their actual implementing slice. + +### Accepted G12/G13 answers + +**Alternatives.** Startup-import-only cannot update an existing realm. A realm +overwrite/database reset violates preserved-account upgrade requirements. General +declarative realm-management tools remain viable for Phase 11 production +provisioning; this small development-only operation instead owns an explicit +Admin REST field allowlist and its preservation tests without introducing a +second configuration-state tool. This is a maintenance responsibility, not a +claim that those tools cannot preserve unrelated state or are more expensive. + +For issuance, keeping the existing 1800s development value reduces refresh churn +but leaves a longer stale-scope window. The accepted 900s follows the existing +Keycloak README's production target while reducing that window for new tokens. +It is a selected development default, not a measured production capacity claim. +The API's 3600s rejection ceiling is not an issuance recommendation; BFF session +lifetimes are a different decision. + +| Gate part | Accepted answer | What acceptance does not close | +|---|---|---| +| G12: realm and reconciliation | One owned development Admin REST reconciler, reused by `make seed` and `make demo`, with the exact profile, identity/preservation and failure rules below | No production realm provisioning, runtime identity-provider port, membership service or browser session | +| G12: callbacks | Exact HTTPS Auth.js Keycloak callback on each existing seed host at port 3000; exact same-host root post-logout URI | No callback implementation, login transaction/session cookie, return-URL/CSRF policy or production domain registration | +| G13: access-token issuance lifetime | Security 11 owns the rule: the tenant development realm's configured access-token lifespan is 900s; `learnstack.json` owns its executable value and reconciliation enforces it without a conflicting client override. Actual timestamps follow the provider semantics below | BFF idle/absolute session life, refresh custody/serialization, CSRF and logout remain P02b-7; API's one-hour acceptance ceiling and 30s skew remain unchanged | + +**G13 authority follow-up — 2026-10-21.** Separately Accepted +[ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) records +the issuance decision above, including inherited ID-token expiration and session +clamping. Security 11 remains the ongoing-rule owner. This adds no BFF session +lifetime or change to API acceptance. + +**Client and claim configuration.** Preserve the Accepted `learnstack-web` +confidential client with S256, `azp=learnstack-web` and `learnstack-api` resource +audience. Keep standard code flow; disable implicit flow, direct access grants +and service accounts on that client. The resource client's legacy service +account is not converted into a tenant user; its tokens still fail ADR-0064's +user profile. Disable the resource client's browser code flow and remove its +old localhost callback list; it does not act as a second login client. Preserve +its existing service credential. The API receives no client secret. + +Use one named default client scope, `learnstack-tenant-api`, attached to both +named clients. Its three scalar String user-attribute mappers emit `user_id`, +`tenant_id` and optional `organization_id` in access/ID tokens, without placing +those attributes in the user-info response. Its explicit audience mapper adds +`learnstack-api` to access tokens only. Preserve/declare actual built-in scopes, +especially `basic` and its `sub` mapper; do not replace all existing default or +optional scope assignments with a partial list. Reject another effective mapper +that can contradict the protected identity claims, rather than deleting an +unrecognized administrator customization. The API audience need not be exclusive; +standard account audiences do not grant application permission. + +Declare all three identity attributes single-valued, with admin-only view/edit, +including `organization_id`. Missing tenant/org attributes remain optional; +self-registration is not an actor-provisioning mechanism. Leave unrelated +profile attributes/groups and the existing unmanaged-attribute policy intact. +The disabled unmanaged policy is omitted/null in 26.6.2, not a `DISABLED` enum. +Legacy tenant roles and assignments remain inert authentication metadata; +P02b-5's complete role-authority census still forbids application permission from +them. No new application role, membership or implicit administrator is seeded. + +For each `SeedData` host the exact redirect is +`https://:3000/api/auth/callback/keycloak`; its post-logout URI is +`https://:3000/`. No localhost HTTP/wildcard callback, alternate host +or browser client secret is accepted for this BFF. Encode the two post-logout +values with Keycloak's supported multivalue representation. Retain manual hosts, +leaf certificate and trust setup. These registrations reserve P02b-7's callback +contract without claiming a browser login exists. + +**Identity preservation.** `SeedData` owns four stable, distinct, nonreserved +actor UUIDs and their tenant/org references. Keep the two existing English demo +usernames; add `demo-admin@tenant-b.test` and `demo-learner@tenant-b.test` for +Yoga with its existing default organization. These are development personas, +not application permission grants; their credentials use the existing +create-only development password convention. +The declaration/projection owns their exact usernames and UUIDs; the realm +export must agree with it. Do not derive actor identity from email, username or +Keycloak `sub`, or generate one on each reconciliation/login. + +- Preserve the Keycloak ID/`sub` of every existing account, including seed users. +- Create a missing named demo account with its declared development credential. + Never reset an existing password, credential, MFA factor, required action, + enabled state, email/name or role assignment to make a proof pass. +- On the two legacy English accounts only, accept the known old `tenant-a` + attribute as a named migration to their declared tenant UUID. Missing identity + values may be installed; semantically matching UUIDs are retained. Conflicting + nonempty actor/tenant/org values refuse, including an unexpected organization + on a tenant-wide English identity. No reassignment of a real UUID is guessed. +- Preserve unrelated user attributes through a read/merge/write operation. + Do not send only the three attributes: that can delete the others. +- Do not update nonmanifest accounts. Their missing actor/scope is not filled by + invention: the API continues to refuse an incomplete profile until Phase 03 + owns its durable mapping. Never modify `learnstack-hub` or master users/roles. + +**Client-secret continuity.** Add one private development source, +`KEYCLOAK_WEB_CLIENT_SECRET`, documented in `.env.example`. Fresh import and the +reconciler must agree with the selected value. Converting the old public client +installs that selected BFF secret once; any unused generated public-client +secret is not a previously consumed confidential contract. An already +confidential client's secret is verified and preserved; a mismatch fails without +rotation. A missing key in an older private environment is reported with setup +instructions, never silently filled or printed. P02b-7 receives only this BFF +credential, never the admin credential. Production secrets remain Phase 11's +provisioning responsibility. + +**TTL semantics.** Remove the named clients' `access.token.lifespan` overrides; +they inherit the realm's configured 900s value. Keycloak 26.6.2 sets `iat` and +calculates expiration from separate clock reads: crossing a second boundary can +yield `exp - iat == 901`, and a scheduling pause can widen it further. Do not +assert exact subtraction equality or substitute a fixed 901s ceiling. + +Check the exact realm value and absence of either client override independently. +For a fresh code exchange, record UTC and monotonic bounds around the request, +with its finite HTTP deadline, and require the signed expiration to lie within +that observed interval plus the configured lifespan. Any qualified fixture/ +provider clock tolerance is explicit, at most the existing 30s API clock skew; +an indeterminate clock/window fails the proof rather than widening the tolerance. +The measurement bound is test evidence, not a new production token-acceptance +rule. Separate session-clamping coverage permits shorter late-session issuance. +Exact configuration/override controls and independent wrong-expiration mutants +prevent timing tolerance from hiding a different configured lifespan. + +Changing the realm does not invalidate earlier +1800s tokens; they remain subject to the API acceptance bound. The reduced TTL +reduces the ordinary stale-scope window; 900s is the configured lifespan, not a +hard `exp - iat` guarantee. It does not implement immediate revocation, live +membership checks or refresh-token rotation. The latter's existing Phase 11 +owner is unchanged. + +### Reconciler ownership, failure and security boundaries + +The implementation is a closed tooling client under `scripts/`, using Node's +HTTP support rather than a new Keycloak SDK/application service. Both entry +points await it after successful application seed convergence and before API/web +launch or CI-owned Keycloak stop. Direct .NET Seeder remains application-data +only. The read-only identity projection never opens a data source. + +- Validate selected Compose project/service and pinned-image identity, loopback + development endpoint, exact realm discovery issuer and private source agreement + before admin authentication. Verify authenticated server version before writes. + Do not accept arbitrary realm, + URL, command, import file or destructive-reset CLI arguments. Test transport + injection stays internal and cannot widen the production CLI. +- Read the selected environment as data; inherited aliases cannot silently point + reconciliation at a different instance. Admin credentials, access tokens and + BFF secret stay in memory and the specifically owned tooling environment, + never argv, URLs, temp JSON, stdout/stderr, receipts or the API/public renderer + environment. The .NET Seeder keeps its existing credential allowlist. +- Use one local lock owner per selected target for both entry points; refuse a + second invocation rather than interleave. Hold through actual HTTP/child + cleanup. A stale lock is not removed merely because time elapsed. This is a + local tooling boundary, not a distributed lock against another checkout or + Admin Console. Concurrent external administration is unsupported: operators + run reconciliation while this development realm is otherwise quiescent. +- Read and validate the complete bounded change plan before the first write. + Detect conflicting users, scope/mappers, secret and unsupported configuration + up front. Apply only named owned fields/resources, merge unrelated data and + perform independent post-write readback. Never replay a full realm import, + drop a database, recreate a realm or delete existing accounts/scopes/roles. + + **Bounded replacement — 2026-10-22:** [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) + permits one qualified metadata preparation before a fresh full plan, after all + identity-independent prechecks. Every other Admin REST write remains ordinary + and requires that complete plan. Its implementation/proof remains pending. +- Bound each HTTP wait to 5s, ordinary decoded responses and every outbound body + to 256 KiB, and the complete reconciliation to 120s. Only the fixed authenticated + successful `GET /admin/serverinfo` read has the + [accepted 512 KiB allowance](#server-information-response-budget--proposal-2026-10-21). + Reject redirects on discovery, authentication and Admin + REST alike. No automatic token-request or mutation retry loop. + Creation conflicts permit one exact fresh-state check, not blind overwrite. + Abort/close the owned request and await cleanup before dropping the lock; + elapsed time is not evidence that a remote write failed. +- A partial/uncertain run exits nonzero. Previously completed mutations stay + durable; the next explicit run reads current state and resumes missing acts. + No cross-Keycloak/application transaction or automatic rollback is promised. + A completed third run performs zero mutating Admin REST calls. +- Errors emit a fixed stage/reason, never an upstream body/exception or arbitrary + claim/mapper/user values. Exercise credential/token/URL canaries and positive + closed diagnostics. No LearnStack audit row is added for development setup; + Keycloak retains its own administrative event mechanism. + +These choices deliberately trade broad realm-management convenience for safe +local convergence. A customized incompatible identity or confidential secret +requires explicit operator reconciliation. Existing nonmanifest users are +preserved but may lack the strict API profile. Admin credentials remain powerful +local setup credentials, never production runtime authority. A deadline can +leave a remotely committed partial update; a later run must verify it. + +### Test ownership and required controls + +Acceptance registers the following two canonical behavioural obligations in +Standards 21, with actual owners and **Registered** status until shipped. Their +sources are ADR-0004/0064, Security 11 and this accepted package, not a new +architecture decision. Counts/status assertions change with the same registry +edit; no new Implemented rule is claimed by approval. + +**Test-source follow-up — 2026-10-21.** ADR-0067's separate acceptance adds its +dedicated issuance authority to the existing issuance obligation's sources. +It introduces no new canonical obligation or test owner. The later Step 4 +delivery retains the two existing obligations' Implemented status; the original +registration paragraph above remains an acceptance-time receipt. + +| Canonical obligation | Owner/level | Required evidence | +|---|---|---| +| [`Tenant_Realm_Issuance_Matches_The_Api_Profile`](../standards/21-architecture-tests-catalogue.md#tenant_realm_issuance_matches_the_api_profile) | `TenantRealmIssuanceTests` and `TenantRealmProfileProtectionTests`, backend Integration with Docker | Import committed JSON into pinned Keycloak, exercise real code/S256 issuance, validate through real API handler/AccountSite and app-role PostgreSQL; protect profile attributes with a working account-edit positive control. | +| [`Development_Realm_Reconciliation_Preserves_Accounts`](../standards/21-architecture-tests-catalogue.md#development_realm_reconciliation_preserves_accounts) | `TenantRealmReconciliationTests`, backend Integration with Docker; Node tooling controls for bounded transport/process/CLI | Execute the actual shared reconciler against a pre-mapper warm realm; preserve users/credentials/IDs/unrelated data/Hub, converge protected mappers/attributes/TTL, and prove a third run has zero mutations. | + +Required controls include: + +- All four seed identities: real `sub`, fixed actor, expected tenant and Yoga org, + correct scalar JSON types, API audience/`azp`, RS256/purpose, exact configured + 900s lifespan and bounded fresh-issuance evidence under the TTL semantics above. + Compare export and issued values to typed `SeedData`, not duplicated test IDs. +- Exact registered callbacks on both seed hosts, missing/wrong secret, missing + challenge, plain challenge and missing/wrong verifier refusals; unregistered + redirect rejection, direct grant + still disabled, and ID tokens without API audience. The fixture consumes the + code callback without requiring the unimplemented BFF; it does not launch a + browser or bypass TLS validation to claim a browser cookie proof. +- Actual AccountSite 200 on both host classes with their real tokens, cross-tenant + and cross-organization refusal, claimless protected refusal, Hub-issuer refusal, + and the unchanged absent-token/public-IdP-down path. API/database reads stay + `learnstack_app`; Keycloak uses its own separate fixture database. +- Through the built-in account-console code/S256 flow for the same seed user, + prove an ordinary profile edit succeeds. Then try each protected attribute and + read it independently as admin: no user write or deletion succeeds. Admin-only + profile/account attribute visibility and registration attempts need their own + controls; deliberate token claims are not profile visibility. A + user-editable variant must make the protection proof fail. +- Removed/misattached identity/audience/basic mappers, duplicate/conflicting + identity mappers, wrong scalar types/UUIDs, writable profile attributes, ID-token + audience, client override and TTL drift all have planted dirty/clean controls. +- Warm legacy migration plus additional unrelated users/attributes/profile groups, + changed passwords, required actions, client scopes/roles, confidential secret + and Hub account state; exact snapshots remain preserved. Also cover an + incompatible managed identity, duplicate lookup, partial-write interruption, + failed readback, uncertain HTTP result, a race and zero-write completed rerun. +- Shared `make seed`/`make demo` wiring, target/env mismatch before writes, + secret leakage across child environments, timeout/cancellation/cleanup and + lock ownership; errors never turn into success or erase another owner's lock. + +The new fixture uses Keycloak 26.6.2 with its separate PostgreSQL database and +actual committed import. No ROPC shortcut or test-only mapper/client weakening +is made in the production realm. Every consuming class carries the Docker trait; +the source sweep must detect fixture references outside `Database/` too, with a +planted missing-trait control. Offline Node controls run in the existing meta +job; real provider cases enter ordinary Docker shards through normal discovery. +Preserve the seven required names, complete assignment and existing time ceilings. +Measure fixture/hosted costs; tune only within ADR-0063's accepted plan envelope. + +`Provider_SDK_Types_NotImportedOutsideInfrastructure` remains unimplemented: the +script introduces no runtime SDK type. Acceptance moves its owning-phase cell +from Phase 02b to Phase 03's first runtime identity-provider adapter, with actual +SDK introduction as the trigger. Phase 03 re-verifies that subject before its +first writer; a direct-HTTP implementation cannot be reported as SDK coverage. +The existing SDK exception rule remains enforced. This is the explicit handoff +required by this phase's enforcement scope, not missing P02b-6 work. + +### Four implementation steps after acceptance + +Each step ends in a clean local commit, then two fresh independent review-agent +rounds. Reviewers first run standards-check, then the security, correctness, +operation/lifecycle, testing and corpus lenses. Confirmed findings are reproduced, +fixed and committed before the next round/step. Fresh focused review verifies a +material correction after round two. Routine progress automatically proceeds; +a new architectural choice returns for maintainer approval. + +| Step | Deliverable | Validation before review/commit closure | +|---|---|---| +| 1 — executable realm and identity declaration | Register accepted gates/rules and current carriers; typed seed identity projection, complete import/profile/scopes/mapper/client configuration, private secret source and drift controls | JSON/schema/source and SeedData agreement; real fresh import/code-token smoke; unsafe mapper/profile/basic/TTL variants fail. No warm overwrite or BFF session claimed. | +| 2 — owned warm-realm reconciliation | Closed Admin REST tooling, explicit owned-field diff/merge/readback, preflight, secrets, locking and deadlines; actual fresh/warm/partial/no-op integration tests | Real pre-mapper PostgreSQL-backed Keycloak retains identities, passwords, unrelated state and Hub; conflict/timeout/uncertainty/race/canary controls; zero writes on completed third pass. | +| 3 — unified seed/demo lifecycle | Both existing entries call the same implementation after application seed; preserve exact configuration/credential/process ownership and CI's stopped-Keycloak public proof | Real fresh/warm `make seed` and repeated convergence; demo lifecycle wiring/proof, no foreign cleanup, private environments and bounded cancellation. Preserve public routes, API authentication and generated contract. | +| 4 — real identity/security qualification and closeout | Full production-token API/profile protection/mutation proofs, Docker fixture census, measured ordinary CI assignment, remaining mutable carriers and packet status | All relevant backend/frontend/meta suites; complete hosted PR qualification and unchanged required checks, public production demo regression. Account/registration positive and dirty controls prove the tests can fail. | + +Do not start the next feature packet as part of this work. P02b-7 retains Auth.js +session/callback implementation, custody/refresh/CSRF/logout and SDK bearer +transport. Phase 03 retains `User`/membership/permission/provisioning/mapping and +tenant switching; Phase 06 retains the full browser OIDC journey; Phase 11 retains +production realm provisioning, rotation and deployment identity hardening. + +### Acceptance carriers and implementation handoff + +The 2026-10-21 acceptance closes G12's realm/reconciliation/callback choices and +G13's issuance-TTL part with this anchor; G13's session portion stays Open. +The acceptance and Registered catalogue owners form the first commit before +runtime changes. Security 11 owns the issuance rule and points to the executable +value; target sketches link to that authority. Dated old observations retain +their as-of state. The implementation carriers below remain owned by their steps. + +**Issuance handoff follow-up — 2026-10-21.** Separately Accepted ADR-0067 is the +dedicated decision carrier for the issuance choice; Security 11's authority +header and the existing issuance obligation now cite it. This documentation +acceptance introduces no runtime work or change to the earlier approval, +G12's reconciler/callback boundary, or P02b-7's session ownership. + +| Carrier | Action and implementing owner | +|---|---| +| This phase, `CLAUDE.md`, roadmap README | Decision status, steps, source-bound evidence and the exact-equality TTL completion-criterion correction above; completed only after implementation/reviews/qualification. | +| `docs/standards/11-security.md`, architecture 13 | Distinguish issuance/acceptance/session TTL, scalar identity controls and current real-issuance boundary; Steps 1/4. | +| Standards 06/12/21 and integration/run-test skills | Keycloak container/trait, tooling secret/CLI boundaries, two named rules and explicit provider-SDK handoff; Steps 1/4. | +| `infra/keycloak/README.md`, Compose README and root README | Exact local client/callback/secret/migration and non-destructive warm setup; remove the database wipe as the upgrade recipe; Steps 1/3/4. | +| Realm JSON, `.env.example`, seed/demo and source/fixture controls | Apply only after acceptance; Steps 1–4. No Hub-realm edit. | +| Phase 03/11, architecture 14/30 and glossary | Explicit identity-provider handoff, consumer/production boundaries and updated terminology/status; Steps 1/4. | + +The maintainer explicitly approved the package as a whole on 2026-10-21: +900s development issuance, four stable seed identities/legacy migration, exact +callbacks, secret continuity/refusal, the bounded development-only reconciler +and the four implementation steps. The independent review and mechanical +validation receipts below qualify the reviewed proposal; they do not claim +runtime delivery or final-source hosted qualification. + +### Decision-pass inspection and review evidence + +Two independent read-only inspections used GPT-6.1 Sol at xhigh effort: one +covered the identity/profile/upstream boundary, the other covered reconciliation, +seed/demo and credential/lifecycle ownership. Their findings informed the +premise/action inventory above, including the demo bypass, real `basic` scope, +protected organization attribute and account-edit positive control. + +Isolated pinned-26.6.2 development-container feasibility probes confirmed the +profile component import, protected definitions, merged unrelated account data +and credential preservation, generated-once/retained client secrets and actual +`basic` attachment. A custom-scope-only variant lost built-in `basic`; the +corrected explicit definition restored its attachment. Form-based authorization +experiments did not complete successfully: no code-flow, token or TTL success +is claimed. Both owned containers and temporary imports were removed and their +absence independently checked. These experiments support the design, not a +committed regression suite or PostgreSQL/production qualification. + +**Review round 1.** Two new independent reviewers used GPT-6.1 Sol at xhigh +(security/correctness) and high (corpus/ownership) effort. Both approved the +current proposal for maintainer decision without a verified actionable finding. +They checked Accepted decision compatibility, G12/G13 vehicle and split, +Keycloak update/profile/scope/TTL source semantics, real protection controls, +client-secret continuity, bounded HTTP/lifecycle and the provider-SDK handoff. +This is document approval, not maintainer acceptance or implementation proof. + +**Review round 2 correction.** Fresh reviewers used GPT-6 Astra at xhigh +(boundary/security) and GPT-6.1 Sol at high (delivery/corpus) effort. The latter +approved; the boundary reviewer found the exact TTL assertion incompatible with +the pinned provider's separate clock reads. Independent source inspection +confirmed the counterexample: `iat` sampled at 1,000,999ms is 1000; expiration +sampled at 1,001,000ms plus 900s is 1901. This is a source-level control, not a +runtime reproduction. The gate, TTL contract, proof obligations and acceptance +carrier inventory now distinguish configured lifespan from measured issuance. +The old equality completion criterion is explicitly replaced at acceptance. +The original boundary reviewer and a fresh GPT-6.1 Sol xhigh reviewer independently +verified the correction and approved without another actionable finding. + +**Local document validation.** Five Release corpus-consistency cases and seven +ADR parser/lifecycle controls pass with zero failures/skips. All 757 relative +links/fragments in the five changed Markdown files resolve. Added prose wrapping, +whitespace and private-reference checks pass. Accepted ADRs and the complete +dated P02b-5 decision/delivery record remain unchanged. Runtime, real-token, +PostgreSQL, frontend and hosted qualification are not claimed by these checks. + +### Source references + +- [Keycloak import semantics](https://www.keycloak.org/server/importExport), + [Auth.js Keycloak callback/client guidance](https://authjs.dev/getting-started/providers/keycloak). +- Keycloak 26.6.2: + [default/basic scopes](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java), + [token issuance/expiration](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java), + [issuance timestamp](https://github.com/keycloak/keycloak/blob/26.6.2/core/src/main/java/org/keycloak/representations/JsonWebToken.java#L140), + [clock reads](https://github.com/keycloak/keycloak/blob/26.6.2/common/src/main/java/org/keycloak/common/util/Time.java#L31), + [profile resource](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java), + [user updates](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java), + [account REST](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java), + [built-in account client](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/managers/RealmManager.java). + +**Source-pointer correction — 2026-10-21.** The timestamp and clock links above +targeted the wrong raw-source lines. The actual pinned assignments are +[`JsonWebToken.java` lines 150–151](https://github.com/keycloak/keycloak/blob/26.6.2/core/src/main/java/org/keycloak/representations/JsonWebToken.java#L150-L151) +and [`Time.java` lines 33–34](https://github.com/keycloak/keycloak/blob/26.6.2/common/src/main/java/org/keycloak/common/util/Time.java#L33-L34). +The provider-semantics conclusion is unchanged; ADR-0067 carries the corrected +issuance/expiration and ID-token-expiration references. + +## P02b-6 Step 1 — executable realm and identity declaration (2026-10-21) + +**Status: complete locally; both independent review rounds and fix review pass.** +Acceptance is committed at `285db56b`; implementation stays on `development`. +This record claims no warm-realm reconciliation, BFF session or final-source CI +qualification. Both independent review rounds and their verified corrections +are complete. Step 2 starts next; final-source hosted qualification stays Step 4. + +- `SeedData.IdentityUsers` owns four fixed actor identities and their tenant/host + association. The read-only `--identity-manifest` mode projects scalar facts, + including the host's organization and the narrowly recognized legacy value, + without credentials, persistence access or membership creation. +- The tenant export defines the shared scalar-identity/access-only-audience scope, + actual pinned built-ins including `basic/sub`, protected admin-only profile + attributes, confidential code/S256 client, exact HTTPS callbacks and configured + 900s realm lifespan without named-client overrides. Hub export is unchanged. +- `.env.example` and Compose expose the selected server-only BFF secret source. + Existing confidential-secret verification and the refusal for an older private + file missing the key remain Step 2–3 work, before either entry invokes it. +- `TenantRealmExportTests` compares the committed artifact to typed SeedData and + initially rejects 24 planted mapper, profile, client, scope, identity and TTL + variants; round 1 extends this to 43 as recorded below. + The Docker fixture imports the actual JSON into pinned Keycloak with a separate + PostgreSQL database and owned HTTPS/custom-root trust, with hostname validation + retained. No host certificate store is changed. Real code/S256 issuance passes + for all four declared identities; complete API/profile protection stays Step 4. +- The existing Docker source census now covers `KeycloakFixture` and + `SharedKeycloakRealm` references outside `Database/`, with clean and missing-trait + controls. Existing catalogue counts are unchanged; both P02b-6 behavioural rules + stay Registered until their complete owners ship. + +**Current local validation.** Release solution build passes with zero warnings +and errors. Two export cases, seven corpus/Docker-census cases and seven ADR +parser controls and the existing demo-manifest case pass with zero skips. The +guarded frontend run passes all 1,699 cases across both packages with zero +skips/todos. Eleven actual Keycloak/PostgreSQL cases pass: all four signed token +profiles, live TTL and scope/client configuration, both exact callbacks, +missing/wrong secret and verifier, missing/plain challenge, unregistered redirect, +disabled password grant and correct/wrong/correct CA controls. Fresh expiration +uses the observed UTC/monotonic window with the explicit 2s fixture clock +tolerance; exact realm configuration is independently checked. Owned containers +and network are independently confirmed absent after the run. + +The fixture corrections retain normal provider behavior. Both realm imports use +the unambiguous byte-array/file mapping: the pinned Testcontainers string/string +overload interprets its target as a directory. Owned HTTPS resolves the pinned +provider's Secure loopback cookie behavior without a cookie-forwarding workaround +or changes to the production realm. Sources: +[Testcontainers 4.15.0 mapping](https://github.com/testcontainers/testcontainers-dotnet/blob/4.15.0/src/Testcontainers/Builders/ContainerBuilder%603.cs#L208-L223), +[Keycloak 26.6.2 secure context](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/utils/SecureContextResolver.java#L32-L55). + +**Review round 1.** Two fresh GPT-6.1 Sol reviewers used xhigh effort for +security/correctness and high effort for proof/lifecycle/corpus. The latter +found the new `identity-username` vocabulary kind absent from the existing G20 +completeness allowlist. The existing architecture case was independently run +and failed on that exact discrepancy. The correction includes every declared +username/actor in the completeness assertion and the decoded-literal controls. +It also removes stale PostgreSQL-only skill input descriptions and reconciles +the testing standard's population count. + +The security reviewer reproduced a separate offline export-predicate gap: +optional `sub`, `aud` and dotted identity destinations passed the previous +collision check. The committed realm is clean; no live provider vulnerability +was claimed. The correction checks decoded top-level claim roots, reserved token +setters, known implicit subject/audience writers and unknown effective mapper +types across direct, default and optional sources. Forty-three dirty variants and +an escaped-dot healthy control qualify the extended predicate. Pinned semantics: +[claim setters](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java#L295-L324), +[escaped claim paths](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/utils/JsonUtils.java#L35-L58). +**Round 1 fix validation.** Release solution/integration builds pass with zero +warnings/errors. The complete domain-genericity class plus corpus/Docker-census +controls pass 14 cases; export/manifest proofs pass three cases, including all +43 dirty variants and the escaped-dot healthy control. All have zero skips. +All 1,329 relative links/fragments in the twelve changed Markdown files resolve; +whitespace checks pass. + +**Review round 2.** Fresh GPT-6 Astra/xhigh and GPT-6.1 Sol/high reviewers +approved with four Minor corrections. The boundary reviewer independently +reproduced an offline drift gap: client-role `${client_id}` interpolation could +select an identity/reserved top-level destination. The guard now refuses a +dynamic claim root while preserving the fixed `resource_access` built-in shape. +Three dirty cases extend the total to 46; a healthy prefixed interpolation +control distinguishes the intended boundary from a blanket mapper refusal. +The current committed export was clean throughout; no live mutant consequence +is claimed. Source: +[pinned role mapper](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/AbstractUserRoleMappingMapper.java#L72-L106). + +The delivery reviewer identified stale Docker prerequisites, first-import client/ +persona inventory and live-phase status. Those descriptions now match the +delivered slice; the destructive warm-upgrade recipe is removed. A fresh +GPT-6.1 Sol/high focused review approves all corrections without another finding. +The two offline export cases pass with zero skips and a clean Release build; +all 1,344 links/fragments in thirteen changed Markdown files resolve before this +closeout addition. Whitespace checks pass. Existing eleven-case provider and +1,699-case frontend receipts above remain source-applicable; the second-round +correction changes only offline proof/docs, not provider configuration or code. + +## P02b-6 Step 2 — owned warm-realm reconciliation (2026-10-21) + +**Status: complete locally; both independent review rounds and fix review pass.** +Step 1 is closed at `95d0a0c8`. The shared development-only client, owned target/ +transport/lock boundaries and field planner are implemented locally. The first +actual complete-core provider run refused before writes at `version/body_limit`; +the accepted allowance and complete-core receipt below resolve that conflict. +The review/correction receipts below close Step 2. Entry-point activation +and final-source hosted qualification remain Steps 3–4. + +### Server-information response budget — proposal (2026-10-21) + +**Status: Accepted — 2026-10-21.** The maintainer approved the named 512 KiB +exception after the additional investigation below, recorded at `fbd9e7dd`. +The acceptance receipt below precedes dependent implementation. +This is a bounded adjustment to G12's accepted phase-package HTTP envelope, using +its existing decision vehicle. It changes no Accepted ADR, runtime identity +provider, credential, realm authority, mutation policy or application surface. + +**Observed conflict.** The original package requires an authenticated version check +before writes and caps every decoded HTTP response at 256 KiB. Pinned Keycloak's +`GET /admin/serverinfo` returns the full provider/mapper inventory along with the +version; its resource exposes no projection/filter parameter. The actual pinned +PostgreSQL-backed HTTPS fixture returns **292,707 decoded bytes** on each of two +authenticated reads, version `26.6.2`, identity encoding. The current production +client correctly refuses this response at the accepted cap. Source: +[pinned server information resource](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/info/ServerInfoAdminResource.java#L120-L169). + +**Accepted delta.** Only the fixed, authenticated, read-only +`GET /admin/serverinfo` exchange may consume at most **512 KiB decoded bytes**. +Every other response and every outbound body retains **256 KiB**. The 5s exchange +and 120s whole-pass deadlines, redirect/compression refusals, full JSON parsing, +exact version check and request/response cleanup remain unchanged. No caller, +configuration or CLI option can widen this budget or choose another endpoint. +Wrong version, malformed JSON, overflow or uncertainty still refuses before +the first mutation. The extra retained response allowance is at most 256 KiB for +one sequential read; it is not a general transport-limit increase. + +**Alternatives.** Keeping the universal cap with the current server-information +probe leaves this implementation unusable. The authenticated partial-export +alternative examined below can preserve that cap with a broader realm read. +Skipping version validation would weaken the selected target contract. +Parsing only a prefix and treating an aborted/truncated JSON response as success +would not independently validate the complete version document. A general 512 KiB +limit expands every exchange unnecessarily. A provider extension solely for a +version projection adds a custom deployment surface. The named read exception +preserves the stock provider and the other bounds. + +**Qualification.** The two measurement reads use a temporary, explicitly +measurement-only 1 MiB cap, owned HTTPS CA/hostname verification, 5s wait and no +redirects. Only byte count, exact version and encoding leave the probe. Its +temporary source is removed; this is feasibility evidence, not a committed +regression or authorization to change the client. Approval must precede the +dependent bound change. Committed controls must accept the actual document and +an exact 512 KiB document, reject a 512 KiB + 1 overflow, malformed/truncated JSON +and a wrong version before writes. Wrong method/path, unauthenticated/unsuccessful +reads and ordinary exchanges retain the 256 KiB refusal boundary independently. +The complete actual-core provider suite must pass after that implementation. + +**Proposal reviews.** Two fresh independent read-only reviewers, GPT-6 Astra at +xhigh effort and GPT-6.1 Sol at high effort, approve this proposal for maintainer +decision without a verified actionable finding. They confirm the endpoint's +authenticated inventory/no-projection source and G12's decision vehicle. The +security review also inspects 240 pinned resource/runtime source files without +finding a narrower authenticated version route. Neither reviewer reruns the +measurement or claims implementation/hosted qualification. The response-byte +allowance does not bound peak heap: retained chunks, concatenation, UTF-8 text +and parsed JSON add memory overhead. Acceptance was pending at that checkpoint. + +**Independent progress.** Sixty-five offline transport/target/lock controls pass +with zero skips in 10.162s. They cover actual HTTP deadline/body/redirect/abort +behavior, closed canary diagnostics, selected private-source agreement and local +exclusive ownership. They establish local request/response/socket cleanup before +settlement and separately bounded peer closure; neither claims remote rollback. +Actual field-planner diagnostics at this checkpoint remain separate from the +later complete-core receipt below. + +**Independent transport foundation.** The unused-by-entrypoints transport and +target/lock primitives are committed separately from the incomplete planner/core +and the proposed larger read. All their exchanges still enforce 256 KiB. A later +loopback refinement pins `localhost` lookup to IPv4 loopback while preserving the +issuer, HTTP Host and normal TLS hostname checks. Its actual HTTP control poisons +system DNS, observes the supplied lookup and socket address, and verifies the +unchanged localhost Host. The twenty transport cases pass in 10.141s with zero +skips; the unchanged forty-six target/lock controls above remain applicable. +This is an implementation checkpoint, not Step 2 review/qualification closure. + +### Additional version-source investigation (2026-10-21) + +The maintainer requested further research before accepting the proposed budget +change. At this research checkpoint the proposal remained unaccepted and code +still used the universal 256 KiB cap. These read-only experiments run against isolated +pinned PostgreSQL-backed HTTPS Keycloak, with owned CA/hostname validation and +no redirects. They do not qualify the unfinished whole reconciler. + +| Fixed version-source probe | HTTP result | Decoded bytes | Live version available | +|---|---|---:|---| +| `GET /admin/serverinfo` | 200 | 292,705 | `26.6.2` | +| Same GET with `fields=systemInfo.version` | 200 | 292,705 | `26.6.2`; no projection | +| Same GET with `briefRepresentation=true` | 200 | 292,704 | `26.6.2`; no projection | +| Same GET with `Range: bytes=0-262143` | 200, no Content-Range | 292,705 | `26.6.2`; range ignored | +| `HEAD /admin/serverinfo` | 200, no qualified version header | 0 | No | +| `POST /admin/realms/learnstack/partial-export?exportClients=false&exportGroupsAndRoles=false` | 200 | 44,655 | `26.6.2` | + +The four complete server-information documents use identity encoding and parse +successfully; their one-byte variation is consistent with dynamic scalar fields. +Earlier independent reads measured 292,707 bytes. Parsing takes 0.565–0.769ms in +this small sample. The partial export parses in 0.245ms. These are diagnostic +samples, not a latency comparison, capacity result or production percentile. +Only the oversized measurement reads use the temporary 1 MiB measurement cap; +the partial export succeeds through the unchanged 256 KiB client. + +**A genuine existing-budget alternative.** The fixed partial export is an +authenticated read: its route/export implementation has no configuration-write +or admin-event call, and it assigns `keycloakVersion` from the running JVM's +`Version.VERSION`, not imported realm metadata. Independent tenant realm/user/ +client/profile/credential and Hub snapshots remain identical after the actual +request. POST alone is not evidence of a mutation. Sources: +[route](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java#L1356-L1398), +[live version assignment](https://github.com/keycloak/keycloak/blob/26.6.2/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java#L84-L96), +[export and secret stripping](https://github.com/keycloak/keycloak/blob/26.6.2/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java#L175-L183). + +False/false excludes users, client objects, role/group collections and service +accounts. It still returns realm authentication/configuration data: the measured +fixture contains 15 client scopes, 21 authentication flows, four authenticator +configurations, 14 required actions and 15 component entries. The export source +masks known secrets; this experiment has no positive credential-masking control +and does not claim the complete document is nonsensitive. Its aggregate size +depends on unrelated realm customization, which can exceed the existing cap. +Scope/mapper and client-policy growth are not wholly new dependencies: the +planner's existing scope-list read already includes all scopes and protocol +mappers, and its realm GET includes client policies. Additional export coupling +includes authentication flows/configurations, required actions, component trees, +localization, IdPs/mappers and scope-role mappings, aggregated into one bounded +document. Sources: +[scope-list projection](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java#L95-L100), +[scope and mapper projection](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java#L828-L841), +[shared realm/client-policy projection](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java#L658-L674). +Using it would require a fixed semantic read classification in the zero-write +proofs; the current method-only test counter would misclassify that POST. + +**Other options.** `kcadm --fields` is client-side output filtering: the complete +HTTP body is copied and parsed before filtering, so it does not solve this +decoded-response limit. Source: +[pinned CLI filtering](https://github.com/keycloak/keycloak/blob/26.6.2/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractRequestCmd.java#L364-L384). +The Admin Console's resource-version value is not the live release-version +contract. Container `kc.sh --version` is supported but starts another JVM and +reports installed code, rather than an authenticated response from the running +HTTP process. Sources: +[console resource version](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java#L318-L353), +[resource-version reassignment](https://github.com/keycloak/keycloak/blob/26.6.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/storage/database/jpa/QuarkusJpaConnectionProviderFactory.java#L119-L143), +[CLI version option](https://github.com/keycloak/keycloak/blob/26.6.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Main.java#L57-L86), +[JVM launcher](https://github.com/keycloak/keycloak/blob/26.6.2/quarkus/dist/src/main/content/bin/kc.sh#L82-L162). +Prefix acceptance weakens complete-document validation; streaming the complete +body or compressing it does not reduce total decoded bytes. + +**Recommendation, not acceptance.** Prefer the fixed 512 KiB server-information +read because version qualification then remains independent of unrelated realm +authentication/configuration growth and avoids exporting that configuration for +a version check. The partial-export route is a viable alternative if retaining +256 KiB universally takes priority. No technical minimum requires 512 KiB: +320 KiB also fits the measured document, with approximately 34 KiB headroom; +512 KiB leaves approximately 226 KiB. This is a bounded headroom choice, not an +automatic expansion rule or a peak-memory guarantee. All other bounds and the +proposed falsifiable controls above remain unchanged. Server-information size +can itself grow with provider inventory; exceeding 512 KiB would still refuse +before writes and require a new explicit decision, not a larger automatic limit. + +All temporary source/scenarios/output and compiled diagnostic classes are +removed. Release integration builds pass with zero warnings/errors. Concrete +owned provider/database/Ryuk containers and networks are independently confirmed +absent. No configuration mutation, bound change or dependent activation is +performed by this investigation. + +### Server-information allowance acceptance (2026-10-21) + +The maintainer explicitly selects the named 512 KiB server-information exception, +after considering the viable 256 KiB partial-export alternative. Only a fixed +authenticated successful `GET /admin/serverinfo`, without query parameters or a +request body, can use it. All other responses and every outbound body remain at +256 KiB; the 5s/120s deadlines and existing failure/ownership boundaries remain. +There is no configurable limit, automatic fallback or partial-export activation. + +The acceptance commit updates this package and the current navigation carriers +before dependent code. At that checkpoint it claims no completed Step 2, +implemented exception or new provider/hosted proof. The exact-boundary, +negative-purpose and full provider +controls above remain mandatory; the approved two-review-round loop is unchanged. + +### Shared implementation and pre-review verification (2026-10-21) + +The shared core, field planner and closed launcher are implemented under +`scripts/`. Seed/demo activation remains Step 3; neither entry point invokes +this implementation yet. Step 2's two independent review rounds remain open at +this checkpoint; the local complete-provider receipt follows below. + +The launcher verifies the selected local Compose service, private-source +agreement and exact issuer before admin authentication, then verifies the live +version before planning. Its compiled Seeder identity projection receives only +the existing public tooling environment. A target-specific exclusive file lock +is held through actual owned HTTP/child cleanup; uncertain cleanup retains it. +The accepted response allowance above is implemented without a caller-selected +limit or broader transport change. + +The planner reads the complete bounded plan before applying owned field changes. +Missing named users use one fixed single-user `partialImport`, with +`ifResourceExists: FAIL`, their create-only credential and declared inert role. +It never replays a realm import or assigns a role/password to an existing user. +Independent readback covers preserved state as well as desired values; a fresh +post-apply plan must be empty. + +Actual-provider investigation and pinned-source inspection corrected these +preservation details before review: + +- A profile can project email into username. Census and exact username lookup + must agree on the declared name and provider ID before treating a user as + present or missing. The conflicting imported case refuses before any write. +- Client attributes merge on PUT. Removing the token-lifespan override requires + an explicit null tombstone, rather than omission. Other attributes are retained. +- Client PUT can derive origins, disable authorization services or change service + accounts. Requests explicitly retain the declared origin list and existing + authorization state while omitting unchanged service-account flags. Incompatible + service-account transitions/cleanup refuse before writes; scope assignments + receive exact merged-state readback. +- A needed client PUT refuses existing expiry/rotated-secret metadata that the + provider would discard. Ordinary secret creation time is preserved. Existing + credentials are not rotated to force convergence. + +The offline provider double now models these pinned merge/default/cleanup +semantics. Dirty omission/tombstone controls must fail independent readback; +successful repetitions are not a substitute for the first pass's verification. +The five Node control files pass **122 cases, zero failures/skips**, in 10.210s. +They cover exact response boundaries and negative purposes, real HTTP cleanup/ +deadlines, credential canaries, target/source/lock refusals, complete preflight, +preservation, 409 checks, partial progress and the closed production CLI. +Syntax and whitespace checks pass. No final-source hosted proof is claimed here. + +**Complete actual-core provider receipt.** All four +`TenantRealmReconciliationTests` cases pass with zero failures/skips in 1.7384min +against isolated PostgreSQL-backed HTTPS Keycloak 26.6.2. The actual shared +transport/core uses the accepted allowance without a version projection, cap +override or diagnostic code. The TRX SHA-256 is +`02ffba155dcf5662ed7a7db6e15a849cd9f631f1c63830a93f3973189cc08b77`. + +The controlled warm import preserves provider IDs, changed passwords, MFA, +disabled state, required actions, role assignments, nonmanifest accounts, +unrelated profile/client data, the API service credential/resource-server +settings and the Hub realm. A deliberately removed service-account scope stays +removed; both clients retain empty web origins. Actual code/S256 logins witness +old/new credential continuity and strict signed identity claims. Second/third +completed passes perform zero mutating Admin REST calls. + +The acknowledgement-loss control injects refusal after one real successful +response closes; it does not claim packet loss or remote rollback. Cancellation +and failed readback require explicit verified reruns. Planted identity, mapper, +secret, duplicate and username-projection conflicts refuse before any write even +with a legitimate TTL change pending. Local lock/HTTP race/deadline boundaries +are established by the separate actual-resource Node controls above. + +Account preservation compares exact full stored-account details resolved through +their provider IDs. The intentionally changed global user-profile metadata is +verified separately; including it in an account's preservation baseline would +mistake the owned profile change for a stored-account mutation. All temporary +diagnostic source is removed before the passing complete run. Formal independent +Step 2 review rounds have not yet started. Destruction events and independent +post-run inventories confirm all five owned provider/database pairs, Ryuk and +five networks are gone; foreign resources are untouched. +Fourteen Release corpus, domain-genericity and Docker-trait controls pass with +zero failures/skips in 13s; the changed-file link/fragment audit has zero broken +targets. The scoped C# whitespace verification passes. + +#### Step 2 review round 1 — identity visibility and plan feasibility + +Two fresh independent reviewers inspected the complete committed Step 2 change +through `3776947e`, its callers, accepted contract and pinned provider source. +Both identified a blocking identity-preservation defect: Admin user reads are +profile projections. A missing definition with a disabled unmanaged policy can +hide a stored conflicting actor; the planner had treated it as absent. One +reviewer also reproduced projected inventory/body limits being exceeded only +after an earlier mutation had committed. These findings supersede the initial +local proof's coverage claim for those states; its warm fixture explicitly +enabled unmanaged attributes and did not exercise hidden stored values. + +The actual PostgreSQL-backed Keycloak reproduction performed six writes and +changed the hidden actor before refusing at readback. The corrected planner +qualifies protected attribute visibility before account reads or writes. Hidden, +defaulted, selected or otherwise unqualified identity projections refuse with +`plan/identity_conflict`; it does not write profile settings to reveal conflicts. +The duplicate-actor census also refuses external user-storage projections that +can override realm metadata. Safe explicit admin-readable policies remain +supported, and reconciliation retains the existing unmanaged policy. + +**Remedy follow-up — 2026-10-22:** The preceding no-profile-write result describes +that shipped correction. Accepted [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) +now authorizes the separately bounded preparation remedy after independent +preconditions, without widening unmanaged policy or rewriting this evidence. +Its automatic path remains pending implementation and proof. + +Every static mutation body is checked in the complete read-only plan against +the existing 256 KiB UTF-8 limit. Projected collection and account counts must +fit the same readers used during independent verification and a later run. +The additional byte-feasibility check covers growing response inventories too: +a small valid write must not knowingly make the next scope/user read too large. +Full user listings repeat profile metadata, and inserted accounts can move page +boundaries. Conservative byte accounting can refuse before a count ceiling; +the ceilings do not promise every combined configuration will fit. Transport +and independent readback checks remain backstops. Boundary controls prove +supported states still converge, while excess states refuse with zero writes +even when another legitimate change is pending. + +The identity correction's source-frozen actual-provider class passes **eight +cases, zero failures/skips**, in 2.8699min. Four new cases cover hidden unmanaged +identities, managed identities hidden from administrators, and explicit identity +defaults or selectors. Each refuses before the pending TTL write; an independently +captured snapshot is unchanged, and test-owned post-outcome visibility inspection +proves the stored actor was retained. Existing fresh/warm, credential, partial +progress and zero-write rerun proofs still pass. The TRX SHA-256 is +`1e7206d44e2dc4db21f0e78ef1d02dd2f8c6bd76dd7dbd6693a9b212f200b620`. +The five Node files passed **130 cases, zero failures/skips** at that checkpoint. +After the response-feasibility refinement they pass **133 cases, zero +failures/skips**, in 10.18s, including capped user/scope responses, repeated +profile metadata and a missing metadata-template control. A final-source provider +rerun remains required for Step 2 closeout. Scoped whitespace and diff checks +pass. Independent post-run inventories show no owned provider +containers or networks; the bounded Docker event history lacks one older +destruction event, so it is not claimed as a complete event receipt. Foreign +resources remain untouched. The second fresh independent review is still due +at this checkpoint. + +#### Step 2 review round 2 — derived metadata and inherited identity sources + +Two new independent reviewers inspected `95d0a0c8..29735c78`. Both independently +ran all 133 offline controls with zero failures/skips and identified a false +preservation refusal: intended profile changes alter the derived +`userProfileMetadata` in unrelated-account listings. The earlier warm test's +first two interrupted passes had already applied the profile change before its +successful resumed pass, masking uninterrupted convergence. Actual-provider +confirmation performed eleven writes, retained every independently inspected +account field/actor/credential/role, then falsely refused at readback; the next +explicit pass had zero pending writes. Account-field comparisons remain strict; +only this derived metadata is separated from them, with global profile +configuration still checked independently. + +One reviewer additionally proved that an existing protected mapper's +`aggregate.attrs=true` survived planning and independent verification. Actual +code/S256 exchanges confirmed both defects: aggregation produced the group's +actor instead of the stored actor, while the canonical nonaggregating mapper +inherited an unexpected organization when the user's field was absent. Both +unsafe reconciliations had incorrectly succeeded with zero writes. The token +signatures were independently validated; no credential or token value entered +diagnostics. + +The correction rejects protected aggregation instead of silently repairing the +administrator's setting. A bounded full ordinary-group inventory includes +nested groups and validates parent closure, count and protected attributes. +Each censused account's membership IDs are read without `search`, `first` or +`max`, following the mapper's no-argument group stream rather than selecting a +different paginated branch. Unknown/nonordinary memberships refuse before +writes. The first actual-provider run disproved an initial fixture assumption: +the pinned cached model filters out Keycloak Organization groups even after +the feature is disabled; an enabled organization-member API read confirms the +retained association independently. The corrected fixture must qualify actual +strict token identity, rather than claim an unobserved group fallback. A +separate same-fixture query control found that `search=` is injected as absent +and lists only the parent: two stored ordinary groups produced one result. A +nonempty encoded-space search selects the pinned all-group branch before its +trim, returning both parent and child with complete ancestry. Safe unrelated +groups and memberships remain unchanged. All existing HTTP/count/pass budgets remain +in force; no group, membership or feature-setting write is introduced. + +The fixed single-user import uses the pinned `addUser(false, false)` path and +does not inherit realm default roles or groups. Independent readback requires +each new account's membership set to be empty; unrelated default-group settings +remain preserved. This qualification concerns provider claim sources, not +LearnStack organization membership or application permission. + +**Correction qualification.** The final unchanged-source actual-provider class +passes **13/13 cases, zero failures/skips**, in 4.4234 test minutes (292s including +the owned build/lifecycle wrapper). This local run uses Debug; complete final +Release/hosted qualification remains Step 4. All 23 runtime/test input hashes +match before/after the run and independently match the current source. The TRX +SHA-256 is `ead5c9ed7fb89428afc223664b5bb5a68aabd309b4d6ffe0656d3b7443f97e29`. +Live lifecycle capture binds every one of 14 Keycloak/PostgreSQL pairs, Ryuk and +14 networks to creation/destruction; no owned resource remains, and the foreign +database is retained. The cached-organization control verifies a genuine +member and protected canaries through the enabled organization API, issues a +fresh signed token from a pre-existing code/S256 session while enabled, and +checks a fresh code/S256 token after disabling. Original identity is retained; +test-owned post-result visibility restoration verifies stored organization +attributes and membership unchanged. + +The root independently reruns **142 offline cases, zero failures/skips**, in +10.149s, including the faithful empty-search/descendant control. Scoped syntax, +whitespace/diff, seven ADR parser controls and 1,347 relative links/fragments +across thirteen changed Markdown files pass. The correction is committed at +`b94f6a24`. Two new independent reviewers, GPT-6 Astra and GPT-6.1 Sol at xhigh +effort, approve the bounded correction without a verified actionable finding. +Both independently rerun all 142 offline controls and verify the 13-case TRX and +23 source hashes against the commit. They inspect the real-source group/cache +branches and preservation controls; neither reruns .NET or Docker. Both formal +review rounds, their verified fixes and fresh correction verification are closed. +Step 3's shared seed/demo wiring starts next; Step 4 retains final qualification. + + + +### Account-console self-service capability — proposal (2026-10-21) + +**Status: Accepted — 2026-10-21.** The maintainer explicitly approved the narrow +capability/mapper package after its two independent reviews and actual-provider +counterfactuals. The reviewed proposal is recorded at `2ef1e148`; subsequent +wrapping corrections do not change its policy. G12's existing phase-package +vehicle owns this bounded adjustment; it introduces no new application +authorization decision or ADR. Dependent implementation remains Step 4 work. +Steps 2–3 can close independently; Step 4's same-seed account-edit proof depends +on resolving this conflict. + +**Verified conflict.** The committed seed users have only their declared inert +realm role. The pinned importer calls `addUser(false, false)`, so it does not +assign Keycloak's default-role composite. An unchanged, PostgreSQL-backed fresh +import completes the built-in `account-console` code/S256 flow and token exchange +with HTTP 200, but authenticated Account REST GET and ordinary-profile POST both +return **401**. Independent effective-role readback finds zero `account` client +roles; `manage-account` and `view-profile` are absent. The built-in console has +zero default client scopes, so the measurement requests only `openid`; it changes +no client, mapper, profile or role. No successful account edit or profile +protection is claimed from these refusals. The temporary measurement is removed +from the committed regression source; its focused run passes three measured +cases with zero skips. TRX SHA-256: +`5ff77dff45f09941046d7e42b3cef57cc29bc5839d57aba5fb9ec457172601fb`. + +**Exact existing constraint.** The accepted Identity preservation clause says: + +> Never reset an existing password, credential, MFA factor, required action, +> enabled state, email/name or role assignment to make a proof pass. + +It also requires an ordinary account-console edit for the same seed user. The +current import cannot satisfy that positive control. A test-only role grant +would qualify a different setup and cannot substitute for delivered behavior. +The proposal changes only the role-assignment part of that constraint for the +named self-service capability below. All other preservation rules remain. + +**Accepted delta.** Declare the built-in `account` client's `manage-account` +role for exactly the four managed development personas. Fresh import and the +single-user create-only import include it. Warm reconciliation adds that exact +mapping only when the capability is missing; it never removes or replaces a +prior mapping. Before any mutation, qualify the built-in client/role identity +and its bounded composite closure: only the stock `manage-account-links` child, +with no realm role or foreign client role. Incompatible administrator +customization refuses rather than editing role definitions or composites. +Already effective capability needs no new mapping. Independent readback checks +both the capability and every original mapping; a completed rerun writes zero. + +The console also needs an account-role claim emitter. Its existing audience +resolver alone does not populate `resource_access.account.roles`; Account REST +checks that field even after its configured introspection fallback. Add one +supported stock client-role mapper directly to the built-in `account-console`, +with a fixed `account` client ID and fixed `resource_access.account.roles` claim. +It emits a String array only in access/introspection tokens, with no role prefix, +ID-token, user-info or lightweight-token emission. Do not attach the broader +`roles` scope or add a realm/all-client role emitter. Retain the console's stock +S256, audience resolver and `fullScopeAllowed=false` settings. + +Before writes, qualify the console's direct/default/optional scope role allowance +and expanded composite closure within the stock account set: `manage-account`, +`manage-account-links` and `view-groups`. The last role remains an allowed stock +scope mapping, not a new user grant. Refuse a wider/customized role allowance or +an effective conflicting emitter/role-transforming mapper, without changing it. +A compatible existing fixed-account emitter needs no duplicate. Preserve all +compatible prior client settings, callbacks, scopes and mapper IDs; independent +readback verifies the exact intended addition and unchanged baseline. + +**Bounded replacement — 2026-10-22:** [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) +permits only the three independently qualified stock-console assignment +detachments; customized graphs still refuse and compatible graphs retain priority. +This adds assignment-only DELETE authority, not deletion of global resources. +Implementation and automatic-path proof remain pending. + +No realm-wide default-role assignment, group inheritance, unrelated account-data +or grant change, Hub/master mutation, new application role or membership is +introduced. +The API continues to ignore all role claims for application authorization. +Existing disabled state, MFA, required actions, passwords and account IDs remain +unchanged; this capability does not make a disabled or action-blocked user able +to log in. The protected profile definitions and BFF callback/client boundaries +remain unchanged. The built-in account-console keeps its existing S256 and +audience machinery; the new mapper carries only its resolved account roles. + +**Cost and alternatives.** `manage-account` is Keycloak's standard owner +self-service capability, not a profile-field-only permission. It includes +credential management and, through its stock composite, linked-account +management for that user's own provider account. This explicitly increases the +four warm personas' provider capability and may add the standard account +audience/role claims to their tokens; it grants no LearnStack permission. The +console mapper belongs to a shared client: it can also restore account-console +access for nonmanifest users who already hold eligible account roles. Their +records and grants stay unchanged. The four-persona restriction bounds new role +grants, rather than every effect of the shared client configuration. +Preserving the current mappings without this addition leaves those personas +unable to use standard account self-service. Manual grants leave a nonconvergent +setup prerequisite. Granting the realm default composite can include unrelated +administrator additions; granting every account or changing role definitions +exceeds this narrow need. Replacing the required positive with a generic 401/403 +would leave attribute protection unproved. + +**Implementation and proof owner.** After acceptance, Step 4 updates the typed +identity declaration/export agreement, the planner's explicit role/client diff +and readback, create-only import, Keycloak README and catalogue assertions. Tests +cover unchanged-import refusal before the change, actual same-seed ordinary edit +after it, protected writes/deletions/visibility with independent admin readback, +fresh/warm exact capability and console mapper, original mappings retained, +shared-client behavior for an already eligible nonmanifest user without a new +grant, no unrelated account-data or Hub changes, zero-write reruns and unsafe +role/scope/mapper customization refusing before writes. The existing actual +warm-provider suite must pass against that final source. No fixture-only role +grant may provide the passing baseline. + +**Sources.** Pinned Keycloak 26.6.2 +[importer](https://github.com/keycloak/keycloak/blob/26.6.2/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java#L978-L1035), +[default-role admission](https://github.com/keycloak/keycloak/blob/26.6.2/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java#L103-L133), +[built-in account/console setup](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/managers/RealmManager.java#L470-L535) +and [Account REST requirements](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java#L126-L156). + +**Proposal qualification.** Two independent source/corpus reviews correctly found +that the first role-only recipe omitted the console's role-claim carrier. The +revised fixed-account mapper is qualified by an isolated actual-provider +counterfactual: a fresh code/S256 exchange is HTTP 200 at each stage; the +unchanged user's Account GET/POST are 401, the role-only variant returns 403, +and the role-plus-mapper variant returns GET 200 and ordinary-edit POST 204. +Independent admin readback confirms the changed ordinary field. A combined +protected-attribute replacement returns 400; a deletion request returns 204 +while preserving both stored protected values and the absent organization +field. These are feasibility observations, not complete three-field/profile/ +registration proofs. Default and optional console scopes remain empty. A +whole-client JSON comparison includes the new mapper and is not evidence of +unchanged client settings; exact preservation remains an implementation +obligation. The temporary variant is removed and makes no production change. +The single-case TRX SHA-256 is +`b3d57aecb27543e3c97f6537c75e259fc6d554c858b6fb4844f4a99617424d09`. +The lifecycle receipt confirms the one owned Keycloak/PostgreSQL/Ryuk/network +set is removed and the foreign database retained. No fixture-only grant is a +production or protection baseline. The selected mapper uses Keycloak's supported +[fixed-client role mapper](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserClientRoleMappingMapper.java) +and typed resource-access projection. The independent protection proofs remain +Step 4 obligations after any accepted implementation. + +Two independent reviewers, GPT-6 Astra and GPT-6.1 Sol at xhigh effort, approve +the revised package for maintainer decision without a remaining actionable +design finding. Their review verifies the recorded counterfactuals, bounded +capability/mapper ownership and disclosure; it grants no implementation approval. +They do not claim present-tree parity with the experiment while independent +Step 3 entry-point changes proceed. Scoped prose wrapping and link checks pass. + + +## P02b-6 Step 3 — shared seed/demo lifecycle (2026-10-21) + +**Status: complete locally after both independent review rounds, verified fixes +and fresh correction reviews.** Work stays on `development`. Step 2 +is closed at `b94f6a24`; the account-console adjustment above is Accepted and +retains Step 4 implementation ownership. + +- `seed.sh` checks selected/ambient database agreement and the application role, + then performs read-only private realm preflight before stack/migration work. + `seed.mjs` owns the confined Compose prerequisite, selected stack startup, + PostgreSQL readiness and migration, then full readiness, a fresh Release Seeder + build, its argument-free DLL with only the app credential, and the awaited + shared realm reconciliation. +- The demo performs old-private-file preflight before preparation or commands. + It awaits the same reconciler with the exact selected project/file/mode and + signal after application seed, before CI stops Keycloak or starts API/web. +- Build/setup children receive credential-free prerequisites. Migration keeps + only its own credential and explicit selected-stack metadata; API, public web + and Seeder environments do not receive private admin/BFF credentials. Fixed + diagnostics share the missing-secret setup instruction; no existing private + file is silently filled and no old-password promise is printed. +- Parent cancellation joins the shared operation and owned command cleanup. + Local data stays retained; CI's own teardown and stopped-Keycloak public proof + remain part of the shared lifecycle. + +**Local validation.** The three focused lifecycle/source files pass 142 cases +with zero failures/skips/todos; their actual JSON report passes the repository's +validator. The complete guarded frontend run passes **1,722 cases in two +packages, zero skips/todos**. Workspace typecheck/lint and 142 separate realm Node +controls pass. Seventeen relevant Release architecture/corpus/Seeder/Docker-trait +controls pass with zero skips; the Release build reports no compiler warning or +error. Syntax, Bash parsing and scoped diff checks pass. These checks establish +local wiring and source controls, not actual Compose convergence, product +browser behavior, final-source hosted CI or a BFF session. + +#### Step 3 review round one + +Two fresh independent reviewers inspected `2ef1e148..ce05931e` through the +standards, security/lifecycle and testing/corpus lenses: `gpt-6-astra` at `xhigh` +and `gpt-6.1-sol` at `xhigh`. No verified runtime, security or proof-correctness +finding remained. The testing/corpus reviewer identified five over-width prose +locations in the realm guide, catalogue, seed skill and root README. They are +wrapped without changing the contract. The security reviewer independently ran +all 142 realm Node controls with zero failures, cancellations, skips or todos. +The actual seed/demo owner remains separate from both reviewers; no offline +receipt substitutes for its pending lifecycle qualification. + +#### Step 3 review round two and credential correction + +Two new independent reviewers, `gpt-6-astra` and `gpt-6.1-sol` at `xhigh`, inspect +`2ef1e148..9b51a0bc`. The proofs/corpus reviewer independently passes all 142 +focused frontend cases with zero skips/todos and validates the actual report. +The security reviewer independently passes all 142 realm Node controls, then +reproduces a credential-confinement defect through an actual copied `make seed`: +the unchanged Makefile prerequisites run before the new Node owner, so matching +inherited Keycloak admin/BFF secrets reach migration restore, build and EF +children. Synthetic credentials and command stubs expose only presence flags; +no Docker or .NET process is required to falsify the confinement claim. + +The correction moves `dev`, PostgreSQL wait and `migrate` into the seed process +owner with selected project/file/mode and only the migration credential. The +Docker-version prerequisite is also owned and receives no private credential. +Compose child environments retain only fixed Docker process prerequisites, so +unrelated inherited application/BFF secrets cannot reach readiness or target +inspection. The existing general migration target and its explicit database +connection argument remain unchanged; the no-argument claim concerns the +application Seeder DLL, not every descendant of `make migrate`. +The actual Makefile regression passes and its planted old-prerequisite variant +fails the same confinement predicate. Selected Docker context/socket/config, +database credentials, private-source continuity and unchanged files are checked +independently across stack, migration and readiness. No real Docker or .NET +execution is claimed from these executable stubs. The two focused changed test +files pass 134 cases; the full guarded frontend run passes 1,728 cases in two +packages with zero skips/todos. Typecheck, targeted lint, 142 realm Node controls, +ADR parser and link checks pass. Fresh correction review and a corrected-source +actual seed run remain pending. Earlier actual seed evidence is bound to +`ce05931e` and requires that new run for this corrected entry path. + +#### Step 3 actual lifecycle evidence before the credential correction + +An isolated clone on `development` at `ce05931e` runs the actual entries without +reading the root checkout's private files or foreign working directories. +`make seed` completes a fresh import with zero realm writes. An owned warm-only +change sets TTL to 1800 and detaches the managed API scope from both named +clients; the next seed performs three writes and independently restores TTL 900 +and both scopes. The third pass performs zero writes. Four-user account-state, +client-ID and Hub digests remain identical. These are bounded drift controls, +not a substitute for Step 2's pre-mapper/profile upgrade proof. + +The clean-clone CI demo completes in 650.4 seconds with Keycloak stopped before +the public API/web proof: nine browser navigations, 24 visibility controls and +27 of 27 audits, with all 27 JSON/HTML report pairs and nine screenshots. All +reports share invocation, commit `ce05931e` and tree +`f446d80096bf3a8119553182df3c1629996b85b4`. All 134 API completions are HTTP 200, +with no malformed or pending output. TLS refusal and timeout controls pass. +Cleanup completes in 10.73 seconds; independent exact-project checks find zero +containers, networks or volumes. Private files, environment and lock are removed; +the unrelated database remains running and tracked clone source remains clean. +This receipt qualifies the named source, not the subsequent credential fix or +Step 4's final PR head. Final-source production demo/hosted qualification remains +Step 4 work. + +#### Step 3 correction review and single readiness owner + +Two fresh independent correction inspections, `gpt-6-astra` and `gpt-6.1-sol` at +`xhigh`, inspect the credential fix at `29bb18df`. Both independently reproduce +an additional cancellation defect: the nested `make wait-postgres` CLI creates +its own detached Docker command owner. Outer and inner two-second escalation +can race, killing the inner owner before it kills Docker. Synthetic Docker +commands continue heartbeating after the outer seed reports cancellation and +completed cleanup. Each probe cleans only its recorded owned groups. + +The correction keeps PostgreSQL readiness directly in the seed's existing +`DemoProcesses` owner: selected `make dev`, direct PostgreSQL readiness, selected +`make migrate`, then full readiness. Stack startup receives no migration +credential; only migration receives it. Increasing a teardown timeout or a +successful happy-path run does not substitute for that ownership change. +The proof reviewer also exposes the actual-Makefile regression's default +five-second test allowance under contention: the combined run passes 147 of +148 cases, while its isolated rerun passes in 3.84 seconds. Its two bounded +subprocess runs require an explicit test allowance. + +The updated two-file focused run passes 136 cases. Actual copied seed/process/ +Makefile/readiness sources now prove cancellation and deadline cleanup with a +TERM-ignoring synthetic Docker command and a 300ms post-return heartbeat check. +The checked nested-readiness mutation fails the same survival predicate in both +cases. Only the nested fixture's cleanup is delayed to expose that competing +owner deterministically; the clean owner retains the native stop helper and +canonical readiness budget. Deadline shortening is internal to the test driver. +Fixture retirement guards never re-probe or signal a handle after observed +absence. The actual-Makefile case has a finite 45-second allowance; each cleanup +case has 30 seconds and bounded child drivers. No Docker or .NET behavior is +inferred from executable stubs. Full guarded frontend validation passes 1,730 +cases in two packages with zero skips/todos; typecheck, targeted lint, 142 Node +controls and link checks pass. Fresh correction review and actual replay on this +single-owner source remain pending. + +A separate isolated actual replay at `29bb18df` again completes fresh, warm and +third seed passes with **0 / 3 / 0 realm writes**. Independent readback restores +TTL 900 and both scopes while retaining account, client-ID and Hub digests. All +three closed logs have zero generated-secret matches. Exact-project teardown +finds zero containers, networks or volumes, removes only the owned environment +and retains the unrelated database. That replay predates the single-owner +readiness correction and does not prove its cancellation behavior. + +#### Step 3 final correction verification and local closeout + +Two further fresh independent reviewers, `gpt-6-astra` and `gpt-6.1-sol` at +`xhigh`, inspect the complete Step 3 source through `1f5cc04e`, including the +single-owner correction. Each independently passes all 31 realm-lifecycle +cases. No production lifecycle or credential-confinement finding remains. +The proof reviewer identifies one Minor in the fixture observer: saved tracked +group IDs are probed again after the native helper observed absence. The fixture +now retains that native ESRCH witness and refuses a later probe/signal through a +retired handle; both observation samples consult it. The same reviewer verifies +the correction with no remaining actionable finding. Production source is +unchanged. The corrected file independently passes all 31 cases with its actual +JSON accepted by the repository report validator; targeted lint and formatting +pass. The latest complete guarded run passes 1,730 cases with zero skips/todos. + +The final actual replay uses runtime commit `1f5cc04e` and tree +`62a27504adfeeaae32ca1dad60abcb213b5b0dff`: fresh seed writes zero in 35.66 +seconds, the owned TTL/two-scope drift repairs three in 14.82 seconds, and the +third run writes zero in 14.38 seconds. All exits are zero with no timeout or +generated-secret match. Independent readbacks retain all four account +projections and the user/client-ID/Hub digests. Exact-project cleanup completes +in 10.49 seconds with zero containers/networks/volumes; the unrelated database +remains running. Only the hash-matching owned private environment is removed; +the isolated `development` clone remains clean. The receipt SHA-256 is +`adf46a23598adc7e116210d5925cdcc283d03cdad2d76a2bde5fe2c1ade6732e`. +The subsequent fixture-observer correction changes no runtime qualification +input. Step 3 closes locally. Step 4 retains the approved account-console delta, +actual API/profile/registration qualification, final-source production demo and +complete hosted CI; this closeout claims neither a BFF session nor packet merge. + + +## P02b-6 Step 4 — real identity qualification and closeout (2026-10-21) + +**Status: implementation and independent reviews complete; the PR checks own +final-head qualification.** Work stays on `development`. Steps 1–3 are complete locally; +their source-bound receipts +above retain their limits. The maintainer approved the narrow account-console +capability and mapper adjustment above. No further decision approval is pending. + +Three implementation areas proceed together: the declared four-persona +self-service capability and non-destructive warm reconciliation; actual account +and registration protection with independent stored-state observations; and +real provider credentials through the production API boundary and PostgreSQL. +The API proof retains the existing physical read-only/application-role witness; +it supplies neither alternate claims nor a replacement token validator. + +Fresh import declares both stock account clients, their stock role/scope +baseline and only the approved account mapper/persona-grant additions. Actual +provider comparison against automatic stock bootstrap remains required before +claiming that parity. Warm reconciliation qualifies the existing builtin +configuration before any write and preserves unrelated mappings and identities. + +Offline transport/ownership/convergence controls enter the existing meta job +with pinned Node setup. Each named test file must produce a fresh, nonempty +JUnit report with actual named cases, zero failures and zero skips/todos. The +exact report gate passes its positive control and refuses planted empty-file, +failed, skipped and todo cases. Seven required check names, execution topology +and existing job ceilings remain unchanged. Actual provider cases enter the +ordinary Docker discovery; final timing, complete assignment and hosted fit +remain qualification work. + +No Step 4 runtime success, catalogue promotion, final-source demo, hosted CI or +packet completion is inferred from source implementation or offline controls. +The implementation commit, both fresh independent review rounds and verified +corrections remain required before this step closes. + +### Step 4 initial qualification + +The frozen implementation compiles in Release with `CI=true`, zero warnings +and zero errors. The exact new meta step passes 148 named Node cases. The full +guarded frontend run passes 1,730 cases in two packages with zero skips/todos; +workspace typecheck and lint pass. Unit, Docker-free HTTP and contract runs pass +2,616, 289 and five cases respectively, with zero skipped cases. + +Initial actual-provider qualification finds fixture defects and remains red: +the API run passes 11 of 12 cases, while its organization-mutation cleanup gets +400 from an attributes-only Admin user update. That final assertion masks any +earlier failure, so no organization-mismatch success is inferred. The provider's +user-profile update needs the unchanged ordinary fields with its attribute map. +The profile/issuance run passes 13 of 19 cases. Five profile cases receive HTML +from Account GET rather than JSON; explicit JSON content negotiation is required +before any success can count as an account-data observation. A live TTL mutant +also shows that omitting an attribute from a client update does not remove its +existing value; restoration requires explicit removal and independent readback. +Registration's ordinary positive and late-session expiration clamping pass. +These are implementation qualification findings, not independent review rounds. + +The broader account/upgrade run executes 24 cases: eight pass and 16 fail, with +zero skips. Failures include the missing JSON negotiation and a real planner/ +readback incompatibility: the pinned provider removes an empty mapper role-prefix +entry, while the intended configuration comparison requires that empty entry. +One warm attempt therefore fails readback after six durable writes. The stock +builtin comparison itself passes before a later status assertion fails; this +does not make its containing test green. + +The narrow correction accepts only the fixed-account mapper's documented +empty/absent role-prefix equivalence. It preserves every required option, +unknown-option refusal and raw preservation check for existing mappers. The +independent fake provider now models the pinned conversion rather than echoing +the requested configuration. Four additional controls cover normalization, +required/unknown options, create/conflict readback and existing-mapper identity; +all five Node suites pass 152 cases with zero skips. Actual-provider reruns and +the JSON/restore fixture corrections remain pending. + +All three completed provider-run resource sets have zero retained containers, +networks or volumes after normal owned teardown; the unrelated database remains +running. The complete architecture run continues against unchanged production +and architecture binaries. Body-only integration fixture corrections are built +separately, without rebuilding those dependencies; case identities and trait +declarations remain unchanged. Current-source Docker census checks remain +required after the collection-boundary extension. +Neither status-only Account HTML nor a failed restore is proof of the intended +protection or recovery. + +### Step 4 corrected local qualification + +The corrected source passes **55 actual-provider cases, zero skips**: 24 account, +export and reconciliation cases; 19 profile and issuance cases; and 12 production +API cases. Account observations explicitly request JSON and verify its ordinary +fields. User mutations preserve the unchanged ordinary identity fields, restore +the exact attribute map and verify a fresh positive token/API result. Client TTL +restoration explicitly removes the override and independently verifies its +absence before accepting the recovered 900-second token. + +The account run proves fresh builtin parity, four narrow role additions plus +one console mapper on warm upgrade, zero writes on rerun, existing effective +group/composite capability without a new direct grant, and a nonmanifest user's +403-to-JSON-200/edit-204 transition without changing that user's mappings or +data. Five incompatible account configurations refuse before a write after a +working clean twin. Its complete 24-case TRX SHA-256 is +`a2b000ff5f9646ed7e4f03e20e74054a831e71410f863331892fbb3c0b30ca39`. + +Profile mutation uses its own class fixture, separate from the readonly shared +issuance baseline. The final 19-case run executes those classes concurrently on +independent providers. It verifies all three protected attributes, ordinary +account edits and registration, user-editable counterfactuals, fifteen live +token-profile variants with clean recovery, and the late-session expiry clamp. +Its complete TRX SHA-256 is +`f470c252cbbfc908e1d4996278cf116ba2b78eb514755d729c57a6ae6f8a2aa7`. + +The real API run admits all four declared personas through the stock production +authentication path and application-role PostgreSQL owner. Tenant/organization +disagreements stop before the read and persist one defended-tenant rejection +with the expected actor/source/scope. Claimless public reads preserve their +HostOnly ceiling; actual ID/Hub tokens and the wrong CA are refused. Anonymous +reads remain independent of an unavailable identity provider. Its complete +12-case TRX SHA-256 is +`7fafa97c24ea554cb0a629b5c3c952942d1ee54b767a7a988f4b9c5a5e8eb02e`. +Exact-session teardown leaves no owned provider resources; the unrelated +database remains running. + +The complete architecture assembly passes **433 cases with zero skips**. +Production and architecture input binaries remain unchanged across the separate +integration-fixture correction build; 359 SHA-256 observations retain that +boundary. Current-source Docker/corpus checks pass seven cases, followed by two +Docker census cases after the profile class-fixture isolation change. The census +now detects direct public-read fixtures, the named API collection and partial +test classes; its planted missing-trait controls fail the same detector. +Release builds report zero warnings/errors. The corrected exact meta step passes +**152 named cases**, with all report-refusal controls retained. The previously +recorded complete frontend, unit, HTTP and contract runs remain valid for their +unchanged source inputs. Link/fragment and whitespace checks are clean. + +These are local receipts. Step 4's implementation commit, two fresh independent +review rounds, final-source production demo and complete hosted qualification +remain pending. Both P02b-6 catalogue entries remain Registered; no BFF session, +packet completion or merge is claimed. + +### Step 4 review round one and executed-case correction + +Implementation commit `ea0363e8` is inspected by two new independent reviewers: +`gpt-6-astra` at `xhigh` for security/ownership and `gpt-6.1-sol` at `xhigh` for +proof correctness, API/fixture isolation and CI/corpus. The security inspection +finds no actionable defect and independently passes all 152 Node cases. +The proof inspection reproduces one Major: Node 22.23.1's JUnit reporter emits +passing `testcase` elements for empty named `describe` suites even though its +actual executed-test count is zero. The initial gate therefore cannot establish +the claimed nonempty execution from JUnit alone. + +The correction adds a second reporter to the same Node invocation. It requires +exactly one completed root `test:summary`, positive safe-integer executed-test +count, all executed tests passed, successful completion and zero failures, +cancellations, skips and todos. The JUnit filename/shape checks remain separate. +Three offline controls execute the actual workflow command/gate in twelve child +runs. Both empty-suite counterexamples pass a checked variant removing only the +counter reporter and fail the real command; ordinary/nested real tests pass, +while empty files, failures, cancellations, skips and todos are refused. These +controls run in meta. The original reviewer independently verifies the correction +and the exact complete meta step: **152 executed tests and 152 JUnit cases pass**, +with no remaining actionable correction finding. Formal round two remains +required. + +The CI plan adds advisory weights for 37 realm method selectors (35 live-provider +methods and two offline export controls) from the three complete 55-case TRXs +above. Those receipts contain 53 live-provider cases and two offline controls. +Exact completed row durations are summed and +scaled by the existing historical ordinary ratio, 2.271245. Artifact digests +are retained; source commit is null because it is not embedded in the TRXs. +Fixture startup and discovery costs are not inferred from case durations. +Topology, selectors, default unknown-method estimate, algorithms and time +ceilings are unchanged. All 52 shard controls pass. + +Actual local adapter discovery independently finds **5,902 cases**: 2,910 base, +433 architecture, 2,528 ordinary Docker and 31 native. Both complete assignments +cover the exact population once, genuinely differ and stay within the existing +selector/filter bounds. Forty-two non-base methods retain the explicit unknown +weight default. Estimated summed case durations are not wall-time guarantees; +no hosted fit or execution success follows from this local discovery. + +### Step 4 review round two and production demo + +Two further fresh independent reviewers, `gpt-6-astra` and `gpt-6.1-sol` at +`xhigh`, inspect the complete Step 4 source through `4a3173d8`. Both approve +without an actionable finding. Each independently passes all 152 executed Node +cases, the three actual-workflow gate controls and all 52 shard controls. The +proof reviewer additionally rejects thirteen missing, duplicate, malformed or +unsuccessful summary streams against the real reporter. The security reviewer +independently verifies that only the 37 measured weights and three provenance +records change the plan. Neither reviewer substitutes a local receipt for +complete hosted qualification. Separately, five environment, eighteen +provisioning and forty native-owner offline controls pass. + +An isolated clean `development` clone at runtime commit `ea0363e8` and tree +`34f391c6d837762a94bf6b704cdc1c01f220d041` passes the actual production +`make demo DEMO_MODE=ci` in 649.497 seconds. Contextual application seed and +four-user realm reconciliation complete before Keycloak is stopped for the +public proof. Nine product pages, 24 visibility controls and all 27 audits pass; +all 27 JSON/HTML report pairs and nine visually inspected screenshots exist. +Accessibility scores are 1.00 throughout, with zero warnings, runtime errors, +failures or skips. Both HTTPS refusal controls and the actual timeout refusal +pass; all 134 API completions are HTTP 200. Cleanup completes in 10.750 seconds: +zero owned containers/networks/volumes, vacant owned ports and removed private +files/environment/lock. The unrelated database identity/state/start time remain +unchanged and tracked clone source stays clean. The receipt SHA-256 is +`e4e823a088e90cbcee37988d239741d86bc300bb66dcd4db7e92a405b075d45b`. +The subsequent commit changes CI controls, timing data and this record, not the +demo runtime; final PR-head hosted qualification remains separately required. + +### Step 4 initial hosted qualification and source ownership correction + +Workflow-dispatch run `39587211807` checks candidate `4a3173d8` against main +`24705a7a`; OpenAPI, meta and secret scanning pass. Frontend typecheck/lint/build +pass, but its test gate refuses the newly added Python counterexample helper as +unclassified repository source. The prior complete frontend receipt predates +that helper; its runtime source census depends on this additional repository +input. The hosted test result is therefore 1,657 passed and one failed, not a +complete passing frontend run. Other execution jobs retain their independent +results; no full-workflow success is claimed. + +The correction declares only `scripts/test-realm-node-gate.py` as non-runtime, +beside the other exact offline CI control owners. The existing source-classifier +test asserts that exact path; the live census also explicitly requires the new +Node reporter among runtime sources. Unknown Python files and unmodeled runtime +formats remain refused. The complete guarded frontend rerun passes **1,730 cases +in two packages, zero skips/todos**. Fresh focused review verifies this correction +before final-source qualification. + +Two new independent correction reviewers, `gpt-6.1-sol` and `gpt-6-astra` at +`high`, approve `1f0a8d06` without an actionable finding. The former independently +passes all nineteen focused genericity cases and validates their actual JSON +report with the repository guard; both pass the three actual-workflow controls. +The latter also checks exact helper ownership, refusal of five adjacent/unknown +paths, continued reporter inclusion and refusal of a runtime import of the +excluded Python source. Workspace typecheck, targeted lint, hook +formatting, whitespace and link/fragment checks pass. The correction changes +neither the product runtime nor CI membership or time ceilings. + +### Step 4 implementation closeout and PR qualification handoff + +All four approved implementation steps, their two independent review rounds and +the required fresh correction reviews are complete on `development`. + +| Step | Delivered result | Review status | +|---|---|---| +| 1 | Executable import/profile/scopes, four typed stable identities and real fresh issuance | Both rounds and verified corrections complete | +| 2 | Bounded non-destructive warm reconciliation, credential continuity and preflight/readback | Both rounds and fresh correction reviews complete | +| 3 | One seed/demo operation and cancellation/cleanup owner with real lifecycle replay | Both rounds and fresh correction reviews complete | +| 4 | Narrow account self-service, real profile/registration/API proofs, CI ownership and carrier closure | Both rounds and fresh correction reviews complete | + +The completed first hosted run +[`39587211807`](https://github.com/HodeTech/LearnStack/actions/runs/39587211807) +binds its twenty successful execution owners to source `4a3173d8`. Independent +download/readback recomputes assignment from the actual adapter discovery and +verifies **5,902 distinct passing case identities, zero skips**: 2,910 base, +433 architecture, 2,528 ordinary Docker and 31 native. The 53 live-provider +cases and two offline export controls all pass. Every selected case matches its +completed record; all owners +report actual completion and cleanup, without forced cleanup, and all Docker +owners report no retained resources. The longest execution job takes 860 +seconds, below the unchanged twenty-minute ceiling. Both required backend +aggregates pass and verify their artifact/API provenance. Meta, OpenAPI, secret +scan and the hosted production demo also pass. The known frontend source-owner +failure above keeps this **whole run failed**; no corrected-head success is +inferred from it. + +The plan digest is +`c380356092978a923d4ba98108c3a1f7f13e5160bec1522f7848a368af68092d`; +the hosted manifest digest is +`736b7f268398e429ea8b5c04cc2d619d29418dfc9dd3f37a8637639d1fdf9422`. +The actual hosted toolchain is SDK 10.0.402, VSTest +18.7.0-release-26505-111 and adapter 2.8.2, recorded rather than inferred from +local SDK 10.0.302. The declared SDK roll-forward permits this feature band. + +Both P02b-6 behavioural catalogue obligations are now **Implemented** with their +actual Integration/meta owners. Published counts are 231 Implemented, 133 in the +Architecture assembly and 98 outside; the five corpus-consistency cases verify +those counts and owners with zero skips. No new structural rule or test-method +count is invented. The seven live required contexts and strict protection are +independently read back unchanged; no repository setting is modified. + +This is the implementation/review closeout. The PR's required checks own final +head qualification, including complete unchanged-plan A/B assignments and the +production demo. A full rerun selects B; rerunning only failed jobs cannot +establish a complete alternate assignment. No partial run, old-head receipt or +local success substitutes for those checks, and this record claims no merge. +P02b-7 is next: its decision pass owns BFF/session custody, lifetimes, refresh, +CSRF/logout and SDK bearer transport. P02b-8 owns final enforcement and erasure +inventory; Phase 03 owns live membership/authorization. G13's session part stays +Open until that next packet's accepted decision pass. + +### P02b-6 PR review corrections (2026-10-21) + +Six review observations are verified against `204a1039` and corrected. Tooling +retains explicit proxy, private-CA and package-cache prerequisites; the Docker +version probe uses the existing Compose environment owner. Application +credentials remain confined. Reconciliation preserves cancellation/deadline +classification after transport rejection. Failed lock initialization closes its +handle and removes its exclusive marker before refusal. Seed stack startup and +migrations retain finite 600s and 300s command budgets respectively; the realm +HTTP/pass budgets remain 5s/120s. The integration pyramid and current realm +issuance wording now match delivered provider coverage. + +Local qualification passes 160 Node controls, the complete guarded frontend +suite (1,730 cases, zero skips/todos), web typecheck and focused lint. New fault +controls fail four times against the original implementation and pass against +the correction. Two fresh independent correction reviewers (`gpt-6-astra` and +`gpt-6.1-sol`, both at high effort) approve `204a1039..e0f9798f` without an +actionable finding; each independently passes all 67 affected Node controls. +The PR checks own new-head qualification; earlier source-bound CI receipts are +not promoted to this head. + + +### P02b-6 systematic corrections and hosted qualification (2026-10-21) + +The correction pass remains on `development`. The implementation/review receipts +above describe their recorded source, not approval of a later PR head. The live +PR checks own complete current-head qualification; this record retains the +source-bound history and the correction obligations. + +**Failed hosted evidence.** Run +[`39598693980`](https://github.com/HodeTech/LearnStack/actions/runs/39598693980) +tests head `eb707ee84f7c3cbc29c4f7ded2b86cb1b7cb5834` through merge source +`c390635d0efeb92b08b3c75485fc541b4ca83d3f` with the identical head/merge tree +`fd08acbbdc0710f0fb5de579bc1c5975ef95a3a9`. + +- Attempt 1 completes all 27 jobs and seven required contexts, with 5,902 distinct + passing cases, zero skips, exact selected/completed/TRX equality, actual cleanup + and verified job/artifact provenance. Its 55 realm cases comprise **53 live + provider cases and two offline export controls**, not 55 provider executions. +- Attempt 2 is failed evidence. Ordinary-10 exports 185 passing and two failing + cases: both `independentFailure: false` variants of + `Native_completed_cancellation_survives_restart_and_independent_failure_is_not_reclassified`. + Its owner reports `tests_failed`, `owned_completion_missing`, forced cleanup + and no Docker-absence witness. Sanitized records and retained job output omit + assertion/stack details. Neither port pressure, resource pressure nor Keycloak + co-membership is established as the cause; no speculative backend fix follows. +- Attempt 3 reruns only failed jobs. Its ordinary-10 passes 195 cases, but the + integration aggregate fails before issuing a receipt. The exact current-attempt + `ci-proof-39598693980-3-base` artifact is absent. The jobs API can project reused + successful sibling rows into the current attempt; those rows do not prove a + fresh execution. The missing receipt upload is a consequence of refusal, not + a different output path. This partial run does not qualify either full layout. + +**Verified corrections.** + +| Boundary | Correction and proof ownership | +|---|---| +| Aggregate evidence | Preserve current-attempt artifact identity and fail closed; distinguish `artifact_api_missing`, `artifact_api_not_unique` and invalid metadata. An executing partial-rerun control refuses reused sibling evidence. | +| Node execution | A single owner derives the complete test-file census and requires exact reviewed per-file executed counts plus same-invocation JUnit. Added/missing files, deleted cases, redistributed cases and empty suites are rejected. | +| Fixture census | The first correction lists 82 classes. Independent round one finds that qualified collection references and other explicit fixture types remain omitted; the follow-up below corrects this census. No measured fixture startup cost is invented. | +| Realm ownership | Only an exact successful single-user `ADDED` receipt establishes creation with the declared credential. An ambiguous 409 or a publicly registered reserved username without the admitted legacy identity refuses. Existing credentials/MFA and unrelated state remain preserved. | +| Effective login | Disabled realm/clients, consent changes and incompatible stock account callbacks refuse before writes. The two legacy declarations remain tied to the typed seed authority; tooling adds no institution-name branch. | +| Token availability | Bounded proactive administrator-token renewal reserves the complete exchange budget and never replays a failed Admin request. The whole pass still ends at 120s. | +| Real provider repair | A planted warm scope drift exposes Keycloak's required `name` on scope updates. The repair carries only retained `name`, retained `protocol` and merged attributes; a model that accepted attributes-only updates no longer masks this path. | +| Local lifecycle | Read selected private files through no-follow owner-only regular-file handles. New Makefile files use mode 0600; old files require explicit operator repair. Check the effective local Docker context before stack work. Remove the implicit BFF-secret fallback. Print fixed seed stage/reason diagnostics without child output or credentials. Migration credentials remain in the confined factory environment, not process arguments. | +| Independent security proof | Inspect actual loopback Docker bindings, exact stored identity for all four personas, ordinary profile preservation, exact ID-token subject/client/nonce/issuer, transport cancellation and trusted-CA behavior. | + +**Plan revision witness.** On the unchanged 5,902-case manifest +`736b7f268398e429ea8b5c04cc2d619d29418dfc9dd3f37a8637639d1fdf9422`, the timing +revision from source `ea0363e8b936c209a9a1bae6e9a027d6877ae596` moves 2,318 +ordinary cases in A (1,043 methods) and 2,256 in B (1,034 methods); Architecture +moves zero cases. This is the old-plan-to-new-plan difference, distinct from +A-to-B independence. No weight is reverted to conceal the failed layout. +The first, still incomplete fixture-class census changes its eligibility witness +to 2,191 cases, of which 2,046 change owner across A/B. The earlier 1,985 witness +covered a smaller subset. Architecture/ordinary A-to-B changes remain 371/2,339 on +that fixed manifest. New tests require a newly discovered manifest and new full +qualification; none of these historical counts are promoted to that source. + +**Receipt corrections and limits.** The Step 1 round-two total of 46 export +variants is historical; it became 58 after the account-console extension, and +this pass expands the existing export proof again. The early Step 2 receipt of +65 transport/target/lock controls precedes the later 20 + 46 = 66 checkpoint; +neither is a current suite count. Those two historical Step 2 review rounds did +not retain model/effort metadata in this record; it is not reconstructed by +inference. Catalogue status was Registered at the earlier Step 4 checkpoint and +became Implemented only at its later recorded promotion. + +The previous-head `39590028974` A/B capture retained and API-digest-verified 46 +raw archives locally. That is a capture-time receipt, not a claim that all 46 +remain downloadable: later attempt uploads overwrite shared artifact names. +Its 152 meta Node controls belong to that previous source; `eb707ee8` executes +160. Auxiliary artifact names now include run and attempt identity. + +A 999-user ceiling bounds inventory, not completion throughput. An independent +999-user/40ms-per-exchange model reaches the unchanged 120s deadline and refuses; +no real scale benchmark or guaranteed convergence at that ceiling is claimed. +All nonmanifest/group safety checks remain. The measured 913s hosted demo job +leaves a thin margin in its existing allowance, but is not a reproduced deadline +failure. The sixteen-minute proof allowance, four-minute cleanup/artifact +reserve, twenty-minute execution ceilings and required context names remain +unchanged. Pinned image preparation is separately bounded before fixture startup +and must fit the same whole-job budget. Sequential aggregate downloads already +complete in 16/30s against their 240s allowance; no unmeasured parallel rewrite +or timeout expansion is bundled. + +**Local verification at `d8139de3`.** The source-censused Node owner passes 234 cases +(30 core, 41 HTTP, 56 ownership, 95 planner and twelve real CLI cases), with no +skips/todos. The guarded frontend passes 1,735 cases in two packages; typecheck +and lint pass. CI controls pass nineteen aggregate, 57 shard and six Node-gate +cases. The actual CLI success/failure/SIGTERM paths execute target validation, +private-source handling, lock ownership, credential-free child commands, full +planner/readback and cleanup; only the external provider boundary is substituted. +Separate actual HTTP and live-provider proofs retain their own coverage. +The readiness fault controls use an actual child-ready marker before injected +deadline expiry and an explicit cleanup release barrier, replacing two fixed +startup/hold sleeps without changing production timeouts. + +The first expanded provider invocation executes 59 cases (53 live and six +offline): 58 pass and the newly exercised scope-update branch fails. The +attributes-only request is independently reproduced and corrected as above. +The current affected set then passes all 21 cases, including all thirteen +reconciliation cases, actual startup/bindings, transport/export and backchannel +controls. Twenty-two architecture/migration/Seeder/Docker controls also pass; +Release builds have zero warnings/errors. This is an affected-source rerun, +not a claim that the earlier 59-case invocation passed. Cached-image fixture +startup measures 19.491s; no cold-pull or fixture-cost estimate is inferred. +The existing export proof now rejects 66 variants; the earlier 46/58 totals +remain as-of receipts. + +Both fresh independent correction review rounds and full frozen-source hosted +A/B qualification remain pending at the `d8139de3` checkpoint. No passing +partial retry, old-source receipt or missing cleanup witness closes that gate. +This correction record claims no merge or P02b-7 delivery. + +**Independent correction round one.** Three fresh reviewers inspect +`eb707ee8..d8139de3`: security/runtime (`gpt-6-astra`, xhigh), CI/provenance +(`gpt-6.1-sol`, xhigh), and provider/corpus (`gpt-6.1-sol`, xhigh). They verify +two Major findings, now corrected: + +- Compose selection reads the selected source before the hardened realm reader. + A temporary FIFO blocks both actual CLI entries before cancellation can run. + The shared first read now uses one no-follow, nonblocking, owner-only regular + file handle and caps bytes read even if the file grows. Six actual-entry + controls cover FIFO, symlink and oversized sources for seed validation and + reconciliation; every refusal exits naturally with no signal or child command. +- The first census misses qualified collection references and other explicit + fixture types. Its declaration-bound replacement recognizes the repository's + explicit `IClassFixture` classes and members of `ICollectionFixture` + collections, excluding comments, literals and unrelated declarations. Unknown + or ambiguous fixture forms refuse. All 116 such classes are included; manually + constructed fixtures are outside this source-declaration census. This is not + a measurement of fixture starts or cost. + +On the same historical 5,902-case manifest, the corrected fixture witness covers +2,527 ordinary cases / 1,147 methods; 2,338 cases / 1,057 methods change owner +across A/B. The census correction changes no case assignment, source timing or +weight. Its plan digest is +`cbe9f7086f0304aa1d8483aba04ba8feb9b462c9468b085f2fec1fcadaa978f5`. + +The updated Node owner passes 240 cases (30/41/56/95/18), including the six +actual-entry controls. Full guarded frontend verification passes 1,735 cases; +typecheck passes. CI controls pass 59 shard, nineteen aggregate and six Node-gate +cases; source topology remains complete. The provider reviewer independently +passes seven offline transport/export/backchannel controls and both Docker +census architecture cases. The mutable root status now distinguishes earlier +completed step reviews from this correction gate. Round two and full +frozen-source hosted A/B qualification remain pending at this checkpoint. + +**Independent correction round two.** Three different fresh reviewers inspect +`eb707ee8..837d9d50`: security/runtime and CI/provenance (`gpt-6-astra`, xhigh) +and provider/corpus (`gpt-6.1-sol`, xhigh). The provider/corpus review approves; +the other two verify one Major each. Their corrections preserve the plan and +all existing execution/cleanup budgets: + +- A collection-attribute alias can hide the marker used by the source census. + Local and separate global-using aliases now explicitly refuse before the + marker fast path; an executing control covers both attribute types. The + 116-class census, plan digest and assignments remain unchanged. The shard + control suite now passes sixty cases. +- Demo's early context check precedes Compose's removal of inherited keys + assigned by the private source. It can therefore inspect a different Docker + environment from the eventual stack. A second five-second check validates + the exact selected environment before every Compose command. Two actual-entry + lifecycle controls fail before the fix and pass after it. The same comparison + exposes seed's reintroduction of inherited `HOME` into its inspection; that + probe now also uses the exact selected environment. Its additional regression + fails before the one-line fix. Docker's + [CLI configuration and context precedence](https://docs.docker.com/reference/cli/docker/) + make these inputs relevant to engine selection; no real Docker or private + environment is used by the fault controls. + +Combined verification passes 240 Node and 1,738 guarded frontend cases, with +zero skips/todos; typecheck, lint and source topology pass. The independent +provider reviewer again passes seven offline cases and two Docker-census +architecture cases, without claiming live-provider execution. Independent +focused verification of these final corrections and full frozen-source hosted +A/B qualification remain pending. No historical failed layout is relabelled. + +**Final focused correction verification.** Two further fresh independent +reviewers approve `f09b16d1`: CI/provenance (`gpt-6.1-sol`, xhigh) independently +reproduces the alias bypass on the prior source and its current refusal, then +passes 60 shard, nineteen aggregate and six Node-gate cases plus topology. +Docker-environment/lifecycle (`gpt-6-astra`, xhigh) independently passes all 38 +lifecycle cases; the real frontend report validator accepts their exact file +inventory with zero skips/todos. Both inspect the fixes without editing source. + +No measured failure justifies extending the operation, HTTP, fixture or CI +deadlines. Proposed aggregate parallelization adds no demonstrated benefit to +the retained 16/30s result; optional password/certificate policy expansion and +a second seed-wide lock do not fix an established defect in this boundary. +Already-rejected incompatible 409 readback remains rejected. Those suggestions +do not replace the verified ownership, target and creation corrections above. + +Local implementation, both full correction review rounds and focused fix +verification are complete before hosted qualification. Full current-source A/B +execution remains the next gate; the +[live PR checks](https://github.com/HodeTech/LearnStack/pull/34/checks) own its +current status. This source checkpoint claims no merge readiness, passing retry +or next-packet delivery. Source-bound hosted receipts are retained separately +from this local checkpoint. + +**Frozen-source A failure and diagnosis follow-up.** Run +[`39628288185`](https://github.com/HodeTech/LearnStack/actions/runs/39628288185), +attempt 1, tests `0c9d28768758aff8f350abbb5b09daf6a7f6e75a` through merge source +`8560880e96320ce82a7eb3b2f124dba8b71d1668`; both have tree +`b71883fd423567db55b48c5b74cdad6b3bbd647a`. All 27 jobs finish: eighteen pass, +seven execution owners fail and both dependent aggregates refuse. No B attempt +or failed-only retry follows this failed A. + +- Base discovery contains 5,907 cases: 2,915 base, 433 Architecture, 2,528 + ordinary and 31 native. The manifest digest is + `187da443e8fc5e3b179714fe7bf8f718ae4a246a7d1ad7ea9d13d8cce5073d34`; + the plan digest remains + `cbe9f7086f0304aa1d8483aba04ba8feb9b462c9468b085f2fec1fcadaa978f5`. + These discovery counts are not passing execution counts. +- The actual genericity assertion rejects a demo username in the manifest + projection. A local run reproduces it. The projection now binds a legacy + identity to its complete declared actor, tenant, username and legacy attribute + in `SeedData`, without a demo-name branch or guard exception. Eight affected + Architecture cases and all three offline export cases pass; unique-username + swaps, tenant rebinding and a fresh valid UUIDv7 actor are rejected. +- Six ordinary owners retain eighteen failed Keycloak cases with 1 ms result + durations. Their existing closed fixture-startup message is absent from + retained diagnostics. Failed ownership receipts report forced cleanup without + a Docker-absence witness; neither a provider leak nor a startup cause is + inferred from those fields. An unchanged owned Linux fixture starts in + 21.665s with its original two-minute cap, strict TLS and actual loopback + binding checks. That ARM64 container probe does not reproduce the hosted + x64 VM or its load, and does not establish the failed cause. +- Frontend, meta, OpenAPI, secret scan and the production demo pass. Frontend + executes 1,738 cases; meta executes 240 Node cases. Its sixty combined CI + controls comprise **41 shard/plan and nineteen aggregate controls**; running + the aggregate module separately repeats those nineteen, not nineteen new + controls. The demo retains nine pages, 27 report pairs, nine screenshots, + actual TLS/timeout refusals and complete owned cleanup. These successes do + not qualify the failed backend execution or the whole source. + +The bounded diagnosis follow-up preserves only the fixture's existing finite +startup stage/reason/cleanup sentence for five explicit integration classes. +It rejects unknown, ambiguous and oversized messages and never exports raw +provider text. Cleanup diagnostics do not supply a cleanup witness; test +outcomes, complete case/result guards and failure status remain unchanged. +This uses ADR-0063's existing sanitized finite-fault seam, with no new authority, +timeout, selection or admission policy. Current-head full A/B qualification +remains open until actual complete executions pass. + +**Diagnosis follow-up reviews.** Two independent reviewers complete each of two +rounds on `5fbbf093`: first genericity (`gpt-6.1-sol`, xhigh) and diagnostics +(`gpt-6-astra`, xhigh), then fresh genericity (`gpt-6-astra`, xhigh) and diagnostics +(`gpt-6.1-sol`, xhigh). All approve their scoped changes without an actionable +finding. The final diagnostics reviewer independently executes all three new +offline controls. Root verification passes all 43 launcher controls, sixty +combined shard/aggregate controls, the eight affected Architecture cases, three +offline export cases and five corpus cases, with zero skips. No local check or +review establishes the hosted startup cause; a new complete frozen-source run +remains necessary. + +**Second frozen-source A and provider classification.** Run +[`39633149831`](https://github.com/HodeTech/LearnStack/actions/runs/39633149831), +attempt 1, tests `6109268ff5b8ba19d71ed569682e763256e8f903` through merge source +`31f701a48fa9ae407b69c3ad0f6e4e43e2c8fdbc`, with identical head/merge tree +`468302f4ef3520fb80cc59467fb99d8434170b0d`. All 27 jobs finish: nineteen pass, +seven ordinary owners fail and integration refuses. Backend aggregate and all +six Architecture owners pass, confirming the corrected genericity boundary. +Frontend/meta/contract/secret/demo successes retain their own current-source +API-bound receipts; no B or partial retry is started on this failed A. + +All twenty failed Keycloak cases now retain the exact closed diagnostic +`database/provider_error/completed`: nine issuance, ten reconciliation and one +self-service case. Every failed owner archive is checked against its immutable +API ID, digest and payload identity. This proves the diagnosis channel, not the +underlying provider cause or Docker absence. A small owned Linux batch starts +six PostgreSQL containers concurrently (three exact custom-network/loopback +fixtures, three default-network controls) in 6.85–7.61s; all containers and +networks are disposed and independently absent. It does not reproduce the +hosted failure. + +The pinned Testcontainers path checks readiness inside the PostgreSQL container, +without a host Npgsql/TLS login. Normal not-ready results retry; the generic +provider code cannot establish a readiness deadline failure. The next narrow +diagnostic distinguishes Docker API status, resource-reaper initialization, +socket and filesystem failure families without using any provider message or +response body. The pinned resource-reaper exception discards its inner cause, +so its code does not invent a timeout/cancellation distinction. The +[Docker exception](https://github.com/testcontainers/Docker.DotNet/blob/1e4015a84fa48cbcfe9002ecc4e2cf14177edc2d/src/Docker.DotNet/DockerApiException.cs) +and [resource-reaper exception](https://github.com/testcontainers/testcontainers-dotnet/blob/37352559c0af87d5c7eeaced1accc2d21e3a4e69/src/Testcontainers/Containers/ResourceReaperException.cs) +are checked at the installed package repository commits. Existing startup, +HTTP and job deadlines, cleanup and failure outcomes remain unchanged. One new +offline canary control checks the classifier and its unknown-message fallback; +it is diagnostic proof, not a provider repair or hosted qualification. + +The first classifier source checkpoint passes seven offline cases and 43 owner +controls; two first-round reviewers approve its privacy and diagnostic logic. +A subsequent root Architecture run exposes the static fixture reference in the +offline control: six cases pass and the Docker-trait sweep fails. The pure +classifier now lives in `KeycloakStartupFailure`; neither the offline test nor +its helper starts or owns a fixture. The guard is unchanged, with no exemption +or misleading Docker trait. The corrected source passes all seven offline +cases with warnings as errors, all seven corpus/Docker-sweep cases and all 43 +owner controls. Final independent verification follows before a new hosted run. + +Two final second-round reviewers (`gpt-6.1-sol` and `gpt-6-astra`, high effort) +approve the complete `6109268f..dc25ebb0` correction, including the fixture-free +boundary, without a remaining actionable finding. Both independently build and +pass all four transport controls in Release with warnings as errors; the first +also passes all 43 owner controls, and the second all three focused Keycloak +Python controls. Both inspect the final seven-case offline and seven-case +Architecture receipts. The sixty combined plan/aggregate controls pass again. +This completes the narrow diagnostic review gate, not provider repair. New +complete current-source A/B qualification remains required. + +**Third frozen-source A and daemon-family diagnosis.** Run +[`39637263886`](https://github.com/HodeTech/LearnStack/actions/runs/39637263886), +attempt 1, tests `fddfc2665ae401e7c8d15bdce988be65f59b327d` through merge source +`567dce35b3b8eee5c79f808edb2338d8c7083728`, with identical head/merge tree +`f451491961a7e9cb33ca21ed4c46c5e55a27df1b`. All 27 jobs finish: seventeen pass, +nine ordinary owners fail and integration refuses. All 37 failed provider cases +retain `database/docker_server_error/completed`, establishing a Docker API HTTP +500 response, not its cause. Their nine archives have independently verified +immutable API IDs, digests and current-source payload identities. All five +auxiliary checks pass with their own source-bound receipts; these do not qualify +the failed integration result. No B or failed-only retry is started. + +The next diagnostic records last-reached PostgreSQL SDK creation/start/readiness +milestones. It privately classifies bounded, shallow HTTP 500 JSON with exactly +one string `message` into six fixed networking/task families; unknown or malformed +responses retain `docker_server_error`. It exports no daemon text, address, +port, path or exception type. Actual owned cleanup precedes classification, and +the diagnostic remains separate from cleanup witnesses and result acceptance. +The categories follow the pinned Moby +[network setup wrapper](https://github.com/moby/moby/blob/6430e49a55babd9b8f4d08e70ecb2b68900770fe/daemon/start_linux.go#L37) +and [port mapping implementation](https://github.com/moby/moby/blob/89c5e8fd66634b6128fc4c0e6f1236e2540e46e0/libnetwork/drivers/bridge/port_mapping_linux.go). +The hosted daemon version and actual response body remain unobserved. + +A source-level port-allocation hypothesis is not a provider repair: an isolated +unchanged-old/corrected-upstream allocator comparison detects mixed wildcard/ +specific-address allocation differences, but the surrounding TCP bind path can +retry collisions. It neither reproduces hosted HTTP 500 nor justifies changing +application fixture bindings. Startup deadlines, test selection, ownership and +failure outcomes remain unchanged. Root validation passes seven offline C# +cases with warnings as errors, seven corpus/Docker-sweep cases, all 43 launcher +controls and sixty combined shard/aggregate controls. Independent reviews and +actual hosted diagnosis remain required before this follow-up can claim +qualification. + +The first independent lifecycle reviewer (`gpt-6.1-sol`, xhigh) approves +`fddfc266..9bbf2a31` and independently passes all 43 launcher controls. The +privacy reviewer (`gpt-6-astra`, xhigh) reproduces a malformed-string fallback +gap: raw unpaired UTF-16 surrogates throw `ArgumentException`, unlike the +already covered escaped-surrogate form. Four before-red controls reproduce +this against the unchanged helper; the correction catches that specific +exception and adds all four controls to the existing offline case. This is a +bounded .NET string-contract correction, not a demonstrated HTTP decoder path +or the hosted Docker cause. + +One owned Linux/ARM64 startup at `9bbf2a31` uses byte-identical fixture/helper +sources and completes in 20.632s with the original 120s startup budget and +strict loopback checks. Awaited disposal and direct API checks confirm both +fixture containers, its network and the isolated runner absent. The successful +isolated startup validates the milestone subscriptions; it neither exercises +the failure classifier nor reproduces hosted load or HTTP 500. + +Two fresh second-round reviewers (`gpt-6.1-sol` privacy and `gpt-6-astra` +lifecycle, both xhigh) approve `fddfc266..fb4ab5eb` without an actionable +finding. They independently pass all seven offline C# cases with warnings as +errors and all 43 launcher controls respectively. The lifecycle review also +checks pinned event ordering, the seven-case Architecture receipt, actual +third-A source/job counts and all 37 retained closed failures, plus isolated +startup source hashes. Both preserve the distinction between a finite +diagnostic, actual resource absence and complete hosted qualification. These +two rounds complete the diagnostic correction review gate; a new frozen-source +full A remains necessary before B. + +**Fourth frozen-source A and publication repair.** Run +[`39642441764`](https://github.com/HodeTech/LearnStack/actions/runs/39642441764), +attempt 1, tests `4467492b363bdb8dd794721c58897d08a1a348c2` through merge source +`38117937fa67a4ecf680ee73e23689e3eda44bb9`, with identical head/merge tree +`b3ccb2ad280c6f6f9150e94dd70be4cc2aff92ee`. All 27 jobs finish: twenty pass, +six ordinary owners fail and integration refuses. All twelve ordinary owners +finish: six pass and six fail. Five failed owners retain eighteen provider failures at +`database_start/docker_port_listen/completed`: PostgreSQL creation succeeds, +then Docker reports a TCP listen failure. The sixth owner retains one distinct +54.013s public-admission failure at `browser 429/browser-discovery`. Each failed +archive has verified immutable API identity/digest, exact current head/merge +payload identities and the same 5,908-case discovery. These failures do not +authorize B, an old-artifact fallback or a failed-only qualification retry. + +The publisher census finds one mixed-address boundary in ordinary execution: +the application PostgreSQL fixture uses Docker's wildcard default, while both +identity publishers use `127.0.0.1`. The native proof PostgreSQL publisher +already uses loopback and runs in its separate VM. In the +[pinned old Moby path](https://github.com/moby/moby/blob/6430e49a55babd9b8f4d08e70ecb2b68900770fe/libnetwork/drivers/bridge/port_mapping_linux.go#L220), +allocation and bind are retried for dynamic ranges, but TCP listen occurs later +outside that retry loop. An isolated Linux socket experiment with unchanged old +and corrected upstream allocators forces both binds before either listen: both +mixed-address orders fail at the second listen with the old allocator; both +pass with distinct ports using the +[corrected allocator](https://github.com/moby/moby/commit/d6620915db0072817d1eccb41a765de69cb38e9c). +Same-address controls +pass on both. The non-root, network-isolated runner is removed and confirmed +absent. This establishes a mechanism, not the hosted daemon version, socket +errno or actual interleaving. + +The application publisher now uses the same loopback modifier and independent +configured/runtime inspection predicate as the identity publishers. Its live +inspection fails startup and disposes the owned container on a mismatch. +Ryuk startup/readiness precedes fixture creation, so its wildcard listener is +already listening before these fixture binds; ordinary bind-time retry remains +available. Roles, credentials, SQL commands, connection identities, test +assignments and deadlines are unchanged. A new application test requires the +actual loopback publication, and an offline control rejects wildcard/foreign +addresses, invalid mapped ports and extra publications. + +An identical isolated Linux application harness proves the change before/after: +the old publication check returns false, the corrected check true. Both runs +verify all four role postures and an actual `learnstack_app` login with neither +superuser nor bypass authority. Disposal is awaited; direct inspection confirms +the exact PostgreSQL container and runner absent after each run. These are two +real fixture startups with copied source/helper/roles-SQL hashes checked, not +a hosted repair or full regression claim. + +The distinct browser failure establishes only its closed discovery phase. The +[CDP contract](https://chromedevtools.github.io/devtools-protocol/index.html) +does not equate an advertised debugging port with an available page target. +Twenty exact-flag isolated local launches succeed immediately; no natural race +is reproduced. A constructed real-browser control suppresses the startup +window, observes an empty target list, then creates an owned page within the +original five seconds. The original single-read assertion refuses that valid +transition; the production readiness helper accepts it in 324ms. Owned browser +groups/profiles are cleaned. This proves a readiness gap, not the CI case's +raw cause; discovery also includes later WebSocket setup. + +The fixture now polls only successful, valid empty target lists. One unchanged +five-second deadline covers HTTP, body and polling; malformed, foreign, +non-page and transport failures are not retried. Owner closure, browser death +and closed diagnostic branding remain enforced. Focused validated JSON reports +pass 44 browser controls with zero skips/todos. Root verification passes all +1,756 guarded frontend cases in two packages, nine C# cases (eight offline and +the actual application publication test) with warnings as errors, seven +corpus/Docker-sweep cases, all 43 launcher controls and sixty combined +shard/aggregate controls. Scoped lint/typecheck pass. These receipts precede +the new independent correction review rounds and complete hosted qualification. + +The fourth A's five auxiliary checks independently pass on its frozen source: +frontend 1,738 cases, Node 240, launcher 43, combined shard/aggregate 60 and +Node-gate six; OpenAPI transport/fixture controls pass and SARIF has no findings. +The demo retains 27 hash-validated report pairs for nine pages, nine screenshots, +all visibility/TLS/timeout controls and complete owned cleanup. Its source-bound +Keycloak-stop/absence prerequisite is also verified. These successes qualify +their individual evidence, not the seven failed jobs or a later head. + +Two first-round reviewers approve `4467492b..1bf18b80` without an actionable +finding: security/ownership (`gpt-6-astra`, xhigh) and browser/proof correctness +(`gpt-6.1-sol`, xhigh). The first independently builds Release with warnings as +errors, passes the offline publication control and both Docker-trait guards, +and verifies the pinned inspection client's existing 100s timeout. The second +independently passes all 44 browser cases with the repository JSON validator. +Both inspect the full correction and preserve the limits on live author proofs, +constructed readiness evidence and unknown hosted daemon/browser causes. A +fresh second review round follows before the next frozen-source full execution. + +Two fresh second-round reviewers approve `4467492b..faa8c2b8` without an +actionable finding: security/publication (`gpt-6.1-sol`, xhigh) and browser/CI +proof lifecycle (`gpt-6-astra`, xhigh). The first independently builds Release +with warnings as errors, passes the offline publication case, both Docker-trait +guards and six real PostgreSQL cases covering publication, four role postures +and application-login privilege denial; exact PostgreSQL and Ryuk containers +are confirmed absent. The second independently passes all 44 browser cases +with validated JSON and checks the full correction's source/ownership boundary. +Neither promotes constructed examples to an observed hosted cause. Both review +rounds are complete; the PR's current-head checks and complete same-source A/B +receipts own hosted qualification. Seven required contexts, strict protection, +the existing shard plan and execution ceilings are unchanged. + +### Further setup and proof corrections — 2026-10-21 + +The complete A/B qualification at `abd45611` belongs to +[run `39647583156`](https://github.com/HodeTech/LearnStack/actions/runs/39647583156): +both full attempts pass all 27 jobs and all 5,910 .NET cases, with zero skips. +Independent verification checks 25 API-hash-bound archives per attempt, both +aggregate recomputations, identical source/toolchain/population/plan and changed +actual co-membership for Architecture, ordinary and shared-fixture methods. +Those receipts precede the following corrections and do not qualify a later head. + +- Compose, seed and reconciliation distinguish unsafe file properties with the + closed `preflight/private_file` reason and fixed owner/type/link/permission + guidance, including explicit `chmod 600` remediation. Files are not repaired; + paths and contents are not emitted. Oversized or malformed configuration + retains its existing configuration refusal. Existing real-CLI controls cover + the notice before commands, including FIFO/symlink refusal. +- The export drift predicate records malformed profile JSON as a violation. + Its added malformed-profile mutation first throws `JsonReaderException`, then + passes after extending the existing exception filter with `JsonException`. +- The old username swap does not create a duplicate. Duplicating legacy user 0 + onto legacy user 1 would still be masked by the legacy-identity guard. The + corrected control changes only nonlegacy user 3 to user 2's username; an + isolated exact-source mutant removing only username uniqueness admits it, + while the original predicate refuses it. +- The owned fixture CA/leaf expire after 25/24 hours, preserving both five-minute + backdated starts and all other certificate parameters. Isolated certificate + controls prove a trusted chain at +23 hours and leaf rejection after expiry; + the actual provider custom-CA/wrong-CA test also passes and its two owned + containers are independently confirmed absent. +- Testing Standards now states each integration population's actual dependency + requirement while retaining no mocked repositories and happy/edge coverage. + +Local validation passes all 240 source-censused Node cases, 1,756 guarded frontend +cases, three offline export cases and one real provider TLS case, with zero +skips/todos. Seven corpus/Docker-trait controls also pass. The isolated duplicate +and certificate harnesses each pass six +controls. Scoped formatting, lint and typecheck pass. Both fresh independent +correction review rounds remain pending at this implementation receipt. + +[ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) is a +Proposed dedicated carrier for the already approved 900-second development +issuance choice. The original phase approval remains valid; this new ADR needs +separate maintainer acceptance before its authority/cross-link changes. It does +not change issuance, API acceptance or the Open G13 session portion. + +#### First independent correction review + +Two new reviewers inspect `abd45611..47069be2`: `gpt-6-astra` at `xhigh` for +runtime/security/proof behavior, and `gpt-6.1-sol` at `high` for ADR/corpus and +approval provenance. The first finds no actionable runtime issue and independently +passes 74 owner/reconciliation Node controls, three export cases, 38 frontend +lifecycle cases and a Release build with zero warnings/errors; no skips/todos. +The second verifies seven ADR parser controls and all scoped links/fragments. +It finds one documentation overstatement: provider-only cases do not configure +application modules. The corrected population sentence requires committed realm +configuration for provider tests and application module configuration for the +combined provider/API cases. ADR-0067 remains Proposed with no verified finding. +The second fresh review round remains pending at this correction receipt. + +#### Second independent correction review + +Two different reviewers inspect `abd45611..d2467543`: `gpt-6.1-sol` at `xhigh` +for runtime/security/proof behavior and `gpt-6-astra` at `high` for ADR/corpus and +decision provenance. Both approve without an actionable finding. The first +independently passes 74 Node controls, three rebuilt export cases and 38 frontend +lifecycle cases, with zero skips/todos. Its isolated exact-source checks pass +four refusal/cleanup, six username-mutant and ten certificate controls, including +late validity, expiry, wrong-root/hostname refusals, SANs and retained algorithms. +The second passes all seven ADR parser controls, all 619 scoped relative +links/fragments and whitespace checks, and verifies the original phase approval +and pinned provider semantics. Both independent review rounds are complete. +ADR-0067 remains Proposed pending its separate maintainer approval; the earlier +hosted A/B receipts do not qualify this changed head. Current-head PR checks own +new hosted qualification; review/merge remain separate. + +### Dedicated issuance-carrier review corrections — 2026-10-21 + +The maintainer's review of Proposed ADR-0067 identifies the missing dedicated +authority in Security 11's header. The revised draft makes that rationale and +the acceptance action explicit: only after separate approval will the header +derive issuance from ADR-0067 and replace its draft/pending notices. The earlier +phase approval remains valid. Neither that approval nor this review makes the +new ADR Accepted. + +The draft corrects the pinned source anchors against raw Keycloak 26.6.2 lines, +clarifies the absence of an override on either named client and distinguishes +shortening a configured lifespan from reducing token production. It names the +planner's action/readback contract, the core's apply/verify loop and the shared +entry's qualification/ownership responsibilities separately. Dated adjacent +notes preserve the original package's no-new-ADR statement and correct the +historical source pointers without rewriting their original text. Both front +doors now link the Proposed dedicated carrier. + +The provider already copies access-token expiration into the paired ID token. +The draft explicitly retains that consequence, including session clamping; it +selects no independent ID-token or BFF session lifetime. Separate issuance +clocks still preclude exact `exp - iat == 900` assertions. Renewal demand, +refresh-custody/rotation ownership and explicit reasons to revisit the +development setting are recorded without changing the executable 900s value, +API acceptance or G13's Open session portion. + +Three existing real-provider Facts pass with zero failures/skips: fresh signed +issuance for all four identities, shorter late-session issuance/refresh and the +existing live mapper/scope/audience/TTL mutation case. Each positive ID token +is cryptographically validated before paired expiration is compared. An +in-memory ID-expiration control preserves the unchanged access token and both +fresh-window predicates while breaking only expiration equality. Against the +old profile predicate, the fresh-profile Fact produces the expected failed +TRX; the final predicate refuses the planted mismatch. This is a payload +predicate control, not a provider-signed altered token. The red run's exact +process exit code was not retained. Both final executions exit zero, their +owned process groups are absent, and all ten retained exact fixture container +IDs have destruction events plus direct-inspect absence. Existing Fact/Theory +declarations and the 13-case population are unchanged. + +[Run `39656889029`](https://github.com/HodeTech/LearnStack/actions/runs/39656889029) +passes at the preceding `61924f0f` source. That earlier result neither qualifies +these corrections nor supplies a second assignment for their source. Local +validation passes seven corpus/Docker-trait cases, seven ADR parser controls, +all 676 scoped relative links/fragments and whitespace checks. Two fresh +independent correction review rounds follow this implementation receipt; +current-head qualification and ADR acceptance remain separate. + +#### First independent issuance-carrier review + +Two new reviewers inspect `61924f0f..d0528d16`: `gpt-6-astra` at `xhigh` for +security/test-proof behavior and `gpt-6.1-sol` at `high` for ADR/corpus and +approval provenance. Both approve without an actionable finding. They verify +the actual predicate and signed-token call sites, unchanged 13-case population, +raw pinned source lines, reconciliation responsibilities and Proposed-only +authority. The first independently confirms all ten exact fixture containers +absent and inspects the retained red/green summaries. The second independently +passes seven ADR parser controls; both pass whitespace checks. Neither reruns +provider execution or claims hosted qualification. All review subprocesses +finish. A second fresh independent round follows before correction closeout. + +#### Second independent issuance-carrier review + +Two different reviewers inspect `61924f0f..7bb9a3ee`: `gpt-6.1-sol` at `xhigh` +for security/test-proof behavior and `gpt-6-astra` at `high` for ADR/corpus and +approval provenance. Both approve without an actionable finding. They verify +clock boundaries, signed-token validation, the independent in-memory expiration +control, exact pinned upstream lines and the unchanged 13-case population. +Both independently pass seven ADR parser controls and whitespace checks. The +first checks retained TRX counts and directly confirms all ten exact fixture +containers absent. No build/provider execution is repeated; all review +subprocesses finish. Both independent correction review rounds are complete. +ADR-0067 remains Proposed awaiting its own maintainer acceptance. The authority +header/index actions remain gated; these local results do not claim current-head +hosted A/B qualification or permission to merge. + +### ADR-0067 acceptance and carrier reconciliation (2026-10-21) + +The maintainer explicitly approves the revised ADR-0067 after the completed +correction reviews. Its lifecycle is now **Accepted — 2026-10-21** and its single +index row moves from Proposed to Active. Security 11 derives development +issuance from this ADR while preserving the earlier package approval and +ADR-0064's independent API acceptance boundary. Draft/pending authority notices +are removed from current guidance; both front doors record separate acceptance. + +Dated adjacent notes reconcile the package's vehicle, G13 answer, test-source +and implementation handoff passages without rewriting historical approvals or +Proposed-state reviews. The existing catalogue source/header gains ADR-0067; +canonical obligations, counts, owners and implementation statuses are unchanged. +Architecture 13 and the Keycloak guide retain Security 11 as the ongoing-rule +pointer and distinguish the dedicated issuance decision from phase provenance. + +No runtime, executable realm value, credential, API policy, BFF session decision +or CI/shard setting changes. The earlier actual-token and hosted receipts remain +bound to their recorded sources. Local validation passes seven corpus/Docker- +trait cases, seven ADR parser controls, all 1,439 scoped relative links/fragments +and whitespace/wrap checks. The parser reports Accepted and the index contains +one Active ADR-0067 row. Two fresh independent acceptance-carrier review rounds +follow before publication. + +The preceding [run `39664005457`](https://github.com/HodeTech/LearnStack/actions/runs/39664005457) +at `c33e529e` fails ordinary-6 and the integration aggregate. Its cause is under +separate investigation; this documentation acceptance does not qualify or waive +that failure. + +#### First independent acceptance-carrier review + +Two new reviewers inspect `c33e529e..03ebd77c`: `gpt-6-astra` and +`gpt-6.1-sol`, both at `high`. Both approve without an actionable finding. +They independently verify the single Accepted lifecycle/Active index row, +completed authority references, unchanged approved semantics and catalogue +obligations, and preserved original phase history. Whitespace, added-prose and +tracked-worktree checks pass. No build, provider execution or hosted +qualification is repeated; all review subprocesses finish. A fresh second +round remains required before acceptance-carrier publication. + +#### Second independent acceptance-carrier review + +Two different reviewers inspect `c33e529e..94618013`: `gpt-6.1-sol` and +`gpt-6-astra`, both at `high`. Both approve without an actionable finding. +They independently verify lifecycle/index consistency, complete authority +references, unchanged obligations and preserved original approval chronology. +The second passes seven ADR parser controls; both pass whitespace and clean +tracked-worktree checks. No build, provider execution or hosted qualification +is repeated; all review subprocesses finish. Both fresh independent +acceptance-carrier review rounds are complete. + +The preceding CI triage independently verifies the ordinary-6 archive against +GitHub's digest and finds 224 completed cases: 223 pass and one existing +background-job cancellation/restart case fails. The failed variant has +`independentFailure = false` and `lieOnlyOnReconcile = true`; both sibling +variants pass. Its sanitized TRX has no error payload and the closed failure +location projection retains no location for this source. Neither a timeout nor +an identity regression is established. The integration aggregate correctly +refuses with `producer_not_successful`; the missing aggregate upload is +consequential. The failed receipt is preserved, without a guessed runtime fix. + +#### Closed lifecycle failure-location follow-up + +Inspection confirms that `BackgroundJobPositiveSdkTests.CausalRestart.cs` is +absent from the launcher's finite source-code projection. The correction adds +only that literal filename as `positive_sdk_causal_restart`. When VSTest supplies +that stack location, existing parsing retains its closed code and bounded line +number; it still emits no raw stack, path, exception or provider message. +This enables future diagnostics without claiming to reconstruct the lost +assertion or fix the observed lifecycle failure. Execution, success witnesses, +aggregate refusal and all time/selection budgets are unchanged. + +The existing projection control plants that filename beside allowed/unknown +sources, duplicates, private canaries and an out-of-range line. It first fails +because the causal-restart location is missing, then all 43 offline launcher +controls pass after the exact mapping is added. Source-inventory and per-entry +projection controls cover the same new literal. No new test case, host mutation, +provider run or hosted qualification is claimed. Two fresh independent reviews +of this diagnostic correction follow before publication. + +**Diagnostic correction, first independent round.** Two new reviewers inspect +`330a8e2e..4cb69463`: `gpt-6.1-sol` at `high` and `gpt-6-astra` at `medium`. +Both approve without an actionable finding. The first independently passes all +43 offline launcher controls; the second passes the focused projection control +and independently makes it fail by removing only the new mapping in memory. +They verify literal-source/line bounds, deduplication, finite output and raw +error/path exclusion. Whitespace and clean tracked-worktree checks pass; all +review/fixture subprocesses finish. No build, provider or hosted execution is +repeated. A fresh second diagnostic review round remains required. + +**Diagnostic correction, second independent round.** Two different reviewers +inspect `330a8e2e..e7a5bf04`: `gpt-6-astra` at `medium` and `gpt-6.1-sol` at +`high`. Both approve without an actionable finding. Each independently passes +the focused projection and removed-mapping negative; the second also passes all +43 offline controls and literal/path/line/trace/output-boundary probes. The +launcher is byte-identical to its baseline after removing the one added mapping. +Whitespace and clean tracked-worktree checks pass; all review/fixture +subprocesses finish. Both diagnostic review rounds are complete. No provider or +hosted qualification is claimed; the failed lifecycle receipt remains unwaived. + + +### P02b-6 additional verification and correction pass (2026-10-22) + +**Status: implementation and local validation complete on `development`.** +Source-bound reviews and correction evidence are recorded below. +[PR #34](https://github.com/HodeTech/LearnStack/pull/34) records current-head +hosted qualification and maintainer review; merge remains separate. External +review assertions are hypotheses until checked against current source or retained +execution evidence. They do not authorize policy changes, host mutations or +cleanup of another task's resources. No next packet or merge starts here. + +The preceding source `57772a2a` has a complete successful hosted execution in +[run `39668841753`](https://github.com/HodeTech/LearnStack/actions/runs/39668841753), +attempt 1: all 27 jobs and seven required contexts pass. Independent artifact +verification binds ordinary-6's 224 distinct passing records to that source; +all three cancellation/restart variants pass, in approximately 24.3–24.5 seconds. +This does not explain or erase earlier failed variants. The failed receipts and +missing error payload are linked by the preceding triage and diagnostic record; +a green run is neither a root-cause analysis nor a reason to relax a deadline. + +ADR-0063 requires materially different full A/B assignments for rollout and +plan/algorithm/fixture-lifetime changes. Normal later pushes require one complete +assignment. Earlier references to current-head A/B are qualification requests for +their recorded changes, rather than an additional permanent requirement on every +push. The `abd45611` A/B receipts remain historical and source-bound. Advisory +weights and source provenance have changed during this packet; unchanged VM counts, +aggregate names and execution ceilings do not mean the plan file is unchanged. + +#### Verified corrections and retained boundaries + +- Actual Compose argv selects a nonsecret, process-only web-secret placeholder + only for recognized lifecycle commands and exact `config --services`. + Container-capable, ambiguous and full-config invocations remain strict. + Private-file ownership/type/link/permission checks remain mandatory: lifecycle + parsing is not permission to read an unsafe secret file. No source file or + provider credential is changed by the placeholder. +- Seed recognizes only locally typed, fixed Compose-selection notices. Demo + setup cancellation exits 130 after owned cleanup; command/readiness deadlines + retain their closed reason. Arbitrary provider/configuration text remains + outside public diagnostics. +- Migration credentials are removed from the inherited environment before tool + restore/build and supplied only to no-build EF children. The migration skill + uses that same environment-only pattern instead of a secret command argument. +- `public-env` qualifies, reads and writes one no-follow descriptor. It refuses + unsafe mode, ownership, link count, file type and size before reading, and no + longer follows a symlink or silently changes permissions. Copied-CLI controls + cover successful preparation, retained secrets, truncation and refusals. +- Node controls exercise forbidden credential destinations, exact issuer/custody, + independent callback/password-grant repair, username query behavior, actor + collisions, created-account checks and lost-write readback. The inherited-secret + exclusion control now contains matching and unrelated canaries. These are + behavioral checks; census counts alone cannot establish assertion adequacy. +- The actual production workflow validator rejects disabled, nonblocking, missing + or duplicate realm-test gates. Offline mutants call that validator; testing + command-string presence alone is insufficient. +- Actor-format and scope-collision export controls use nonlegacy personas so the + separate legacy identity binding cannot mask the rule under test. The latter + retains its own deterministic replacement-actor specimen. +- Failure receipts add only recognized, finite top-level adapter exception kinds. + An unlabelled xUnit assertion or unknown exception remains unclassified; no + message-text guess, raw exception, provider payload or new success witness is + introduced. This improves future diagnostics without diagnosing a lost error. + +#### Findings requiring no new runtime policy + +Proxy casing variants, private CA/package-cache prerequisites, Docker context/config +handling, transport-abort remapping, lock-initialization cleanup and certificate +validity had already been corrected. Existing ID-token expiry, independent expiry +predicate, session-clamp and live TTL controls are retained. The issuance proof uses +its measured clock window and separate readback; it does not promise exact +`exp - iat == 900` or replace that contract with a guessed five-second interval. +The production backchannel's certificate/hostname checks remain intact. + +The committed BFF example is public development data, whereas a missing runtime key +has no automatic fallback; the guide now states both facts. Automatic rotation, +removing the retained API service account/credential, narrowing the accepted full +scope or removing retained provider features are policy changes, not demonstrated +regressions. The approved account-console capability has its own dated maintainer +acceptance above. LearnStack application permissions remain separate. + +Redundant protection mutants need not all fail independently when another operative +check enforces the same refusal. The declared test-file census does not claim to +detect an empty assertion body. A named legacy migration literal is bounded tooling +data, rather than evidence of a domain-specific application branch. Broad historical +CLAUDE consolidation is outside this minimal correction; history is preserved. +Raw historical review transcripts are not committed evidence. Dated summaries and +source commits record the available scope/results; this pass does not invent missing +transcripts or infer a review's validity from elapsed time alone. + +Fresh independent correction rounds and source-appropriate hosted qualification +follow the implementation receipts below. Author checks are not independent reviews. + +[ADR-0068](../decisions/0068-development-warm-realm-qualification.md) is a +Proposed warm-realm correction. Acceptance would change first-write ordering and +permit three independently qualified stock-console assignment detachments. Separate +approval is required before dependent implementation. Existing visibility and +account-console refusals remain active; approval would not itself +prove complete stock-main upgrade compatibility. + + +#### Initial correction validation and tooling review + +The lifecycle correction is committed as `7e8c54f7`. Author controls pass 187 +lifecycle/migration cases and ten held-file CLI cases. Two fresh first-round +reviewers inspect `57772a2a..7e8c54f7`: `gpt-6-astra` and `gpt-6.1-sol`, both at +`high`. They independently pass twenty file/migration cases and 177 demo cases, +respectively; the latter validates JSON with the repository report guard. The +security review demonstrates one additional defect with a fake `dotnet`: the +skill's explicit build can fail and still reach `--no-build` EF in a shell +without errexit. The corrected example uses a subshell and guards build success; +three isolated shell controls prove no EF after failed build, EF-only credential +custody and preserved build/EF failure statuses. No real database is used by +those shell controls. The complete correction still needs its fresh full reviews. + +The expanded author validation passes **251 source-censused Node cases**, all +**43 launcher controls**, six Node-gate controls and sixty combined +shard/aggregate controls. Root validation passes **1,801 frontend cases** in two +packages with the actual repository guard, zero skips/todos. Fresh Integration +and Architecture Release builds produce zero warnings/errors. All three offline +export cases and seven corpus/Docker-trait cases pass. + +Three separate serial testhost invocations execute all three existing native +cancellation/restart variants: **nine passed, zero failures/skips**. Command +wall times are 76.669s, 74.219s and 85.571s, including fixture/runner work; these +are not per-case durations. The same three exact case IDs occur in every run. +All testhost process groups join, and direct inspections confirm absence of +each invocation's two exact created container IDs. The first immediate readback +still finds one owned container; later reads of those same IDs confirm absence. +That first observation is retained, not rewritten as immediate cleanup. No +foreign-resource inventory or cleanup, runtime fix, retry-until-green, deadline +increase or historical root-cause claim accompanies these results. + + +#### Complete correction reviews + +Two fresh reviewers inspect the complete `57772a2a..dbc8e025` correction, +including the guarded migration example: `gpt-6-astra` and `gpt-6.1-sol`, both at +`high`. Both approve without an actionable finding. They independently pass all +251 Node cases, 43 launcher controls and six Node-gate controls; the proof reviewer +also passes the sixty combined shard/aggregate controls. The separately executed +nineteen aggregate cases are included in sixty, not additional coverage. In +isolated source copies, the new username/census controls pass clean and fail when +their respective production `exact=true` or `exact=false` parameter is removed. +All review subprocesses join; no provider or .NET execution is repeated. + +Two different fresh second-round reviewers inspect that same complete correction: +`gpt-6.1-sol` and `gpt-6-astra`, both at `high`. Both approve without an actionable +finding. The security reviewer independently passes 197 focused frontend controls +across the four affected suites. The proof reviewer passes 251 Node, 43 launcher, +six gate and sixty combined controls. Its initial hyphenated-file `unittest` +discovery executes zero cases and is excluded from evidence; direct script runs +supply the recorded results. All review subprocesses join. Proposed ADR-0068, +its later documentation revision and new hosted qualification are outside these +code approvals. Both full correction rounds are complete; none establishes the +historical cancellation failure's cause. + +#### Historical-realm and account-capability feasibility + +A separate private archive at `57772a2a` uses the existing owned PostgreSQL-backed, +TLS-qualified Keycloak 26.6.2 fixture. The historical tenant import is exactly +`24705a7a`'s export, SHA-256 +`39659a4e505c8ad3c1b2963e69e58dadb725e4fb37b408dcf87214a2bb0d2d07`. +Temporary measurement source and raw output remain private; these are decision +support probes, not committed regressions or automatic-path qualification. +Intermediate harness compile/assertion failures are not counted as product defects +or passing qualification. No private workstation environment is read or changed. + +The original visibility guard refuses `plan/identity_conflict`, zero writes. +Test-owned append of three canonical admin-only definitions has independent full +profile deep-equal readback and reveals the existing legacy attribute. The tool +then refuses `plan/unsupported`, again before ordinary mutations. Assignment +summaries omit protocol; full by-ID reads identify six default and five optional +OIDC scopes. A SAML explanation is not the observed result. The current +bootstrap-parity control derives from today's export and cannot establish this +historical bootstrap path. A separate exposed-conflicting-actor probe preserves +the original account/credential and still refuses with zero reconciler writes. + +An initial feasibility variant manually detaches all eleven console assignments, +then the unchanged reconciler completes thirteen mutations and a zero-write rerun. +One legacy provider ID and its complete credential hash remain unchanged. Actual +code/S256 exchanges validate signed identity/scope for all four personas; console +code/S256 and Account REST GET succeed. Its one Fact passes in 29.52s; TRX SHA-256 +is `1d6f21549028cd903a1d276bb3df480163cbf45507b1c0dcab218ea5710b6a64`. +Both exact fixture containers and the network are directly confirmed absent. + +The narrower follow-up removes only default `roles` and optional `microprofile-jwt` +and `offline_access`. A private copied account module resolves full scope IDs and +binds ID/name instead of assuming summary protocol. Five default and three optional +assignments remain. Thirteen ordinary mutations, preserved legacy ID/credential +hash, zero-write rerun, four strict signed personas and console Account GET 200 all +pass. Its one Fact takes 28.95s; TRX SHA-256 is +`d205a91fce22cfdfd730994b0ddd3dd215398d354bd5855c76ad11478f3cd255`. +The measurement-source hash is +`6224f96197cde534084e00c1073bb3cd32387ccb08e2d71916bdce80800eed70`; +the private account-module variant hash is +`b7db43895726f3964f8da64686167677f5b4fc56c8c611e78dfc6a7e00d22c59`. +Both exact fixture containers and network are directly confirmed absent. This +variant is not the unchanged reconciler. It does not prove automatic preparation, +canonical-stock discrimination, planned DELETE ordering, uncertainty recovery or +preservation for every customized realm. ADR-0068 requires those regressions after +approval; its proposed three-edge path preserves compatible remaining scope state +and needs no durable continuation marker. + +A fresh-import BFF-token probe independently demonstrates Account REST GET and +ordinary profile POST, with stored-field readback. A protected actor replacement +does not persist. Removing the owned account capability and obtaining a new token +produces Account REST 401. The explicit JSON Accept header matters: a preceding +HTML/405 observation is not an account-authorization proof. Under current profile +settings, an ordinary email POST returns 204 but leaves email, username and actor +unchanged; original login and zero-write reconciliation still work. Pinned +Keycloak's ACCOUNT email condition explains the read-only projection. This does +not qualify the separate UPDATE_EMAIL required-action journey. Existing provider +self-service capability remains accepted; automatic email rebinding or a narrower +BFF token capability is not introduced. Its exact fixture pair/network are absent. + + +#### ADR-0068 proposed correction plan and first review + +The maintainer decision covers ADR-0068 and these three correction steps together. +They are Proposed; no dependent runtime starts before approval. Each implemented +step receives a commit, two fresh independent review rounds, verified fixes and +another commit before the next step proceeds automatically. + +1. **Qualified console graph.** Record acceptance and pending catalogue extensions + while retaining shipped Implemented coverage; + add the immutable historical fixture/canonical bootstrap expectations. Resolve + assignment IDs through full representations, preserve compatible graphs and + plan only the three qualified detachments. Prove custom/alias/category/role/ + mapper refusals, partial last-edge recovery and existing grant preservation. +2. **Owned visibility preparation.** Reuse all identity-independent qualifiers + before one absent-definition preparation within the existing lock/deadline. + Add exact trigger/stage/write accounting, durable-progress notices, exact + profile readback and a discarded/rebuilt full plan. Preserve readable prior + definitions during preparation; retain any separately planned ordinary PUT. + Prove real stock upgrade with changed credentials/MFA and hidden-actor + refusal after metadata only; exercise malformed/oversized/uncertain progress, + cancellation and explicit rerun without automatic replay or identity overwrite. +3. **Complete integration and qualification.** Exercise both seed/demo entries, + four-persona signed API issuance and manifest/nonmanifest console self-service, + all protected attributes and shared claim changes. Reconcile catalogue owners, + counts, guides and case/plan evidence. Run source-appropriate complete hosted + qualification under ADR-0063, including full A/B where its change triggers apply; + all seven checks and existing ceilings remain required. No merge or next packet + is authorized by this plan. + +Two fresh first-round reviewers inspect the revised three-edge proposal and its +carriers at `2ef9cf97`: `gpt-6-astra` at `xhigh` for security/design, and +`gpt-6.1-sol` at `high` for governance/proof honesty. Both approve as Proposed +without a verified Blocker/Major. The governance review finds three long prose +lines; they are reflowed without policy changes. It independently passes seven +parser controls, 731 links/fragments and whitespace checks, and verifies the +historical export hash and exact phase quotes. The design review confirms the +subset/last-edge compatible handoff and no additional Minor. All subprocesses +join; neither reviewer repeats provider execution or grants implementation approval. + +Full frontend typecheck independently finds three fixture-only child environments +missing Next's required `NODE_ENV`. The correction explicitly supplies `test` +without inheriting credentials or changing production filtering. Complete frontend +typecheck and lint pass; the four affected suites pass all 197 cases. Fresh +independent checks of this small correction follow before publication. + +#### Final proposal and fixture correction reviews + +Two first-round reviewers inspect the three isolated `NODE_ENV` additions and +proposed plan at `55ff0fe5`: `gpt-6-astra` at `xhigh` and `gpt-6.1-sol` at +`high`. Both approve the fixture correction and approve the plan as Proposed, +without an actionable finding. The latter independently passes seven parser +controls, 306 relative links/fragments and whitespace, and confirms all three +prior wrapping corrections. No frontend execution is repeated by these reviewers. + +Two different fresh second-round reviewers inspect the complete final ADR-0068, +carriers, plan and fixture correction at that same source: `gpt-6-astra` at +`xhigh` and `gpt-6.1-sol` at `high`. Both approve as Proposed, and separately +approve the fixture fix, without an actionable finding. They verify the exact +phase replacements, stock-ID qualification, compatible-mode priority, partial +last-edge recovery, preserved budgets and pending automatic proof. The governance +review independently passes 729 links/fragments and whitespace; both verify the +historical export hash and pinned provider premises. All review subprocesses +join. No build, provider or full frontend execution is repeated. + +Both fresh independent rounds for the small fixture correction and final proposal +are complete. Author verification passes seven parser controls and all 1,065 +scoped relative links/fragments; ADR-0068 still parses as Proposed. Its decision +and three-step implementation plan require separate maintainer approval. The +existing guards remain active; these reviews do not grant runtime authority or +qualify the final source on hosted CI. Publication and its complete current-attempt +assignment follow independently; historical green receipts remain source-bound. + + +#### Maintainer follow-up findings and conditional acceptance authority + +The maintainer directs verification of the remaining findings, correction of +still-valid issues, then acceptance and implementation without another approval +request. This is conditional authority, not a claim that outstanding checks or +new runtime have already shipped. + +The metadata-only first draft's missing second obstacle is superseded by the +current three-edge decision and the two existing phase feasibility receipts. +Those receipts remain private decision-support experiments: the automatic path, +canonical discrimination and actual self-service POST still need committed proof. +The read-only assignment-summary defect is independently corrected under the +existing compatible-scope contract; it grants neither preparation nor DELETE. + +Verified design corrections require every identity-independent refusal/capacity +check before preparation, an exact missing-definition/unmanaged-policy trigger, +admin-view-only preservation, a closed preparation stage and durable-progress +notice, and accounting for preparation plus a separately planned ordinary profile +PUT. All other Admin REST writes are ordinary mutations. The exact three DELETE +paths/status, baseline extraction/regeneration prohibition, opt-in/partial/manual/ +temporary-visibility alternatives and provider-change/retirement triggers are +explicit. Existing Implemented catalogue status/counts remain; acceptance adds +pending extension paragraphs and precise Asserts boundaries instead of erasing +shipped coverage. No universal console role-loss or relative BFF security claim +is inferred without token evidence. + +Raw 26.6.2 source confirms that browser-rendered line indices omit blank lines. +The earlier review's substantive premises hold, but several GitHub line anchors +were wrong. The current draft and mutable guide correct physical source spans; +previous dated review receipts remain intact. The implicit-to-stored profile +configuration consequence and shared-budget worst-case refusal are stated. +A fresh source-bound token/action measurement follows; it cannot replace final +automatic-path qualification. + + + +#### Additional console measurement and independent compatible-read correction + +The independent compatible-read correction is `25de7a61`: full scope GETs bind +summary ID/name/protocol before existing allowance/mapper checks. The provider +fixture now models actual two-field assignment summaries. Author validation +passes 256 source-censused Node cases and six gate controls; no preparation or +DELETE authority is added. Two fresh correction review rounds follow. + +A new private measurement imports the same exact historical export into pinned +26.6.2 and captures 83 endpoint representations, including full console scope, +mapper, allowance and referenced role/composite state. Graph SHA-256 is +`183f1790db8e500a04f8fd16bde0f105455d63bc143b99d7dc8d04106b5d0881`; +measurement-source hash is +`cb50701c8bb182ca16b93e31f67dfb36f86b7f8d00b956b8dd91de24725a40fd`; +the console receipt hash is +`489189669f7e1e548d537b8126e610d5e8cac1a3cb8387c95cd063f38b8796df`. +This captured input is not a qualified production baseline or automatic-path proof. + +For the two existing users, stock `openid` access has seventeen claim keys, no +audience and no realm/client roles. The three test-owned detachments leave it +unchanged. ID tokens retain nineteen keys and console audience. Explicit +`microprofile-jwt` adds only `upn` before detachment; afterward authorization +refuses that scope. Both stock clients directly report full scope false. After +thirteen ordinary writes, all four console access tokens add only the observed +account audience and `manage-account`/`manage-account-links` role projection; +ID claims remain unchanged, and a fresh pass writes zero. The final private Fact +then rejects its own overstrict role-hash equality assertion on the intended grant +addition, before POST. This is a harness failure, not a product defect or passing +regression. Actual profile POST/readback remains unverified by this run. Earlier +compile/assumed-audience/missing-user/optional-scope expectations are measurement +errors and excluded. Every measurement process joins; exact owned container IDs +and networks are directly confirmed absent. No foreign resource is inspected. + +The hosted correction run +[`39687329554`](https://github.com/HodeTech/LearnStack/actions/runs/39687329554) +at `8e914a8b`, attempt 1, independently fails ordinary-10. Its immutable artifact +`12387397602` has API-verified SHA-256 +`30be17f155a2c7bbd0bc0b6a31beba3294a62b46450a7a4f4aef985fa4de153e`. +The completed records contain 241 cases and one failed knownFailure=false Outcome +ACK/held-cleanup variant, ID `4172c42b-b84f-59bf-426f-60d6c521ec52`, in 61.95s. +Its sibling passes in 17.19s. The failed execution requires forced cleanup and +lacks successful owned completion. No assertion/location is retained. This is a +different method from the earlier cancellation/restart failure; duration alone +establishes no cause. Investigation and any verified diagnostic correction remain +separate from ADR-0068; no deadline or ownership witness is relaxed. + + + +#### First revised-decision and compatible-read correction review + +Two fresh independent reviewers inspect `8e914a8b..2c06b4a6`: `gpt-6-astra` and +`gpt-6.1-sol`, both at `high`. Both approve the compatible-read correction and +approve the revised ADR for conditional acceptance, without Blocker/Major. The +governance review finds one long plan line; it is reflowed without changing scope. +It independently passes all 256 Node cases, six workflow/census controls, seven +parser controls, 306 relative links/fragments and whitespace. Both verify the +corrected physical provider spans, pre-preparation refusal requirements, preserved +readable definitions, two distinct profile calls, diagnostics/accounting, exact +DELETE paths, baseline limits and pending catalogue extension. All subprocesses +join; no provider, .NET or full frontend execution is repeated. A fresh second +review follows before the acceptance/implementation handoff. + +**Failed-envelope interpretation.** The new ordinary-10 archive is not evidence +that Docker remained live or that native runtime ownership leaked. The launcher +sets `forcedCleanup=true` for failed workload status and sets `dockerAbsent=true` +only on its success path; `owned_completion_missing` also rejects nonzero workload +status despite joined cleanup. Its `cleanupOwnershipComplete=true` is retained. +The successful-completion claim correctly fails, but its cause needs actual test +evidence. NativeSettlement was absent from the finite source projection; the +separate diagnostic correction adds only that literal source and a closed code, +with planted privacy/near-match controls. No deadline, runtime or success witness +is changed by that diagnostic correction. + + + +### ADR-0068 acceptance and implementation (2026-10-22) + +**Status: Accepted; three implementation steps authorized.** The maintainer directs +correction of still-valid review findings, then acceptance and implementation +without another approval request. Both fresh revised-decision review rounds are +complete. The second pair inspects `02b886f7`: `gpt-6.1-sol` and `gpt-6-astra`, both +at `high`, approve the compatible-read correction and revised decision without an +actionable finding. Both independently pass 256 Node cases, six gate controls, +43 launcher controls and whitespace; every subprocess joins. Their separate first +review approves `d3e69b7e`'s finite source diagnostic; its fresh second review remains +required and is included in Step 1's correction review scope. + +ADR-0068 is now Accepted — 2026-10-22. The two original phase clauses receive dated +bounded replacements; the Step 2 review receives an adjacent remedy follow-up. +Security 11, Architecture 13, Keycloak guidance, index and front doors distinguish +accepted authority from pending implementation. The two existing catalogue rules +retain Implemented status/counts with dated pending extension Asserts and owners. +No Accepted ADR body other than this newly accepted decision is changed. + +The three correction steps above proceed automatically with commit and two fresh +independent review rounds per step. Step 1 qualifies the fixed historical graph +and scoped detachments; Step 2 adds owned preparation and fresh planning; Step 3 +completes actual-provider/lifecycle/POST/CI proof and catalogue extension delivery. +No automatic-path activation, complete upgrade claim, green final-source CI or +merge follows merely from acceptance. + +Acceptance-carrier checks pass five CorpusConsistency cases, seven ADR parser +controls, 1,454 actual Markdown relative links/fragments and whitespace. The parser +reports Accepted; the index has exactly one Active ADR-0068 row. Existing inline +code examples are not treated as live Markdown references. + +The bounded native-settlement investigation executes three serial paired runs: +all six cases pass on an isolated `2c06b4a6` source, whose backend is identical to +failed source `8e914a8b`. Release build passes without warnings/errors. All exact +six observed fixture containers are directly confirmed absent without forced +removal; no created network is observed. macOS/SDK 10.0.302 differs from hosted +Linux/10.0.402. This is targeted evidence, not the lost assertion's root cause or +a waiver of the failed hosted assignment. No runtime, deadline or ownership +witness is changed; final source still requires complete qualification. + +### ADR-0068 Step 1 — qualified stock assignment implementation (2026-10-22) + +The first correction step adds full stock-graph qualification and the closed +assignment-only DELETE owner. Compatible existing graphs retain priority, +including compatible custom definitions with historical scope names. Only a +successfully read, definitively incompatible graph can reach the fixed historical +qualifier. Unknown fields, wrong IDs/categories, extra edges, mapper/configuration +changes and unqualified role/composite/container bindings refuse before apply. + +The committed stock expectation preserves stored and effective mapper DTOs +separately; generated IDs are normalized only after independent current reads. +The [extractor](../../scripts/extract-development-realm-stock-console.mjs) checks +an observation against that fixed expectation and cannot overwrite it. Its +[provenance](../../scripts/development-realm-stock-console-baseline.json) records +the original historical source, credential-only fixture transformation, provider +image observation and independent capture/extraction hashes. The committed +historical fixture replaces exactly three published example credential values +with placeholders; the provider test injects test-only values in memory. No other +historical configuration is changed. + +Only opaque qualified bindings can invoke the Admin owner's three assignment +paths, each requiring HTTP 204. Their projected remaining graph must pass the +normal compatible policy. A concurrent or uncertain response ends the pass; +explicit reruns qualify the durable remaining subset, and the last incompatible +edge hands later passes to compatible preservation without a marker. All eleven +global stock definitions, account role closure and other clients' prior edges +remain protected. An ordinary repair of a qualifying stock definition refuses +before writes rather than invalidating its own preservation witness. + +Local offline validation passes 287 exact executed Node cases across six +source-censused files, six census/workflow controls and 43 native-launcher/parser +controls, with no cancellations, skips or todos. The actual-core controls cover +exact DELETE paths, all seven incompatible subsets, compatible custom priority, +non-204/lost acknowledgement, explicit resumption, transport/JSON refusal and +conflicting projected global-definition changes. The NativeSettlement diagnostic +correction remains separately source-bound; these results establish no cause for +the failed hosted case and waive no required check. + +The new `TenantRealmStockBaselineTests` passes both real-provider Facts in +47.1781s after a Release build with zero warnings/errors. The first imports the +credential-redacted historical fixture into pinned 26.6.2 and independently +captures 89 endpoint representations, including account assignment reads and the +four referenced role-name/ID/composite bindings. It matches the fixed canonical +hash recorded in the provenance; a second fresh bootstrap changes generated IDs +while preserving that same canonical shape. Actual image identity is recorded +for the observed Linux/arm64 fixture, not asserted as a universal platform ID. + +The second Fact explicitly appends the three definitions as test-owned setup, +then invokes the production ordinary reconciler: sixteen mutations, followed by +zero on an explicit rerun. It independently verifies the five default/three +optional console edges, unchanged eleven stock definitions/roles, existing IDs, +credential DTO hashes, private API secret and other retained state. The two named +clients' separately approved managed-scope additions are explicitly projected; +other clients' assignments remain exact. An earlier harness rejected those +intended additions after successful sixteen-write convergence. The corrected +preservation predicate and complete two-Fact rerun pass; that earlier run supplies +no passing whole-ordinary-path receipt. All four final-run containers and both +networks are independently absent after joined disposal. + +Seven corpus/Docker-trait cases, seven ADR parser controls, 864 scoped relative +links/fragments, project-scoped C# whitespace verification and diff whitespace +checks pass. The broader solution format invocation reports a local workspace +loading warning and exits nonzero; the explicit integration-project verification +covers the three changed C# files. Current-source hosted checks remain required. + +Committed-source review rounds follow before this step closes. This step supplies +no production preparation implementation, password/MFA login or token/profile +POST proof, full 120-second automatic-path claim, hosted qualification or complete +ADR extension implementation claim. Those remaining proofs retain the following +steps' owners. + +#### First independent Step 1 review and interruption correction + +Two fresh reviewers inspect `db3778c8..57497f01`: `gpt-6-astra` at `xhigh` +for security/runtime/proof behavior and `gpt-6.1-sol` at `high` for provenance, +corpus and test meaning. Both find one persisted proof gap: the original +non-204/lost-acknowledgement loop interrupts only the first DELETE. The successful +subset controls do not establish uncertain failure after the final edge followed +by compatible-mode resumption. Both independently reproduce the later positions +successfully; no runtime defect or new decision is claimed. + +The corrected existing control now covers all three deletion ordinals for each +non-204 response, lost acknowledgement and cancellation. It checks the exact +stopped durable prefix, no repeated DELETE, remaining write counts of three/two/ +one, five default/three optional retained edges, last-edge compatible handoff and +subsequent zero-write convergence. The six-file guarded Node census again passes +287 exact cases, with zero cancellations/skips/todos. Runtime behavior, deadlines, +mutation authority and provider expectation remain unchanged. + +The first reviewer independently passes 67 guarded focused Node cases, 43 native +controls and six supplemental lost-acknowledgement/cancellation scenarios. The +second independently passes the full 287-case Node census, 43 native controls and +supplemental resumption probes. Both independently verify historical/redacted +fixture transformations and hashes, capture/extraction provenance and the bounded +pending-proof claims. Both approve the separate acceptance carriers and provide +the second review of the finite NativeSettlement diagnostic correction. Neither +reruns a provider or build; all review subprocesses finish. A different fresh +second review pair follows this committed test correction before Step 1 closes. + +#### Second independent Step 1 review and closeout + +Two different fresh reviewers inspect `db3778c8..72ee83a5`: `gpt-6.1-sol` at +`xhigh` for security/runtime/proofs and `gpt-6-astra` at `high` for governance, +provenance and test meaning. Both approve without an actionable finding. Both +independently pass the full six-file 287-case guarded Node census and verify +source/redacted/patch/canonical/extraction hashes. The first additionally passes +nine interruption-before-commit probes spanning all DELETE positions. The second +passes six gate controls, 43 native controls and seven ADR parser controls, and +independently reproduces canonical extraction from the private 89-endpoint capture. +Whitespace checks pass. Neither repeats provider/build execution or claims hosted +qualification; every review subprocess joins. + +Step 1 is complete locally after both independent review rounds and its verified +proof correction. Step 2 now proceeds automatically under the same Accepted +three-step authority: bounded metadata preparation, all identity-independent +preconditions first, durable-progress diagnostics and fresh complete planning. +Step 3 retains automatic-path, token/password/MFA/self-service and final-source CI +qualification. Nothing here accepts a background-job failure or authorizes merge. + + +### ADR-0068 Step 2 — owned visibility preparation (2026-10-22) + +The second correction step implements the exact missing-definition/closed-policy +trigger. A preparation probe reads actual provider state and reuses the ordinary +planner's identity-independent qualifiers and capacity checks. It defers only +visibility-dependent identity admission; it invents no account projection or +ownership. Provisional ordinary actions never escape the probe. A successful +probe returns only the absent-definition append and its private exact verifier. + +The owner dispatches at most one preparation PUT, requires HTTP 200 and a JSON +object, then separately GETs and compares the entire profile, including ordered +arrays and unrelated fields. Existing admin-view-only definitions remain exact. +All prior projections/actions are discarded before a fresh complete plan; any +separately necessary ordinary profile PUT has its own admission and write count. +Known independent secret/client/mapper/scope/account/group/storage and byte/count +conflicts refuse before preparation. Newly revealed identity conflicts may leave +metadata durable but admit no ordinary mutation. + +The closed `prepare` stage and dispatched-progress flag survive transport loss, +cancellation, deadlines, later plan/apply/readback refusal and outer cleanup or +lock-release errors. The fixed failure notice instructs inspection of current +realm state before an explicit rerun. Progress is recorded at actual transport +handoff after refresh and pre-dispatch cancellation checks, without claiming a +provider commit merely because the request was dispatched. All ordinary mutation +counts also use this conservative handoff boundary. The existing five-second +request, 120-second pass and response/body/refresh budgets remain unchanged. + +The guarded Node census passes 335 executed cases across seven exact files, +with zero cancellations/skips/todos. Twenty-two new pure preparation controls +include the complete trigger table, preserved readable definitions, strict +profile equality and independent refusal/capacity pairs. Sixty-two core and +24 entry controls exercise actual transport ordering, fresh replanning, separate +profile writes, malformed/non-200 responses, lost acknowledgement, cancellation, +deadline and durable-progress preservation through owned cleanup. These offline +controls do not prove real-provider convergence or final-source hosted fit. + +The actual-provider focused run passes all eleven cases without retries or +skips in 289.261s after Release builds with zero warnings/errors. Seven new +cases exercise the true historical automatic path (seventeen writes, then zero), +a hidden actor (one preparation, fresh-plan refusal, no ordinary writes), +independent secret/custom-stock conflicts (zero writes), lost acknowledgement and +cancellation (one dispatched preparation, explicit sixteen-write resume, then +zero), and partial admin-view-only preservation followed by a separately counted +ordinary profile PUT. Four existing hidden/default/selector variants also pass; +only the unmanaged-hidden variant now admits the exceptional metadata write. +The renamed theory says “before ordinary writes” rather than claiming zero total +writes for that variant. + +The retained TRX SHA-256 is +`89f40f90c099897aca2bfff0454407242dc9195035a4a4200c340040188c0fc9`. +Each new fixture joins disposal and independently checks its two exact container +IDs and network ID absent: fourteen containers and seven networks. The four +existing variants and shared collection fixture complete their existing cleanup; +no separate resource-absence observation is claimed for those lifetimes. +The historical positive control preserves actual provider IDs, password credential +DTO hashes, grants except the approved account capability, all preexisting global +scope definitions, API secret and Hub state. A credential DTO hash is not a +changed-password or MFA login proof. + +Seven corpus/Docker-trait cases, six census/workflow controls, seven ADR parser +controls, 1,283 scoped relative links/fragments, project-scoped C# whitespace and +diff whitespace checks pass. Committed-source independent review rounds follow +below before this correction step closes. Step 3 retains +complete seed/demo lifecycle, changed-password/MFA login, four-persona token and +manifest/nonmanifest self-service POST proof, request/phase cost and hosted +qualification. A passing Step 2 subset does not complete those obligations. + + + +#### First independent Step 2 review and partial-profile correction + +Two fresh first-round reviewers inspect `52c73e60..8b99de3`: `gpt-6-astra` at +`xhigh` for security/runtime/proofs and `gpt-6.1-sol` at `high` for governance, +reliability and test meaning. Both independently pass all 335 guarded Node cases +and reproduce a knowable refusal deferred until after one metadata PUT. With all +four accounts already present, a visible legacy tenant makes an ordinary account +update necessary, but an unrelated writable defaulted attribute's preservation +guard is skipped by the preparation-only branch. Both also reproduce a visible +actor mismatch or duplicate actor being deferred merely because another claim's +definition is missing. These are two verified Major mechanisms of overly broad +preparation deferral, not authorization to adopt conflicting identities. + +The correction derives readable claims from the actual current profile and reuses +the ordinary actor, field and duplicate-actor validators for those values. Only +checks depending on newly exposed values wait for the fresh full plan. The shared +attribute-preservation guard also precedes every prospective existing-account +write. Six new clean/dirty controls cover the profile-preservation failure, +readable actor mismatch/absence, the legacy absence exception, tenant/organization +disagreement, duplicate actor and the genuinely hidden-conflict boundary. The +complete source-censused suite passes 341 cases across seven files, zero +cancellations/skips/todos. The first provider receipt remains bound to the earlier +implementation. After the partial-profile correction, the same eleven actual +provider cases pass without retries/skips in 270.549s after a zero-warning/error +Release build. TRX SHA-256 is +`b701d3166e5266dab904ae7e921a08034db5d2f6683d17c605bc625c8fa5dc83`. +The seven new fixtures again independently establish fourteen exact containers +and seven networks absent after joined disposal; the existing fixtures retain +only their normal joined-cleanup claim. + +Both reviewers find no further verified diagnostic, cleanup, immutable-ADR or +catalogue-status defect; their limits explicitly exclude provider/.NET/hosted +execution. All review and counterexample subprocesses join without tracked edits. +A different fresh second pair reviews the committed correction before Step 2 +closes. Complete Step 3 obligations remain unchanged. + + + +#### Step 1 hosted mapper-reason regression and correction + +[Run `39694542640`](https://github.com/HodeTech/LearnStack/actions/runs/39694542640) +at head `52c73e60`, merge checkout `54730795`, finishes with 25 successful jobs +and failures in ordinary-1 and its integration aggregate. Ordinary-1 retains +220 executed cases: 218 pass and the two unsafe account-console direct/implicit +mapper variants fail. Artifact ID `12391396743` is independently bound to API +archive SHA-256 +`219bdb75b6f13537a9dba07ae386778a396898feb9869151d94e92cea18a77c4`. +The sanitized TRX exposes no assertion payload; empty finite failure locations +are not a diagnosis. Its failed completion envelope likewise proves neither a +runtime resource leak nor unjoined disposal. + +A targeted actual-provider invocation of those exact two rows reproduces both +failures, with no retry, at the reason assertion: stage `plan` is correct, +`unsupported` differs from the existing `mapper_conflict` contract. It is not a +startup failure. The invocation executes two failed cases, zero skips, in +61.217s; retained TRX SHA-256 is +`104eb1c6178f7002c40c902dd967e28fddbcdf7a597656f34e6a3a7a32e72b8d`. +The later zero-write/preservation assertions are not reached in that failed run +and supply no provider-preservation claim. Offline exact variants independently +refuse without writes. + +The stock fallback had reclassified direct mapper incompatibility while trying +its strict baseline. Direct mappers are outside assignment-normalization repair +authority. The correction qualifies that subset with the shared mapper policy +before fallback, preserving `mapper_conflict`. A direct subset need not contain +the resolver if its compatible scope supplies it; the complete graph still +requires exactly one resolver. Two new controls require the precise direct +refusal and prove inherited-resolver preservation plus duplicate rejection. +The full seven-file Node census passes 343 cases with zero cancellations, +skips or todos. The initial inherited-resolver test incorrectly placed it in a scope also +assigned to application clients, triggering their valid duplicate-resolver guard; +the corrected console-only scope isolates the intended existing compatibility. +The code does not weaken application mapper validation. + +The corrected-source provider subset passes all seven selected cases without +retries/skips in 179.348s. All five unsafe variants reach their final zero-write +and exact provider-state assertions; direct and implicit mappers retain +`plan/mapper_conflict`, while the other three retain `plan/unsupported`. Both +historical automatic/partial-admin-view proofs pass with their original write +counts, graph and state assertions. TRX SHA-256 is +`4497a991aab51936afb923cbd60aa6d01d9480a700211580f6a9fe0b183a1db8`. +The two new historical fixtures independently establish four exact containers +and two networks absent; the existing self-service fixtures finish normal +cleanup without a separate absence observation. Every invocation joins. + +Fresh independent reviews follow before this correction closes. No failed +hosted assignment or earlier native settlement failure is waived, and historical +green receipts remain source-bound. + + + +#### Entry-caller durable-progress preservation + +An author follow-through of the seed/demo call chain finds that their existing +outer cleanup can mask the new dispatched-preparation notice. Seed's finally +can replace either a prepared failure or a successful prepared result; demo's +cleanup replacement and local setup-cancellation wrapper can replace a prepared +reconciliation error. These callers are outside the already tested shared +reconciliation owner's own cleanup boundary. + +Ten targeted entry controls first run against unchanged callers. All five real +`RealmError` prepared-progress cases fail while all five nonpreparation twins +pass, isolating this propagation defect. The correction retains the closed +metadata notice through seed cleanup and failed-reconciliation demo cleanup or +local cancellation, without changing unrelated runtime/public-proof errors, +credential flows or the local cancellation exit convention. Six further actual +seed CLI cases extend the entry controls to sixteen additions. The complete +lifecycle file passes all 61 cases, zero skips, in 24.13s; workspace typecheck +and lint pass. Its changed test file receives the configured Prettier formatting. Prepared and unprepared twins prove error/result cleanup propagation, +fixed diagnostics and actual local CLI cancellation/cleanup exits. Successful +demo progress is not retained into unrelated renderer/runtime failures. All +commands join; no actual Compose/provider work is claimed by these stubbed +entry controls. Fresh independent correction reviews follow before closeout. + + + +#### First-round Step 2 correction verification + +Both first-round reviewers independently inspect `8b99de3..16771fc1` and approve +without a remaining verified finding. Each independently passes all 343 guarded +Node cases across seven files and all 61 lifecycle controls. The security reviewer +also passes ten supplemental assertion controls covering the original failures, +genuinely hidden legacy admission, direct mapper conflicts in compatible/stock +graphs and missing/optional-only resolver refusal. The governance reviewer +independently reproduces the old clean/default/visible-actor configurations and +confirms that each dirty version now refuses before preparation with zero writes. +Whitespace checks pass and every review subprocess joins without tracked edits. +Neither repeats provider/.NET/Docker/hosted execution or converts the reported +receipts into independently observed qualification. A different fresh second +pair reviews the whole Step 2 change before closeout. + + + +#### Second independent Step 2 review and closeout + +Two different fresh reviewers inspect the complete `52c73e60..3810b071` Step 2 +change and dependencies: `gpt-6.1-sol` at `xhigh` for security/runtime/proofs and +`gpt-6-astra` at `high` for governance, reliability and evidence meaning. Both +approve without a verified finding. Each independently passes all 343 guarded +Node cases across seven exact files and all 61 lifecycle controls. The security +reviewer additionally passes 92 visibility/mapper cross-product controls across +missing-definition subsets, four personas, visible/hidden conflicts and duplicate +actors, direct mapper failures and explicit rerun. Its first supplemental harness +omits an await on an async fixture; the corrected harness passes and supplies no +product-failure claim. The governance reviewer also passes seven ADR parser +controls and rejects an unverified metadata-capacity hypothesis after checking +the actual derived metadata and reserve model. + +Both verify unchanged Accepted ADR bodies, retained catalogue status/counts, +exact preparation/readback/fresh planning, early readable-fact refusals and fixed +caller progress diagnostics. Whitespace and tracked-tree checks pass; all +review commands join. Neither repeats provider/.NET/Docker/hosted execution or +claims that reported receipts are independently observed. Complete Step 3 proof +is still explicitly pending. + +Step 2 is complete locally after both independent rounds and verified fixes. +Step 3 proceeds automatically under the Accepted plan: genuine historical seed/ +demo entry journeys, changed credentials/MFA, strict signed API issuance and +manifest/nonmanifest self-service, measured request/phase costs, remaining +catalogue/carrier reconciliation and source-appropriate complete hosted checks. +No deadline, identity refusal, required check or earlier failure is waived. + + + +### ADR-0068 Step 3 — complete historical-path qualification (2026-10-22) + +The third correction step starts after Step 2 closes at `f714a5af`. Its provider +journey adds changed-password/MFA, strict signed persona/API issuance and +manifest/nonmanifest console self-service proof. A separate serialized owner +exercises actual seed/demo entries in private source copies with test-owned +historical bootstrap input. No new production seam or identity policy is inferred +from test setup. Complete request/phase costs, catalogue/carrier reconciliation, +case assignment and hosted qualification follow actual evidence. + +The private entry proof freezes `f714a5af`, keeps production scripts, Makefile and +the desired current realm byte-for-byte, and changes only the proof copy's Compose +import bind to an external owned historical-input directory. This explicit +bootstrap delta allows a newly occupied random CI project to start historical; +it neither prewarms a supposedly empty CI project nor mutates a workstation +realm. Credentials remain private, source attestation belongs to the proof copy, +and its results cannot replace untouched final-source hosted qualification. + +The first private entry experiment freezes source tree +`2b3663df5c8dd566158d8eaeaee12542530d7a6f`; its archive SHA-256 is +`41126981657cc35148af17c470ead345cdc23cde85af4c9e431effe0be4133f3`. +Only three historical credential literals are transformed in the private bootstrap +input; the Hub input is byte-identical. The desired export and production entry +sources remain unchanged. The private receipt SHA-256 is +`353fa9338bc155d363f2f54fc8da5321bfebd8b137d089d31540e700b6c38d17`. + +Actual `make seed` succeeds with seventeen writes, then zero on explicit rerun, +in 36.224 and 16.491 seconds. Independent reads confirm the original provider and +client IDs, credential DTOs, the complete appended profile, retained five default/ +three optional console edges and observed Hub state. The second full snapshot is +identical. Credential DTO preservation alone is not a usable password/MFA proof. +Natural teardown takes 10.416 seconds; all seven exact owned containers and the +owned network are independently absent, with no fallback or forced stop. + +The first actual historical `make demo DEMO_MODE=ci` experiment is **red**: +323.004 seconds, make exit 2, an audit worker exit 1 without a signal at lane 1, +lesson run 3. Nine page navigations and twenty-four visibility controls pass, but +only eight of twenty-seven Lighthouse report pairs complete. Each retained report +has no runtime error/warning and accessibility score 1.0; these partial results do +not establish the full audit suite. The initial two-user/eleven-edge state and live +four-user/eight-edge/three-definition convergence are independently observed. +The demo entry does not expose reconciliation mutation counts; none is inferred. +Its native public receipt SHA-256 is +`ad6a02b69c8561401312ad913e77d0826b579d743449c85a50cfaeeb884876d3`. + +The retained finite diagnostic establishes worker exit only, with no worker stage +or failed report; it cannot identify an audit, protocol or budget root cause. +Natural cleanup takes 10.722 seconds and all owned processes join. The selected +Compose project is empty; the two remaining container IDs and network captured +from the exact owned process tree are independently absent. Five earlier container +IDs were not observed, so their individual absence is not claimed. No retry, +threshold relaxation or automatic replay converts this failed receipt into success. +A separately recorded fresh experiment may assess repeatability; it cannot erase +this failure or substitute for final-source hosted proof. + +The committed historical journey in `TenantRealmApiTests.WarmQualification.cs` +starts the genuine stock profile/eleven-edge graph with a test-owned changed legacy +password, usable OTP credential and an already eligible nonmanifest account. +Immediate pre/post reconciliation snapshots avoid confusing subsequent successful +logins with credential metadata changes. All old provider IDs, credential DTOs, +groups/grants, unrelated account fields, global scope/role graphs, other-client +assignments, API secret and observed Hub state are preserved. Only the approved +manifest capability/identity changes and three console edges differ. + +The final-source Fact passes, one case and zero skips. Four independently signed +access/ID token pairs reach matching AccountSite hosts through the production +bearer handler and physical ReadOnly `learnstack_app` transactions. Changed +password/MFA work before and after; missing/wrong OTP refuses. Both manifest and +eligible nonmanifest users complete ordinary Account REST POST with independent +admin readback. All three protected attribute replacements fail to persist, and +fresh signed claims preserve the manifest identity/nonmanifest authority boundary. +ID-token access hashes are checked before comparing complete signed claim deltas. +The closed legacy-console profile alone admits an absent audience, while still +requiring signature, issuer, lifetime, purpose and `account-console` authorized +party; all normal web/API audience checks remain strict. + +Before upgrade, ordinary Account REST returns 401 for the legacy manifest user +and 200 for the already eligible nonmanifest user. The approved mapper/grant +changes add only the measured account capability; no universal role-loss or +security-gain claim is made. Explicit `microprofile-jwt` has the measured `upn` +access/ID delta before detachment; subsequent new requests for either detached +optional scope return the verified `invalid_scope` callback. An actual pre-upgrade +offline refresh succeeds and yields the credential retained for the later check. +Both original and newly returned refresh credentials are positively checked as +nonempty before the post-upgrade negative, preventing an empty-input false proof. +After upgrade, that credential returns 400 `invalid_grant`, with no access/ID token, +while its independently read offline session remains present. This is the observed +owned historical case, not a universal revocation or session-deletion guarantee. + +Production core passes perform seventeen, zero and zero writes. The third +explicit pass follows real self-service edits and preserves their stored fields, +credentials and grants. They issue 481/301/301 bounded requests in 3.126/1.352/1.280 +seconds; preparation uses two requests and the initial/fresh plan each use 112. +Fixture startup is separately measured at 19.130 seconds and token/API/profile +work at 13.220 seconds. These are small-fixture observations, not worst-case +capacity or deadline-fit bounds. The existing five/120-second request/pass limits +are unchanged. The Fact's exact recorded duration is 40.2057204 seconds; its TRX +SHA-256 is `3487a4510af4444908eaef908365e8cc4334b0dfc6a531b0af25276f49e657c9`. +A separate seven-file source manifest has SHA-256 +`92cde32454dfb23f0421ce4e2d92ffe3c054281138cfe2d8a2ebe8c734960da3`; +TRX does not embed a commit. Real disposal joins and both exact owned provider +containers and their network are independently absent. + +Exploratory red Facts exposed pinned v2 OTP-form differences and incorrect test +expectations for historical status/callback behavior. A redundant second failed +password stimulus also triggered the real provider's quick-login protection. +The fixture preserves the exact OTP credential selector and closed `invalid_scope` +error, removes that redundant stimulus and checks attack-detection state. No +provider security policy is weakened. The final-source Fact is rerun after those +harness corrections and scoped formatting, rather than treating an earlier +partially reached assertion as complete proof. Eighteen adjacent provider cases +and seven corpus/Docker-trait guards pass with zero skips. Integration and +Architecture builds have zero warnings/errors; scoped whitespace/style verification +passes for all six C# files. Only newline formatting and the two refresh-presence +proof guards follow the eighteen-case receipt; the final historical Fact runs +again against those exact bytes. +Independent correction reviews and full hosted qualification remain pending. + +The new historical journey source also receives one literal CI failure-location +code, `realm_warm_qualification`, with the existing bounded line projection. +No raw stack, path, message, token or provider value is exported. The existing +planted trace control fails without the mapping, then all forty-three offline +launcher controls pass with it. Source inventory includes the exact new partial +file; no diagnostic code is inferred from arbitrary filenames. + +Measured weights replace the renamed selector and cover the nine completed +Step 2 method groups. Two previously unknown historical-baseline methods gain +actual hosted durations from this Step 2 run's independently verified ordinary-1 +and ordinary-4 receipts: 98,022 and 102,868 milliseconds. Local row sums retain +the existing advisory scaling; hosted rows are not scaled. These weights are +assignment hints, not wall-clock upper bounds or proof of final hosted fit. +The offline shard/result controls pass sixty and nineteen cases respectively. +The journey adds a 91,318-millisecond advisory weight from its actual TRX row; +the plan now has 1,382 weights. Twenty explicit bounded VSTest discovery calls +complete in 20.356 seconds with all owned process groups/readers joined. Actual +ID/FullyQualifiedName metadata preserves all 5,919 earlier case identities and +adds only the new journey: 5,920 total, 3,350 non-Docker, 2,539 ordinary and 31 +native. Every changed selector belongs to that manifest; the retired selector is +absent. Source, plan, parsers and four Release assembly hashes stay frozen. + +On that same complete manifest, the revised plan moves 1,055 methods/2,289 cases +in layout A and 932 methods/2,018 cases in B relative to the Step 2 plan. Both +layouts have complete unions and materially different co-memberships. Maximum +filter/argv bytes are 15,491/16,137 in A and 26,048/26,695 in B, below the unchanged +32,768/65,536 limits. Unknown method weights fall from 53 to 43; those remaining +use the existing default and are not represented as measured. The manifest digest +is `c79e7e37f5304b3e27d966f0e628b80d8746872aa911933a52383c434ac4bc8e`; +the finite validation-summary SHA-256 is +`9f9f400132c21ff9ffd291a2bd4b606bd69f45199c79edfea347f4720210b4b1`. +This is complete metadata/assignment validation, not final-source execution. +The execution VM count of twenty, seven required names and whole-job ceilings +remain unchanged; both final hosted layouts still require execution. + +[Run `39699380813`](https://github.com/HodeTech/LearnStack/actions/runs/39699380813) +finishes successfully at Step 2 head `f714a5af`: all twenty-seven jobs and seven +required contexts pass. Checkout `d55ae6fc37b794e482c42ebbdd49d3e59a9c3f0a` +has the recorded source tree. The hosted fresh demo independently completes all +nine pages and twenty-seven hash-bound report pairs; it does not erase the failed +local historical-input experiment. Native proof passes all thirty-one cases, and +base passes 2,917 cases, with zero skips. Independent revalidation of all twenty +producer archives verifies API digests, current-attempt ownership and exact case +unions: backend 3,350 plus integration 2,569 (2,538 ordinary and 31 native), all +5,919 passing with no skips. Both aggregate receipts match recomputation using +that source's validators. The verification-summary SHA-256 is +`60cf4e7af91b76a512f717b6c7df875e996c7804edcc1197a29a92ab9a637012`. +The failed `52c73e60` assignment remains failed; its reproduced mapper +classification defect is fixed and independently qualified at this later source. +Final Step 3 source and its revised plan still require ADR-0063's materially +different complete assignments at the same frozen source, rather than borrowed +receipts. + +#### Step 3 first independent review round + +Implementation is committed at `ca3560df`. Two fresh independent reviewers inspect +the complete thirteen-file `f714a5af..ca3560df` change and adjacent authorities: +`gpt-6-astra` at `xhigh` for security and nonvacuous provider proof, and +`gpt-6.1-sol` at `high` for governance, assignment/evidence meaning and reliability. +Both approve without a verified Blocker, Major or Minor finding. Neither changes +repository files or executes provider, Docker or .NET/build/format commands. + +The security reviewer reads the complete historical journey and independently +checks the final TRX and seven source hashes. It verifies usable password/MFA +controls, strict web/API versus closed historical-console validation, nonce and +access-token hash binding, stored self-service/protected-field readback, the +positively nonempty offline credential and the exact preservation/cleanup claims. +It distinguishes the observed offline result from universal revocation and keeps +the failed historical demo and final hosted obligations visible. + +The governance reviewer independently verifies the eighteen adjacent and seven +corpus outcomes, all twelve timing upserts/one retirement/five sources and their +actual selector bindings. It reparses all twenty retained discovery diagnostics +to the exact 5,920-case manifest, preserving every prior case identity plus one +new case. Both complete assignment unions, distinct co-memberships, movement and +filter bounds recompute. All forty-three offline launcher controls pass, including +the literal finite failure-location projection. Accepted ADR bodies and catalogue +statuses/counts remain unchanged. All review commands join. A different fresh +second review pair and final-source complete hosted assignments remain required. + +#### Independent historical demo repeat + +One explicit independent repeat of the same historical-input experiment succeeds +without runtime, budget, capture or threshold changes. It freezes the same +`f714a5af` production archive and identical transformed historical/Hub inputs. +The private proof commit is `d7e981ab102b7ae4502789773f422ddba2b61a48`, tree +`304651aa11d636cc456b8bfd40ebb2142a429367`; its only source delta is the owned +Compose import bind, SHA-256 +`3a4773ed123eb159fafe2aae88a7e202485504fd93772c55819935a2769c755a`. +Production-source hashes remain equal and the private proof source stays clean. + +Actual `make demo DEMO_MODE=ci` exits zero in 662.279 seconds within the unchanged +1,200-second whole-run budget. Nine navigations, twenty-four visibility controls +and all twenty-seven JSON/HTML report pairs complete. All reports have no runtime +error/warning and accessibility score 1.0; all nine screenshots are inspected for +readability/clipping. All 134 observed API completions return 200, and both TLS +refusal controls plus the timeout refusal control pass. Independent reads observe +the original two users/eleven edges, then four declared protected identities, +eight retained edges and exactly three appended profile definitions. The unchanged +demo entry exposes no reconciliation mutation count; none is inferred. + +Natural cleanup takes 10.711 seconds. All seven proactively captured owned +container IDs and the network independently return not-found; the selected +Compose project is empty, private environment/lock files are removed and all +owned processes/readers join. No forced or fallback cleanup runs. The final +private receipt SHA-256 is +`64c647657a3a3afe671996d7da957c78e81cca4040fbd820ae80d463ead189d1`; +the native public receipt SHA-256 is +`ae53906f2a476abc4c3f208a2dd0daf646e838ef3ab2f4a8bf83b13b6f336cae`. +This completes the independently recorded historical entry proof. It neither +explains nor erases the first red experiment and does not replace untouched +final-source hosted qualification. + +#### Step 3 second independent review round + +Two different fresh reviewers independently inspect `f714a5af..ca3560df` and +one-hop dependencies: `gpt-6.1-sol` at `xhigh` for security, runtime correctness +and nonvacuous provider proof; `gpt-6-astra` at `high` for governance, CI/evidence +meaning and reliability. Both approve without a verified Blocker, Major or Minor +finding. Both read the entire new historical journey and all thirteen changed +files. Their review approval does not substitute for the separately executed +historical demo or final-source hosted checks. + +Each independently verifies the final historical TRX, its exact duration and all +seven committed source hashes. The security reviewer checks actual credential/ +MFA controls, nonce/access-hash binding, strict web/API and closed historical +console boundaries, physical application-role ReadOnly observations and stored +POST/protected-field assertions. The governance reviewer reparses all twenty +discovery diagnostics, verifies every prior identity plus the one new journey, +and recomputes both complete assignments, limits and local/hosted timing weights. +Both pass all 122 offline launcher/assignment/result controls; the governance +reviewer also checks that removing the diagnostic mapping breaks its planted +control. No reviewer executes provider, Docker or .NET/build/format commands; +all review commands join. Accepted ADR bodies and catalogue counts/statuses stay +unchanged. All three correction steps now complete their local implementation +and two independent review rounds. Full final-source A/B hosted qualification is +the remaining completion gate; no merge or P02b-7 work is authorized by this +record. + +#### Final hosted qualification + +[Run `39705264083`](https://github.com/HodeTech/LearnStack/actions/runs/39705264083) +freezes Step 3 head `ca3560df775ac86304fdfefbc0c3bf81cc118cef`. Checkout +`0d65aa34cf54d3424f1d7870c5a042a971e0b374` has that exact second parent and the +same tree, `e124af40d71057ba73657a925c32c67413956409`. No code, plan, deadline or +success witness changes between assignments. + +Attempt 1, layout A, passes all twenty-seven jobs and seven required contexts. +Its public demo archive is downloaded and independently verified before rerun: +all twenty-seven report pairs, nine screenshots, nine navigations and twenty-four +visibility controls pass. Its private verification-summary SHA-256 is +`aa0f3c673e83ca7bd5ffc10de14f36253c8251cd1ab09bc92a82c89fb2cb61a5`. +After the full rerun starts, prior artifact API endpoints return not-found; no +cause is inferred. Initial job-log capture is separately blocked by the installed +CLI's terminal-escape output guard, not an observed HTTP refusal. Using its +documented flag only into private pipes/files later recovers both original +aggregate log receipts. They report 3,350 backend and 2,570 integration cases at +the exact source/attempt; their private canonical receipt SHA-256 values are +`1242916895281b40c7e21d8d50f18a382904fa95a244fda953a6ab1a6a0e7a19` +and `c8602bea4b1c508a06ee1dcc65f4ae308749247f3e74a1091181d545efee688b`. +The original successful jobs API response and downloaded public proof also remain +retained, but complete independent producer-archive revalidation is not claimed +for attempt 1. The later A assignment is explicitly for complete retained +qualification evidence, not a failed-test retry or permission to combine siblings. + +Attempt 2, layout B, passes all twenty-seven jobs and seven required contexts. +Before any further rerun, all twenty producer archives and both aggregate archives +are downloaded, API-digest checked and independently revalidated with the frozen +source's pure validators. Exact current-attempt job/source identities, build/ +execution/upload gates, cleanup, toolchains, runner observations and complete +case/TRX unions pass. Backend contains 3,350 cases (2,917 base and 433 Architecture); +integration contains 2,570 (2,539 ordinary and 31 native). All 5,920 cases pass, +with zero skips. Both aggregate payloads match independent recomputation. +The longest observed producer whole-job wall time is 911 seconds, within the +unchanged 1,200-second ceiling; queue delay is separate and not hidden in that +measurement. Observed SDK/console are `10.0.402` and +`18.7.0-release-26505-111`, with their actual binary hashes retained. + +The B verification-summary SHA-256 is +`dfb122abc2bab511b8be916baa6566385a408f4eeb62d84f001334265f7554a1`. +The original capture checks both aggregate API digests, but does not retain the +integration aggregate's raw API envelope. Its ZIP, digest, payload comparison and +the verifier source remain retained; the latter has SHA-256 +`1e7d235d74aa63ecf6fdb715a7f68be6f965d9f118717431f6daad8cd6dde5f6`. +A subsequent independent reviewer can replay all twenty cached producer API/ +archive bindings and both complete unions/payloads, but cannot independently +replay that one aggregate API binding from a cached raw envelope. This limit is +distinct from the original capture's checked assertion. Later capture retains +both raw aggregate API envelopes before another transition. +Its plan digest is +`aec25a0597f0944dd4168fc5caf36faef983eb96fc0f053b6eb74befefd2e4be`, +manifest digest +`c79e7e37f5304b3e27d966f0e628b80d8746872aa911933a52383c434ac4bc8e`, +and assignment digest +`fe4cde10ee32f0ef85df6abbfa7e3137cc6a783a1791215abfa182e267d9b244`. +The separate B public-demo verification-summary SHA-256 is +`713a5dd039532fd4c847975c864ed7e466ef37795c467421f7174e3f566b4344`: +all twenty-seven JSON/HTML pairs and nine PNGs match their native manifest, +the full report contract passes, all 134 API completions return 200 and all +cleanup flags pass. This hosted clean-bootstrap evidence remains distinct from +the private historical-import experiment. + +The complete third attempt, layout A, starts only after B's archives and full +revalidation are retained. Its source and plan stay frozen. It finishes **red**: +twenty-four jobs pass, ordinary-7 and frontend fail independently, and integration +correctly refuses the failed producer. No earlier green assignment waives it. +The public demo producer separately passes all twenty-seven report pairs and +owned cleanup; its independent verification-summary SHA-256 is +`6afe2a1b664de4c59013f2a0a85077af3326634a151bada78734cc21bd9c09cf`. + +Ordinary-7 retains one failed known-failure variant of the actual Outcome ACK/ +held-cleanup test, 62.8366378 seconds, with finite source locations 164/179/183 in +`BackgroundJobPositiveSdkTests.NativeSettlement.cs`. The owner reports +`owned_completion_missing`, workload `tests_failed` and forced cleanup. Source +line 164 combines an awaited count query and the exact-two assertion; the retained +TRX/message cannot distinguish a query fault from a count mismatch, and no actual +count is recorded. A simple ledger visibility-delay explanation is not supported +by the transactional ordering. No wait, assertion or deadline is weakened. + +Frontend fails at the first development-server readiness boundary after the +production ingress/optimizer controls. No probe response completes; all existing +closed compilation/configuration flags are false. Current-child progress and the +probe-error kind are absent, so preparation delay, health-route delay, connection +refusal and TLS failure cannot be separated. The subsequent failed-fixture cleanup +command is not reached. Test-only finite diagnostics and exercised transport +controls are the justified next correction; neither failure has a proven +functional root cause from this receipt. Complete corrected-source qualification +remains required. + +#### CI failure diagnosis and exercised transport + +The settlement correction separates the original count read from its assertion +and records a bounded, case-bound observation before cleanup. A separate +application-role query observes ledger stages, counters and closed state; its +later snapshot is never represented as the failed assertion's original count. +Unknown/unavailable observations stay explicit. A bare rethrow preserves the +original failure. Only the three exact settlement cases and actual xUnit +`StdOutMsgs` can project these finite facts; raw output, identities, error text +and SQL are excluded. No assertion, wait or cleanup deadline changes. + +All forty-six offline launcher controls pass. A private copied-source experiment +changes only the last known-failure assertion's expected count to an impossible +negative value. The real pinned adapter transports that failed case's output: +the original count is two, the later snapshot is two (normal/finalize one each), +and the original assertion/stack remain intact. Two sibling cases pass and the +intended case fails, with zero skips. Sanitization removes output and preserves +the failed outcome. Initial immediate reaper absence is not proven; that refusal +is retained separately from later exact-ID proof that both owned containers +disappear naturally. Private compile-harness setup refusals precede execution; +no repository rule is disabled. + +The untouched source is rebuilt and all three real cases pass, with zero skips; +their command takes 62.599 seconds including runner/fixture work. All owned +processes and containers finish. Release builds report zero warnings/errors and +scoped format verification passes. The combined private receipt SHA-256 is +`0074b9dd2bab6371f992163cd76f6f4e4b3920999092cd0b3e58a80969286489`; +root independently checks all nine source hashes and six retained receipt hashes. +Rebuilt binary hashes are recorded per build; byte-identical production binaries +are not claimed. + +Fresh complete metadata discovery executes twenty bounded commands in 18.086 +seconds: the same 5,920 exact cases, manifest, plan and both assignments. Its +summary SHA-256 is +`11ac0e91f607bf3b70cd41e20e2f2da3e4c472388e60937b16ea1e485b8d11b8`. +These receipts qualify diagnostics and preserve the historical A3 failure; they +do not establish that failure's missing root cause. + +Frontend's first instrumented local run remains red: its development child is +running in preparation, with no observed bind and 196 refused probes/zero +responses in 20.058 seconds. A second, separately instrumented run observes +Next 15.5.27's exact informational npm version fetch still pending after 19.533 +seconds. The unchanged readiness boundary fails at 20.052 seconds. Production, +stock and optimizer-mutant listeners had readied in 1.068/0.526/0.642 seconds. +The pinned `HotReloaderWebpack.start()` awaits `getVersionInfo()` before compiler +startup; that exact global fetch has no timeout. This establishes the pending +dependency in the local reproduction, without guessing a DNS/remote/network +cause or claiming to recover the earlier CI child's missing observation. +The second private receipt SHA-256 is +`943f5ce5d18631e2c8151b43c06079172c679fb23643a674f38f9f1b81b7118f`. +Both failed runs retain their outcomes and complete owned cleanup. + +The correction adds an owned two-second abort only to that exact, one-argument +informational fetch inside the disposable development launcher. Next's existing +failure/unknown-staleness path handles the actual rejection. No synthetic HTTP +response or competing completion race is introduced; a delegate ignoring abort +remains pending until its real settlement. Other URLs, URL objects and calls +with options/explicit second arguments pass through unchanged. Any remaining +timer is cleared on actual settlement; notifications cannot replace the original +response/error. +Fetch resolves at response headers: the two-second budget does not extend through +Next's later JSON body consumption. The existing readiness refusal still bounds +that preparation check; no universal body/network I/O deadline is claimed. +The shipped launcher, workflow, plan and all ingress/HMR readiness, probe, +cleanup and success conditions remain unchanged. + +Twenty-three focused controls pass, including actual signal expiry, ignored-abort +settlement, timer cleanup, private-error containment, current-child attribution +and separation from upgrade messages. The single corrected actual native run +passes in 21.828 seconds. Both development children observe a real timeout at +2.004 seconds, then genuine healthy owned binds at 4.298/3.390 seconds; original +TLS, GET/HEAD, stock-bypass, optimizer mutant, DEBUG, query, HMR and both shutdown +signals pass. Its private receipt SHA-256 is +`00743393b8da99607eae39693803a4b51d6415f56f2ab958987bc8295fbb3b4e`. +Root independently matches five proof-source hashes and the observed Next package, +hot-reloader, lockfile and build-ID hashes. This fixes the demonstrated local +informational-lookup stall; it does not invent the earlier CI child's missing +internal trace. Full correction reviews and hosted qualification remain required. + +Final frontend validation passes workspace lint/typecheck, all 1,840 guarded +cases in two packages with zero skips/todos, and all nineteen cleanup controls. +An initial typecheck rejects two unchecked mock-call tuple accesses in the new +controls; explicit call-count/tuple guards correct only that test source. The +immutable native receipt is retained, and all four harness/helper/declaration +files still match it. The final guard binds all five current sources; its +private receipt SHA-256 is +`9422382afa7da79557c63fa799e76db2c23667b34cc8a554d12f600df2a0f29e`. +Root independently matches those five hashes. The unchanged, attested production +build is exercised by the native proof; no new production build is claimed. + +Root passes seven corpus/Docker-trait controls with zero skips; all testhost +processes finish. Changed-document validation checks 1,415 real relative links/ +fragments with no broken target, new analysis reference or overlong added prose. +The diagnostic correction still needs its two fresh independent review rounds +and complete corrected-source hosted qualification before closeout. + +#### First independent CI-correction review + +Two fresh reviewers inspect all seventeen files in `61e47ff3..87ecc114` and +their relevant dependencies: `gpt-6-astra` at `xhigh` and `gpt-6.1-sol` at +`high`. Both approve without a verified actionable finding. They independently +check application-role snapshot attribution, original-count separation, closed +parser output, actual failure preservation, exact fetch targeting/settlement, +current-child admission and unchanged success/cleanup conditions. + +The security reviewer passes all forty-six offline controls and independently +checks nine source hashes, six receipt hashes, the real mutant's original +assertion/stack and sanitized failed outcome. The frontend reviewer independently +passes all twenty-three focused cases through the actual runner/report guard, +with zero skips/todos, and verifies native/final source bindings and installed +provider/build hashes. An initial unsupported runner invocation refuses before +test execution; the corrected repository runner completes. Both inspect the +recorded evidence limits and retain the historical A3 failure. No provider, +native or full frontend experiment is repeated. All subprocesses finish and the +tracked tree remains clean. The second fresh review round remains required. + +#### Second independent CI-correction review + +Two different fresh reviewers inspect all seventeen files in +`61e47ff3..8ae35ecf` and their relevant dependencies: `gpt-6.1-sol` at `xhigh` +and `gpt-6-astra` at `high`. Both approve without a verified actionable finding. +The first independently passes the three added Python controls, checks raw +VSTest events alongside original/sanitized TRX and verifies nine source, six +top-level and seven mutant-subreceipt hashes. The second independently passes +all twenty-three focused frontend cases with the actual report guard and zero +skips/todos. Their source/receipt checks confirm original-count separation, +application-role observation, strict parser output, real fetch settlement, +unchanged readiness/cleanup admission, provider/build hashes and unchanged plan. + +The explicit limit above distinguishes fetch/header settlement from later body +consumption; it adds no runtime change. Both reviewers preserve the historical +A3 failure and pending corrected-source hosted qualification. No provider, +native, .NET or broad frontend suite is repeated. All subprocesses finish and +the tracked tree stays clean. Both fresh correction review rounds are complete; +complete corrected-source hosted qualification is the remaining gate. + +#### Reviewed-source hosted failure and identity attribution + +[Run `39716534536`](https://github.com/HodeTech/LearnStack/actions/runs/39716534536), +attempt 1/layout A, freezes reviewed head +`7b0fd1b6f718d49c7c5daad318685295b48d90cc`. All twenty-seven jobs finish: +twenty-five pass; ordinary-7 fails and integration refuses that producer. The +frontend job now passes. No layout B starts and no earlier green receipt waives +this failed source. All twenty producer archives and the successful backend +aggregate archive are retained and API-digest checked before another transition. +The private failed-run capture summary has SHA-256 +`0c4c7c37ab39af1ed37afa6494a57e68efc63876c4fdbc9d76ba62ed7a6a2394`; +it records a failed run, not complete qualification. + +The known-failure Outcome ACK/held-cleanup case fails in 20.6750301 seconds at +source line 221: the final process-wide `LeaseLossRenewalRefused == 0` assertion. +Its earlier count, generation, outcome and provider assertions pass. Runtime +stop/completion and the preceding zero-execution/elapsed-loss assertions also +pass. This location is distinct from the earlier count boundary. The retained +artifact has SHA-256 +`6f37596cf9acd045fe6993f67adf8c30e6513617d74648566ac8ff2742479c70`. +The diagnostic catch precedes these post-cleanup assertions, so no marker records +the counter value, execution identity or triggering renewal branch. None is +inferred from the stack location alone. + +Independent source inspection identifies a legal candidate interleaving: the +later Finalize execution holds the job row, renewal skips its lock and observes +no renewed row before managed commit acknowledgement. Conservative refusal can +then increment the cumulative counter while a commit already admitted completes. +This is a source-supported counterexample to treating the cumulative counter as +an observation of only the held Outcome identity; it is not a recovered CI trace. +The existing refusal/commit policy remains unchanged. Exact-identity attribution +and a controlled real-store interleaving are required before correcting the +proof; waits and deadlines are not increased. + +Public demo independently passes on the same source. Its immutable artifact +`12400165434`, SHA-256 +`c6c5ab29fb83ca43e187a95d00758cde9eab7db690fbec6ffd539aab9a7fde99`, +is retained before any rerun. All twenty-seven JSON/HTML pairs, nine PNGs, nine +navigations and twenty-four visibility checks pass; all 134 API completions +return 200, both TLS and the timeout refusals pass, and all cleanup flags pass. +Natural cleanup takes 10.732 seconds. Independent verification-summary SHA-256 is +`a0e3688c3ab02d6403d145845485b43499dc8d6f9bbde4ac5ec0866832cd1ef2`. +This successful producer does not change the run's failed conclusion. + +#### Exact lease-loss attribution proof + +The corrected stock proof keeps all three case identities, its held-cleanup +barrier, exact counts, generation, provider observations and original deadlines. +It checks the captured Outcome execution's sticky first cause after actual +cleanup instead of attributing later process totals to that identity. The later +Finalize control is captured independently. Aggregate zero checks remain while +only the original held identity exists. Post-cleanup and cleanup-first failures +now produce the same bounded diagnostics; cleanup cannot replace an earlier +exception. Closed per-identity causes and cumulative counts remain observations, +never admission or completion evidence. + +A separate explicit-owner subphase runs inside the existing known-failure case. +It uses real publication, provider, Outcome, Finalize and PostgreSQL control +owners; it does not claim a stock Hangfire handoff. A test-only proxy holds the +real Finalize COMMIT request only after that exact gate admits commit. An actual +`SKIP LOCKED` read confirms its row remains locked. The production renewal lane +then refuses the omitted identity. A test-owned cancellation callback holds +propagation while the already-admitted real COMMIT request and ACK complete. +All barriers release before actual joins. No control field, counter, completion +or production policy is manufactured or changed. + +The initial experiment uses a COMMIT ordinal and refuses at the required +`CommitStarted` precondition: the held transaction is preparatory. It is not +accepted as the intended negative; both owned containers and all processes +finish. The corrected barrier uses the exact gate predicate and measures the +actual before/after COMMIT count rather than guessing an ordinal. + +The source-attested one-substitution experiment restores the old global-zero +assertion only at the final explicit-owner boundary. Two siblings pass and that +case fails exactly at `later_post_cleanup`, with zero skips. The real pinned +adapter transports original cause `none`, Finalize cause `renewal_refused`, +cumulative renewal loss one, elapsed loss zero and zero active executions. +Actual attempts are two (one normal, one Finalize), generation advances once and +one finalized row exists. The original assertion survives; sanitization removes +output and preserves the failed outcome. Both owned containers are absent. +The private mutant receipt has SHA-256 +`ac261c52d4a5b261cb5928b397b8993e4cb17b24432dd491544f5d3468e7c906`. +Root independently checks its finite facts and receipt hash. This proves the +attribution counterexample, without asserting the missing CI interleaving. + +Untouched-source Release rebuilds report zero warnings/errors, scoped format +verification passes and all forty-seven offline launcher controls pass. The +single clean real run passes all three cases with zero skips: false 16.184 +seconds, known failure 19.449 seconds including the separate counterexample, +and retained recovery 21.383 seconds. Command wall time is 63.415 seconds. +Both owned containers and the process group are absent; all commands join. +The combined private receipt has SHA-256 +`10658b0cf90dea34b533b80941514f243085e4eac0e48d1a71b538570fe3b735`. +Root independently verifies seven current source hashes, nineteen retained +artifact hashes and both clean TRX hashes. Rebuilt production binary identity +is recorded per build; byte-identical binaries are not claimed. + +Fresh complete metadata discovery executes twenty bounded commands in 19.077 +seconds. All 5,920 exact identities, the plan, manifest and both assignment +digests remain unchanged. The private summary has SHA-256 +`f64f219670a3d12a8efa99b81001c05740aec3e6ea4ba63f96d30cd99c0bbdd0`. +Seven corpus/Docker-trait controls pass with zero skips in 9.465 seconds and +their process group is absent. Independent review and complete corrected-source +hosted qualification remain required; no earlier red receipt is waived. + +#### First independent attribution review + +Two fresh reviewers inspect all five files in `7b0fd1b6..9adba275` and their +ownership, renewal, persistence and diagnostic dependencies: `gpt-6-astra` at +`xhigh` and `gpt-6.1-sol` at `high`. Both request changes for the same verified +Major: the new explicit-owner failure cleanup releases barriers but can call +`JoinAsync` before issued business/admission/external tasks actually return. +An unfinished task then makes cleanup fault sticky and retains the invocation; +preserving the first exception does not prove a completed join. The successful +path's retained receipt does not qualify this early-failure path. + +Both otherwise confirm the attribution boundary, real gate-bound COMMIT/ACK, +unchanged default proxy behavior, finite diagnostic privacy and honest evidence +limits. The first passes all forty-seven offline controls in 17.125 seconds and +independently verifies seven source and nineteen artifact hashes. The second +independently verifies changed executable sources and four raw/sanitized TRX +hashes. Neither reruns provider/.NET/Docker work, and all commands join. +The pre-existing stock pre-ACK guards remain setup requirements; this review +does not claim an earlier loss is impossible or change conservative refusal. + +The verified correction retains each actually issued admission, external, +business and disposition flight before its synchronous prefix. Failure cleanup +closes future issuance, requests owned cancellation and releases its barriers, +then independently awaits actual flights and their cleanup results before +positive execution/private joins. An already-started private completion task is +retained and reused. One failed step cannot skip independent stop/join steps, +and an unfinished or cleanup-faulted operation never supplies a positive witness. + +The original failure observation remains separate from a closed post-cleanup +witness. Only the exact known-failure case and stdout category can carry that +optional witness; finite states/counts, malformed/ambiguous/private-output +refusals and sanitizer behavior remain enforced. It grants no completion or +admission authority and cannot turn a failed case into success. + +A single copied-source substitution plants an impossible expected renewal count +after the cancellation callback enters, while the real business task is pending +and COMMIT remains held. The actual adapter reports the intended `later_refusal` +failure with one active execution. Its separate cleanup witness records both +admissions, external/business flights, callback, both identities, private work, +control and generation joined; unissued disposition stays `not_issued`. +Executions, running work and retained work are zero. Two siblings pass, the +intended case fails and zero cases skip. The original assertion survives, +sanitized TRX remains failed and both owned containers/process group are absent +without forced cleanup. The private mutant receipt has SHA-256 +`99cff1933d6de69c190542b2141e04799f7406f45b5de7c6ac3b62399f166889`. +Root independently verifies the pre/post-cleanup facts and seven source hashes. + +Corrected-source Release builds report zero warnings/errors; scoped format +verification and all forty-nine offline controls pass. The single clean real +run passes three cases with zero skips: false 16.070 seconds, known failure +18.092 seconds and recovery 21.263 seconds; command wall time is 61.189 seconds. +Both owned containers and the process group are naturally absent and all +commands join. Combined private receipt SHA-256 is +`bdbf89a86f718b30c6d449dc5f127efa467c961e86beb9bb697c66460d3968c6`. +Root independently verifies seven current source hashes, nineteen artifact +hashes and both clean TRX hashes. The earlier attribution and rejected ordinal +experiments remain source-bound historical evidence, not new-source reruns. + +Twenty fresh metadata commands complete in 16.080 seconds, preserving every +one of the 5,920 identities, the plan/manifest and both complete assignments. +Private summary SHA-256 is +`67d477db5a547e837ac7ea3e908b80f89463884cf7bef3b1670f4ef68c8f41c0`. +Seven corpus/Docker-trait controls pass with zero skips in 8.868 seconds; +their process group is absent. The second fresh independent review round and +corrected-source hosted qualification remain required. + +#### Second independent attribution review and source freeze + +Two different fresh reviewers inspect all six files in +`7b0fd1b6..c9abc978` and their dependencies: `gpt-6.1-sol` at `xhigh` for +ownership/security and `gpt-6-astra` at `high` for evidence/governance. Both +approve without a verified Blocker, Major or Minor. Both independently confirm +the first round's cleanup correction: actual flights precede positive joins, +private completion is reused, independent cleanup continues and first failure +survives. The early negative verifies issued business and held cancellation +cleanup; it does not infer cleanup from process exit. + +The first passes all forty-nine offline controls in 16.588 seconds. Both inspect +the actual driver, adapter output, four raw/sanitized TRX hashes and nineteen +artifact hashes, preserving the intended failure and clean three-case outcome. +The second also approves the unpublished PR description and its explicit +qualification boundary. Neither repeats .NET/provider/Docker/native experiments. +All commands join. One initial status probe reports an existing untracked +directory entry; no contents are opened or enumerated, and later status probes +exclude untracked paths. + +The commit hook normalizes only line 461's leading indentation from sixteen +spaces to twelve. Reversing the exact retained mutant substitution reconstructs +the tested source, SHA-256 +`4538ae656ffded55fe4c39d2918a143a882276958403ea6a4dd8be0db7da68df`. +Both reviewers independently verify the one-line whitespace mapping to committed +SHA-256 `1cefcb2f5c5f6e996cd3f4c9ada7164b2ca487b2d8225361ba62b52ae810c1cd`; +the other six attested files match byte-for-byte. Seven-file byte identity is +not claimed after the hook. The separate private binding receipt has SHA-256 +`e3b73f02e9b572ab602a5875360be7e48450005b23c4195d85695871d2d6b0cc`. +It records a formatting bridge, not a new runtime or binary-identity claim. + +Local implementation, validation and both fresh independent attribution review +rounds are complete. [PR #34](https://github.com/HodeTech/LearnStack/pull/34) +records the frozen head's exact run/attempt identities, complete A/B assignments, +retained producer/aggregate/public artifacts and current seven-check result. +Complete current-source qualification is required before readiness; a successful +sibling or earlier revision supplies no waiver. Merge and P02b-7 remain separate. + + +#### Reviewed-source recurrence failure and evidence boundary + +[Run `39723329277`](https://github.com/HodeTech/LearnStack/actions/runs/39723329277), +attempt 1/layout A, freezes reviewed head +`4e06ffa1359bb2701e7a097a1fb1b7f9244ad9d1`, tree +`565a62db2958306fc29d198b2672e531b5318fb1`. All twenty-seven jobs finish: +twenty-five pass; ordinary-8 fails and integration refuses that producer. No +layout B starts. All twenty producer archives and the successful backend +aggregate archive are retained with their raw API inventory and verified +digests. The failed-run capture summary has SHA-256 +`46a0af0a9af9b64593ef834a8474d5a8fa84887c6d0c1fd9051dd848f8b669e3`. +This capture records failure, not complete qualification. + +The held actual native recurrence callback case fails in 32.5851077 seconds. +Its artifact, `12402495557`, has SHA-256 +`c19332a22de1977c1368e4262430f29bd677ac52d50b40ea81a3d333d1c79be9`. +The retained top-level exception class is `timeout`; no mapped source location +is available. The owner reports `owned_completion_missing`, workload +`tests_failed` and forced cleanup. Multiple independent thirty-second waits +exist; the test's cleanup can also replace an earlier exception. Duration alone +identifies none of these boundaries. + +The actual job log contains no underlying adapter stack or error message. +Ordinary console output is discarded, raw execution diagnostics are deleted and +TRX errors are sanitized. Neither native recurrence source file has a finite +location mapping. Empty exported locations therefore do not establish an empty +original stack. Two independent read-only inspections find no source-proven +production defect; this run's exact timeout boundary is unrecoverable from its +retained evidence. The next correction preserves the first exception, maps both +source files and records finite first-failure and later cleanup facts separately. +It changes no production behavior, assertion, wait or admission rule. + +Public demo independently passes on this same source. Immutable artifact +`12403135471`, SHA-256 +`3d31ba7b57bb3de2c19a868fed71eb7b42fde79fde4ca794ec63b925e2bccd55`, +is retained with its raw API binding. All twenty-seven report pairs, nine PNGs, +nine navigations and twenty-four visibility checks pass; all 134 API completions +return 200. Toolchain, TLS/timeout controls and natural cleanup pass; cleanup +takes 11.783 seconds. Independent verification-summary SHA-256 is +`5335e9a6dcde02f51cda4c103508afdc32e07e75018974d4f1db801847957a72`. +This successful sibling supplies no waiver for the failed recurrence case. + + +#### Recurrence failure transport and first-exception proof + +The correction retains the existing case identity, assertions and timeouts. +Its first body, explicit cleanup or post-cleanup exception is preserved through +`ExceptionDispatchInfo`. Later cleanup steps are attempted independently. An +early body failure also requests the two actual source disposals; only their +positive completion can establish cleanup. No production code changes. + +One exact integration case may project its two test-owned `StdOutMsgs` markers. +Finite boundary, task/thread, owner-count and source-state fields distinguish +the first failure from the later cleanup observation. The sibling task arrays +retain their production-published order. Neither diagnostic proves completion +or changes a failed outcome. Unknown or malformed data is refused; identities, +raw errors, SQL and arbitrary text are excluded. Both recurrence source files +now have finite source-location mappings. These bounds constrain output; the +owner snapshots are not represented as lock-free or universally nonblocking. + +A private one-substitution control changes only the held callback assertion's +expected count from one to an impossible negative value. The actual pinned +adapter transports the intended failed case at `held_assertions`: callback one, +thread alive, return pending and both sources undisposed. Its original assertion +and stack survive. After callback release, the actual thread returns, all three +pools, three control loops and fixed sibling/control/runtime tasks complete, +and both source disposals complete without unconfirmed cleanup. The failed +outcome remains failed after output sanitization; no process exit supplies the +internal cleanup evidence. + +A separate two-substitution control adds an awaited cleanup failure after the +actual stop completes. The exact case's output proves this second fault was +reached. The first assertion and its stack still survive, while subsequent +completion, thread and source cleanup continue successfully. An initial private +build refuses a literal throw under CA2219; the corrected awaited failure keeps +that rule enabled. Offline proof validators initially refuse duplicate summary +output and replay-file bindings. Their refusals remain recorded; validation is +corrected against the same unchanged actual execution and exact case output, +without rerunning the test to replace its result. + +Both fault experiments have one intended failure and zero skips. The untouched +source is then rebuilt with zero warnings/errors; scoped format verification +passes. The original case passes once with zero skips in 2.7962847 test seconds, +9.522 command seconds. Each experiment's process group and two exact owned +containers disappear naturally. The combined private receipt has SHA-256 +`f37b584d22c72191a54465b4dd2e1649ea2d9db641a9a4d5f0139ec1885926b4`. +Root independently verifies seven source hashes and sixty-one retained artifact +hashes, including raw protocol, original/sanitized TRX and actual owner cleanup. +These local controls qualify the diagnostic correction; they cannot recover the +earlier hosted timeout's missing boundary. + +All fifty-three offline launcher controls pass. Twenty fresh metadata commands +complete in 22.886 seconds, retaining all 5,920 exact cases and unchanged plan, +manifest and A/B assignment digests. Their private summary has SHA-256 +`127e95b0d206e067c89d4cbc0061e3c1b706938c62c50569880161c7925e71c9`. +Seven corpus/Docker-trait controls pass with zero skips in 9.863 seconds; their +process group is absent. Changed-document validation finds no broken relative +link or fragment, added analysis reference or overlong prose. +Fresh independent correction reviews and complete current-source hosted +qualification remain required before readiness. + + +#### First independent recurrence diagnostic review + +Two fresh reviewers inspect all five files in `4e06ffa1..55dfeaea` and their +relevant dependencies: `gpt-6-astra` at `xhigh` for ownership/security and +`gpt-6.1-sol` at `high` for export/evidence/governance. Both approve without a +verified Blocker, Major or Minor. Both confirm first-fault preservation, +independent cleanup, unchanged assertions/timeouts, actual thread/owner/source +completion, ordered sibling observations and failure-only finite transport. +Neither treats a disposal request, elapsed wait or process exit as cleanup. + +Both independently verify seven current source hashes and all sixty-one retained +artifact hashes, reconstruct the exact private substitutions and inspect actual +protocol, original/sanitized TRX, proof drivers and cleanup records. The dual +control's exact-case marker proves its second fault was reached while the first +assertion and stack survive. The export reviewer independently replays both +failed controls and the clean result through the decoder, verifies forty-two +clean assembly hashes and passes all fifty-three offline controls in 17.016 +seconds. The core reviewer independently checks the production completion chains +and the sibling helper's unchanged adjacent caller. + +The export reviewer also checks all twenty-one failed-run archives against their +raw API inventory. The record retains the unrecoverable historical timeout and +requires complete corrected-source qualification. No .NET/provider/Docker/Node +experiment is repeated. All commands join, no files change and the tracked tree +stays clean. The second fresh independent review round remains required. + + +#### Second independent recurrence diagnostic review and source freeze + +Two different fresh reviewers inspect all five files in +`4e06ffa1..196a5b4e` and their relevant dependencies: `gpt-6.1-sol` at +`xhigh` for ownership/correctness and `gpt-6-astra` at `high` for +privacy/evidence/governance. Both approve without a verified Blocker, Major or +Minor. They independently confirm first-fault preservation, continued cleanup, +unchanged assertions/waits, actual completion chains, the adjacent helper caller +and strict finite failure-only export. No production code changes. + +Both verify seven source hashes, sixty-one artifact hashes, exact private +substitutions, actual adapter/first-stack transport, original/sanitized outcomes +and forty-two clean assembly hashes. Both also check all twenty-one historical +archives against the raw API inventory. The core reviewer reconstructs the twenty +fresh metadata commands, every identity and both distinct assignment digests, +and verifies the seven-case corpus TRX and build/format records. Independent +launcher runs pass all fifty-three controls in 16.713/18.139 seconds. All commands +join; neither repeats .NET/provider/Docker/Node experiments or changes a file. + +The governance reviewer also approves the unpublished final PR draft subject to +replacing its pending qualification marker with actual current-source results. +Local implementation, validation and both fresh recurrence diagnostic review +rounds are complete. [PR #34](https://github.com/HodeTech/LearnStack/pull/34) +records the frozen head's exact hosted run/attempt identities and retained +producer/aggregate/public evidence. Complete corrected-source A/B qualification +remains required before readiness; the earlier timeout boundary stays +unrecoverable. Merge and P02b-7 remain separate. + + +#### Reviewed-source startup failure and retained evidence + +Frozen-source assignment A at +`dbfad06b1302c76c9d2b4a29dfca0d6db0795026` did not qualify. Run +[39727855228](https://github.com/HodeTech/LearnStack/actions/runs/39727855228), +attempt 1, completed 25 of 27 jobs successfully; `backend ordinary-7` failed +and the required integration aggregate refused its incomplete evidence. No +assignment B was started and no failed-job retry substitutes for this result. + +- Failed case `4172c42b-b84f-59bf-426f-60d6c521ec52`, the Outcome-ACK + settlement case (`knownFailure: False`), returned cancellation after + 32.0671777 seconds. + Its source locations identify the startup wait for the Outcome cleanup + barrier. The retained finite snapshot reports `prepared`, one normal + attempt, one renewal refusal, zero elapsed losses and zero executions. + No original Outcome owner was captured. This establishes failure before + the intended post-ACK proof; it does not identify the renewal refusal branch. +- Producer artifact `12404570952` has SHA-256 + `29cc4750561aabf3efc51b2e59b841033ae5a9c95a7601ff4d66a97b50a42dbc`. + It reports `tests_failed` and `owned_completion_missing`, with forced + cleanup, incomplete ownership and separately completed cleanup ownership. + All 20 producer archives and the successful backend aggregate are retained + against the raw Actions API sizes and digests. The final 21-archive capture + has SHA-256 + `44bb709c4683ad3d9a077ea5ec6686f83790746d6a1cfa30cc0a6ebdb32a1ae6`; + its qualification flag remains false. +- The previous recurrence failure's exact case passed in this run after + 5.5832527 seconds, with complete natural ownership. This supplies no missing + historical timeout cause. The public proof also passed independently; + neither result overrides the failed whole assignment. + +Source inspection identifies a possible legal interleaving: Prepare's real +row lock can exclude the still-active identity from a successful +`FOR NO KEY UPDATE SKIP LOCKED` renewal batch. A timely, acknowledged empty +batch closes that identity's permission; Prepare may still receive its +already-issued COMMIT acknowledgement. The observed startup facts are +compatible with this branch, but do not prove that it occurred in CI. +The correction owner is the settlement test fixture. Production renewal +policy, SQL, lease deadlines and refusal semantics remain unchanged. + +Failed-job reruns do not replace this evidence. Attempt 2's integration job +`119676062757` refused `artifact_api_missing`: only `ordinary-7` produced +an attempt-2 archive. Its artifact `12405733408`, SHA-256 +`68252513daa900062f3f8ed7823922f3dd09da91a6942b3a588d0f2302dee91a`, +contains 240 passing cases under assignment B; the previous failed case is +absent. Reusing the same shard name does not rerun the same assignment. +Attempt 3's integration job `119676234308` refused +`jobs_api_population_changed`; no attempt-3 producer archive exists. +The original API response at that refusal was not retained, so its exact +population discrepancy is not recovered from a later consistent response. +The rerun capture has SHA-256 +`b730e8293ae0a806865579c6e838d0418892f7ddf881fd06efcd34c691d13496`. +The [existing recovery procedure](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +requires **Re-run all jobs**, with all producer evidence from the same attempt. + +#### Settlement fixture scheduling and real counterexamples + +The three existing settlement cases now isolate the intended post-ACK proof +from unrelated legal pre-ACK renewal refusals. Their fixture uses a distinctive +validated 1125 ms renewal cadence instead of one second. No production code, +SQL, lease duration, deadline or assertion is weakened. + +- The first typed native lease factory creates and retains its real system + timer, then waits outside locks for the actual renewal pacing timer. The + execution identity is still unpublished, so the immediate initial renewal + scan sees no eligible identity. Native lease and deadline clocks keep running. +- The first actual pacing callback waits until the original Outcome ACK and + held cleanup. Its release exercises the real renewal loop. The existing + four-second observation budget also requires the second actual pacing timer + to arm before releasing Outcome cleanup. Holding that callback prevents an + unrelated pre-ACK Finalize race from contaminating this settlement proof. + The existing 30/80-second startup/completion waits remain unchanged. +- Cleanup seals and opens both holds before stopping the runtime. Actual timer + disposal joins queued callbacks before synchronization objects are disposed; + incomplete runtime cleanup cannot close the factory census. Synchronous + timer disposal never joins itself from inside a forwarded callback. The + second callback's separate 120-second safety watchdog cannot extend any + caller deadline or prove disposal. +- An independent explicit-owner counterexample holds the real Prepare COMMIT + request and verifies its PostgreSQL row lock. A separate actual renewal + operation returns a timely, clean, physically acknowledged empty result, + with its exact original job/attempt/token probe. The identity closes with + renewal refusal while the admitted Prepare later ACKs. Application-role + readback confirms prepared state, no execute intent/outcome and no provider + invocation. This proves an available legal interleaving; it does not recover + the historical CI branch or claim stock Hangfire handoff. The existing + later-Finalize attribution counterexample remains independently asserted. +- Failure export adds two fixed source roles and optional finite pacing facts, + retaining the eight-location bound. Prepare-only facts bind the exact + `knownFailure: True` case. No raw path, exception/provider text or identity + enters this projection; diagnostics cannot qualify completion. + +One frozen private copy binds 1,927 source files and five exact changed-source +overlays. Clean execution passes all three original case IDs, zero skips, in +64.145 seconds; their durations are 16.1845573, 21.3963182 and 18.9924022 +seconds. The actual adapter is `2.8.2.0`. + +Two private controls exercise the proof rather than merely its setup: + +- Removing both active-selection and empty-result settlement exclusions makes + all three cases fail at `renewal_second`, on the unchanged open-Run assertion + after physical Outcome ACK. Actual Outcome cleanup is still pending and the + durable phase is `finalize_pending`. The original sticky cause and both loss + counters remain empty/zero, as required for an already settled identity. + Actual pacing callbacks/timers join without a pacing fault. +- A planted Prepare assertion after the genuine empty-ACK observations makes + only the named known-failure case fail; the other two pass. The original + assertion survives. Issued admission, external/business work, cancellation + callback, identity, control, private invocation and generation all join; + execution/running/retained counts are zero. No unrelated operation is labelled + issued or joined. + +All six test-created containers across these runs are naturally absent; owned +process groups join. The complete receipt has SHA-256 +`1ef5254dc6086a722e6d8b95c63b3b3451a755b1222a31d5d4634fd40b19576a`. +It binds original/sanitized TRX, protocol, exact private substitutions, source +and binary evidence, seven command receipts and 749 final workspace outputs. +The final clean workspace Release build reports zero warnings/errors and +scoped format verification passes. Independent pure replay of all three +actual protocol streams agrees with their retained outcomes and finite facts. +All fifty-five launcher controls and nineteen aggregate controls pass. + +Fresh metadata discovery completes twenty bounded commands in 17.379 seconds: +5,920 cases, the same complete manifest and unchanged, materially different A/B +assignments. Its receipt has SHA-256 +`19ab2cfd147b8477983f5addf40eee6bbf207e29c484f887f92189883ca563be`. +This is metadata evidence, not execution of the whole suite. Seven current +corpus/Docker-trait cases pass without skips in 8.042 seconds; their receipt has +SHA-256 +`25d949b4a406b9ad1ecd322166023de788894ae185ab8592a74ead9840196445`. +The phase's 288 relative links/fragments resolve. Independent implementation +reviews and complete corrected-source hosted A/B qualification remain required. + +#### First independent settlement scheduling review + +Two fresh reviewers inspect all six files in `dbfad06b..0dc962dc` and their +relevant production owners, publication, timer, proxy and cleanup dependencies: +`gpt-6-astra` at `xhigh` for correctness/ownership and `gpt-6.1-sol` at +`high` for privacy/evidence/governance. Both approve without a verified +Blocker, Major or Minor. They confirm unchanged production policy, exact +case identities/deadlines, actual timer joins, first-fault preservation and +positive cleanup only after every issued operation is observed. + +Both independently match all five executable-source overlays and forty-eight +scenario artifacts. Raw TRX confirms three clean passes, three intended +post-ACK exclusion failures and the single intended Prepare failure with two +passing siblings. The evidence reviewer additionally reconstructs all three +actual adapter protocols, checks the 1,927-file clean manifest, all twenty-one +historical archives against their API inventory and the partial-rerun capture. +Neither starts tests, builds, processes or containers, edits a file or claims +that the historical refusal branch is recovered. The second fresh independent +review round and complete corrected-source hosted A/B qualification remain +required. + +#### Second independent settlement review: deadline finding + +Two different fresh reviewers inspect the complete six-file change through +`c971f4cc`, with `gpt-6-sol` at `xhigh` for ownership/correctness and +`gpt-6-astra` at `high` for privacy/evidence/governance. The governance +reviewer identifies one verified Major: normal publication calculates the +remaining deadline before creating the lease timer, but starts its deadline +observer only after that factory returns. The fixture's blocking handshake +therefore shifts the native deadline observation by its wait duration. The +running system clock and lease timer do not compensate for that distinct delay. +The ownership reviewer initially approves, then independently confirms this +ordering in an explicit addendum that supersedes that approval. Both require +the correction; neither reports another verified finding. + +This limits the preceding unchanged-deadline claim. The three clean passes and +both private controls remain retained evidence for their exact source, not +qualification of deadline preservation. Recovery's separate elapsed-time +re-debit does not repair the normal publication path. The correction must start +both actual normal timers before holding publication, retain their real +cleanup ownership and keep the initial renewal scan ahead of identity +publication. Production code and deadline policy remain unchanged. + +The reviewers independently verify source/receipt/artifact hashes, the finite +projection, raw/sanitized outcomes and retained failure chronology. No runtime, +test, build, process or container is started. The original API response behind +attempt 3's population refusal remains unavailable. Fresh source-bound proof, +independent fix verification and complete hosted A/B qualification are required +after the deadline correction. + +#### Deadline-preserving settlement correction + +The normal fixture returns the actual lease-timer handle immediately and +intercepts only the next deadline-timer factory in that same execution flow. +A shared consumed slot prevents inherited execution contexts from selecting it +again. The actual System deadline timer starts with its original duration +before the publication hold; its callback is never gated. The test deliberately +holds first pacing-timer creation, verifies both actual normal timers and the +unpublished identity, spends real time inside the existing startup budget, then +opens pacing creation. An expired deadline cannot authorize business execution +when that synchronous construction path resumes. This does not claim the +execution-close action runs while its own synchronous prefix is held. + +Retained recovery keeps its separate lease-return handshake and elapsed-time +re-debit; it has no normal deadline observer. Cleanup seals factories before +opening waits, observes every issued factory return, and joins actual timer +and callback disposal before releasing synchronization resources. The three +case identities, production source, stage durations, four-second post-ACK +observation and physical application-role assertions remain unchanged. + +The finite diagnostic projection adds the normal-only `deadline_armed` +boundary and actual deadline-timer state. Retained recovery cannot claim that +boundary or an issued normal deadline timer. Fifty-six offline launcher +controls pass, including case binding, closed values and canary rejection; +the existing eight-location cap remains unchanged. + +The corrected-source actual pinned-adapter run passes all three settlement +cases with zero skips. Both normal cases prove actual lease/deadline arming +before the held first pacing factory; the known-failure case also executes the +unchanged real Prepare and later Finalize counterexamples. A copied complete +settlement-exclusion defect makes all three fail at `renewal_second`, preserving +the original assertion, physical acknowledged Outcome and actual timer/runtime +joins. These results are source-bound local proof, not hosted qualification. + +A separate copied deadline-ordering defect moves actual deadline creation after +the publication handshake. Both normal cases fail at `deadline_armed`, and +retained recovery passes. The original cancellations survive sanitization and +the selected deadline and first pacing timers finish/cancel, but runtime +completion has no positive join within the unchanged drain budget. Its full cleanup oracle therefore +refuses. The bounded test process exits naturally and both exact owned +containers become absent; those observations do not replace the missing +internal runtime witness. This is retained detection evidence with an explicit +cleanup limit, not a passed cleanup control. No corrected-source change or +production change is inferred from that deliberately broken construction. +The earlier planted Prepare control remains evidence for its original frozen +source only; the unchanged helper body does not turn an old binary into fresh +corrected-source proof. + +The retained corrected-source summary has SHA-256 +`195fff62567643010e27f0dc5f0cde5bb2af24a94fe50ef5cd3f319891cb8f5e`; +its 1,927-file frozen source manifest has SHA-256 +`5b0988882c9a6331c4f0c878c063597256566711a2104a5dbdf6a26649813d37`. +Independent replay matches all actual adapter protocol completions, original +and sanitized TRX outcomes/durations, finite facts and source substitutions. +All retained artifact hashes and five workspace overlay hashes match. The +final workspace Release build passes with zero warnings/errors in 47.217 +seconds; scoped format verification passes in 23.198 seconds. Every owned +command group exits. The initial clean Docker absence inspection inherited +its helper environment, with application credential-prefix presence checked +false; all .NET commands and subsequent Docker checks use the explicit tool +environment allowlist. This distinction remains in the provenance receipt. + +Fresh metadata discovery completes twenty bounded commands in 18.006 seconds: +5,920 exact identities, unchanged complete A/B unions and materially different +assignments. Its summary has SHA-256 +`940090d702545aa7eb791559b333f7c46408cb0b30ad902c75c2ce6f9ea6072d`. +Seven current corpus/Docker-trait controls pass in 8.631 seconds; their receipt +has SHA-256 +`5e092c42b844d746d7ad6a5be99682641c95a6ec71b08b62542f94253a857ad3`. +All 749 final workspace build outputs match their retained manifest. +Independent fix verification and complete corrected-source hosted A/B remain +required before final qualification. + +#### Independent deadline-fix verification + +Two new independent reviewers approve `c971f4cc..c618414e`: `gpt-6-astra` +at `xhigh` for correctness/ownership and `gpt-6.1-sol` at `high` for +privacy/evidence/governance. Both inspect all five changed files and the +adjacent publication, recovery, native lease and cleanup paths. Neither finds +a verified Blocker, Major or Minor. Each independently checks the original +deadline ordering, actual timer arming before the hold, publication/gate safety, +factory/callback ownership and the recovery distinction. Their offline replay +matches exact identities, durations, raw/sanitized outcomes, substitutions and +retained hashes; focused in-memory parser checks also pass. Neither executes +.NET, providers, Docker, Node or a build, edits the repository or scans processes. + +Both explicitly preserve the ordering control's refused runtime-cleanup +oracle. Its natural process/container absence is not substituted for internal +quiescence. The earlier unchanged-deadline claim remains limited by the dated +finding above. Post-commit hashes match all five tested overlays. The reviewed +correction is ready for a new complete hosted A/B qualification; no prior green +sibling or failed-job rerun supplies that missing final-source proof. + ## Completion Criteria Each criterion below is observable: a named test can pass or fail on it. Criteria whose @@ -13300,12 +17644,14 @@ also cover the combined HTTP/event-delivery tracing criterion: actual committed rows match the enqueuing activity, announced tenant/org and exact nonempty partition. The [P02b-3 merge closeout](#p02b-3-merge-and-closeout-2026-10-15) owns final combined -event verification. P02b-4's job infrastructure is locally complete, with exact -qualification in its [local closeout](#p02b-4-local-closeout-2026-10-18). P02b-5 is -locally complete after all five steps and both independent review rounds per -step. Its [Step 5 closeout](#p02b-5-step-5--local-implementation-and-review-closeout-2026-10-19) -owns exact validation limits and live PR-head qualification. P02b-6–8 and merge -remain pending. +event verification. P02b-4's job infrastructure is complete and merged; its +[merge closeout](#p02b-4-merge-and-closeout-2026-10-19) owns exact qualification. +P02b-5 is complete and merged after all five steps and their independent reviews; +its [merge closeout](#p02b-5-merge-and-closeout-2026-10-20) owns exact final-source +qualification and validation limits. P02b-6's four implementation steps and local +reviews are complete; its Step 4 and correction records own local qualification, +independent reviews and source-bound complete hosted receipts. +P02b-7–8 remain pending. The whole phase is not complete. - An aggregate change and its outbox row commit together and roll back together, @@ -13461,10 +17807,11 @@ CI and merge verification; its dated local receipts retain their original scope. what **G20** records, over a non-empty route set, and the authenticated refusal carries `tenant_mismatch` while the anonymous one stays byte-identical to an unknown host's. -- The realm export declares `tenant_id` and `user_id` in its user-profile configuration - with admin-only view and edit; a variant export marking either attribute user-editable - fails the case; and a seed user's own token cannot write either through Keycloak's - account REST surface. +- The realm export declares `tenant_id`, `user_id` and `organization_id` in its + user-profile configuration with admin-only view and edit; a variant marking any + of them user-editable fails the same protection predicate. A seed user's own + account token cannot write/delete any of them, while ordinary edits succeed; + registration cannot inject them into the stored account. - Authenticated and id-addressed routes have tenant and organization isolation cases as `learnstack_app`: an identifier from another tenant or an invisible sibling organization reveals no resource, and each route has a visible-resource @@ -13483,8 +17830,12 @@ CI and merge verification; its dated local receipts retain their original scope. JSON leaves every existing account and the `learnstack-hub` realm intact, applies the client scope and the mappers, and yields a token carrying a UUID-valued `tenant_id`. A third run changes nothing. -- `exp - iat` on a dev-realm token equals the lifetime the realm export declares, so - raising one without the other fails. +- The development realm declares the exact accepted access-token lifespan with + no override on either named client. Fresh signed expiration fits the observed + UTC/monotonic code-exchange window plus that configured lifespan, under the + accepted [TTL semantics](#accepted-g12g13-answers). Independent configuration, + client-override and wrong-expiration controls detect drift; shorter late-session + issuance is covered separately. Exact `exp - iat` equality is not required. - The callback route rejects a response whose `state` does not match the transaction cookie and one with no `code_verifier`; the session cookie is `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` with no `Domain`; and no response body or script-readable diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index 21eb4a96..f943ec88 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -32,6 +32,15 @@ those flows. It owns durable subject/actor mapping, real membership, permissions and permission-sensitive routes. Bootstrap seed mapper emission remains P02b-6; this phase extends that integration to the live identity/membership lifecycle. +**P02b-6 SDK-boundary handoff — 2026-10-21.** The +[accepted package](phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +uses development Admin REST tooling without a runtime provider SDK. Phase 03 +owns the first runtime identity-provider adapter and the +[`Provider_SDK_Types_NotImportedOutsideInfrastructure`](../standards/21-architecture-tests-catalogue.md#awaiting-backfill) +backfill when that adapter introduces an actual SDK. Re-verify the subject +before the first writer; direct HTTP alone does not qualify SDK coverage. +The rule remains Awaiting backfill; the SDK exception rule remains enforced. + Before replacing deny-all membership, qualify affirmative row-7 organization selection and row-14 claim-only resolution through real HTTP controls. Decide header-mismatch evidence ownership after a successful claim-only platform-host diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 5812fdad..1992be40 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -24,7 +24,7 @@ title: LearnStack Test Pyramid flowchart TB e2e[End-to-end / Playwright
handful of golden flows] contract[Contract & API tests
OpenAPI + provider fakes] - integration[Integration tests
Testcontainers Postgres] + integration[Integration tests
Testcontainers Postgres + pinned Keycloak issuance] arch[Architecture tests
module boundaries + tenant invariants] unit[Unit tests
domain + application + UI logic] @@ -35,7 +35,9 @@ Text fallback (for renderers without Mermaid support — pyramid base → top): - **Unit tests** (base layer, widest) — domain + application + UI logic. - **Architecture tests** — module boundaries + tenant invariants. -- **Integration tests** — Testcontainers **Postgres**. Valkey, SeaweedFS and the rest arrive with the phase that ships something calling them ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). +- **Integration tests** — Testcontainers **Postgres** and pinned Keycloak issuance. + Other providers arrive with their actual consumers + ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). - **Contract & API tests** — OpenAPI + provider fakes. - **End-to-end / Playwright** (top, narrowest) — handful of golden flows. @@ -60,7 +62,7 @@ We invest most at **unit + integration**. Architecture tests are zero-flake. E2E ### Integration Tests -`LearnStack.Tests.Integration` holds **three populations**, and which one a test +`LearnStack.Tests.Integration` holds **four populations**, and which one a test belongs to is decided by what it needs, not by what it is about: - **Host tests** — a real HTTP pipeline through `WebApplicationFactory`, no @@ -91,12 +93,35 @@ belongs to is decided by what it needs, not by what it is about: resources. Test-only probes remain separate from the production endpoint set. These live under `Database/` with the rest of the Docker-bound suite and carry the same trait. +- **Identity-provider tests** — P02b-6's `TenancyIdentity/KeycloakFixture` imports + the committed realms into Keycloak 26.6.2 with its own PostgreSQL database. + `SharedKeycloakRealm` shares a baseline provider, not an immutable or read-only + store: real login changes provider sessions, and the actual reconciler runs + against it with independently checked zero-write expectations. Cases in that + collection serialize. Warm/mutated realm variants own private providers; + tests changing shared owner state restore it before returning. The provider + uses owned HTTPS with a generated + fixture CA, explicit custom-root trust and normal hostname verification; no + host trust-store mutation or certificate-validation bypass is allowed. + Code/S256 login and signed tokens are real; + capturing the registered HTTPS callback without following it does not prove a + browser/BFF session. Application isolation still requires the separate + `learnstack_app` fixture. Provider/admin credentials and tokens never enter + failure output. These cases carry the Docker trait and enter ordinary shards. + `TenantRealmApiTestGroup` separately combines the provider with the actual + `PublicReadFixture` and preserves its physical read-only/application-role + observations. It is nonparallel within its process; do not add that database + fixture to the provider-only collection. `TenantRealmProfileProtectionTests` + owns a class fixture rather than changing the shared baseline's profile. + Offline realm tooling controls run in meta with + fresh named-case reports and zero skips/todos. **Where a Docker-bound test lives, and what it takes, is what routes it.** `Every_Database_Test_Carries_The_Docker_Trait` scans the whole of `LearnStack.Tests.Integration` and requires the trait on every test class under `Database/` and on every one elsewhere that takes a container fixture — -`SchemaFixture`, `PostgresFixture` or the `SharedSchema` collection, as +`SchemaFixture`, `PostgresFixture`, `PublicReadFixture`, `KeycloakFixture`, or the +`SharedSchema` / `SharedKeycloakRealm` / `TenantRealmApiTestGroup` collections, as `ApiHandlerCompositionTests` does. It scanned `Database/` alone until Packet 9's fifth review, which left a class beside the HTTP suites unchecked. A missing trait does not fail the class, it runs it in the `backend` job: the hosted runners also @@ -108,8 +133,11 @@ All Docker executions and their aggregate additionally require their exact discovered case identities, complete results and zero skips: a filter matching nothing exits `0` without those guards. -All three: real module configuration, no mocked repositories, and coverage of the -happy path and the edges. +Host tests use real application module configuration. Data and request-level +isolation tests use real PostgreSQL plus application module configuration. +Identity-provider tests use real Keycloak with the committed realm configuration; +combined provider/API cases also use real application module configuration. +All four use no mocked repositories and cover happy paths and edge cases. Under [ADR-0062](../decisions/0062-native-crash-proof-ci-provisioning.md), `scripts/ci-native-tests.py` owns the complementary Docker populations: @@ -126,6 +154,14 @@ empty project populations remain covered by discovery. Final results reconcile those records with TRX, including all summary counters. A display-name-only progress record cannot distinguish colliding theory rows. +For the five enumerated live Keycloak test classes, failed execution measurements +retain only the fixture's existing closed startup stage, reason and cleanup +result. Unknown, ambiguous or oversized messages supply no diagnostic fields; +raw messages, exception types and provider output remain private. These fields +neither establish quiescence nor replace Docker absence, complete results or a +successful execution receipt. Completed-case streams and retained TRX contain +no raw startup failure text. + Native alone uses the exclusive disposable Ubuntu 24.04 VM and closed `scripts/ci-native-provisioning.py` owner. Setup verifies a supported collector or writes only literal `core`; it never restores a pipe. The unchanged child fixture diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 1982f57a..1cecdaa9 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -276,7 +276,7 @@ The actual singleton-budget suppression path is also exported through the root's `LearnStack.Api` meter subscription. Its test waits for the correlated Warning while the logger owner is alive: a metrics flush is not a log-delivery receipt. The ordinary-destination controls are implemented; the -[P02b-5 remediation record](../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) +[P02b-5 merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) owns their current qualification status. Step 3 implements recorder/claimless/quota diagnostics; Steps 4–5 qualify the diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 8599f9c7..8931a545 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -6,6 +6,10 @@ model, and session-variable placement**), [ADR-0004 Authentication Strategy](../decisions/0004-authentication-strategy.md) (Amendment 1: `learnstack-hub` realm), +[ADR-0067 Development Tenant Access-Token Issuance](../decisions/0067-development-tenant-access-token-issuance.md) +(the configured development issuance lifespan and provider timestamp semantics), +[ADR-0068 Development Warm-Realm Qualification](../decisions/0068-development-warm-realm-qualification.md) +(the bounded preparation and stock-console assignment authority), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), [ADR-0036 Tenant Resolution and Trusted Inputs](../decisions/0036-tenant-resolution-trusted-inputs.md) @@ -154,6 +158,54 @@ General rules: - Account lockout after 5 failed attempts in 10 minutes; lifted after 15 minutes or admin unlock. +**Development warm-realm qualification — Accepted 2026-10-22.** +[ADR-0068](../decisions/0068-development-warm-realm-qualification.md) owns the +exception allowing qualified missing-definition preparation before a fresh full +plan and the three stock-console assignment detachments. All identity-independent +refusals precede preparation; all other writes require complete planning. Existing +readable definitions, credentials and unrelated state remain preserved. This is +account-preserving reconciliation with named assignment deletion, not general +delete authority. All three correction steps complete local implementation and +both independent review rounds. The +[qualification record](../roadmap/phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +owns actual historical lifecycle, usable credential/MFA, signed API and stored +self-service proof, together with exact hosted assignments and evidence limits. +Acceptance or a partial experiment alone does not establish qualification. + +**Development access-token issuance — Accepted 2026-10-21.** This standard owns +the issuance-lifetime rule decided by +[ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md), retaining +the earlier approval in the +[P02b-6 package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20): +the tenant development realm uses a configured 900-second access-token lifespan. +[`learnstack.json`](../../infra/keycloak/realms/learnstack.json) owns the executable +value; the development reconciler enforces it with no conflicting override on +either named client. P02b-6 Steps 1–3 supply the fresh-import definition and +bounded reconciliation through both seed/demo entries; their delivery records +own actual local qualification. Step 4 delivers complete real-token/API proof +after both independent reviews; its +[record](../roadmap/phase-02b-events-auth.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) +owns local proof/review closure and source-bound qualification receipts. The +required CI checks qualify their exact source and assignment. ADR-0064's one-hour +API acceptance ceiling and 30-second skew remain separate from issuance. +P02b-7 owns BFF idle/absolute lifetimes and refresh custody; that part of G13 +remains Open. + +The pinned provider also copies the access-token expiration into generated ID +tokens, including shorter session-clamped issuance. ID tokens remain identity +credentials rejected by the tenant API; this shared expiration does not select +a BFF session lifetime. + +Keycloak 26.6.2 reads issuance and expiration clocks separately. The configured +lifespan is not an exact `exp - iat` guarantee or a fixed 901-second ceiling. +Qualify exact realm configuration and absence of client overrides independently; +fresh signed expiration must fit the observed UTC/monotonic code-exchange window +plus that lifespan. Any explicit fixture/provider clock tolerance is at most the +API's existing skew; an indeterminate clock/window fails qualification. Preserve +session-clamping coverage for shorter late-session issuance and independent +mutants with wrong expiration or configuration. Existing tokens retain their API +acceptance bound; a shorter issuance setting does not revoke them immediately. + ## Ingress / Gateway (APISIX) All tenant-facing traffic enters through **APISIX** in standalone mode per diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index ffd25ae5..7faf1838 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -254,6 +254,13 @@ See [10-observability.md](10-observability.md). demand-gated to Phase 11 by ADR-0035. Application code uses `ISecretProvider` in either case; direct `VaultClient` usage is forbidden. - Local: `.env` (not committed) — sufficient for `Development` mode. +- P02b-6's development realm uses `KEYCLOAK_WEB_CLIENT_SECRET` as a private + import/reconciliation source; P02b-7's BFF is its later runtime consumer. + Never pass it or Keycloak admin credentials to the API, public renderer or + .NET Seeder. Existing confidential secrets are verified, never silently + rotated. The [accepted package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) + owns bounded tooling, selected-target validation and the no-credential-output + contract; the [realm guide](../../infra/keycloak/README.md) owns local setup. - **Development-only defaults in `infra/compose/*.yml` are not committed secrets.** A `${VAR:-literal}` fallback that only ever reaches a container in `Development` mode is exempt from [Standards 17 § Blockers](17-code-review.md)'s committed-secret rule, and diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index abc768c5..d71502a8 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -39,17 +39,21 @@ owns review, packet and exact PR-head CI/job-budget status), (Accepted 2026-10-18; general CI obligation implemented; the phase record owns final-source hosted qualification), [ADR-0064 Tenant API Authentication Boundary](../decisions/0064-tenant-api-authentication-boundary.md) -(Accepted 2026-10-19; P02b-5 obligations implemented; Step 5 and the remediation -record own local/source evidence; PR #33 owns current-head hosted qualification), +(Accepted 2026-10-19; P02b-5 obligations implemented and merged through PR #33; +the [merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) +owns final-source hosted qualification), [ADR-0065 Bounded Tenant Assertion Diagnostics](../decisions/0065-bounded-tenant-assertion-diagnostics.md) (Accepted 2026-10-20; extends existing obligations below; audit-budget and telemetry controls implemented; the -[P02b-5 remediation record](../roadmap/phase-02b-events-auth.md#p02b-5-external-review-remediation-2026-10-20) +[P02b-5 merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) owns current qualification status), [ADR-0066 Bounded Tenant Subject Admission](../decisions/0066-bounded-tenant-subject-admission.md) (Accepted 2026-10-20; extends the existing subject-admission obligation; the [delivery record](../roadmap/phase-02b-events-auth.md#adr-0066-acceptance-and-implementation-2026-10-20) -owns source-bound validation, reviews and hosted qualification). +owns source-bound validation, reviews and hosted qualification), +[ADR-0067 Development Tenant Access-Token Issuance](../decisions/0067-development-tenant-access-token-issuance.md) +(Accepted 2026-10-21; formalizes the existing issuance obligation's authority +without adding a rule or changing its implementation status). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -183,8 +187,8 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**229 rules in this catalogue are Implemented, and 133 of them are in that assembly.** -The other 96 are no less binding, and most could not live there. The table says where +**231 rules in this catalogue are Implemented, and 133 of them are in that assembly.** +The other 98 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite that holds ten. @@ -1687,7 +1691,8 @@ catalogue as the carrier of their status — so all three are Packet 10's. - **Asserts:** every test file in `LearnStack.Tests.Integration` that declares a test and needs a container — one under `Database/`, or one anywhere that names `SchemaFixture`, - `PostgresFixture` or the `SharedSchema` collection — carries + `PostgresFixture`, `PublicReadFixture`, `KeycloakFixture` or the `SharedSchema` / + `SharedKeycloakRealm` / `TenantRealmApiTestGroup` collections — carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`. ADR-0062's shared native trait belongs to exactly the two complete native proof classes, including all predicate/theory/mutant rows. A nonempty source closure @@ -1707,7 +1712,8 @@ catalogue as the carrier of their status — so all three are Packet 10's. the whole project in Packet 9's fifth review. Its companion, `The_Docker_Trait_Sweep_Can_Actually_Fail`, feeds the predicate the shapes it must and must not flag, including the added native ownership/closure controls in P02b-4's - ADR-0062 follow-up. Actual discovered case IDs and complete partition results + ADR-0062 follow-up and P02b-6's provider-fixture/missing-trait controls. + Actual discovered case IDs and complete partition results are additionally checked by `scripts/ci-native-tests.py`. - **Phase:** 02a (Packet 6; widened in Packet 9). @@ -3848,7 +3854,7 @@ implements it. | Test | Rule | Kind | Owning phase | Named in | |---|---|---|---|---| -| `Provider_SDK_Types_NotImportedOutsideInfrastructure` | A provider SDK's types — LiveKit, the Keycloak admin client, SeaweedFS, a payment SDK — appear only in the `LearnStack.Infrastructure.*` adapter that wraps it, never in a module. A superset of [`Adapters_Wrap_Provider_Exceptions`](#adapters_wrap_provider_exceptions), which already holds the exception types of the SDKs it lists — the one slice of this rule that runs today | structural | [Phase 02b](../roadmap/phase-02b-events-auth.md), with the first provider adapter; it then binds per provider as each adapter lands | [Tenant Isolation](../architecture/09-tenant-isolation.md) | +| `Provider_SDK_Types_NotImportedOutsideInfrastructure` | A provider SDK's types — LiveKit, the Keycloak admin client, SeaweedFS, a payment SDK — appear only in the `LearnStack.Infrastructure.*` adapter that wraps it, never in a module. A superset of [`Adapters_Wrap_Provider_Exceptions`](#adapters_wrap_provider_exceptions), which already holds the exception types of the SDKs it lists — the one slice of this rule that runs today | structural | [Phase 03](../roadmap/phase-03-identity-admin.md#goal), at the first runtime identity-provider adapter's actual SDK introduction; re-verify before its first writer. Direct-HTTP tooling does not qualify; it then binds per provider as each SDK adapter lands | [Tenant Isolation](../architecture/09-tenant-isolation.md); [P02b-6 accepted handoff](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) | | `LicenseKey_Payload_MatchesSchema` | `entitlement-v1.schema.json` is pinned by a snapshot test run in **both** repositories against the same checked-in schema | structural | [Phase 02c](../roadmap/phase-02c-hub-foundation.md), with the schema | [Hybrid License Model](../architecture/26-hybrid-license-model.md); [ADR-0021](../decisions/0021-feature-based-entitlement.md), as `EntitlementProjection_Shape_IsStable` — see § Canonical names | | `Entitlement_Read_Path_Falls_Through_To_Durable_Row` | With L1 and L2 flushed and the Hub unreachable, the tenant resolves from `platform_entitlement_cache` and no exception escapes the flag read | behavioural | [Phase 02c](../roadmap/phase-02c-hub-foundation.md), with `HubEntitlementProvider` | [Hybrid License Model](../architecture/26-hybrid-license-model.md) | | `CustomDomain_TenantId_NeverReadFrom_RequestBody` | Custom-domain submission derives the tenant from the authenticated session, never from the body or the query | behavioural | [Phase 02c](../roadmap/phase-02c-hub-foundation.md), Hub side, per [ADR-0022](../decisions/0022-custom-domain-tls.md) | ADR-0022; [Custom Domain TLS](../architecture/27-custom-domain-tls.md) | @@ -4499,6 +4505,117 @@ structural test proves — and what it does not. accounting under `Tenant_Assertions_Are_Compared_Not_Resolved` above. - **Phase:** 02a (Packet 4); P02b-5 2026-10-20 remediation extends coverage. +#### `Tenant_Realm_Issuance_Matches_The_Api_Profile` + +- **Asserts:** the committed tenant realm imported into pinned Keycloak issues + ADR-0064's real scalar identity/API profile through code/S256 exchange for all + four declared seed identities. Exact callbacks, confidential-secret/PKCE + refusals, protected admin-only attributes and the actual `basic`/identity/ + audience mappers hold. Real tokens reach AccountSite through the API handler + and app-role PostgreSQL; cross-scope/Hub requests refuse, while claimless + principals retain the public ceiling without protected-read authority. + Exact realm lifespan and absent client overrides are checked independently + from bounded observed fresh expiration and shorter late-session issuance. + Ordinary account-edit success, independent admin readback and dirty/clean + mapper/profile/TTL variants prove protection and drift controls can fail. + The same seed personas use only the declared self-service baseline; actual + registration and writable-profile counterfactuals reach stored values rather + than passing through a generic refusal or account-console HTML response. +- **Source:** ADR-0004; ADR-0064; + [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) + (development issuance); + [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) + (historical warm qualification); + [Security 11](11-security.md#authentication); + [accepted P02b-6 package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20). +- **Type:** xUnit Integration with Docker, real Keycloak code exchange, API + HTTP and `learnstack_app` database controls. **Kind:** behavioural. +- **Test owner:** `TenantRealmIssuanceTests`, `TenantRealmProfileProtectionTests` + and `TenantRealmApiTests` in + `backend/tests/LearnStack.Tests.Integration`. +- **Status:** **Implemented** — `TenantRealmIssuanceTests`, + `TenantRealmProfileProtectionTests` and `TenantRealmApiTests`; P02b-6 Step 4. + Actual provider/API controls run in ordinary Docker CI; local and hosted + receipts and independent review closure live in the Step 4 record. +- **ADR-0068 Accepted extension — 2026-10-22, implemented:** The added Asserts + obligation covers historical changed-password/MFA login, four signed API + personas, full before/after console claims and manifest/nonmanifest POST with + independent stored-field readback. + `TenantRealmApiTests.WarmQualification.cs` implements the actual historical + journey alongside the existing self-service/issuance/profile controls. It + measures both new scope refusals and the retained offline session/credential; + no universal role-loss or immediate-revocation guarantee is inferred. + [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) and its + [three-step record](../roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22) + own actual receipts, completed correction reviews and source-bound hosted proof. + Existing Implemented status and rule counts remain unchanged. +- **Phase:** P02b-6 Steps 1/4. + +#### `Development_Realm_Reconciliation_Preserves_Accounts` + +- **Asserts:** the actual shared development reconciler converges fresh and + pre-mapper warm realms without resetting existing credentials or changing + account IDs, unrelated user/profile/client state or the Hub realm. Independently + observable identity/configuration conflicts, unqualified visibility definitions, + group aggregation/inheritance and membership sources refuse before preparation. + A conflict revealed by qualified metadata preparation permits no ordinary write; + the fixed durable-progress notice survives failure and cleanup. + Projected request/response and collection limits are checked before apply. + Safe groups/memberships and actual account fields remain unchanged; derived + profile metadata is verified with its global configuration rather than + mistaken for stored-account data. The approved self-service adjustment adds + only missing four-persona account capability and a compatible fixed-account + console mapper. Stock bootstrap parity, existing/inherited grants and shared + mapper effects on an already eligible nonmanifest user are independently + observed; unsafe role/scope/mapper customization refuses before any pending + write. Partial/uncertain writes require an + explicit verified rerun. Independent readback checks protected claims/TTL; + a completed third run performs zero mutations. Both seed/demo entries retain + exact target/environment identity, bounded HTTP/process cleanup, secret + confinement and local lock ownership under failure/cancellation/races. +- **Source:** ADR-0004; ADR-0064; + [ADR-0068](../decisions/0068-development-warm-realm-qualification.md); + [Security 11](11-security.md#authentication); + [accepted P02b-6 package](../roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20). +- **Type:** xUnit Integration with Docker and actual warm Keycloak Admin REST; + offline Node tooling transport/process/CLI controls in meta. + **Kind:** behavioural. +- **Test owner:** `TenantRealmReconciliationTests` and + `TenantRealmSelfServiceTests` in `backend/tests/LearnStack.Tests.Integration`; + shared reconciler Node controls under `scripts/`, including + `reconcile-development-realm.test.mjs`, own bounded transport/process/CLI + evidence. `frontend/apps/web/src/test/demo-realm-lifecycle.test.ts` owns the + seed/demo caller's awaited handoff, selection, confinement and cancellation + cleanup controls; its reconciler stub does not prove Admin REST convergence. +- **Status:** **Implemented** — `TenantRealmReconciliationTests` and + `TenantRealmSelfServiceTests`, with offline Node controls in meta; P02b-6 + Steps 2–4. Actual warm-provider, lifecycle and cancellation receipts and + independent review closure live in the Step 4 record. +- **ADR-0068 Accepted extension — 2026-10-22, implemented:** The added Asserts + obligation is reproduced stock-graph qualification, all identity-independent + refusals before preparation, exact metadata-only progress, separately counted + ordinary writes, three qualified DELETEs and explicit partial-progress reruns. + `TenantRealmReconciliationTests`, + `TenantRealmSelfServiceTests`, `TenantRealmStockBaselineTests` and the shared + Node controls own this extension. The stock-baseline owner reproduces the + immutable historical expectation and the ordinary assignment path after explicit + test-owned metadata setup; it does not prove automatic preparation. + The second correction step adds actual historical automatic preparation, + exact-profile readback, independent pre-write refusal and explicit interrupted + resumption controls in `TenantRealmReconciliationTests`, with shared Node + transport/progress/cleanup controls. The third step adds the actual historical + credential/token/API/profile journey and a zero-write pass after self-service + in `TenantRealmApiTests.WarmQualification.cs`. Actual entry experiments and + their distinct bootstrap-source/cleanup limits live in the step record; stubbed + lifecycle tests alone do not prove warm Admin REST or complete public audits. + A hidden fresh-plan conflict may leave preparation metadata, but no ordinary + mutation; the earlier zero-write scope remains historical implemented coverage. + [ADR-0068](../decisions/0068-development-warm-realm-qualification.md) and its + [three-step record](../roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22) + own actual receipts, completed correction reviews and source-bound hosted proof. + Existing Implemented status and rule counts remain unchanged. +- **Phase:** P02b-6 Steps 2–4. + #### `Api_Registers_Only_The_Tenant_Realm_Authority` - **Asserts:** the composition root registers exactly one tenant bearer scheme, diff --git a/docs/standards/README.md b/docs/standards/README.md index eee54477..e85543b5 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -67,7 +67,7 @@ bookkeeping pass. ### Honest status today -The table below describes the implementation as of 2026-10-13: +The table below records delivered enforcement and its owning evidence: [Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–6 are complete and merged. P02d-5's ingress, configured server caller, dynamic entry and frontend guards are delivered through PR #26; the @@ -114,7 +114,7 @@ included. | 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2 ships supported-write validation and the default-enabled CHECK constraint; raw deletes can still leave an enabled locale set without a default. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; P02d-4 Step 3 delivers exact enabled public locale admission and response labels. P02d-5 Step 3 delivers membership-first redirect G36. P02d-6 Step 1 implements Accepted ADR-0027/G39's next-intl 4.14.9 plugin/request runtime, app-local `src/i18n/messages/{en,tr}/public.json` and catalogue controls. Phase 04 inherits the foundation for CMS/Studio. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | -| 11 | [Security](11-security.md) | **Active** | No authentication yet, and the isolation half of this document is live and mechanical. Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters is mechanical — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. Authentication and authorisation land in [Phase 02b](../roadmap/phase-02b-events-auth.md) and [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections are **not** enforced by anything today and are the document's own carve-out: § Transport, § HTTP Headers and § CORS — nothing sets HSTS, `nosniff`, a CSP or an origin policy, at the edge or in either app. [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) owns them, at APISIX and in the ASP.NET layer beside it. | +| 11 | [Security](11-security.md) | **Active** | P02b-5 delivers tenant bearer authentication and the closed AccountSite boundary; its [merge closeout](../roadmap/phase-02b-events-auth.md#p02b-5-merge-and-closeout-2026-10-20) owns final verification. The isolation half is live and mechanical: Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. BFF sessions remain P02b-7; application membership and permission enforcement belong to [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections retain the document's enforcement carve-out: § Transport, § HTTP Headers and § CORS — HSTS, `nosniff`, CSP and the production origin policy remain [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) work, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. P02d-7 activates the seventh `public demo + lighthouse` context after complete real PR success and verifies all six prior contexts, strict mode and every unrelated setting preserved; its [CI and exit closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records the receipts. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | @@ -122,7 +122,7 @@ included. | 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. P02d-7 adds hard Lighthouse automated accessibility and a narrow library browser walkthrough; full Playwright Test/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request/concrete integration-handler identity by `AuditCoverageTests`; actual API/Seeder catalogue parity includes Off coordinators. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | -| 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | +| 19 | [Permissions](19-permissions.md) | **Adopted** | No application permission key or application role exists. P02b-5's AccountSite admission policy grants neither; the permission registry and enforcement land in [Phase 03](../roadmap/phase-03-identity-admin.md). | | 20 | [Infrastructure Stack](20-infrastructure-stack.md) | **Active** | `ISecretProvider` shipped in Packet 3, the foundation ports and their defaults in Packet 5, and the entitlement socket in Packet 9; `DeploymentMode` branching happens once, at the composition root. P02b-2 delivers producer dispatch; P02b-3 Steps 1–5 add durable acceptance, shared inbox and independently scheduled default consumers. P02b-3 is complete and merged through PR #31; its [merge closeout](../roadmap/phase-02b-events-auth.md#p02b-3-merge-and-closeout-2026-10-15) owns final verification. Packet 10 made the bans this document states mechanical: no module reaches a cache client, a Hub namespace, `audit_log` or `platform_entitlement_cache`, and every entitlement key a call site names is a registry member. The adapters themselves — Dapr, Kafka, Valkey, Vault, APISIX — arrive on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s triggers, which is the model rather than a gap in it. | | 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | The catalogue's own § Implemented today carries the counts, and `The_Catalogue_Counts_Its_Own_Rules` recomputes them, so this row does not keep a third copy. Rules run in the architecture assembly and beside it in the unit, integration and frontend suites — each where it can actually fail, against an applied schema, a real host or a real ESLint configuration. `Every_Implemented_Rule_Names_A_Test_That_Exists` holds each Implemented entry to a method of that name. `No_Architecture_Test_Is_Skippable` governs backend suites; P02d-5 delivers G38(d)'s guarded frontend runner and planted skip/todo, empty-report and omitted-workspace controls. | diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index 287bb555..0c7ff07f 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -45,6 +45,11 @@ export function waitForBrowserDevTools(options: { readonly isLive: () => boolean; readonly readPort: () => number | null; }): Promise; +export function waitForBrowserPage(options: { + readonly assertActive: () => void; + readonly isLive: () => boolean; + readonly port: number; +}): Promise; export function withBrowserCleanup( operation: () => T | Promise, cleanup: () => void | Promise, diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index 33ef54c2..7861e31f 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -122,6 +122,66 @@ export function waitForBrowserDevTools({ assertActive, isLive, readPort }) { ); } +/** Empty target lists may precede the page; all discovery shares the original 5 s. */ +export function waitForBrowserPage({ assertActive, isLive, port }) { + return atBrowserPhase('browser-discovery', async () => { + const controller = new AbortController(); + let pollTimer; + let deadlineTimer; + const deadline = new Promise((_, reject) => { + deadlineTimer = setTimeout(() => { + const error = new Error('Owned browser discovery deadline'); + controller.abort(error); + reject(error); + }, 5000); + }); + const assertBrowserActive = () => { + assertActive(); + return atBrowserPhase('browser-launch', () => { + assert.equal(isLive(), true, 'The owned browser remains live'); + }); + }; + const withinDeadline = async (operation) => { + try { + return await Promise.race([operation(), deadline]); + } finally { + await assertBrowserActive(); + controller.signal.throwIfAborted(); + } + }; + try { + while (true) { + await assertBrowserActive(); + controller.signal.throwIfAborted(); + const response = await withinDeadline(() => + // eslint-disable-next-line no-restricted-globals -- Owned local browser CDP discovery, never a LearnStack API request. + fetch('http://127.0.0.1:' + port + '/json/list', { signal: controller.signal }), + ); + assert.equal(response.ok, true, 'Owned browser discovery succeeds'); + const pages = await withinDeadline(() => response.json()); + assert.ok(Array.isArray(pages), 'Owned browser exposes a target list'); + if (pages.length === 0) { + await withinDeadline( + () => new Promise((resolve) => (pollTimer = setTimeout(resolve, 50))), + ); + continue; + } + const page = pages.find((candidate) => candidate.type === 'page'); + assert.ok(page, 'Owned browser exposes its initial page'); + const endpoint = new URL(page.webSocketDebuggerUrl); + assert.equal(endpoint.protocol, 'ws:'); + assert.equal(endpoint.hostname, '127.0.0.1'); + assert.equal(endpoint.port, String(port)); + return endpoint; + } + } finally { + clearTimeout(deadlineTimer); + clearTimeout(pollTimer); + controller.abort(); + } + }); +} + /** Isolated profile and certificate-specific trust; never touches a user's browser. */ export async function verifyBrowserFallback(options) { const phase = { value: 'browser-launch' }; @@ -209,16 +269,11 @@ async function runBrowserFallback( }, }); phase.value = 'browser-discovery'; - // eslint-disable-next-line no-restricted-globals -- Owned local browser CDP discovery, never a LearnStack API request. - const response = await fetch('http://127.0.0.1:' + port + '/json/list', { - signal: AbortSignal.timeout(5000), + const endpoint = await waitForBrowserPage({ + assertActive: () => owner.assertActive(), + isLive: () => !failed && child.exitCode === null && child.signalCode === null, + port, }); - const pages = await response.json(); - const page = pages.find((candidate) => candidate.type === 'page'); - assert.ok(page, 'Owned browser exposes its initial page'); - const endpoint = new URL(page.webSocketDebuggerUrl); - assert.equal(endpoint.hostname, '127.0.0.1'); - assert.equal(endpoint.port, String(port)); const WebSocket = createRequire(join(app, 'package.json'))('next/dist/compiled/ws'); const socket = new WebSocket(endpoint.href, { maxPayload: 2 * 1024 * 1024 }); const pending = new Map(); diff --git a/frontend/apps/web/scripts/ingress-readiness.d.mts b/frontend/apps/web/scripts/ingress-readiness.d.mts new file mode 100644 index 00000000..f25d9b5c --- /dev/null +++ b/frontend/apps/web/scripts/ingress-readiness.d.mts @@ -0,0 +1,17 @@ +import type { ChildProcess } from 'node:child_process'; + +export function isReadinessNotification(message: unknown): boolean; + +export function boundNextVersionFetch( + delegate: typeof fetch, + notify: (observation: { state: string; errorKind: string; elapsedMilliseconds: number }) => void, +): typeof fetch; + +export function createIngressReadiness(child: ChildProcess): { + output(chunk: string | Buffer): void; + wait(options: { + port: number; + probe: () => Promise<{ status?: number; body: string }>; + assertActive: () => void; + }): Promise>; +}; diff --git a/frontend/apps/web/scripts/ingress-readiness.mjs b/frontend/apps/web/scripts/ingress-readiness.mjs new file mode 100644 index 00000000..4810f167 --- /dev/null +++ b/frontend/apps/web/scripts/ingress-readiness.mjs @@ -0,0 +1,223 @@ +const READINESS_MILLISECONDS = 20000; +const phases = ['launched', 'preparing', 'prepared']; +const keywords = { + compilation: ['Failed to compile'], + missingModule: ['Module not found', 'Cannot find module'], + typescript: ['TypeScript'], + postcss: ['postcss'], + symlink: ['symlink'], + middleware: ['middleware'], +}; + +export function isReadinessNotification(message) { + return ['bound', 'readiness', 'version-fetch'].includes(message?.kind); +} + +function probeKind(error) { + // Never project an error message, hostname, URL, stack or arbitrary code. + let code; + try { + code = error?.code; + } catch { + return 'other'; + } + switch (code) { + case 'ECONNREFUSED': + return 'connection_refused'; + case 'INGRESS_PROBE_TIMEOUT': + case 'ETIMEDOUT': + return 'timeout'; + case 'ECONNRESET': + return 'connection_reset'; + case 'DEPTH_ZERO_SELF_SIGNED_CERT': + case 'SELF_SIGNED_CERT_IN_CHAIN': + case 'ERR_TLS_CERT_ALTNAME_INVALID': + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': + case 'CERT_HAS_EXPIRED': + return 'tls'; + default: + return 'other'; + } +} + +/** Bound only Next 15.5.27's one-argument informational lookup in this fixture. */ +export function boundNextVersionFetch(delegate, notify) { + return function (...args) { + if (args.length !== 1 || args[0] !== 'https://registry.npmjs.org/-/package/next/dist-tags') + return Reflect.apply(delegate, this, args); + const started = performance.now(); + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new DOMException('Fixture version lookup timeout', 'TimeoutError')); + }, 2000); + timer.unref?.(); + const emit = (state, errorKind = 'none') => { + try { + notify({ + state, + errorKind, + elapsedMilliseconds: Math.min( + 120000, + Math.max(0, Math.round(performance.now() - started)), + ), + }); + } catch { + /* Observation cannot change the original fetch outcome. */ + } + }; + emit('pending'); + let result; + try { + result = Reflect.apply(delegate, this, [args[0], { signal: controller.signal }]); + } catch (error) { + clearTimeout(timer); + emit('failed', probeKind(error)); + throw error; + } + return Promise.resolve(result).then( + (response) => { + clearTimeout(timer); + emit('settled'); + return response; + }, + (error) => { + clearTimeout(timer); + emit('failed', timedOut ? 'timeout' : probeKind(error)); + throw error; + }, + ); + }; +} + +/** One owned child's observations; absence of a bind message means unobserved. */ +export function createIngressReadiness(child) { + const launched = performance.now(); + const bindings = new Map(); + const flags = Object.fromEntries(Object.keys(keywords).map((key) => [key, false])); + let phase = 'launched'; + let phaseStarted = launched; + let spawnFailed = false; + let outputOverlap = ''; + let versionFetch = 'unobserved'; + let versionFetchError = 'none'; + let versionFetchCount = 0; + let versionFetchStarted = launched; + let versionFetchMilliseconds = 0; + child.once('error', () => { + spawnFailed = true; + }); + child.on('message', (message) => { + if (child.pid === undefined || message?.pid !== child.pid) return; + if (message.kind === 'readiness' && phases.indexOf(message.phase) > phases.indexOf(phase)) { + phase = message.phase; + phaseStarted = performance.now(); + } + if (message.kind === 'bound' && [3000, 3011].includes(message.port)) + bindings.set( + message.port, + message.address === '127.0.0.1' ? 'owned_loopback' : 'other_observed', + ); + if ( + message.kind === 'version-fetch' && + ['pending', 'settled', 'failed'].includes(message.state) && + ['none', 'connection_refused', 'timeout', 'connection_reset', 'tls', 'other'].includes( + message.errorKind, + ) && + Number.isSafeInteger(message.elapsedMilliseconds) && + message.elapsedMilliseconds >= 0 && + message.elapsedMilliseconds <= 120000 + ) { + versionFetch = message.state; + versionFetchError = message.errorKind; + versionFetchMilliseconds = message.elapsedMilliseconds; + if (message.state === 'pending') { + versionFetchCount = Math.min(65536, versionFetchCount + 1); + versionFetchStarted = performance.now(); + } + } + }); + const exit = () => + spawnFailed + ? 'spawn_failed' + : child.signalCode !== null + ? 'signalled' + : child.exitCode === null + ? 'running' + : child.exitCode === 0 + ? 'exited_zero' + : 'exited_nonzero'; + const milliseconds = (started) => Math.max(0, Math.round(performance.now() - started)); + + return { + output(chunk) { + const text = outputOverlap + chunk.toString(); + for (const [key, values] of Object.entries(keywords)) + flags[key] ||= values.some((value) => text.includes(value)); + outputOverlap = text.slice(-64); + }, + async wait({ port, probe, assertActive }) { + const started = performance.now(); + const deadline = started + READINESS_MILLISECONDS; + let probeCount = 0; + let responseCount = 0; + let lastStatus = null; + let lastProbeKind = 'none'; + let lastProbeMilliseconds = 0; + const observation = () => ({ + phase, + bind: bindings.get(port) ?? 'unobserved', + exit: exit(), + probeKind: lastProbeKind, + probeCount, + responseCount, + lastStatus, + waitMilliseconds: milliseconds(started), + launchMilliseconds: milliseconds(launched), + phaseMilliseconds: milliseconds(phaseStarted), + lastProbeMilliseconds, + versionFetch, + versionFetchError, + versionFetchCount, + versionFetchMilliseconds: + versionFetch === 'pending' ? milliseconds(versionFetchStarted) : versionFetchMilliseconds, + ...flags, + }); + const failure = (prefix) => new Error(prefix + ': ' + JSON.stringify(observation())); + while (performance.now() < deadline) { + assertActive(); + if (exit() !== 'running') throw failure('Fixture startup failed'); + const probeStarted = performance.now(); + probeCount++; + lastProbeKind = 'pending'; + try { + const response = await probe(); + responseCount++; + lastStatus = + Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 + ? response.status + : null; + lastProbeKind = 'http_unhealthy'; + if (response.status === 200) { + lastProbeKind = 'invalid_json'; + const healthy = JSON.parse(response.body).status === 'healthy'; + lastProbeKind = healthy ? 'healthy_unattributed' : 'http_unhealthy'; + if (healthy && bindings.get(port) === 'owned_loopback' && exit() === 'running') { + lastProbeKind = 'healthy'; + lastProbeMilliseconds = milliseconds(probeStarted); + return observation(); + } + } + } catch (error) { + // A parser error is distinguished from a rejected transport probe. + if (lastProbeKind !== 'invalid_json') lastProbeKind = probeKind(error); + } finally { + lastProbeMilliseconds = milliseconds(probeStarted); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw failure('Fixture did not become ready'); + }, + }; +} diff --git a/frontend/apps/web/scripts/verify-ingress-controls.mjs b/frontend/apps/web/scripts/verify-ingress-controls.mjs index 37e7f851..5e4eeca6 100644 --- a/frontend/apps/web/scripts/verify-ingress-controls.mjs +++ b/frontend/apps/web/scripts/verify-ingress-controls.mjs @@ -132,7 +132,7 @@ try { currentCase = name; const directory = join(proof, String(++caseNumber)); mkdirSync(directory); - for (const file of ['fixture-support.mjs', 'stop-test-child.mjs']) + for (const file of ['fixture-support.mjs', 'stop-test-child.mjs', 'ingress-readiness.mjs']) copyFileSync(join(appRoot, 'scripts', file), join(directory, file)); const script = join(directory, 'fixture.mjs'); writeFileSync(script, source); diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index c92cab92..df44650c 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -27,6 +27,7 @@ import { createPrivateScanner, fixtureEnvironment, } from './fixture-support.mjs'; +import { createIngressReadiness, isReadinessNotification } from './ingress-readiness.mjs'; import { stopTestChild } from './stop-test-child.mjs'; const appRoot = fileURLToPath(new URL('../', import.meta.url)); @@ -47,11 +48,9 @@ const scanners = []; let verificationFailed = false; const responseScanner = createPrivateScanner([secret]); const diagnosticsLeaked = () => scanners.some((scanner) => scanner.leaked); -const diagnosticIncludes = (value) => scanners.some((scanner) => scanner.tail.includes(value)); const fence = randomBytes(16).toString('hex'); const children = []; -const bindings = new WeakMap(); -const startupFailures = new WeakSet(); +const readiness = new WeakMap(); const upgrades = []; const observations = new Map(); let observationId = 0; @@ -205,6 +204,10 @@ try { join(appRoot, 'scripts/development-hmr.mjs'), join(app, 'scripts/development-hmr.mjs'), ); + copyFileSync( + join(appRoot, 'scripts/ingress-readiness.mjs'), + join(app, 'scripts/ingress-readiness.mjs'), + ); writeFileSync(join(app, '.env.production.local'), 'DEBUG=next:*\n'); writeFileSync(join(app, '.env.development.local'), 'DEBUG=next:*\n'); writeFileSync( @@ -226,11 +229,29 @@ try { const launcher = readFileSync(join(appRoot, 'scripts/public-server.mjs'), 'utf8'); const handlerAnchor = ' const handle = app.getRequestHandler();'; const listenerAnchor = " server.listen(PUBLIC_HTTPS_PORT, '127.0.0.1',"; - for (const anchor of [handlerAnchor, listenerAnchor]) + const prepareAnchor = ' await app.prepare();'; + const importAnchor = " const { default: next } = await import('next');"; + for (const anchor of [handlerAnchor, listenerAnchor, prepareAnchor, importAnchor]) assert.equal(launcher.split(anchor).length, 2, 'Ingress instrumentation anchor must be unique'); writeFileSync( join(app, 'scripts/public-server.mjs'), launcher + .replace( + importAnchor, + ` if (process.argv.includes('--dev')) { + const { boundNextVersionFetch } = await import('./ingress-readiness.mjs'); + globalThis.fetch = boundNextVersionFetch(globalThis.fetch, (observation) => { + if (process.connected) process.send({ kind: 'version-fetch', pid: process.pid, ...observation }, () => {}); + }); + } +${importAnchor}`, + ) + .replace( + prepareAnchor, + ` process.send?.({ kind: 'readiness', pid: process.pid, phase: 'preparing' }); + await app.prepare(); + process.send?.({ kind: 'readiness', pid: process.pid, phase: 'prepared' });`, + ) .replace( handlerAnchor, ` let nextCalls = 0; @@ -295,11 +316,11 @@ function start(argv, dev = false) { detached: process.platform !== 'win32', }), ); - bindings.set(child, new Map()); - child.once('error', () => startupFailures.add(child)); + const childReadiness = createIngressReadiness(child); + readiness.set(child, childReadiness); child.on('message', (message) => { - if (message.kind === 'bound') bindings.get(child).set(message.port, message); - else if (message.id) observations.get(message.id)?.receive(message); + if (isReadinessNotification(message)) return; + if (message.id) observations.get(message.id)?.receive(message); else upgrades.push(message); }); children.push(child); @@ -311,6 +332,7 @@ function start(argv, dev = false) { let overlap = ''; stream.on('data', (chunk) => { scanner.push(chunk); + childReadiness.output(chunk); const text = overlap + chunk.toString(); for (const [id, observation] of observations) { if (observation.child === child && text.includes(fence + ':' + id + ':end\n')) @@ -425,8 +447,14 @@ function call(tls, port, path, headers = {}, method = 'GET', tlsOptions = {}) { }); }, ); - const deadline = setTimeout(() => outgoing.destroy(new Error('Readiness timeout')), 2000); - outgoing.on('timeout', () => outgoing.destroy(new Error('Readiness timeout'))); + const timeout = () => + outgoing.destroy( + Object.assign(new Error('Readiness timeout'), { + code: 'INGRESS_PROBE_TIMEOUT', + }), + ); + const deadline = setTimeout(timeout, 2000); + outgoing.on('timeout', timeout); outgoing.on('error', (error) => { clearTimeout(deadline); reject(error); @@ -454,43 +482,12 @@ async function assertOptimizerImage(response) { } async function ready(child, tls, port) { - let lastStatus; - const deadline = performance.now() + 20000; - while (performance.now() < deadline) { - owner.assertActive(); - if (startupFailures.has(child) || child.exitCode !== null || child.signalCode !== null) - throw new Error('Fixture startup failed'); - try { - const response = await call(tls, port, '/api/healthz'); - lastStatus = response.status; - const bound = bindings.get(child).get(port); - if ( - bound?.pid === child.pid && - bound.address === '127.0.0.1' && - child.exitCode === null && - child.signalCode === null && - response.status === 200 && - JSON.parse(response.body).status === 'healthy' - ) - return; - } catch { - /* Readiness may race preparation; no application retry policy here. */ - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error( - 'Fixture did not become ready: ' + - JSON.stringify({ - lastStatus, - compilation: diagnosticIncludes('Failed to compile'), - missingModule: - diagnosticIncludes('Module not found') || diagnosticIncludes('Cannot find module'), - typescript: diagnosticIncludes('TypeScript'), - postcss: diagnosticIncludes('postcss'), - symlink: diagnosticIncludes('symlink'), - middleware: diagnosticIncludes('middleware'), - }), - ); + const observation = await readiness.get(child).wait({ + port, + probe: () => call(tls, port, '/api/healthz'), + assertActive: owner.assertActive, + }); + console.warn('Ingress readiness observation: ' + JSON.stringify(observation)); } async function run() { diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts index f7934145..45a081ca 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -7,6 +7,7 @@ import { browserFailureDetail, verifyBrowserFallback, waitForBrowserDevTools, + waitForBrowserPage, withBrowserCleanup, } from '../../scripts/admission-browser.mjs'; import { createFixtureOwner } from '../../scripts/fixture-support.mjs'; @@ -20,6 +21,8 @@ const flight = { status: 503, }; const document = { url: origin + target, type: 'Document', rsc: false, status: 200 }; +const browserEndpoint = 'ws://127.0.0.1:12345/devtools/page/owned'; +const browserPages = [{ type: 'page', webSocketDebuggerUrl: browserEndpoint }]; afterEach(() => vi.useRealTimers()); @@ -75,6 +78,183 @@ describe('stock browser Flight fallback observation', () => { expect(assertActive).toHaveBeenCalledOnce(); }); + it('waits for an initially empty target list to expose the owned page', async () => { + vi.useFakeTimers(); + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('[]')) + .mockResolvedValueOnce(new Response(JSON.stringify(browserPages))); + const waiting = waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => true, + port: 12345, + }).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(50); + expect(await waiting).toEqual(new URL(browserEndpoint)); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0]?.[1]?.signal).toBe(fetch.mock.calls[1]?.[1]?.signal); + expect(vi.getTimerCount()).toBe(0); + }); + + it('refuses a perpetually empty target list at the original five-second deadline', async () => { + vi.useFakeTimers(); + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('[]')); + const complete = vi.fn(); + const waiting = waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => true, + port: 12345, + }) + .catch((error: Error) => error) + .then((result) => { + complete(); + return result; + }); + await vi.advanceTimersByTimeAsync(4999); + expect(complete).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(browserFailureDetail(await waiting)).toBe(' browser-discovery'); + expect(fetch.mock.calls.length).toBeGreaterThan(1); + expect(fetch.mock.calls.every((call) => call[1]?.signal?.aborted)).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['fetch', 'body'])('does not reset discovery time for a late %s', async (stage) => { + vi.useFakeTimers(); + const response = new Response(JSON.stringify(browserPages)); + if (stage === 'body') { + vi.spyOn(response, 'json').mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(browserPages), 4975)), + ); + } + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('[]')) + .mockImplementationOnce(() => + stage === 'fetch' + ? new Promise((resolve) => setTimeout(() => resolve(response), 4975)) + : Promise.resolve(response), + ); + const complete = vi.fn(); + const waiting = waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => true, + port: 12345, + }) + .catch((error: Error) => error) + .then((result) => { + complete(); + return result; + }); + await vi.advanceTimersByTimeAsync(4999); + expect(complete).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(browserFailureDetail(await waiting)).toBe(' browser-discovery'); + expect(fetch.mock.calls[0]?.[1]?.signal).toBe(fetch.mock.calls[1]?.[1]?.signal); + expect(fetch.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + await vi.advanceTimersByTimeAsync(25); + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + ['invalid shape', '{}', 200], + ['malformed JSON', '{', 200], + ['failed HTTP', '[]', 503], + ['non-page targets', JSON.stringify([{ type: 'worker' }]), 200], + ['invalid page endpoint', JSON.stringify([{ type: 'page' }]), 200], + [ + 'foreign host', + JSON.stringify([{ type: 'page', webSocketDebuggerUrl: 'ws://foreign.invalid:12345/page' }]), + 200, + ], + [ + 'foreign port', + JSON.stringify([{ type: 'page', webSocketDebuggerUrl: 'ws://127.0.0.1:12346/page' }]), + 200, + ], + [ + 'invalid protocol', + JSON.stringify([{ type: 'page', webSocketDebuggerUrl: 'https://127.0.0.1:12345/page' }]), + 200, + ], + ] as const)('refuses %s without retrying discovery', async (_name, body, status) => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, { status })); + const failure = await waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => true, + port: 12345, + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-discovery'); + expect(fetch).toHaveBeenCalledOnce(); + expect(failure).not.toHaveProperty('cause'); + expect(String(failure)).not.toContain(body); + }); + + it('sanitizes a raw network error immediately without trusting its phase token', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue( + Object.assign(new Error('Private browser connection value'), { + admissionStage: 'browser-launch', + }), + ); + const failure = await waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => true, + port: 12345, + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-discovery'); + expect(fetch).toHaveBeenCalledOnce(); + expect(String(failure)).not.toContain('Private browser connection value'); + expect(failure).not.toHaveProperty('cause'); + }); + + it.each(['before request', 'after fetch', 'after body'])( + 'keeps browser death %s distinct from discovery readiness', + async (stage) => { + let live = stage !== 'before request'; + const response = new Response(JSON.stringify(browserPages)); + const body = vi.spyOn(response, 'json').mockImplementation(async () => { + if (stage === 'after body') live = false; + return browserPages; + }); + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + if (stage === 'after fetch') live = false; + return response; + }); + const failure = await waitForBrowserPage({ + assertActive: () => undefined, + isLive: () => live, + port: 12345, + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-launch'); + expect(fetch).toHaveBeenCalledTimes(stage === 'before request' ? 0 : 1); + expect(body).toHaveBeenCalledTimes(stage === 'after body' ? 1 : 0); + }, + ); + + it.each(['before request', 'after body'])('refuses a closed owner %s', async (stage) => { + const owner = createFixtureOwner(); + try { + if (stage === 'before request') await owner.dispose(); + const response = new Response(JSON.stringify(browserPages)); + vi.spyOn(response, 'json').mockImplementation(async () => { + await owner.dispose(); + return browserPages; + }); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + const failure = await waitForBrowserPage({ + assertActive: () => owner.assertActive(), + isLive: () => true, + port: 12345, + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-discovery'); + expect(fetch).toHaveBeenCalledTimes(stage === 'before request' ? 0 : 1); + expect(String(failure)).not.toContain('Fixture owner is closed'); + } finally { + await owner.dispose(); + } + }); + it('preserves a sanitized inner phase without trusting a raw error with the same token', async () => { const failure = await atBrowserPhase('browser-private-carrier', () => { throw new Error('Private browser output'); diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index 558f6560..aee36c60 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -79,6 +79,8 @@ const nonRuntimeFiles = new Set([ 'scripts/test-ci-test-shard-results.py', 'scripts/test-ci-supply-chain.py', 'scripts/test-openapi-ci.py', + 'scripts/test-realm-node-gate.py', + 'scripts/run-realm-node-tests.py', // Offline vendor distribution materials and isolated lifecycle experiments. 'scripts/probe-hangfire-lifecycle.py', 'scripts/probes/hangfire-lifecycle/BoundaryProbe.cs', @@ -135,8 +137,16 @@ const rootFiles = new Set([ 'README.md', ]); +/** Runtime JS/TS cannot be hidden by adding a file to the data/tooling list. */ +export function assertDemoNonRuntimeFiles(files: Iterable = nonRuntimeFiles): void { + for (const path of files) + if (/\.[cm]?[jt]sx?$/.test(path)) + throw new Error(`Runtime source cannot have non-runtime ownership: ${path}`); +} + /** Refuse new roots/formats until their production or non-runtime ownership is explicit. */ export function demoSourceClassification(path: string): 'runtime' | 'other' { + assertDemoNonRuntimeFiles(); if (!path || path.startsWith('/') || path.split('/').some((part) => part === '..')) throw new Error(`Unclassified repository source: ${path}`); const root = path.split('/')[0]!; diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index b2111a43..8b110364 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { demoIdentityFindings } from './demo-genericity-analysis'; import { + assertDemoNonRuntimeFiles, demoSourceClassification, omitDemoSourceDirectory, omitIgnoredDemoLocalFile, @@ -17,6 +18,14 @@ import { import vocabulary from './fixtures/demo-identities.json'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); + +it('refuses a runtime source planted in the non-runtime ownership list', () => { + expect(() => assertDemoNonRuntimeFiles()).not.toThrow(); + for (const path of ['scripts/hidden.mjs', 'frontend/apps/web/src/hidden.ts']) + expect(() => assertDemoNonRuntimeFiles([path])).toThrow( + 'Runtime source cannot have non-runtime ownership', + ); +}); function productionSources(): Map { const inventory = execFileSync( 'git', @@ -90,6 +99,7 @@ describe('P02d-7 exact demo identity boundary', () => { 'frontend/apps/web/scripts/development-hmr.mjs', 'frontend/apps/web/scripts/stop-test-child.mjs', 'scripts/demo.mjs', + 'scripts/require-node-test-cases.mjs', ]) expect(files.has(path), `Production census includes ${path}`).toBe(true); expect( @@ -337,6 +347,7 @@ describe('P02d-7 exact demo identity boundary', () => { 'scripts/test-ci-test-shards.py', 'scripts/test-ci-test-shard-results.py', 'scripts/test-ci-supply-chain.py', + 'scripts/test-realm-node-gate.py', 'scripts/probe-hangfire-lifecycle.py', 'scripts/probes/hangfire-lifecycle/Program.cs', 'third-party/hangfire/verify-distribution.py', diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index 571bf52d..f64dd7fa 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -2,7 +2,16 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { once } from 'node:events'; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { createServer as createHttpsServer } from 'node:https'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; @@ -34,6 +43,10 @@ import { } from '../../../../../scripts/public-runtime.mjs'; import { defaultServices, healthyServices } from '../../../../../scripts/wait-compose.mjs'; +function writePrivateFileSync(path: string, source: Parameters[1]) { + writeFileSync(path, source, { mode: 0o600 }); +} + const roots: string[] = []; const owners: DemoProcesses[] = []; afterEach(async () => { @@ -41,7 +54,7 @@ afterEach(async () => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function temporary() { - const root = mkdtempSync(join(tmpdir(), 'learnstack-demo-control-')); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'learnstack-demo-control-'))); roots.push(root); return root; } @@ -51,6 +64,48 @@ function owner(signal = new AbortController().signal) { return processes; } +function seedFixture(root: string) { + for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); + for (const file of [ + 'seed.sh', + 'seed.mjs', + 'connection-string.awk', + 'compose.mjs', + 'demo-database.mjs', + 'demo-process.mjs', + 'development-realm-owner.mjs', + 'development-realm-errors.mjs', + ]) + writeFileSync( + join(root, 'scripts', file), + readFileSync(new URL(`../../../../../scripts/${file}`, import.meta.url)), + ); + mkdirSync(join(root, 'frontend/apps/web/scripts'), { recursive: true }); + writeFileSync( + join(root, 'frontend/apps/web/scripts/stop-test-child.mjs'), + readFileSync(new URL('../../scripts/stop-test-child.mjs', import.meta.url)), + ); + for (const [file, method, event] of [ + ['wait-compose', 'waitCompose', 'ready'], + ['reconcile-development-realm', 'reconcileDevelopmentRealm', 'realm'], + ]) + writeFileSync( + join(root, `scripts/${file}.mjs`), + `import { appendFileSync } from 'node:fs'; +export async function ${method}() { appendFileSync(${JSON.stringify(join(root, 'events'))}, '${event}\\n'); return { mutations: 0 }; } +`, + ); + return [ + 'ConnectionStrings__Migration=Username=learnstack_migration', + 'ConnectionStrings__Default=Username=learnstack_app', + 'ConnectionStrings__OutboxDispatcher=Username=learnstack_outbox_admin', + 'KEYCLOAK_ADMIN=fixture-admin', + `KEYCLOAK_ADMIN_PASSWORD=${randomBytes(16).toString('hex')}`, + `KEYCLOAK_WEB_CLIENT_SECRET=${randomBytes(16).toString('hex')}`, + '', + ].join('\n'); +} + describe('launcher-owned local database validation', () => { const roles = [ ['Migration', 'learnstack_migration', 'LEARNSTACK_MIGRATION_PW'], @@ -184,12 +239,37 @@ describe('launcher-owned local database validation', () => { }, ); it('keeps every Seeder mode free of inherited credentials and diagnostics', () => { + const tooling = Object.fromEntries( + [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'all_proxy', + 'no_proxy', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'NUGET_PACKAGES', + 'NUGET_HTTP_CACHE_PATH', + 'COREPACK_HOME', + 'PNPM_HOME', + 'XDG_CACHE_HOME', + 'NPM_CONFIG_CACHE', + 'npm_config_cache', + 'NPM_CONFIG_STORE_DIR', + 'npm_config_store_dir', + ].map((key) => [key, `selected-${key}`]), + ); const base = { PATH: '/bin', HOME: '/home', DOTNET_ROOT: '/dotnet', TMPDIR: '/tmp', APPDATA: '/profile', + ...tooling, ConnectionStrings__Default: 'ambient-application', ConnectionStrings__Migration: 'owner', ConnectionStrings__OutboxDispatcher: 'dispatcher', @@ -197,6 +277,7 @@ describe('launcher-owned local database validation', () => { 'ConnectionStrings:OutboxDispatcher': 'colon-dispatcher', LEARNSTACK_OUTBOX_PW: 'dispatcher-password', DEBUG: '*', + NODE_TLS_REJECT_UNAUTHORIZED: '0', }; const prerequisites = { PATH: '/bin', @@ -204,6 +285,7 @@ describe('launcher-owned local database validation', () => { DOTNET_ROOT: '/dotnet', TMPDIR: '/tmp', APPDATA: '/profile', + ...tooling, }; expect(publicSeederEnvironment(undefined, base)).toEqual(prerequisites); expect(publicSeederEnvironment('selected-application', base)).toEqual({ @@ -219,6 +301,178 @@ describe('launcher-owned local database validation', () => { }); describe('shared explicit demo stack selection', () => { + function composeCliFixture() { + const root = temporary(); + mkdirSync(join(root, 'bin')); + mkdirSync(join(root, 'scripts')); + for (const name of ['compose', 'development-realm-errors']) + writeFileSync( + join(root, `scripts/${name}.mjs`), + readFileSync(new URL(`../../../../../scripts/${name}.mjs`, import.meta.url)), + ); + const file = join(root, 'selected.env'); + writePrivateFileSync(file, '# Older private configuration without a web secret\n'); + const canary = randomBytes(20).toString('hex'); + writeFileSync( + join(root, 'bin/docker'), + `#!${process.execPath} +const argv = process.argv.slice(2); +if (process.env.KEYCLOAK_WEB_CLIENT_SECRET !== 'learnstack-lifecycle-placeholder') { + console.error('fixture: required web secret is absent'); + process.exit(43); +} +process.stdout.write(JSON.stringify({ argv, placeholder: true, + inheritedSecret: Object.keys(process.env).some(key => /bff|unrelated_private/i.test(key)) })); +`, + { mode: 0o700 }, + ); + return { + root, + file, + run(args: string[]) { + const result = spawnSync( + process.execPath, + [join(root, 'scripts/compose.mjs'), 'selected', ...args], + { + cwd: root, + env: { + NODE_ENV: 'test', + PATH: join(root, 'bin'), + LEARNSTACK_COMPOSE_MODE: 'e2e', + LEARNSTACK_COMPOSE_PROJECT: 'owned-lifecycle', + LEARNSTACK_COMPOSE_ENV_FILE: file, + KEYCLOAK_WEB_CLIENT_SECRET: canary, + Authentication__Bff__ClientSecret: canary, + UNRELATED_PRIVATE_VALUE: canary, + }, + encoding: 'utf8', + timeout: 5000, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.stdout).not.toContain(canary); + expect(result.stderr).not.toContain(canary); + return result; + }, + }; + } + + it.each([ + ['down'], + ['--profile', '*', 'down', '-v', '--remove-orphans'], + ['logs', '-f', '--tail=100'], + ['ps', '-a', '-q'], + ['stop', 'keycloak'], + ['config', '--services'], + ])('gives the actual lifecycle CLI %j only a nonsecret interpolation placeholder', (...args) => { + const f = composeCliFixture(); + const result = f.run(args); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + argv: [ + 'compose', + '--project-name', + 'owned-lifecycle', + '--env-file', + f.file, + '-f', + join(f.root, 'infra/compose/dev.yml'), + '-f', + join(f.root, 'infra/compose/e2e.yml'), + ...args, + ], + placeholder: true, + inheritedSecret: false, + }); + expect(readFileSync(f.file, 'utf8')).toBe( + '# Older private configuration without a web secret\n', + ); + }); + + it.each([ + ['up', '-d'], + ['create'], + ['run', '--rm', 'keycloak'], + ['start'], + ['restart'], + ['config'], + ['config', '--format', 'json'], + ['config', '--services', '--environment'], + ['--unknown', 'down'], + ['--profile'], + ['--profile', '*'], + ['--profile=*', 'down'], + ['--profile', '*', 'up', '-d'], + ['down', '--file', 'foreign.yml'], + ])('keeps creation, full config and ambiguous CLI %j strict', (...args) => { + const result = composeCliFixture().run(args); + expect(result.status).toBe(43); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('fixture: required web secret is absent\n'); + expect( + readFileSync(new URL('../../../../../infra/compose/dev.yml', import.meta.url), 'utf8'), + ).toContain('KEYCLOAK_WEB_CLIENT_SECRET: ${KEYCLOAK_WEB_CLIENT_SECRET:?'); + }); + + it.each(['shared', 'symlink', 'reserved'])( + 'keeps lifecycle private-source qualification for %s files ahead of Docker', + (kind) => { + const f = composeCliFixture(); + if (kind === 'shared') chmodSync(f.file, 0o644); + else if (kind === 'symlink') { + const target = join(f.root, 'target.env'); + writePrivateFileSync(target, '# private\n'); + rmSync(f.file); + symlinkSync(target, f.file); + } else writePrivateFileSync(f.file, 'COMPOSE_FILE=foreign.yml\n'); + const result = f.run(['--profile', '*', 'down']); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + kind === 'reserved' ? 'Reserved COMPOSE_*' : 'preflight/private_file', + ); + expect(result.stderr).not.toContain(f.file); + }, + ); + + it.each([ + ['missing', 'Compose environment file is missing; run make public-env'], + ['mode', 'Unsupported Compose mode'], + ['project', 'Invalid Compose project'], + ['reserved', 'Reserved COMPOSE_* controls cannot appear in the selected environment'], + ])( + 'retains the fixed %s Compose selection notice through the actual seed CLI', + (kind, notice) => { + const root = temporary(); + const source = seedFixture(root); + if (kind !== 'missing') + writePrivateFileSync( + join(root, '.env'), + source + (kind === 'reserved' ? 'COMPOSE_FILE=foreign.yml\n' : ''), + ); + const result = spawnSync( + process.execPath, + [join(root, 'scripts/seed.mjs'), '--validate-config'], + { + cwd: root, + env: { + NODE_ENV: 'test', + PATH: root, + ...(kind === 'mode' ? { LEARNSTACK_COMPOSE_MODE: 'unknown' } : {}), + ...(kind === 'project' ? { LEARNSTACK_COMPOSE_PROJECT: '../foreign' } : {}), + }, + encoding: 'utf8', + timeout: 5000, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(`seed: ${notice}\n`); + expect(result.stderr).not.toContain(root); + }, + ); + it.each([ 'COMPOSE_PROFILES=gated', ' export COMPOSE_PROFILES = gated', @@ -227,21 +481,25 @@ describe('shared explicit demo stack selection', () => { 'COMPOSE_ENV_FILES=foreign.env', ])('refuses a selected dotenv Compose control before Docker: %s', (assignment) => { const root = temporary(); - writeFileSync(join(root, '.env'), `POSTGRES_PASSWORD=private\n${assignment}\n`); + writePrivateFileSync(join(root, '.env'), `POSTGRES_PASSWORD=private\n${assignment}\n`); expect(() => composeSelection('dev', {}, root)).toThrow('Reserved COMPOSE_*'); - writeFileSync(join(root, '.env'), '# COMPOSE_PROFILES=gated\nPOSTGRES_PASSWORD=private\n'); + writePrivateFileSync( + join(root, '.env'), + '# COMPOSE_PROFILES=gated\nPOSTGRES_PASSWORD=private\n', + ); expect(() => composeSelection('dev', {}, root)).not.toThrow(); }); it('refuses stale ambient seed credentials before stack or database actions', () => { const root = temporary(); - mkdirSync(join(root, 'scripts')); + const privateSource = seedFixture(root); const script = join(root, 'scripts/seed.sh'); - writeFileSync(script, readFileSync(new URL('../../../../../scripts/seed.sh', import.meta.url))); const migration = 'Host=localhost;Username=learnstack_migration'; const app = 'Host=localhost;Username=learnstack_app'; - writeFileSync( + writePrivateFileSync( join(root, 'selected.env'), - `ConnectionStrings__Migration=${migration}\nConnectionStrings__Default=${app}\n`, + privateSource + .replace('Username=learnstack_migration', migration) + .replace('Username=learnstack_app', app), ); const env = { PATH: process.env.PATH, @@ -261,36 +519,58 @@ describe('shared explicit demo stack selection', () => { const result = run({ ...env, [key]: 'Host=foreign;Username=stale' }); expect(result.error).toBeUndefined(); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('disagrees'); + expect(result.stderr).toContain('realm: preflight/configuration'); expect(result.stderr).not.toContain('foreign'); } }); - it('launches the argument-free Seeder without selected or ambient dispatcher credentials', () => { + it('freshly builds the Release Seeder and launches its argument-free DLL with only its credential', () => { const root = temporary(); - for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); - for (const file of ['seed.sh', 'connection-string.awk', 'demo-database.mjs']) - writeFileSync( - join(root, 'scripts', file), - readFileSync(new URL(`../../../../../scripts/${file}`, import.meta.url)), - ); + writePrivateFileSync(join(root, '.env'), seedFixture(root)); writeFileSync( - join(root, '.env'), - 'ConnectionStrings__Migration=Username=learnstack_migration\nConnectionStrings__Default=Username=learnstack_app\nConnectionStrings__OutboxDispatcher=Username=learnstack_outbox_admin\n', + join(root, 'bin/docker'), + `#!${process.execPath} +const { appendFileSync } = require('node:fs'); +if (JSON.stringify(process.argv.slice(2)) === JSON.stringify(['context', 'inspect'])) { + process.stdout.write(JSON.stringify([{ Endpoints: { docker: { Host: 'unix:///var/run/docker.sock' } } }])); + process.exit(0); +} +if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(['compose', 'version']) || Object.keys(process.env).some(key => /keycloak|connectionstrings|_pw$|bff|debug/i.test(key))) process.exit(13); +appendFileSync(${JSON.stringify(join(root, 'events'))}, 'compose-version\\n'); +`, + { mode: 0o700 }, + ); + writeFileSync( + join(root, 'bin/make'), + `#!${process.execPath} +const { appendFileSync } = require('node:fs'); +const args = process.argv.slice(2); +const startup = JSON.stringify(args) === JSON.stringify(['dev']); +const migration = JSON.stringify(args) === JSON.stringify(['migrate']); +const forbidden = Object.keys(process.env).filter(key => /keycloak|connectionstrings|_pw$|bff|debug/i.test(key) && !(migration && key === 'ConnectionStrings__Migration')); +if ((!startup && !migration) || forbidden.length || process.env.ConnectionStrings__Migration !== (migration ? 'Username=learnstack_migration' : undefined) || process.env.LEARNSTACK_COMPOSE_PROJECT !== 'learnstack-dev' || process.env.LEARNSTACK_COMPOSE_ENV_FILE !== ${JSON.stringify(join(root, '.env'))} || process.env.LEARNSTACK_COMPOSE_MODE !== 'dev') process.exit(13); +appendFileSync(${JSON.stringify(join(root, 'events'))}, (startup ? 'stack-up' : 'migrate') + '\\n'); +`, + { mode: 0o700 }, + ); + writeFileSync( + join(root, 'bin/dotnet'), + `#!${process.execPath} +const { appendFileSync } = require('node:fs'); +const { join } = require('node:path'); +const args = process.argv.slice(2); +const build = JSON.stringify(args) === JSON.stringify(['build', 'src/LearnStack.Tools.Seeder', '--configuration', 'Release', '--nologo']); +const seed = args.length === 1 && args[0] === join(${JSON.stringify(root)}, 'backend/src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll'); +const forbidden = Object.keys(process.env).filter(key => /keycloak|outbox|migration|platform|debug/i.test(key)); +if ((!build && !seed) || process.cwd() !== ${JSON.stringify(join(root, 'backend'))} || forbidden.length || process.env.HOME !== '/seed-home' || !process.env.PATH || (build ? process.env.ConnectionStrings__Default !== undefined : process.env.ConnectionStrings__Default !== 'Username=learnstack_app')) process.exit(13); +appendFileSync(${JSON.stringify(join(root, 'events'))}, (build ? 'build' : 'appseed') + '\\n'); +`, + { mode: 0o700 }, ); - for (const [name, body] of Object.entries({ - docker: '[ "$*" = "compose version" ]', - node: 'if [ "$*" = "scripts/wait-compose.mjs" ]; then exit 0; fi\nexec "$REAL_NODE" "$@"', - dotnet: - '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$(basename "$PWD")" = backend ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && [ "$HOME" = /seed-home ] && [ -n "$PATH" ] && [ -z "${ConnectionStrings__OutboxDispatcher+x}" ] && [ -z "${CONNECTIONSTRINGS__OUTBOXDISPATCHER+x}" ] && [ -z "${LEARNSTACK_OUTBOX_PW+x}" ] && ! env | /usr/bin/grep -q "^ConnectionStrings:OutboxDispatcher=" && printf launched > ../launched', - })) - writeFileSync(join(root, 'bin', name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); const launch = () => spawnSync('bash', [join(root, 'scripts/seed.sh')], { cwd: root, env: { PATH: `${join(root, 'bin')}:${process.env.PATH}`, - TEST_ROOT: root, - REAL_NODE: process.execPath, HOME: '/seed-home', NODE_ENV: 'test', ConnectionStrings__OutboxDispatcher: 'ambient-dispatcher', @@ -302,18 +582,19 @@ describe('shared explicit demo stack selection', () => { timeout: 5000, }); expect(launch().status).toBe(0); - expect(readFileSync(join(root, 'launched'), 'utf8')).toBe('launched'); - const script = join(root, 'scripts/seed.sh'); - writeFileSync( - script, - readFileSync(script, 'utf8').replace( - "['run', '--project', 'src/LearnStack.Tools.Seeder']", - "['run', '--project', 'src/LearnStack.Tools.Seeder', '--nologo']", - ), + expect(readFileSync(join(root, 'events'), 'utf8')).toBe( + 'compose-version\nstack-up\nready\nmigrate\nready\nbuild\nappseed\nrealm\n', ); + const script = join(root, 'scripts/seed.mjs'); + const source = readFileSync(script, 'utf8'); + expect(source).toContain('[seederDll]'); + writeFileSync(script, source.replace('[seederDll]', "[seederDll, '--nologo']")); + writeFileSync(join(root, 'events'), ''); const refused = launch(); expect(refused.status).not.toBe(0); - expect(refused.stderr).toContain('tenant seeding failed'); + expect(readFileSync(join(root, 'events'), 'utf8')).toBe( + 'compose-version\nstack-up\nready\nmigrate\nready\nbuild\n', + ); }); it('reads the selected migration env file and refuses its runtime role before any database act', () => { const root = temporary(); @@ -326,7 +607,7 @@ describe('shared explicit demo stack selection', () => { join(root, 'scripts/connection-string.awk'), readFileSync(new URL('../../../../../scripts/connection-string.awk', import.meta.url)), ); - writeFileSync( + writePrivateFileSync( join(root, '.env'), 'ConnectionStrings__Migration=Username=learnstack_platform\n', ); @@ -364,7 +645,7 @@ describe('shared explicit demo stack selection', () => { }); it('keeps project/files/env stable and removes inherited Compose and dotenv shadows', () => { const root = temporary(); - writeFileSync(join(root, '.env'), 'POSTGRES_PASSWORD=private-file-value\n'); + writePrivateFileSync(join(root, '.env'), 'POSTGRES_PASSWORD=private-file-value\n'); const selected = composeSelection( 'e2e', { @@ -374,6 +655,13 @@ describe('shared explicit demo stack selection', () => { COMPOSE_PROJECT_NAME: 'foreign', POSTGRES_PASSWORD: 'ambient-shadow', LEARNSTACK_COMPOSE_PROJECT: 'owned-demo', + Authentication__Bff__ClientSecret: randomBytes(20).toString('hex'), + UNRELATED_PRIVATE_VALUE: randomBytes(20).toString('hex'), + DEBUG: '*', + NODE_OPTIONS: '--trace-warnings', + DOCKER_CONTEXT: 'desktop-linux', + DOCKER_HOST: 'unix:///var/run/docker.sock', + DOCKER_CONFIG: '/docker-config', }, root, ); @@ -393,13 +681,18 @@ describe('shared explicit demo stack selection', () => { expect(selected.env).not.toHaveProperty('COMPOSE_FILE'); expect(selected.env).not.toHaveProperty('COMPOSE_PROJECT_NAME'); expect(selected.env).not.toHaveProperty('POSTGRES_PASSWORD'); - expect(selected.env.PATH).toBe('/bin'); + expect(selected.env).toEqual({ + PATH: '/bin', + DOCKER_CONTEXT: 'desktop-linux', + DOCKER_HOST: 'unix:///var/run/docker.sock', + DOCKER_CONFIG: '/docker-config', + }); expect(Object.isFrozen(selected.args)).toBe(true); }); it('refuses missing environment, unsupported mode and unsafe project names', () => { const root = temporary(); expect(() => composeSelection('dev', {}, root)).toThrow(); - writeFileSync(join(root, '.env'), 'A=B\n'); + writePrivateFileSync(join(root, '.env'), 'A=B\n'); expect(() => composeSelection('unknown', {}, root)).toThrow(); expect(() => composeSelection('dev', { LEARNSTACK_COMPOSE_PROJECT: '../foreign' }, root), diff --git a/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts b/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts new file mode 100644 index 00000000..09b4d68c --- /dev/null +++ b/frontend/apps/web/src/test/demo-realm-lifecycle.test.ts @@ -0,0 +1,1504 @@ +// @vitest-environment node +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { demoFailureMessage } from '../../../../../scripts/demo.mjs'; + +function writePrivateFileSync(path: string, source: Parameters[1]) { + writeFileSync(path, source, { mode: 0o600 }); +} + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'learnstack-realm-lifecycle-'))); + roots.push(root); + for (const directory of ['scripts', 'backend', 'bin', 'frontend/apps/web/.server']) + mkdirSync(join(root, directory), { recursive: true }); + for (const name of [ + 'demo', + 'seed', + 'compose', + 'demo-process', + 'demo-database', + 'demo-observation', + 'public-runtime', + 'development-realm-owner', + 'development-realm-errors', + ]) + writeFileSync( + join(root, `scripts/${name}.mjs`), + readFileSync(new URL(`../../../../../scripts/${name}.mjs`, import.meta.url)), + ); + for (const name of ['seed.sh', 'connection-string.awk']) + writeFileSync( + join(root, 'scripts', name), + readFileSync(new URL(`../../../../../scripts/${name}`, import.meta.url)), + { mode: name === 'seed.sh' ? 0o700 : 0o600 }, + ); + mkdirSync(join(root, 'frontend/apps/web/scripts')); + writeFileSync( + join(root, 'frontend/apps/web/scripts/stop-test-child.mjs'), + readFileSync(new URL('../../scripts/stop-test-child.mjs', import.meta.url)), + ); + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n'); + writeFileSync( + join(root, 'scripts/demo-http.mjs'), + 'export async function vacant() {}\nexport async function demoRequest() { throw new Error("Unexpected product request"); }\n', + ); + writeFileSync( + join(root, 'scripts/demo-source.mjs'), + 'export async function demoSource() { return { commit: "fixture", tree: "fixture" }; }\n', + ); + writeFileSync( + join(root, 'scripts/wait-compose.mjs'), + `export function defaultServices() { return ['postgres', 'keycloak']; } +export async function waitCompose(selected, processes, signal, postgresOnly = false) { + const f = globalThis.lifecycle; + f.events.push(postgresOnly ? 'postgres-ready' : 'ready'); + if (f.selected === undefined) f.selected = selected; + if (f.signal === undefined) f.signal = signal; + f.selectionIdentity &&= f.selected === selected; + f.signalIdentity &&= f.signal === signal; +} +`, + ); + writeFileSync( + join(root, 'scripts/reconcile-development-realm.mjs'), + `import { RealmError } from './development-realm-errors.mjs'; +export async function reconcileDevelopmentRealm({ selected, signal }) { + const f = globalThis.lifecycle; + f.events.push('realm-start'); + f.selectionIdentity &&= selected === f.selected; + f.signalIdentity &&= signal === f.signal && signal instanceof AbortSignal; + f.selectedDescription = { project: selected.project, file: selected.file, mode: selected.mode }; + if (f.scenario === 'reconcile-failure') throw f.realmFailure; + if (f.scenario.startsWith('progress-') && f.scenario.includes('-failure')) + throw new RealmError('plan', 'identity_conflict', f.preparationDispatched); + if (f.scenario === 'cancel' || f.scenario.includes('-cancel')) { + setTimeout(() => f.subject === 'demo' || f.entryCli ? process.kill(process.pid, 'SIGINT') : f.controller.abort(), 10); + await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true })); + f.events.push('realm-cancel'); + await new Promise(resolve => setTimeout(resolve, 30)); + f.events.push('realm-joined'); + throw new RealmError(f.preparationDispatched ? 'prepare' : 'preflight', 'cancelled', f.preparationDispatched); + } + await new Promise(resolve => setTimeout(resolve, 30)); + f.events.push('realm-complete'); + return { mutations: f.preparationDispatched ? 1 : 0, ...(f.preparationDispatched ? { preparationDispatched: true } : {}) }; +} +`, + ); + writeFileSync( + join(root, 'frontend/apps/web/.server/ingress.js'), + `import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseEnv } from 'node:util'; +export const parseLocalEnvironment = parseEnv; +export function loadLocalEnvironment(root) { return parseEnv(readFileSync(join(root, '.env'), 'utf8')); } +export function publicServerConfiguration(values) { + return { apiOrigin: 'http://127.0.0.1:5080', secret: values.LEARNSTACK_PUBLIC_HOP_SECRET, + certificate: values.LEARNSTACK_PUBLIC_TLS_CERT, privateKey: values.LEARNSTACK_PUBLIC_TLS_KEY }; +} +`, + ); + const canaries = [randomBytes(20).toString('hex'), randomBytes(20).toString('hex')]; + const values: Record = { + POSTGRES_DB: 'learnstack', + POSTGRES_PASSWORD: 'fixture-postgres', + KEYCLOAK_ADMIN: 'fixture-admin', + KEYCLOAK_ADMIN_PASSWORD: canaries[0]!, + KEYCLOAK_WEB_CLIENT_SECRET: canaries[1]!, + LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', + LEARNSTACK_PUBLIC_HOP_SECRET: 'fixture-hop', + }; + for (const [suffix, role, key] of [ + ['Migration', 'learnstack_migration', 'LEARNSTACK_MIGRATION_PW'], + ['Default', 'learnstack_app', 'LEARNSTACK_APP_PW'], + ['PlatformAdmin', 'learnstack_platform', 'LEARNSTACK_PLATFORM_PW'], + ['OutboxDispatcher', 'learnstack_outbox_admin', 'LEARNSTACK_OUTBOX_PW'], + ]) { + const password = randomBytes(20).toString('hex'); + values[key!] = password; + // The disposable fixture generates this credential at runtime; none is committed. + values[`ConnectionStrings__${suffix}`] = + `Host=localhost;Port=5432;Database=learnstack;Username=${role};Password=${password}`; // leakwatch:ignore + } + const source = + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + '\n'; + writePrivateFileSync(join(root, '.env.example'), source); + const env = { + PATH: `${join(root, 'bin')}:${process.env.PATH}`, + HOME: root, + NODE_ENV: 'test' as const, + // This unrelated inherited secret must not enter ordinary child processes. + Authentication__Bff__ClientSecret: randomBytes(20).toString('hex'), + ConnectionStrings__OutboxDispatcher: 'ambient-dispatcher', + DEBUG: '*', + }; + for (const name of ['docker', 'dotnet']) + writeFileSync( + join(root, 'bin', name), + `#!/bin/sh\nprintf unexpected >> '${join(root, 'actions')}'\nexit 19\n`, + { mode: 0o700 }, + ); + return { root, env, values, source, canaries }; +} + +type Fixture = ReturnType; +type Result = { + events: string[]; + failure: string; + notice: string; + preparationDispatched: boolean; + exitCode?: number | null; + selectionIdentity: boolean; + signalIdentity: boolean; + selectedDescription?: { project: string; file: string; mode: string }; + commands: Array<{ + command: string; + args: string[]; + label: string; + confined: boolean; + prerequisitesPreserved: boolean; + dockerContext: string | null; + dockerHome: string | null; + timeout?: number; + }>; + migrationSelection: boolean; +}; + +function execute( + f: Fixture, + subject: 'seed' | 'demo', + scenario = 'success', + demoMode: 'local' | 'ci' = scenario === 'preflight' ? 'local' : 'ci', + entryCli = false, +) { + if (subject === 'demo' && scenario !== 'preflight') { + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + join(f.root, 'key.pem'), + '-out', + join(f.root, 'cert.pem'), + '-days', + '1', + '-subj', + '/CN=localhost', + '-addext', + 'subjectAltName=DNS:localhost', + ], + { stdio: 'ignore' }, + ); + if (demoMode === 'local') + writePrivateFileSync( + join(f.root, '.env'), + f.source + + `LEARNSTACK_PUBLIC_TLS_CERT=${join(f.root, 'cert.pem')}\nLEARNSTACK_PUBLIC_TLS_KEY=${join(f.root, 'key.pem')}\n`, + ); + } + const harness = join(f.root, 'scripts/fixture-harness.mjs'); + writeFileSync( + harness, + `import { copyFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DemoProcesses } from './demo-process.mjs'; +import { composeSelection } from './compose.mjs'; +import { RealmError, realmFailureNotice } from './development-realm-errors.mjs'; +const f = globalThis.lifecycle = { events: [], commands: [], scenario: ${JSON.stringify(scenario)}, + subject: ${JSON.stringify(subject)}, entryCli: ${JSON.stringify(entryCli)}, preparationDispatched: ${JSON.stringify(scenario.startsWith('progress-prepared-'))}, + selectionIdentity: true, signalIdentity: true, migrationSelection: false, + controller: new AbortController(), seedFailure: new Error('seed refusal'), + realmFailure: new Error('realm refusal'), boundary: new Error('renderer boundary') }; +DemoProcesses.prototype.dispose = async function() { + f.events.push('dispose'); + if (f.scenario.startsWith('progress-') && f.scenario.endsWith('-cleanup')) + throw new Error('cleanup refusal', { cause: new Error(${JSON.stringify(f.canaries[0])}) }); +}; +DemoProcesses.prototype.daemon = function() { f.events.push('daemon'); throw new Error('Unexpected daemon'); }; +DemoProcesses.prototype.run = async function(command, args, options = {}) { + const env = options.env ?? {}; + const isCompose = command === 'docker' && args[0] === 'compose' && args.includes('--project-name'); + const startup = command === 'make' && args.includes('dev'); + const migration = command === 'make' && args.includes('migrate'); + const allowed = new Set(['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', + 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'NUGET_PACKAGES', 'NUGET_HTTP_CACHE_PATH', 'COREPACK_HOME', 'PNPM_HOME', 'XDG_CACHE_HOME', + 'NPM_CONFIG_CACHE', 'npm_config_cache', 'NPM_CONFIG_STORE_DIR', 'npm_config_store_dir', + ...(startup || migration || command === 'docker' ? ['USERPROFILE', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'DOCKER_CONFIG', 'DOCKER_CONTEXT', 'DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH', 'DOCKER_DEFAULT_PLATFORM', 'DOCKER_API_VERSION'] : []), + ...(startup || migration ? ['LEARNSTACK_COMPOSE_PROJECT', 'LEARNSTACK_COMPOSE_ENV_FILE', 'LEARNSTACK_COMPOSE_MODE'] : []), + ...(migration ? ['ConnectionStrings__Migration'] : []), + ...(options.label === 'Contextual seed convergence' || options.label === 'Application seed convergence' ? ['ConnectionStrings__Default'] : [])]); + // The app seed is recognised by its real, argument-free Release DLL call too. + if (command === 'dotnet' && args.length === 1 && args[0].endsWith('/LearnStack.Tools.Seeder.dll')) allowed.add('ConnectionStrings__Default'); + f.commands.push({ command, args, label: options.label, timeout: options.timeout, + dockerContext: env.DOCKER_CONTEXT ?? null, + dockerHome: env.HOME ?? null, + prerequisitesPreserved: (command === 'docker' ? ['DOCKER_CONFIG', 'DOCKER_CONTEXT'] + : ['HTTP_PROXY', 'http_proxy', 'NUGET_PACKAGES']) + .every(key => process.env[key] === undefined || env[key] === process.env[key]), + confined: isCompose || Object.keys(env).every(key => allowed.has(key)) }); + if (migration) f.migrationSelection = env.LEARNSTACK_COMPOSE_PROJECT?.startsWith('learnstack-demo-') + && env.LEARNSTACK_COMPOSE_ENV_FILE === join(${JSON.stringify(f.root)}, '.env') + && env.LEARNSTACK_COMPOSE_MODE === 'e2e' && env.ConnectionStrings__Migration?.includes('Username=learnstack_migration'); + if (${JSON.stringify(subject)} === 'seed') { + if (command === 'docker' && JSON.stringify(args) === JSON.stringify(['compose', 'version'])) f.events.push('compose-version'); + if (startup) f.events.push('stack-up'); + if (migration) f.events.push('migrate'); + } + if (command === 'dotnet' && args.includes('--demo-manifest')) return JSON.stringify([{ host: 'localhost' }]); + if (command === 'docker' && args[0] === 'context') + return JSON.stringify([{ Endpoints: { docker: { Host: f.scenario === 'remote-context' + || (f.scenario === 'selected-remote-context' && env.DOCKER_CONTEXT === undefined) + || (f.scenario === 'selected-remote-home' && env.HOME === undefined) + ? 'tcp://remote.fixture.invalid:2375' : 'unix:///var/run/docker.sock' } } }]); + if (command === 'openssl' && args[0] === 'req') { + copyFileSync(join(${JSON.stringify(f.root)}, 'key.pem'), args[args.indexOf('-keyout') + 1]); + copyFileSync(join(${JSON.stringify(f.root)}, 'cert.pem'), args[args.indexOf('-out') + 1]); + } + if (isCompose && args.includes('config')) return JSON.stringify({ services: { postgres: {}, keycloak: {} } }); + if (isCompose && args.includes('stop')) f.events.push('stop-keycloak'); + if (command === 'dotnet' && args.length === 1 && args[0].endsWith('/LearnStack.Tools.Seeder.dll')) { + f.events.push('appseed'); + if (f.scenario === 'seed-failure') throw f.seedFailure; + } + if (command === 'pnpm' && args.at(-1) === 'build') { f.events.push('renderer'); throw f.boundary; } + return ''; +}; +let failure = '', notice = '', preparationDispatched = false; +const report = () => ({ events: f.events, commands: f.commands, failure, notice, preparationDispatched, + selectionIdentity: f.selectionIdentity, signalIdentity: f.signalIdentity, + selectedDescription: f.selectedDescription, migrationSelection: f.migrationSelection }); +if (${JSON.stringify(entryCli)}) { + process.on('exit', () => writeFileSync(${JSON.stringify(join(f.root, 'entry-report'))}, JSON.stringify(report()))); +} else { +console.log = () => {}; +try { + if (${JSON.stringify(subject)} === 'seed') { + const { seedDevelopment } = await import('./seed.mjs'); + f.selected = composeSelection('dev', { ...process.env, LEARNSTACK_COMPOSE_PROJECT: 'owned-seed', LEARNSTACK_COMPOSE_ENV_FILE: 'selected.env' }); + f.signal = f.controller.signal; + await seedDevelopment({ selected: f.selected, signal: f.signal }); + } else { + const { startDemo } = await import('./demo.mjs'); + await startDemo(${JSON.stringify(demoMode)}); + } +} catch (error) { + failure = error.message; + notice = ${JSON.stringify(subject)} === 'demo' ? (await import('./demo.mjs')).demoFailureMessage(error) : realmFailureNotice(error); + preparationDispatched = error instanceof RealmError && error.preparationDispatched === true; +} +process.stdout.write(JSON.stringify(report())); +} +`, + ); + const environment: NodeJS.ProcessEnv = { ...f.env }; + if (subject === 'demo') delete environment.ConnectionStrings__OutboxDispatcher; + if (entryCli && subject === 'seed') + Object.assign(environment, { + LEARNSTACK_COMPOSE_PROJECT: 'owned-seed', + LEARNSTACK_COMPOSE_ENV_FILE: 'selected.env', + }); + const execution = spawnSync( + process.execPath, + entryCli + ? [ + '--import', + harness, + join(f.root, `scripts/${subject}.mjs`), + ...(subject === 'demo' ? [demoMode] : []), + ] + : [harness], + { cwd: f.root, env: environment, encoding: 'utf8', timeout: 10000 }, + ); + expect(execution.error).toBeUndefined(); + if (!entryCli) expect(execution.status, execution.stderr).toBe(0); + expect(execution.signal).toBeNull(); + for (const canary of f.canaries) { + expect(execution.stdout).not.toContain(canary); + expect(execution.stderr).not.toContain(canary); + } + if (entryCli) + return { + ...(JSON.parse(readFileSync(join(f.root, 'entry-report'), 'utf8')) as Result), + failure: execution.stderr.trim(), + notice: execution.stderr.trim(), + exitCode: execution.status, + }; + return JSON.parse(execution.stdout) as Result; +} + +type BoundaryEvent = { + stage: string; + confined: boolean; + selected: boolean; + parentPrivate?: boolean; +}; + +function makeSeedFixture() { + const f = fixture(); + const selectedFile = join(f.root, 'selected.env'); + const events = join(f.root, 'boundary-events'); + const project = 'owned-seed-boundary'; + const dockerConfiguration = join(f.root, 'docker-config'); + writeFileSync( + join(f.root, 'Makefile'), + readFileSync(new URL('../../../../../Makefile', import.meta.url)), + ); + for (const name of ['wait-compose', 'demo-http']) + writeFileSync( + join(f.root, `scripts/${name}.mjs`), + readFileSync(new URL(`../../../../../scripts/${name}.mjs`, import.meta.url)), + ); + mkdirSync( + join( + f.root, + 'backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations', + ), + { recursive: true }, + ); + const decoys = [randomBytes(20).toString('hex'), randomBytes(20).toString('hex')]; + f.canaries.push(...decoys); + const defaultSource = f.source + .replace(f.values.KEYCLOAK_ADMIN_PASSWORD!, decoys[0]!) + .replace(f.values.KEYCLOAK_WEB_CLIENT_SECRET!, decoys[1]!) + .replaceAll('Host=localhost', 'Host=127.0.0.1'); + writePrivateFileSync(join(f.root, '.env'), defaultSource); + writePrivateFileSync(selectedFile, f.source); + // These are command-wiring controls. The real provider proofs own Docker and .NET behavior. + writeFileSync( + join(f.root, 'scripts/boundary-observer.mjs'), + `import { appendFileSync, readFileSync } from 'node:fs'; +import { parseEnv } from 'node:util'; +export const values = parseEnv(readFileSync(${JSON.stringify(selectedFile)}, 'utf8')); +export function record(stage, connection, selected = true) { + const permitted = connection === undefined ? undefined : 'ConnectionStrings__' + connection; + const confined = Object.keys(process.env).every(key => + !/keycloak|connectionstrings|_pw$|password|bff|debug|private/i.test(key) || key === permitted); + const connectionMatches = permitted === undefined || process.env[permitted] === values[permitted]; + const metadataMatches = connection !== 'Migration' || ( + process.env.LEARNSTACK_COMPOSE_PROJECT === ${JSON.stringify(project)} + && process.env.LEARNSTACK_COMPOSE_ENV_FILE === ${JSON.stringify(selectedFile)} + && process.env.LEARNSTACK_COMPOSE_MODE === 'e2e'); + const needsDocker = ['compose-version', 'stack-up', 'postgres-readiness', 'readiness-config', + 'readiness-status', 'migration-tools', 'migration-build', 'migration-ef'].includes(stage); + const dockerMatches = !needsDocker || (process.env.DOCKER_CONTEXT === 'fixture-context' + && process.env.DOCKER_HOST === 'unix:///fixture/docker.sock' + && process.env.DOCKER_CONFIG === ${JSON.stringify(dockerConfiguration)}); + appendFileSync(${JSON.stringify(events)}, JSON.stringify({ stage, confined, + selected: selected && connectionMatches && metadataMatches && dockerMatches }) + '\\n'); +} +`, + ); + const preload = join(f.root, 'scripts/boundary-discovery.mjs'); + writeFileSync( + preload, + `globalThis.fetch = async function(input) { + const url = String(input); + if (!/^http:\\/\\/localhost:8080\\/realms\\/(learnstack|learnstack-hub)\\/\\.well-known\\/openid-configuration$/.test(url)) throw new Error('Unexpected fixture request'); + return new Response(JSON.stringify({ issuer: url.slice(0, url.indexOf('/.well-known/')) })); +}; +`, + ); + writeFileSync( + join(f.root, 'bin/node'), + `#!${process.execPath} +import { spawnSync } from 'node:child_process'; +import { record } from '../scripts/boundary-observer.mjs'; +const args = process.argv.slice(2); +if (args[0] === 'scripts/wait-compose.mjs') record('postgres-readiness', 'Migration'); +const child = spawnSync(${JSON.stringify(process.execPath)}, ['--import', ${JSON.stringify(preload)}, ...args], { stdio: 'inherit', env: process.env }); +process.exit(child.status ?? 19); +`, + { mode: 0o700 }, + ); + writeFileSync( + join(f.root, 'bin/docker'), + `#!${process.execPath} +import { record } from '../scripts/boundary-observer.mjs'; +const args = process.argv.slice(2); +if (JSON.stringify(args) === JSON.stringify(['compose', 'version'])) { + record('compose-version'); +} else if (JSON.stringify(args) === JSON.stringify(['context', 'inspect'])) { + process.stdout.write(JSON.stringify([{ Endpoints: { docker: { Host: 'unix:///fixture/docker.sock' } } }])); +} else { + const selected = args[0] === 'compose' && args[args.indexOf('--project-name') + 1] === ${JSON.stringify(project)} + && args[args.indexOf('--env-file') + 1] === ${JSON.stringify(selectedFile)} + && args.filter(arg => arg === '-f').length === 2; + const names = ['postgres', 'keycloak', 'one', 'two', 'three', 'four', 'five']; + if (args.includes('up') && args.at(-1) === '-d') record('stack-up', undefined, selected); + else if (args.includes('config') && args.at(-1) === 'json') { + record('readiness-config', undefined, selected); + process.stdout.write(JSON.stringify({ services: Object.fromEntries(names.map(name => [name, {}])) })); + } else if (args.includes('ps') && args.at(-1) === 'json') { + record('readiness-status', undefined, selected); + process.stdout.write(JSON.stringify(names.map(Service => ({ Service, State: 'running' })))); + } else process.exit(19); +} +`, + { mode: 0o700 }, + ); + writeFileSync( + join(f.root, 'bin/dotnet'), + `#!${process.execPath} +import { record, values } from '../scripts/boundary-observer.mjs'; +const args = process.argv.slice(2); +if (process.cwd() !== ${JSON.stringify(join(f.root, 'backend'))}) process.exit(19); +if (JSON.stringify(args) === JSON.stringify(['tool', 'restore'])) record('migration-tools'); +else if (JSON.stringify(args) === JSON.stringify(['build', 'src/LearnStack.Api', '--configuration', 'Release', '--nologo'])) record('migration-build'); +else if (args[0] === 'ef' && args[1] === 'database' && args[2] === 'update') record('migration-ef', 'Migration', !args.includes('--connection') && !args.some(arg => arg.includes('Password='))); +else if (JSON.stringify(args) === JSON.stringify(['build', 'src/LearnStack.Tools.Seeder', '--configuration', 'Release', '--nologo'])) record('seeder-build'); +else if (args.length === 1 && args[0] === ${JSON.stringify(join(f.root, 'backend/src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll'))}) record('appseed', 'Default'); +else process.exit(19); +`, + { mode: 0o700 }, + ); + writeFileSync( + join(f.root, 'scripts/reconcile-development-realm.mjs'), + `import { appendFileSync, readFileSync } from 'node:fs'; +import { realmEnvironment } from './development-realm-owner.mjs'; +export async function reconcileDevelopmentRealm({ selected, signal }) { + const values = realmEnvironment(readFileSync(selected.file, 'utf8')); + const parentPrivate = Object.entries(values).every(([key, value]) => process.env[key] === value); + const selectedSource = selected.file === ${JSON.stringify(selectedFile)} && selected.project === ${JSON.stringify(project)} + && selected.mode === 'e2e' && signal instanceof AbortSignal; + appendFileSync(${JSON.stringify(events)}, JSON.stringify({ stage: 'realm', confined: true, + selected: selectedSource, parentPrivate }) + '\\n'); + return { mutations: 0 }; +} +`, + ); + const environment = { + ...f.env, + KEYCLOAK_ADMIN: f.values.KEYCLOAK_ADMIN!, + KEYCLOAK_ADMIN_PASSWORD: f.values.KEYCLOAK_ADMIN_PASSWORD!, + KEYCLOAK_WEB_CLIENT_SECRET: f.values.KEYCLOAK_WEB_CLIENT_SECRET!, + ConnectionStrings__Migration: f.values.ConnectionStrings__Migration!, + ConnectionStrings__Default: f.values.ConnectionStrings__Default!, + LEARNSTACK_COMPOSE_PROJECT: project, + LEARNSTACK_COMPOSE_ENV_FILE: selectedFile, + LEARNSTACK_COMPOSE_MODE: 'e2e', + DOCKER_CONTEXT: 'fixture-context', + DOCKER_HOST: 'unix:///fixture/docker.sock', + DOCKER_CONFIG: dockerConfiguration, + UNRELATED_PRIVATE_VALUE: randomBytes(20).toString('hex'), + }; + const sensitiveValues = [ + ...f.canaries, + environment.Authentication__Bff__ClientSecret, + environment.UNRELATED_PRIVATE_VALUE, + ...Object.entries(f.values) + .filter(([key]) => key.startsWith('ConnectionStrings__')) + .map(([, value]) => value), + ]; + return { + root: f.root, + environment, + selectedFile, + sensitiveValues, + run() { + writeFileSync(events, ''); + const execution = spawnSync('make', ['seed'], { + cwd: f.root, + env: environment, + encoding: 'utf8', + timeout: 15000, + }); + expect( + sensitiveValues.some( + (value) => execution.stdout.includes(value) || execution.stderr.includes(value), + ), + ).toBe(false); + expect(execution.error).toBeUndefined(); + expect(execution.status).toBe(0); + expect(readFileSync(join(f.root, '.env'), 'utf8') === defaultSource).toBe(true); + expect(readFileSync(selectedFile, 'utf8') === f.source).toBe(true); + return readFileSync(events, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as BoundaryEvent); + }, + }; +} + +type ReadinessCleanupResult = { + reason: string; + observedDocker: boolean; + readinessTimeouts: number[]; + sameOwner: boolean; + childSurvivesOwnerCleanup: boolean; + otherOwnedGroupSurvivesOwnerCleanup: boolean; + heartbeatContinuesAfterCleanup: boolean; + nestedCleanupStarted: boolean; +}; + +function readinessCleanupFixture(kind: 'cancelled' | 'deadline') { + const f = makeSeedFixture(); + const marker = join(f.root, 'owned-docker.json'); + const heartbeat = join(f.root, 'heartbeat'); + const nestedCleanup = join(f.root, 'nested-cleanup'); + const nestedRelease = join(f.root, 'release-nested-cleanup'); + const cleanupComplete = join(f.root, 'fixture-cleanup-complete'); + const dockerRetired = join(f.root, 'docker-retired'); + const ownership = randomBytes(16).toString('hex'); + writeFileSync( + join(f.root, 'bin/docker'), + `#!${process.execPath} +import { appendFileSync, writeFileSync } from 'node:fs'; +const args = process.argv.slice(2); +if (args.includes('config')) { + process.on('SIGTERM', () => {}); + writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ pid: process.pid, ownership: ${JSON.stringify(ownership)} })); + writeFileSync(${JSON.stringify(heartbeat)}, '.'); + setInterval(() => appendFileSync(${JSON.stringify(heartbeat)}, '.'), 20); +} else if (args[0] === 'context') { + process.stdout.write(JSON.stringify([{ Endpoints: { docker: { Host: 'unix:///fixture/docker.sock' } } }])); +} else if (args.includes('up') || JSON.stringify(args) === JSON.stringify(['compose', 'version'])) process.exit(0); +else process.exit(19); +`, + { mode: 0o700 }, + ); + const stopHelper = join(f.root, 'frontend/apps/web/scripts/stop-test-child.mjs'); + writeFileSync( + join(f.root, 'frontend/apps/web/scripts/stop-test-child-actual.mjs'), + readFileSync(stopHelper), + ); + // Hold only the nested CLI cleanup to expose competing owners deterministically. + // The seed owner still executes the unchanged native stop helper and deadlines. + writeFileSync( + stopHelper, + `import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { stopTestChild as actualStop } from './stop-test-child-actual.mjs'; +export async function stopTestChild(child) { + if (process.argv[1]?.endsWith('/wait-compose.mjs')) { + writeFileSync(${JSON.stringify(nestedCleanup)}, 'started'); + while (!existsSync(${JSON.stringify(nestedRelease)})) + await new Promise(resolve => setTimeout(resolve, 10)); + } + await actualStop(child); + if (existsSync(${JSON.stringify(marker)})) { + const value = JSON.parse(readFileSync(${JSON.stringify(marker)}, 'utf8')); + if (value.ownership === ${JSON.stringify(ownership)} && value.pid === child.pid) + writeFileSync(${JSON.stringify(dockerRetired)}, 'retired'); + } +} +`, + ); + const driver = join(f.root, 'scripts/readiness-cleanup-driver.mjs'); + writeFileSync( + driver, + `import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { composeSelection } from './compose.mjs'; +import { DemoProcesses } from './demo-process.mjs'; +import { seedDevelopment } from './seed.mjs'; +import { stopTestChild } from '../frontend/apps/web/scripts/stop-test-child.mjs'; +console.log = () => {}; +const controller = new AbortController(); +const tracked = []; +const retiredGroups = new Set(); +const nativeKill = process.kill.bind(process); +// Observe the native helper's absence witness before any saved PID can recycle. +// The fixture also fails if a later observer or cleanup reuses that retired handle. +process.kill = (pid, signal) => { + if (pid < 0 && retiredGroups.has(-pid)) throw new Error('Fixture group is already retired'); + try { return nativeKill(pid, signal); } + catch (error) { + if (error.code === 'ESRCH' && pid < 0 && tracked.some(child => child.pid === -pid)) retiredGroups.add(-pid); + throw error; + } +}; +const start = DemoProcesses.prototype.start; +DemoProcesses.prototype.start = function(...args) { + const running = start.apply(this, args); + tracked.push(running.child); + return running; +}; +const run = DemoProcesses.prototype.run; +const readinessTimeouts = []; +const readinessDeadlines = []; +let seedOwner; +let sameOwner = true; +DemoProcesses.prototype.run = function(command, args, options = {}) { + if (options.label === 'Docker Compose prerequisite') seedOwner = this; + if (options.label === 'Compose readiness') { + readinessTimeouts.push(options.timeout); + sameOwner &&= this === seedOwner; + if (${JSON.stringify(kind)} === 'deadline') { + const deadline = new AbortController(); + readinessDeadlines.push(deadline); + const timeout = AbortSignal.timeout; + AbortSignal.timeout = milliseconds => milliseconds === options.timeout + ? deadline.signal : timeout(milliseconds); + try { return run.call(this, command, args, options); } + finally { AbortSignal.timeout = timeout; } + } + } + return run.call(this, command, args, options); +}; +const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } }; +let retired = false; +const ownedDockerAlive = pid => { + retired ||= existsSync(${JSON.stringify(dockerRetired)}); + if (retired) return false; + if (alive(pid)) return true; + retired = true; + writeFileSync(${JSON.stringify(dockerRetired)}, 'retired'); + return false; +}; +const ownedPid = () => { + if (!existsSync(${JSON.stringify(marker)})) return undefined; + const value = JSON.parse(readFileSync(${JSON.stringify(marker)}, 'utf8')); + if (value.ownership !== ${JSON.stringify(ownership)} || !Number.isSafeInteger(value.pid) || value.pid <= 0) throw new Error('Invalid fixture ownership'); + return value.pid; +}; +let cancelled = false; +const poll = setInterval(() => { + if (existsSync(${JSON.stringify(marker)}) && !cancelled) { + cancelled = true; + if (${JSON.stringify(kind)} === 'cancelled') controller.abort(); + else for (const deadline of readinessDeadlines) deadline.abort(); + } +}, 10); +const watchdog = setTimeout(() => controller.abort(), 9000); +let result; +try { + let reason = ''; + try { await seedDevelopment({ selected: composeSelection('e2e'), signal: controller.signal }); } + catch (error) { reason = error.reason ?? 'unexpected'; } + clearInterval(poll); + clearTimeout(watchdog); + const pid = ownedPid(); + const childSurvived = pid !== undefined && ownedDockerAlive(pid); + const trackedGroupAlive = () => tracked.some(child => child.pid !== undefined + && !retiredGroups.has(child.pid) && alive(-child.pid)); + const groupSurvived = trackedGroupAlive(); + const before = existsSync(${JSON.stringify(heartbeat)}) ? readFileSync(${JSON.stringify(heartbeat)}, 'utf8') : ''; + await new Promise(resolve => setTimeout(resolve, 300)); + const after = existsSync(${JSON.stringify(heartbeat)}) ? readFileSync(${JSON.stringify(heartbeat)}, 'utf8') : ''; + result = { reason, observedDocker: pid !== undefined, readinessTimeouts, sameOwner, + childSurvivesOwnerCleanup: childSurvived || (pid !== undefined && ownedDockerAlive(pid)), + otherOwnedGroupSurvivesOwnerCleanup: groupSurvived || trackedGroupAlive(), heartbeatContinuesAfterCleanup: before !== after, + nestedCleanupStarted: existsSync(${JSON.stringify(nestedCleanup)}) }; +} finally { + clearInterval(poll); + clearTimeout(watchdog); + writeFileSync(${JSON.stringify(nestedRelease)}, 'release'); + await Promise.all(tracked.map(child => stopTestChild(child))); + const pid = ownedPid(); + if (pid !== undefined && ownedDockerAlive(pid)) { + try { process.kill(-pid, 'SIGKILL'); } + catch (error) { + if (error.code !== 'ESRCH') throw error; + retired = true; + writeFileSync(${JSON.stringify(dockerRetired)}, 'retired'); + } + const deadline = Date.now() + 3000; + while (ownedDockerAlive(pid)) { + if (Date.now() >= deadline) throw new Error('Fixture Docker did not stop'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + } + writeFileSync(${JSON.stringify(cleanupComplete)}, 'complete'); + process.kill = nativeKill; +} +process.stdout.write(JSON.stringify(result)); +`, + ); + async function cleanupOrphanedDocker() { + writeFileSync(nestedRelease, 'release'); + if (existsSync(cleanupComplete) || existsSync(dockerRetired) || !existsSync(marker)) return; + const value = JSON.parse(readFileSync(marker, 'utf8')) as { + pid: number; + ownership: string; + }; + if (value.ownership !== ownership || !Number.isSafeInteger(value.pid) || value.pid <= 0) + throw new Error('Invalid fixture ownership'); + let retired = false; + const groupAlive = () => { + if (retired) return false; + try { + process.kill(-value.pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + retired = true; + writeFileSync(dockerRetired, 'retired'); + return false; + } + }; + if (groupAlive()) { + try { + process.kill(-value.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + retired = true; + writeFileSync(dockerRetired, 'retired'); + } + } + const deadline = Date.now() + 3000; + while (groupAlive()) { + if (Date.now() >= deadline) throw new Error('Fixture Docker did not stop'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + return { + async run() { + for (const path of [ + marker, + heartbeat, + nestedCleanup, + nestedRelease, + cleanupComplete, + dockerRetired, + ]) + rmSync(path, { force: true }); + let execution; + try { + execution = spawnSync(process.execPath, [driver], { + cwd: f.root, + env: f.environment, + encoding: 'utf8', + timeout: 12000, + }); + } finally { + // A failed driver cannot leave its one recorded fixture group behind. + await cleanupOrphanedDocker(); + } + expect( + f.sensitiveValues.some( + (value) => execution.stdout.includes(value) || execution.stderr.includes(value), + ), + ).toBe(false); + expect(execution.error).toBeUndefined(); + expect(execution.status).toBe(0); + expect(execution.stderr).toBe(''); + expect(existsSync(cleanupComplete)).toBe(true); + return JSON.parse(execution.stdout) as ReadinessCleanupResult; + }, + plantNestedReadiness() { + const path = join(f.root, 'scripts/seed.mjs'); + const source = readFileSync(path, 'utf8'); + const direct = ' await waitCompose(selected, processes, signal, true);'; + expect(source.split(direct)).toHaveLength(2); + writeFileSync( + path, + source.replace( + direct, + ` await processes.run('make', ['wait-postgres'], { + cwd: repositoryRoot, env: selectedPrerequisites, label: 'Compose readiness', timeout: 15000, + });`, + ), + ); + }, + }; +} + +describe('shared realm handoff lifecycle', () => { + it.each(['success', 'seed-failure', 'reconcile-failure', 'cancel'] as const)( + 'awaits the local demo %s handoff and joins cancellation before cleanup', + (scenario) => { + const f = fixture(); + const result = execute(f, 'demo', scenario, 'local'); + expect(result.failure).toBe( + { + success: 'renderer boundary', + 'seed-failure': 'seed refusal', + 'reconcile-failure': 'realm refusal', + cancel: 'demo: setup cancelled', + }[scenario], + ); + const expected = ['postgres-ready', 'ready', 'appseed']; + if (scenario !== 'seed-failure') expected.push('realm-start'); + if (scenario === 'success') expected.push('realm-complete', 'renderer'); + if (scenario === 'cancel') expected.push('realm-cancel', 'realm-joined'); + expect(result.events).toEqual([...expected, 'dispose']); + expect(result.events).not.toContain('stop-keycloak'); + expect(result.events).not.toContain('daemon'); + expect(result.selectionIdentity).toBe(true); + expect(result.signalIdentity).toBe(true); + if (scenario !== 'seed-failure') + expect(result.selectedDescription).toEqual({ + project: 'learnstack-dev', + file: join(f.root, '.env'), + mode: 'dev', + }); + expect(result.commands.every((command) => command.confined)).toBe(true); + expect(existsSync(join(f.root, '.env'))).toBe(true); + }, + ); + + it.each(['cancel', 'deadline', 'cancel-cleanup-failure'] as const)( + 'reports actual demo CLI setup %s with closed reasons after joined cleanup', + (scenario) => { + const f = fixture(); + const events = join(f.root, 'setup-events'); + const preload = join(f.root, 'scripts/setup-control.mjs'); + writePrivateFileSync( + preload, + `import { appendFileSync } from 'node:fs'; +import { DemoCommandError, DemoProcesses } from './demo-process.mjs'; +const record = value => appendFileSync(${JSON.stringify(events)}, value + '\\n'); +DemoProcesses.prototype.run = async function(command, args, options) { + record('setup'); + if (${JSON.stringify(scenario)} === 'deadline') + throw new DemoCommandError(options.label, new Error(${JSON.stringify(f.canaries[0])}), undefined, { reason: 'deadline' }); + process.kill(process.pid, 'SIGINT'); + await new Promise(resolve => setTimeout(resolve, 10)); + this.assertActive(); +}; +DemoProcesses.prototype.dispose = async function() { + await new Promise(resolve => setTimeout(resolve, 20)); + record('joined'); + if (${JSON.stringify(scenario)} === 'cancel-cleanup-failure') throw new Error(${JSON.stringify(f.canaries[1])}); +}; +`, + ); + const result = spawnSync( + process.execPath, + ['--import', preload, join(f.root, 'scripts/demo.mjs'), 'local'], + { + cwd: f.root, + env: f.env, + encoding: 'utf8', + timeout: 5000, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(scenario === 'cancel' ? 130 : 1); + expect(result.stderr).toBe( + { + cancel: 'demo: setup cancelled\n', + deadline: 'demo: Required node failed (deadline)\n', + 'cancel-cleanup-failure': + 'demo: Cleanup could not be confirmed; owned private files retained failed (cleanup)\n', + }[scenario], + ); + expect(readFileSync(events, 'utf8')).toBe('setup\njoined\n'); + expect(existsSync(join(f.root, '.data/demo.lock'))).toBe( + scenario === 'cancel-cleanup-failure', + ); + for (const canary of f.canaries) { + expect(result.stdout).not.toContain(canary); + expect(result.stderr).not.toContain(canary); + } + }, + ); + + it.each(['cancelled', 'deadline'] as const)( + 'joins readiness Docker after %s and rejects a nested readiness owner', + async (kind) => { + const f = readinessCleanupFixture(kind); + const stopped = (result: ReadinessCleanupResult) => + !result.childSurvivesOwnerCleanup && + !result.otherOwnedGroupSurvivesOwnerCleanup && + !result.heartbeatContinuesAfterCleanup; + const clean = await f.run(); + expect(clean.reason).toBe(kind); + expect(clean.observedDocker).toBe(true); + expect(clean.readinessTimeouts).toEqual([15000]); + expect(clean.sameOwner).toBe(true); + expect(clean.nestedCleanupStarted).toBe(false); + expect(clean.otherOwnedGroupSurvivesOwnerCleanup).toBe(false); + expect(stopped(clean)).toBe(true); + + f.plantNestedReadiness(); + const nested = await f.run(); + expect(nested.reason).toBe(kind); + expect(nested.observedDocker).toBe(true); + expect(nested.readinessTimeouts).toEqual([15000]); + expect(nested.nestedCleanupStarted).toBe(true); + expect(nested.otherOwnedGroupSurvivesOwnerCleanup).toBe(false); + expect(stopped(nested)).toBe(false); + expect(nested.childSurvivesOwnerCleanup).toBe(true); + expect(nested.heartbeatContinuesAfterCleanup).toBe(true); + }, + 30000, + ); + + it('confines actual make seed preparation and rejects the old prerequisite topology', () => { + const f = makeSeedFixture(); + const confined = (events: BoundaryEvent[]) => events.every((event) => event.confined); + const events = f.run(); + expect(events.map((event) => event.stage)).toEqual([ + 'compose-version', + 'stack-up', + 'readiness-config', + 'readiness-status', + 'migration-tools', + 'migration-build', + 'migration-ef', + 'readiness-config', + 'readiness-status', + 'seeder-build', + 'appseed', + 'realm', + ]); + expect(confined(events)).toBe(true); + expect(events.every((event) => event.selected)).toBe(true); + expect(events.at(-1)?.parentPrivate).toBe(true); + + const makefile = join(f.root, 'Makefile'); + const source = readFileSync(makefile, 'utf8'); + const planted = source.replace( + /^seed: seed-config(?=\s*##)/m, + 'seed: seed-config dev wait-postgres migrate', + ); + expect(planted).not.toBe(source); + writeFileSync(makefile, planted); + const inherited = f.run(); + expect(confined(inherited)).toBe(false); + expect( + inherited + .filter((event) => + ['migration-build', 'migration-ef', 'postgres-readiness'].includes(event.stage), + ) + .some((event) => !event.confined), + ).toBe(true); + }, 45000); + + it('uses the caller selection and signal through readiness, fresh seed and awaited reconciliation', () => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute(f, 'seed'); + expect(result.failure).toBe(''); + expect(result.events).toEqual([ + 'compose-version', + 'stack-up', + 'postgres-ready', + 'migrate', + 'ready', + 'appseed', + 'realm-start', + 'realm-complete', + 'dispose', + ]); + expect(result.commands.slice(0, 4).map(({ command, args }) => ({ command, args }))).toEqual([ + { command: 'docker', args: ['compose', 'version'] }, + { command: 'docker', args: ['context', 'inspect'] }, + { command: 'make', args: ['dev'] }, + { command: 'make', args: ['migrate'] }, + ]); + expect(result.commands.slice(0, 4).map(({ timeout }) => timeout)).toEqual([ + 5000, 5000, 600000, 300000, + ]); + expect(result.selectionIdentity).toBe(true); + expect(result.signalIdentity).toBe(true); + expect(result.selectedDescription).toEqual({ + project: 'owned-seed', + file: join(f.root, 'selected.env'), + mode: 'dev', + }); + expect(result.commands.every((command) => command.confined)).toBe(true); + expect(readFileSync(join(f.root, 'selected.env'), 'utf8')).toBe(f.source); + }); + + it.each(['seed-failure', 'reconcile-failure'])( + 'halts the seed owner on %s and disposes its children', + (scenario) => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute(f, 'seed', scenario); + expect(result.failure).toBe(scenario === 'seed-failure' ? 'seed refusal' : 'realm refusal'); + expect(result.events).toEqual( + scenario === 'seed-failure' + ? [ + 'compose-version', + 'stack-up', + 'postgres-ready', + 'migrate', + 'ready', + 'appseed', + 'dispose', + ] + : [ + 'compose-version', + 'stack-up', + 'postgres-ready', + 'migrate', + 'ready', + 'appseed', + 'realm-start', + 'dispose', + ], + ); + }, + ); + + it('joins a cancelled reconciliation before disposing the seed process owner', () => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute(f, 'seed', 'cancel'); + expect(result.failure).toBe('realm: preflight/cancelled'); + expect(result.events).toEqual([ + 'compose-version', + 'stack-up', + 'postgres-ready', + 'migrate', + 'ready', + 'appseed', + 'realm-start', + 'realm-cancel', + 'realm-joined', + 'dispose', + ]); + }); + + it('awaits reconciliation after app seed and before CI stops Keycloak or launches API/web', () => { + const f = fixture(); + Object.assign(f.env, { + HTTP_PROXY: 'http://upper.fixture.invalid', + http_proxy: 'http://lower.fixture.invalid', + NUGET_PACKAGES: join(f.root, 'nuget-cache'), + DOCKER_CONFIG: join(f.root, 'docker-config'), + DOCKER_CONTEXT: 'owned-fixture-context', + }); + const result = execute(f, 'demo'); + expect(result.failure).toBe('renderer boundary'); + expect(result.events.filter((event) => event !== 'dispose')).toEqual([ + 'postgres-ready', + 'ready', + 'appseed', + 'realm-start', + 'realm-complete', + 'stop-keycloak', + 'renderer', + ]); + expect(result.events.filter((event) => event === 'dispose')).toHaveLength(2); + expect(result.selectionIdentity).toBe(true); + expect(result.signalIdentity).toBe(true); + expect(result.selectedDescription).toMatchObject({ file: join(f.root, '.env'), mode: 'e2e' }); + expect(result.selectedDescription!.project).toMatch(/^learnstack-demo-[a-f0-9]+$/); + expect(result.commands.every((command) => command.confined)).toBe(true); + expect(result.commands.every((command) => command.prerequisitesPreserved)).toBe(true); + expect(result.commands.find(({ label }) => label === 'Required docker')).toMatchObject({ + command: 'docker', + args: ['compose', 'version'], + prerequisitesPreserved: true, + }); + expect(result.migrationSelection).toBe(true); + expect(existsSync(join(f.root, '.env'))).toBe(false); + }); + + it.each(['seed-failure', 'reconcile-failure'])( + 'halts the CI demo before stop/start on %s', + (scenario) => { + const f = fixture(); + const result = execute(f, 'demo', scenario); + expect(result.failure).toBe(scenario === 'seed-failure' ? 'seed refusal' : 'realm refusal'); + expect(result.events).not.toContain('stop-keycloak'); + expect(result.events).not.toContain('renderer'); + expect(result.events).not.toContain('daemon'); + expect(result.events).not.toContain('realm-complete'); + if (scenario === 'seed-failure') expect(result.events).not.toContain('realm-start'); + expect(existsSync(join(f.root, '.env'))).toBe(false); + }, + ); + + it.each(['local', 'remote'])( + 'checks the selected Docker environment when the private file removes an ambient context and the default is %s', + (endpoint) => { + const f = fixture(); + Object.assign(f.env, { DOCKER_CONTEXT: 'owned-fixture-context' }); + writeFileSync( + join(f.root, '.env.example'), + f.source + 'DOCKER_CONTEXT=owned-fixture-context\n', + ); + const result = execute(f, 'demo', `selected-${endpoint}-context`); + const inspections = result.commands.filter( + ({ command, args }) => command === 'docker' && args[0] === 'context', + ); + expect(inspections.map(({ dockerContext }) => dockerContext)).toEqual([ + 'owned-fixture-context', + null, + ]); + const selectedCommands = result.commands.filter(({ args }) => + args.includes('--project-name'), + ); + if (endpoint === 'remote') { + expect(result.failure).toBe('realm: preflight/configuration'); + expect(selectedCommands).toEqual([]); + expect(result.events).toEqual(['dispose']); + } else { + expect(result.failure).toBe('renderer boundary'); + expect(selectedCommands.some(({ args }) => args.includes('up'))).toBe(true); + expect(selectedCommands.every(({ dockerContext }) => dockerContext === null)).toBe(true); + expect(result.commands.indexOf(inspections[1]!)).toBeLessThan( + result.commands.indexOf(selectedCommands[0]!), + ); + } + expect(existsSync(join(f.root, '.env'))).toBe(false); + }, + ); +}); + +describe('private configuration refuses launch before commands', () => { + it.each(['seed', 'demo'] as const)( + 'refuses a shared private file before %s commands without repairing it', + (subject) => { + const f = fixture(); + const file = join(f.root, subject === 'seed' ? 'selected.env' : '.env'); + writePrivateFileSync(file, f.source); + chmodSync(file, 0o644); + const result = execute(f, subject, 'preflight'); + expect(result.failure).toBe('realm: preflight/private_file'); + expect(result.commands).toEqual([]); + expect(readFileSync(file, 'utf8')).toBe(f.source); + }, + ); + + it.each(['seed', 'demo'] as const)( + 'refuses a remote Docker context before %s starts or migrates a stack', + (subject) => { + const f = fixture(); + if (subject === 'seed') writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute(f, subject, 'remote-context'); + expect(result.failure).toBe('realm: preflight/configuration'); + expect( + result.commands + .filter(({ command }) => ['make', 'dotnet', 'pnpm'].includes(command)) + .every(({ args }) => args.includes('--version')), + ).toBe(true); + expect( + result.commands.some(({ label }) => /startup|migrations|seed convergence/i.test(label)), + ).toBe(false); + expect(result.events).not.toContain('stack-up'); + expect(result.events).not.toContain('migrate'); + expect(result.events).not.toContain('appseed'); + }, + ); + + const keys = ['KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD', 'KEYCLOAK_WEB_CLIENT_SECRET']; + it('checks the selected Docker environment without reintroducing an inherited HOME', () => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source + `HOME=${f.env.HOME}\n`); + const result = execute(f, 'seed', 'selected-remote-home'); + expect(result.failure).toBe('realm: preflight/configuration'); + const inspection = result.commands.find( + ({ command, args }) => command === 'docker' && args[0] === 'context', + ); + expect(inspection?.dockerHome).toBeNull(); + expect(result.events).not.toContain('stack-up'); + expect(result.events).not.toContain('migrate'); + expect(result.events).not.toContain('appseed'); + }); + + it.each( + ['Migration', 'Default'].flatMap((suffix) => + ['missing', 'ambient mismatch'].map((kind) => ({ suffix, kind })), + ), + )('refuses owner $suffix $kind before preparation or readiness', ({ suffix, kind }) => { + const f = fixture(); + const key = `ConnectionStrings__${suffix}`; + let source = f.source; + if (kind === 'missing') source = source.replace(`${key}=${f.values[key]}\n`, ''); + else Object.assign(f.env, { [key]: randomBytes(20).toString('hex') }); + writePrivateFileSync(join(f.root, 'selected.env'), source); + const result = execute(f, 'seed'); + expect(result.failure).toBe('realm: preflight/configuration'); + expect(result.commands).toEqual([]); + expect(result.events).toEqual([]); + }); + + it('keeps private owner preflight ahead of database disagreement and every command', () => { + const f = fixture(); + writePrivateFileSync( + join(f.root, 'selected.env'), + f.source.replace(`KEYCLOAK_WEB_CLIENT_SECRET=${f.values.KEYCLOAK_WEB_CLIENT_SECRET}\n`, ''), + ); + Object.assign(f.env, { ConnectionStrings__Migration: randomBytes(20).toString('hex') }); + const result = execute(f, 'seed'); + expect(result.failure).toBe('realm: preflight/missing_web_secret'); + expect(result.commands).toEqual([]); + expect(result.events).toEqual([]); + }); + + it('keeps the fixed application-role refusal ahead of private configuration preflight', () => { + const f = fixture(); + const source = f.source + .replace('Username=learnstack_app', 'Username=learnstack_platform') + .replace(`KEYCLOAK_WEB_CLIENT_SECRET=${f.values.KEYCLOAK_WEB_CLIENT_SECRET}\n`, ''); + writePrivateFileSync(join(f.root, '.env'), source); + for (const args of [['--validate-config'], []]) { + const execution = spawnSync('bash', [join(f.root, 'scripts/seed.sh'), ...args], { + cwd: f.root, + env: f.env, + encoding: 'utf8', + timeout: 5000, + }); + expect(execution.status).not.toBe(0); + expect(execution.stderr).toBe( + 'seed: ConnectionStrings__Default must name learnstack_app; credentials were not used.\n', + ); + expect(existsSync(join(f.root, 'actions'))).toBe(false); + expect(readFileSync(join(f.root, '.env'), 'utf8')).toBe(source); + } + }); + + it('validates the generated CI private environment before selected-stack commands', () => { + const f = fixture(); + writeFileSync( + join(f.root, '.env.example'), + f.source.replace(`KEYCLOAK_WEB_CLIENT_SECRET=${f.values.KEYCLOAK_WEB_CLIENT_SECRET}\n`, ''), + ); + const result = execute(f, 'demo', 'ci-preflight'); + expect(result.failure).toBe('realm: preflight/missing_web_secret'); + expect(result.commands.some((command) => command.args.includes('--project-name'))).toBe(false); + expect(result.events).toEqual(['dispose']); + expect(existsSync(join(f.root, '.env'))).toBe(false); + }); + + it.each( + keys.flatMap((key) => + ['missing', 'duplicate', 'alias', 'ambient mismatch'].map((kind) => ({ key, kind })), + ), + )( + 'refuses $key $kind in shell validation, normal seed and local demo without rewriting or leaking it', + ({ key, kind }) => { + const f = fixture(); + let source = f.source; + const assignment = `${key}=${f.values[key]}\n`; + if (kind === 'missing') source = source.replace(assignment, ''); + if (kind === 'duplicate') source += assignment; + if (kind === 'alias') source = source.replace(assignment, `export ${assignment}`); + if (kind === 'ambient mismatch') + Object.assign(f.env, { [key]: randomBytes(20).toString('hex') }); + writePrivateFileSync(join(f.root, '.env'), source); + for (const args of [['--validate-config'], []]) { + const execution = spawnSync('bash', [join(f.root, 'scripts/seed.sh'), ...args], { + cwd: f.root, + env: f.env, + encoding: 'utf8', + timeout: 5000, + }); + expect(execution.error).toBeUndefined(); + expect(execution.status).not.toBe(0); + expect(execution.stderr).toContain('realm: preflight/'); + expect(existsSync(join(f.root, 'actions'))).toBe(false); + expect(readFileSync(join(f.root, '.env'), 'utf8')).toBe(source); + for (const canary of f.canaries) { + expect(execution.stdout).not.toContain(canary); + expect(execution.stderr).not.toContain(canary); + } + } + const result = execute(f, 'demo', 'preflight'); + expect(result.failure).toMatch(/^realm: preflight\/(?:configuration|missing_web_secret)$/); + expect(result.commands).toEqual([]); + expect(readFileSync(join(f.root, '.env'), 'utf8')).toBe(source); + }, + ); + + it('keeps the seed CLI closed and validates configuration without starting work', () => { + const f = fixture(); + writePrivateFileSync(join(f.root, '.env'), f.source); + for (const args of [ + ['--validate-config'], + ['--identity-manifest'], + ['--validate-config', 'extra'], + ]) { + const execution = spawnSync(process.execPath, [join(f.root, 'scripts/seed.mjs'), ...args], { + cwd: f.root, + env: f.env, + encoding: 'utf8', + timeout: 5000, + }); + expect(execution.error).toBeUndefined(); + if (args.length === 1 && args[0] === '--validate-config') expect(execution.status).toBe(0); + else expect(execution.status).not.toBe(0); + expect(existsSync(join(f.root, 'actions'))).toBe(false); + expect(readFileSync(join(f.root, '.env'), 'utf8')).toBe(f.source); + } + }); + + it('emits only fixed realm diagnostics and the bounded missing-key instruction', () => { + const f = fixture(); + const execution = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import { RealmError } from './scripts/development-realm-errors.mjs'; + import { demoFailureMessage } from './scripts/demo.mjs'; + process.stdout.write(JSON.stringify([ + demoFailureMessage(new RealmError('apply', 'status')), + demoFailureMessage(new RealmError('preflight', 'missing_web_secret')), + ])); + `, + ], + { cwd: f.root, env: f.env, encoding: 'utf8', timeout: 5000 }, + ); + expect(execution.status, execution.stderr).toBe(0); + const [ordinary, missing] = JSON.parse(execution.stdout) as [string, string]; + expect(ordinary).toBe('realm: apply/status'); + expect(missing).toContain('realm: preflight/missing_web_secret'); + expect(missing).toContain('add KEYCLOAK_WEB_CLIENT_SECRET explicitly'); + expect(missing).toContain('infra/keycloak/README.md'); + expect( + demoFailureMessage(new Error('realm: preflight/missing_web_secret injected-private-value')), + ).not.toContain('injected-private-value'); + }); +}); + +const preparationNotice = + 'realm: profile metadata may already be durable; inspect current realm state before an explicit rerun; see infra/keycloak/README.md'; + +describe('closed preparation progress at the seed and demo entry owners', () => { + it.each( + [true, false].flatMap((prepared) => + ['failure', 'success'].map((outcome) => ({ prepared, outcome })), + ), + )( + 'prints a closed seed CLI cleanup notice for $outcome progress=$prepared', + ({ prepared, outcome }) => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute( + f, + 'seed', + `progress-${prepared ? 'prepared' : 'plain'}-${outcome}-cleanup`, + 'ci', + true, + ); + expect(result.exitCode).toBe(1); + expect(result.notice).toBe( + prepared + ? `realm: cleanup/cleanup\n${preparationNotice}` + : 'seed: setup or convergence failed; verify configuration and migrations.', + ); + expect(result.events).toContain('realm-start'); + expect(result.events.includes('realm-complete')).toBe(outcome === 'success'); + expect(result.events.at(-1)).toBe('dispose'); + }, + ); + + it.each([true, false])( + 'prints a closed seed CLI cancellation notice for progress=%s', + (prepared) => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute( + f, + 'seed', + `progress-${prepared ? 'prepared' : 'plain'}-cancel`, + 'ci', + true, + ); + expect(result.exitCode).toBe(130); + expect(result.notice).toBe( + prepared ? `realm: prepare/cancelled\n${preparationNotice}` : 'realm: preflight/cancelled', + ); + expect(result.events.slice(-3)).toEqual(['realm-cancel', 'realm-joined', 'dispose']); + }, + ); + + it.each( + [true, false].flatMap((prepared) => + ['failure', 'success'].map((outcome) => ({ prepared, outcome })), + ), + )( + 'preserves seed $outcome progress=$prepared when its own process cleanup fails', + ({ prepared, outcome }) => { + const f = fixture(); + writePrivateFileSync(join(f.root, 'selected.env'), f.source); + const result = execute( + f, + 'seed', + `progress-${prepared ? 'prepared' : 'plain'}-${outcome}-cleanup`, + ); + expect(result.failure).toBe(prepared ? 'realm: cleanup/cleanup' : 'cleanup refusal'); + expect(result.preparationDispatched).toBe(prepared); + expect(result.notice).toBe( + prepared + ? `realm: cleanup/cleanup\n${preparationNotice}` + : 'realm: preflight/configuration', + ); + expect(result.events).toContain('realm-start'); + expect(result.events.includes('realm-complete')).toBe(outcome === 'success'); + expect(result.events.at(-1)).toBe('dispose'); + expect(readFileSync(join(f.root, 'selected.env'), 'utf8')).toBe(f.source); + }, + ); + + it.each([true, false])( + 'preserves demo failure progress=%s when setup cleanup fails', + (prepared) => { + const f = fixture(); + const result = execute( + f, + 'demo', + `progress-${prepared ? 'prepared' : 'plain'}-failure-cleanup`, + ); + expect(result.preparationDispatched).toBe(prepared); + expect(result.notice).toBe( + prepared + ? `realm: cleanup/cleanup\n${preparationNotice}` + : 'demo: Cleanup could not be confirmed; owned private files retained failed (cleanup)', + ); + expect(result.events).toContain('realm-start'); + expect(result.events).not.toContain('stop-keycloak'); + expect(result.events).not.toContain('renderer'); + expect(result.events).not.toContain('daemon'); + expect(existsSync(join(f.root, '.env'))).toBe(true); + expect(existsSync(join(f.root, '.data/demo.lock'))).toBe(true); + }, + ); + + it.each( + [true, false].flatMap((prepared) => [true, false].map((cleanup) => ({ prepared, cleanup }))), + )( + 'keeps actual local demo CLI cancellation progress=$prepared cleanup-failure=$cleanup closed', + ({ prepared, cleanup }) => { + const f = fixture(); + const result = execute( + f, + 'demo', + `progress-${prepared ? 'prepared' : 'plain'}-cancel${cleanup ? '-cleanup' : ''}`, + 'local', + true, + ); + expect(result.exitCode).toBe(cleanup ? 1 : 130); + expect(result.notice).toBe( + cleanup + ? prepared + ? `realm: cleanup/cleanup\n${preparationNotice}` + : 'demo: Cleanup could not be confirmed; owned private files retained failed (cleanup)' + : prepared + ? `demo: setup cancelled\nrealm: prepare/cancelled\n${preparationNotice}` + : 'demo: setup cancelled', + ); + expect(result.events.slice(-3)).toEqual(['realm-cancel', 'realm-joined', 'dispose']); + expect(result.events).not.toContain('renderer'); + expect(result.events).not.toContain('daemon'); + expect(existsSync(join(f.root, '.data/demo.lock'))).toBe(cleanup); + expect(existsSync(join(f.root, '.env'))).toBe(true); + }, + ); +}); diff --git a/frontend/apps/web/src/test/demo-source.test.ts b/frontend/apps/web/src/test/demo-source.test.ts index d30ab3ba..36eaaece 100644 --- a/frontend/apps/web/src/test/demo-source.test.ts +++ b/frontend/apps/web/src/test/demo-source.test.ts @@ -174,6 +174,8 @@ describe('observational CI demo source checkpoints', () => { 'public-runtime', 'wait-compose', 'compose', + 'development-realm-owner', + 'development-realm-errors', ]) writeFileSync( join(f.root, `scripts/${name}.mjs`), @@ -183,6 +185,10 @@ describe('observational CI demo source checkpoints', () => { join(f.root, 'scripts/demo-http.mjs'), 'export async function vacant() {}\nexport async function demoRequest() {}\nexport async function boundedBody() {}\n', ); + writeFileSync( + join(f.root, 'scripts/reconcile-development-realm.mjs'), + "export async function reconcileDevelopmentRealm() { throw new Error('Unexpected reconciliation before restore'); }\n", + ); mkdirSync(join(f.root, 'frontend/apps/web/scripts'), { recursive: true }); writeFileSync( join(f.root, 'frontend/apps/web/scripts/stop-test-child.mjs'), @@ -190,7 +196,14 @@ describe('observational CI demo source checkpoints', () => { ); f.git('add', '.'); f.git('commit', '--quiet', '-m', 'actual launcher'); - if (mode === 'local') writeFileSync(join(f.root, 'source.ts'), 'local development change'); + if (mode === 'local') { + writeFileSync(join(f.root, 'source.ts'), 'local development change'); + writeFileSync( + join(f.root, '.env'), + 'KEYCLOAK_ADMIN=source-control-admin\nKEYCLOAK_ADMIN_PASSWORD=source-control-private\nKEYCLOAK_WEB_CLIENT_SECRET=source-control-web-private\n', + { mode: 0o600 }, + ); + } const execution = spawnSync( process.execPath, [ @@ -204,6 +217,7 @@ describe('observational CI demo source checkpoints', () => { DemoProcesses.prototype.run = async function(command, args, options) { commands.push({ command, args }); if (command === 'git') return run.call(this, command, args, options); + if (command === 'docker' && args[0] === 'context') return JSON.stringify([{ Endpoints: { docker: { Host: 'unix:///var/run/docker.sock' } } }]); if (command === 'pnpm' && args[0] === 'install') throw stop; return ''; }; diff --git a/frontend/apps/web/src/test/fixtures/demo-identities.json b/frontend/apps/web/src/test/fixtures/demo-identities.json index 9902f051..48348488 100644 --- a/frontend/apps/web/src/test/fixtures/demo-identities.json +++ b/frontend/apps/web/src/test/fixtures/demo-identities.json @@ -111,6 +111,14 @@ { "kind": "id", "value": "01930000-0000-7000-8000-000000000f23" }, { "kind": "id", "value": "01930000-0000-7000-8000-000000000f24" }, { "kind": "id", "value": "01930000-0000-7000-8000-000000000f25" }, + { "kind": "id", "value": "01930000-0000-7000-8000-00000000a001" }, + { "kind": "id", "value": "01930000-0000-7000-8000-00000000a002" }, + { "kind": "id", "value": "01930000-0000-7000-8000-00000000b001" }, + { "kind": "id", "value": "01930000-0000-7000-8000-00000000b002" }, + { "kind": "identity-username", "value": "demo-admin@tenant-a.test" }, + { "kind": "identity-username", "value": "demo-admin@tenant-b.test" }, + { "kind": "identity-username", "value": "demo-learner@tenant-a.test" }, + { "kind": "identity-username", "value": "demo-learner@tenant-b.test" }, { "kind": "lesson-slug", "value": "agac-durusu" }, { "kind": "lesson-slug", "value": "bridge-pose" }, { "kind": "lesson-slug", "value": "child-pose" }, diff --git a/frontend/apps/web/src/test/ingress-readiness.test.ts b/frontend/apps/web/src/test/ingress-readiness.test.ts new file mode 100644 index 00000000..d43b506a --- /dev/null +++ b/frontend/apps/web/src/test/ingress-readiness.test.ts @@ -0,0 +1,428 @@ +// @vitest-environment node +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + boundNextVersionFetch, + createIngressReadiness, + isReadinessNotification, +} from '../../scripts/ingress-readiness.mjs'; + +function child(pid = 600001) { + return Object.assign(new EventEmitter(), { + pid, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + }); +} + +function owner(process: ReturnType) { + return createIngressReadiness(process as unknown as ChildProcess); +} + +function bind(process: ReturnType, address = '127.0.0.1', pid = process.pid) { + process.emit('message', { kind: 'bound', pid, port: 3000, address }); +} + +const healthy = async () => ({ status: 200, body: '{"status":"healthy"}' }); +const active = () => {}; + +async function failure(pending: Promise) { + const captured = pending.then( + () => { + throw new Error('Planted refusal unexpectedly passed'); + }, + (error: Error) => error, + ); + await vi.advanceTimersByTimeAsync(22000); + const error = await captured; + return { + message: error.message, + value: JSON.parse(error.message.slice(error.message.indexOf(': ') + 2)), + }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('native fixture readiness observations', () => { + it('bounds only the exact one-argument version lookup and preserves unrelated arguments/results', async () => { + const response = new Response('{}'); + const fetch = vi.fn().mockResolvedValue(response); + const notify = vi.fn(); + const observed = boundNextVersionFetch(fetch, notify); + const options = { headers: { 'private-fixture-header': 'PRIVATE' } }; + const receiver = {}; + await expect( + observed.call(receiver, 'https://registry.npmjs.org/-/package/next/dist-tags'), + ).resolves.toBe(response); + expect(fetch).toHaveBeenCalledOnce(); + const signal = fetch.mock.calls[0]![1]!.signal!; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + expect(fetch.mock.contexts[0]).toBe(receiver); + expect(notify.mock.calls.map(([value]) => value.state)).toEqual(['pending', 'settled']); + expect(JSON.stringify(notify.mock.calls)).not.toMatch(/PRIVATE|https:|headers/); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(3000); + expect(signal.aborted).toBe(false); + notify.mockClear(); + const promise = Promise.resolve(response); + fetch.mockReturnValueOnce(promise); + expect(observed('https://unrelated.invalid/PRIVATE')).toBe(promise); + fetch.mockReturnValueOnce(promise); + const url = new URL('https://registry.npmjs.org/-/package/next/dist-tags'); + expect(observed(url, options)).toBe(promise); + expect(fetch.mock.calls.at(-1)).toEqual([url, options]); + fetch.mockReturnValueOnce(promise); + expect(observed('https://registry.npmjs.org/-/package/next/dist-tags', options)).toBe(promise); + expect(fetch.mock.calls.at(-1)![1]).toBe(options); + fetch.mockReturnValueOnce(promise); + expect(observed('https://registry.npmjs.org/-/package/next/dist-tags', undefined)).toBe( + promise, + ); + expect(notify).not.toHaveBeenCalled(); + }); + + it('retains original fetch rejection identity and contains observer failures and arbitrary error values', async () => { + const error = Object.assign(new Error('PRIVATE-FETCH'), { code: 'PRIVATE-CODE' }); + const fetch = vi.fn().mockRejectedValue(error); + const notify = vi.fn(); + await expect( + boundNextVersionFetch(fetch, notify)('https://registry.npmjs.org/-/package/next/dist-tags'), + ).rejects.toBe(error); + expect(notify.mock.calls.map(([value]) => [value.state, value.errorKind])).toEqual([ + ['pending', 'none'], + ['failed', 'other'], + ]); + expect(JSON.stringify(notify.mock.calls)).not.toContain('PRIVATE'); + fetch.mockImplementation(() => { + throw error; + }); + let synchronous: unknown; + try { + boundNextVersionFetch(fetch, notify)('https://registry.npmjs.org/-/package/next/dist-tags'); + } catch (value) { + synchronous = value; + } + expect(synchronous).toBe(error); + expect(vi.getTimerCount()).toBe(0); + const response = new Response('{}'); + fetch.mockResolvedValue(response); + await expect( + boundNextVersionFetch(fetch, () => { + throw new Error('PRIVATE-OBSERVER'); + })('https://registry.npmjs.org/-/package/next/dist-tags'), + ).resolves.toBe(response); + fetch.mockRejectedValue(error); + await expect( + boundNextVersionFetch(fetch, () => { + throw new Error('PRIVATE-OBSERVER'); + })('https://registry.npmjs.org/-/package/next/dist-tags'), + ).rejects.toBe(error); + expect(vi.getTimerCount()).toBe(0); + }); + + it('aborts the exact lookup at two seconds and retains the real delegate rejection', async () => { + let signal!: AbortSignal; + const fetch = vi.fn().mockImplementation((_input, options) => { + signal = options!.signal!; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const notify = vi.fn(); + const pending = boundNextVersionFetch( + fetch, + notify, + )('https://registry.npmjs.org/-/package/next/dist-tags'); + const rejected = pending.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(1999); + expect(signal.aborted).toBe(false); + expect(notify).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(signal.aborted).toBe(true); + expect(await rejected).toBe(signal.reason); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1]![0]).toEqual({ + state: 'failed', + errorKind: 'timeout', + elapsedMilliseconds: 2000, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not abandon or substitute a delegate that has not settled after abort', async () => { + let signal!: AbortSignal; + let complete!: (response: Response) => void; + const fetch = vi.fn().mockImplementation((_input, options) => { + signal = options!.signal!; + return new Promise((resolve) => { + complete = resolve; + }); + }); + const notify = vi.fn(); + const pending = boundNextVersionFetch( + fetch, + notify, + )('https://registry.npmjs.org/-/package/next/dist-tags'); + let settled = false; + const joined = pending.then((response) => { + settled = true; + return response; + }); + await vi.advanceTimersByTimeAsync(2000); + expect(signal.aborted).toBe(true); + expect(settled).toBe(false); + expect(notify).toHaveBeenCalledOnce(); + const response = new Response('{}'); + complete(response); + await expect(joined).resolves.toBe(response); + expect(notify.mock.calls.map(([value]) => value.state)).toEqual(['pending', 'settled']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('records only current-child closed fetch observations, including a pending wait', async () => { + const process = child(); + const readiness = owner(process); + const upgrades: unknown[] = []; + process.on('message', (message) => { + if (isReadinessNotification(message)) return; + upgrades.push(message); + }); + const observation = { + kind: 'version-fetch', + state: 'pending', + errorKind: 'none', + elapsedMilliseconds: 0, + }; + process.emit('message', { ...observation, pid: process.pid + 1 }); + process.emit('message', { ...observation, state: 'PRIVATE', pid: process.pid }); + process.emit('message', { ...observation, errorKind: 'PRIVATE', pid: process.pid }); + process.emit('message', { ...observation, elapsedMilliseconds: Infinity, pid: process.pid }); + let resolve!: (response: Response) => void; + const fetch = vi.fn().mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const pending = boundNextVersionFetch(fetch, (value) => + process.emit('message', { + kind: 'version-fetch', + pid: process.pid, + ...value, + }), + )('https://registry.npmjs.org/-/package/next/dist-tags'); + const { message, value } = await failure( + readiness.wait({ port: 3000, probe: healthy, assertActive: active }), + ); + expect(value).toMatchObject({ + versionFetch: 'pending', + versionFetchError: 'none', + versionFetchCount: 1, + versionFetchMilliseconds: 20000, + }); + expect(message).not.toContain('PRIVATE'); + const response = new Response('{}'); + resolve(response); + await expect(pending).resolves.toBe(response); + const upgrade = { surface: 'sink', sanitized: true }; + process.emit('message', upgrade); + expect(upgrades).toEqual([upgrade]); + }); + + it.each([ + ['ECONNREFUSED', 'connection_refused'], + ['INGRESS_PROBE_TIMEOUT', 'timeout'], + ['ETIMEDOUT', 'timeout'], + ['ECONNRESET', 'connection_reset'], + ['ERR_TLS_CERT_ALTNAME_INVALID', 'tls'], + ['PRIVATE-CODE', 'other'], + ])('classifies planted %s without exporting arbitrary error values', async (code, kind) => { + const process = child(); + const readiness = owner(process); + process.emit('message', { kind: 'readiness', pid: process.pid, phase: 'preparing' }); + const probe = vi.fn(async () => { + throw Object.assign(new Error('PRIVATE-MESSAGE https://PRIVATE-HOST/?token=PRIVATE-TOKEN'), { + code, + cause: 'PRIVATE-CAUSE', + detail: 'PRIVATE-DETAIL', + }); + }); + const { message, value } = await failure( + readiness.wait({ port: 3000, probe, assertActive: active }), + ); + expect(message).not.toMatch(/PRIVATE|https:/); + expect(value).toMatchObject({ + phase: 'preparing', + bind: 'unobserved', + exit: 'running', + probeKind: kind, + probeCount: 200, + responseCount: 0, + lastStatus: null, + waitMilliseconds: 20000, + }); + expect(probe).toHaveBeenCalledTimes(200); + }); + + it('records a two-second pending probe separately from the unchanged whole wait', async () => { + const process = child(); + const readiness = owner(process); + const probe = async () => + new Promise((_resolve, reject) => { + setTimeout( + () => reject(Object.assign(new Error('PRIVATE'), { code: 'INGRESS_PROBE_TIMEOUT' })), + 2000, + ); + }); + const { value } = await failure(readiness.wait({ port: 3000, probe, assertActive: active })); + expect(value).toMatchObject({ + probeKind: 'timeout', + probeCount: 10, + responseCount: 0, + lastProbeMilliseconds: 2000, + waitMilliseconds: 21000, + }); + }); + + it('contains an error whose code accessor itself throws private data', async () => { + const readiness = owner(child()); + const { message, value } = await failure( + readiness.wait({ + port: 3000, + probe: async () => { + throw { + get code() { + throw new Error('PRIVATE-ACCESSOR'); + }, + }; + }, + assertActive: active, + }), + ); + expect(value.probeKind).toBe('other'); + expect(message).not.toContain('PRIVATE'); + }); + + it.each(['missing', 'different_child', 'other_address'])( + 'refuses healthy responses with %s bind evidence', + async (kind) => { + const process = child(); + const readiness = owner(process); + if (kind === 'different_child') bind(process, '127.0.0.1', process.pid + 1); + if (kind === 'other_address') bind(process, 'PRIVATE-ADDRESS'); + const { message, value } = await failure( + readiness.wait({ port: 3000, probe: healthy, assertActive: active }), + ); + expect(message).not.toContain('PRIVATE'); + expect(value).toMatchObject({ + bind: kind === 'other_address' ? 'other_observed' : 'unobserved', + probeKind: 'healthy_unattributed', + responseCount: 200, + lastStatus: 200, + }); + }, + ); + + it('accepts only current-child loopback bind plus actual healthy response', async () => { + const process = child(); + const readiness = owner(process); + bind(process); + const probe = vi.fn(healthy); + const observation = await readiness.wait({ port: 3000, probe, assertActive: active }); + expect(observation).toMatchObject({ + bind: 'owned_loopback', + probeKind: 'healthy', + probeCount: 1, + }); + expect(probe).toHaveBeenCalledOnce(); + }); + + it.each(['spawn_failed', 'exited_zero', 'exited_nonzero', 'signalled'])( + 'records %s before probing', + async (exit) => { + const process = child(); + const readiness = owner(process); + if (exit === 'spawn_failed') process.emit('error', new Error('PRIVATE-SPAWN-ERROR')); + if (exit === 'exited_zero') process.exitCode = 0; + if (exit === 'exited_nonzero') process.exitCode = 73; + if (exit === 'signalled') process.signalCode = 'SIGTERM'; + const probe = vi.fn(healthy); + const { message, value } = await failure( + readiness.wait({ port: 3000, probe, assertActive: active }), + ); + expect(message).toMatch(/^Fixture startup failed:/); + expect(message).not.toContain('PRIVATE'); + expect(value).toMatchObject({ exit, probeCount: 0, probeKind: 'none', waitMilliseconds: 0 }); + expect(probe).not.toHaveBeenCalled(); + }, + ); + + it('keeps phase and diagnostic evidence tied to its own child and ignores unknown phases', async () => { + const first = child(); + const second = child(600002); + const firstReadiness = owner(first); + const secondReadiness = owner(second); + firstReadiness.output('Failed to '); + firstReadiness.output('compile PRIVATE-TOKEN'); + second.emit('message', { kind: 'readiness', pid: first.pid, phase: 'prepared' }); + second.emit('message', { kind: 'readiness', pid: second.pid, phase: 'PRIVATE-PHASE' }); + second.emit('message', { kind: 'readiness', pid: second.pid, phase: 'preparing' }); + second.emit('message', { kind: 'readiness', pid: second.pid, phase: 'prepared' }); + second.emit('message', { kind: 'readiness', pid: second.pid, phase: 'preparing' }); + const { message, value } = await failure( + secondReadiness.wait({ + port: 3000, + probe: async () => ({ status: 503, body: 'PRIVATE-BODY' }), + assertActive: active, + }), + ); + expect(value).toMatchObject({ + phase: 'prepared', + compilation: false, + probeKind: 'http_unhealthy', + lastStatus: 503, + }); + expect(message).not.toContain('PRIVATE'); + const firstResult = await failure( + firstReadiness.wait({ port: 3000, probe: healthy, assertActive: active }), + ); + expect(firstResult.value.compilation).toBe(true); + }); + + it('distinguishes malformed health JSON from a subsequent rejected transport', async () => { + const process = child(); + const readiness = owner(process); + bind(process); + const probe = vi + .fn() + .mockResolvedValueOnce({ status: 200, body: 'PRIVATE-BODY' }) + .mockRejectedValue(Object.assign(new Error('PRIVATE'), { code: 'ECONNREFUSED' })); + const { message, value } = await failure( + readiness.wait({ port: 3000, probe, assertActive: active }), + ); + expect(value).toMatchObject({ + probeKind: 'connection_refused', + responseCount: 1, + lastStatus: 200, + }); + expect(message).not.toContain('PRIVATE'); + const malformed = await failure( + owner(child()).wait({ + port: 3000, + probe: async () => ({ status: 200, body: 'PRIVATE-BODY' }), + assertActive: active, + }), + ); + expect(malformed.value.probeKind).toBe('invalid_json'); + expect(malformed.message).not.toContain('PRIVATE'); + }); +}); diff --git a/frontend/apps/web/src/test/migration-build.test.ts b/frontend/apps/web/src/test/migration-build.test.ts index 55423d2a..2600e5b8 100644 --- a/frontend/apps/web/src/test/migration-build.test.ts +++ b/frontend/apps/web/src/test/migration-build.test.ts @@ -110,7 +110,7 @@ if (stage === 'ef') { const calls = readFileSync(log, 'utf8') .split('\n') .filter(Boolean) - .map((line) => JSON.parse(line) as { args: string[]; cwd: string; connection: string }); + .map((line) => JSON.parse(line) as { args: string[]; cwd: string; connection?: string }); return { result, calls }; }, }; @@ -137,17 +137,37 @@ it.each([false, true])( '--configuration', 'Release', '--no-build', - '--connection', - 'Host=localhost;Username=learnstack_migration', ]), ]); for (const call of calls) { expect(call.cwd).toBe(join(files.root, 'backend')); - expect(call.connection).toBe('Host=localhost;Username=learnstack_migration'); + expect(call.connection).toBe( + call.args[0] === 'ef' ? 'Host=localhost;Username=learnstack_migration' : undefined, + ); + expect(call.args).not.toContain('Host=localhost;Username=learnstack_migration'); + expect(call.args).not.toContain('--connection'); } }, ); +it('confines a selected-file migration credential to EF after credential-free restore and build', () => { + const files = fixture(); + const connection = 'Host=localhost;Username=learnstack_migration'; + writeFileSync(join(files.root, 'selected.env'), `ConnectionStrings__Migration=${connection}\n`, { + mode: 0o600, + }); + const { result, calls } = files.run({ + ConnectionStrings__Migration: '', + LEARNSTACK_COMPOSE_ENV_FILE: join(files.root, 'selected.env'), + }); + expect(result.status, result.stderr).toBe(0); + expect(calls.length).toBeGreaterThan(2); + expect(calls.slice(0, 2).every((call) => call.connection === undefined)).toBe(true); + expect(calls.slice(2).every((call) => call.connection === connection)).toBe(true); + expect(result.stdout).not.toContain(connection); + expect(result.stderr).not.toContain(connection); +}); + it.each(['tool', 'build'])( 'refuses %s failure before any EF operation despite an existing Release DLL and without shell errexit', (stage) => { diff --git a/frontend/apps/web/src/test/public-local-file.test.ts b/frontend/apps/web/src/test/public-local-file.test.ts new file mode 100644 index 00000000..ad556d4b --- /dev/null +++ b/frontend/apps/web/src/test/public-local-file.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment node +import { execFileSync, spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseEnv } from 'node:util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const roots: string[] = []; +const canary = 'private-renderer-file-canary-4826'; +const secret = 'preserved_private_renderer_secret_4826_abcdefgh'; +const defaults = [ + 'LEARNSTACK_PUBLIC_API_ORIGIN=http://127.0.0.1:5080', + 'LEARNSTACK_PUBLIC_TLS_CERT=.data/tls/public.pem', + 'LEARNSTACK_PUBLIC_TLS_KEY=.data/tls/public-key.pem', +].join('\n'); + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'learnstack-public-local-file-'))); + roots.push(root); + mkdirSync(join(root, 'scripts')); + mkdirSync(join(root, 'frontend/apps/web/.server'), { recursive: true }); + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n'); + for (const name of ['public-local.mjs', 'public-runtime.mjs']) + writeFileSync( + join(root, 'scripts', name), + readFileSync(new URL(`../../../../../scripts/${name}`, import.meta.url)), + ); + const parsed = join(root, 'parsed'); + writeFileSync( + join(root, 'frontend/apps/web/.server/ingress.js'), + `import { appendFileSync } from 'node:fs'; +import { parseEnv } from 'node:util'; +export function loadLocalEnvironment() { throw new Error('Unexpected API launch'); } +export function parseLocalEnvironment(source) { + appendFileSync(${JSON.stringify(parsed)}, 'parsed\\n'); + return parseEnv(source); +} +export function validSecret(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{43,}$/.test(value); } +export function publicServerConfiguration(values) { + if (!validSecret(values.LEARNSTACK_PUBLIC_HOP_SECRET) + || !values.LEARNSTACK_PUBLIC_API_ORIGIN || !values.LEARNSTACK_PUBLIC_TLS_CERT + || !values.LEARNSTACK_PUBLIC_TLS_KEY) throw new Error('Invalid fixture configuration'); + return {}; +} +`, + ); + return { root, path: join(root, '.env'), parsed }; +} + +function prepare(root: string) { + return spawnSync(process.execPath, [join(root, 'scripts/public-local.mjs'), 'prepare'], { + cwd: root, + env: { NODE_ENV: 'test', PATH: process.env.PATH }, + encoding: 'utf8', + timeout: 3000, + killSignal: 'SIGKILL', + }); +} + +function refused(f: ReturnType) { + const result = prepare(f.root); + expect(result.error).toBeUndefined(); + expect(result.signal).toBe(null); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('Local public configuration or launch failed\n'); + expect(result.stderr).not.toContain(canary); + expect(existsSync(f.parsed)).toBe(false); +} + +describe('public-local prepare private file ownership', () => { + it.each(['absent', 'empty'])( + 'prepares a private file with an %s hop secret through the actual copied CLI', + (state) => { + const f = fixture(); + const source = `PRIVATE_CANARY=${canary}\n${state === 'empty' ? 'LEARNSTACK_PUBLIC_HOP_SECRET=\n' : ''}`; + writeFileSync(f.path, source, { mode: 0o600 }); + const before = lstatSync(f.path); + const result = prepare(f.root); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe( + 'Private renderer configuration prepared; TLS and hosts remain manual.\n', + ); + expect(result.stderr).not.toContain(canary); + const prepared = readFileSync(f.path, 'utf8'); + const values = parseEnv(prepared); + expect(values.PRIVATE_CANARY).toBe(canary); + expect(values.LEARNSTACK_PUBLIC_HOP_SECRET).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(prepared).toContain(defaults); + expect(prepared.endsWith('\n')).toBe(true); + expect(lstatSync(f.path).ino).toBe(before.ino); + expect(lstatSync(f.path).mode & 0o777).toBe(0o600); + }, + ); + + it('preserves an existing private secret and truncates normalized trailing bytes before a no-op rerun', () => { + const f = fixture(); + const normalized = `PRIVATE_CANARY=${canary}\nLEARNSTACK_PUBLIC_HOP_SECRET=${secret}\n${defaults}\n`; + writeFileSync(f.path, normalized + '\n\n\n', { mode: 0o600 }); + const before = lstatSync(f.path); + for (let index = 0; index < 2; index++) { + const result = prepare(f.root); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).not.toContain(secret); + expect(readFileSync(f.path, 'utf8')).toBe(normalized); + expect(lstatSync(f.path).ino).toBe(before.ino); + expect(lstatSync(f.path).mode & 0o777).toBe(0o600); + } + }); + + it.each(['symlink', 'hardlink'])( + 'refuses a selected %s before parsing or changing its target', + (kind) => { + const f = fixture(); + const target = join(f.root, 'private-target'); + const source = `PRIVATE_CANARY=${canary}\n`; + writeFileSync(target, source, { mode: 0o600 }); + if (kind === 'symlink') symlinkSync(target, f.path); + else linkSync(target, f.path); + const before = lstatSync(target); + const selected = lstatSync(f.path); + refused(f); + expect(readFileSync(target, 'utf8')).toBe(source); + expect(lstatSync(target).mode).toBe(before.mode); + expect(lstatSync(target).ino).toBe(before.ino); + expect(lstatSync(target).nlink).toBe(before.nlink); + expect(lstatSync(f.path).ino).toBe(selected.ino); + }, + ); + + it.each([0o640, 0o644, 0o660])( + 'refuses shared mode %s without reading, repairing or rewriting it', + (mode) => { + const f = fixture(); + const source = `PRIVATE_CANARY=${canary}\n`; + writeFileSync(f.path, source, { mode: 0o600 }); + chmodSync(f.path, mode); + const before = lstatSync(f.path); + refused(f); + expect(readFileSync(f.path, 'utf8')).toBe(source); + expect(lstatSync(f.path).mode).toBe(before.mode); + expect(lstatSync(f.path).ino).toBe(before.ino); + }, + ); + + it('refuses a FIFO without waiting for a writer or changing its metadata', () => { + const f = fixture(); + execFileSync('mkfifo', [f.path], { stdio: 'ignore' }); + chmodSync(f.path, 0o600); + const before = lstatSync(f.path); + refused(f); + const after = lstatSync(f.path); + expect(after.isFIFO()).toBe(true); + expect(after.mode).toBe(before.mode); + expect(after.ino).toBe(before.ino); + }); + + it('refuses an oversized regular private file before parsing or modifying it', () => { + const f = fixture(); + const source = `# ${canary}${'x'.repeat(256 * 1024)}\n`; + writeFileSync(f.path, source, { mode: 0o600 }); + const before = lstatSync(f.path); + refused(f); + expect(readFileSync(f.path, 'utf8')).toBe(source); + expect(lstatSync(f.path).mode).toBe(before.mode); + expect(lstatSync(f.path).size).toBe(before.size); + expect(lstatSync(f.path).ino).toBe(before.ino); + }); +}); diff --git a/infra/compose/README.md b/infra/compose/README.md index aa4b18dd..0933fe6c 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -9,8 +9,13 @@ Services in order they appear in `dev.yml` (data plane → identity → media eventing → secrets → Dapr sidecar → gateway). Bring it up with `make dev` from the repo root (the orchestrator copies -`.env.example` → `.env` on first run, so every `${VAR:-default}` reference -in `dev.yml` resolves against the developer's copy). The end-to-end overlay +`.env.example` → a private `.env` on first run). Before starting Keycloak, +prepare `KEYCLOAK_WEB_CLIENT_SECRET` in that selected private source; Compose +refuses a missing value instead of importing a published fallback. Preserve an +existing confidential secret and follow the +[recovery procedure](../keycloak/README.md#secret-continuity-and-recovery) on +mismatch. Existing file permissions require explicit operator correction. +The end-to-end overlay adds tmpfs volumes for ephemeral test runs — see `e2e.yml` below. ### Data plane (Phase 01 packet 3) @@ -31,11 +36,13 @@ adds tmpfs volumes for ephemeral test runs — see `e2e.yml` below. Two realms imported on first boot from `infra/keycloak/realms/`: -- `learnstack` (tenant users) — clients `learnstack-api` (confidential) + `learnstack-web` (public PKCE); demo users `demo-admin@tenant-a.test` and `demo-learner@tenant-a.test` (both `demo-dev-secret`). +- `learnstack` (tenant users) — confidential API and code/S256 web clients, with + four declared tenant personas. The [realm guide](../keycloak/README.md) owns + the exact identities, private secret source and callbacks. - `learnstack-hub` (operators) — client `learnstack-hub-web` (public PKCE); demo user `demo-operator@learnstack.test` (`demo-dev-secret`). `CONFIGURE_TOTP` required-action present so the MFA flow surfaces in dev. See [../keycloak/README.md](../keycloak/README.md) for the realm-isolation -invariant, re-seed procedure, and the Phase 02b/03 wiring notes. +invariant, non-destructive reconciliation and the Phase 02b/03 wiring notes. Two Postgres init scripts run, in name order, **only on the first start of a fresh `postgres-data` volume**: @@ -58,9 +65,12 @@ docker compose --env-file .env -f infra/compose/dev.yml exec -T postgres sh -c It is idempotent, so re-running it against a cluster that already has the roles is a no-op rather than an error. -Re-seeding the Keycloak realm structure requires either `down -v` (wipes all -volumes) or a manual -`DROP DATABASE keycloak; CREATE DATABASE keycloak OWNER learnstack;`. +Fresh imports do not upgrade an existing realm. P02b-6's bounded warm-realm +reconciler is shared by `make seed` and `make demo` after application seed. Both +entries validate the selected private source before infrastructure work; older +files must add the BFF secret key explicitly. Existing credentials and provider +IDs are preserved. The [realm guide](../keycloak/README.md) owns exact behavior, +refusals and current qualification status; a database wipe is not an upgrade. ### Live media (Phase 01 packet 5) diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index 4d84743d..f5168207 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -220,6 +220,7 @@ services: # via ISecretProvider — `.env.example` is the local-dev parity only. KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin-dev-secret} + KEYCLOAK_WEB_CLIENT_SECRET: ${KEYCLOAK_WEB_CLIENT_SECRET:?Add the retained confidential web client secret to the selected private environment} KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: ${POSTGRES_USER:-learnstack} diff --git a/infra/keycloak/README.md b/infra/keycloak/README.md index 9feceee8..e75deedc 100644 --- a/infra/keycloak/README.md +++ b/infra/keycloak/README.md @@ -1,128 +1,310 @@ # Keycloak — Two-Realm Identity (Dev) -Self-hosted Keycloak in dev compose, per -[ADR-0004 (Authentication Strategy)](../../docs/decisions/0004-authentication-strategy.md) -and [ADR-0019 (LearnStack Hub)](../../docs/decisions/0019-learnstack-hub.md). Two -realms, **hard-isolated from each other** — neither realm trusts tokens issued by -the other. This is an architectural invariant, not a configurable policy. - -## Realms - -| Realm | Purpose | User population | Clients | Demo users | -|-------|---------|-----------------|---------|------------| -| `learnstack` | Tenant-facing: admins, instructors, learners | All customer users | `learnstack-api` (confidential, service-account) + `learnstack-web` (public PKCE) | `demo-admin@tenant-a.test`, `demo-learner@tenant-a.test` (both `demo-dev-secret`) | -| `learnstack-hub` | Operator-facing: platform admin, support, billing-viewer | LearnStack staff only | `learnstack-hub-web` (public PKCE) | `demo-operator@learnstack.test` (`demo-dev-secret`) | - -## Access - -- **Admin console** — http://localhost:8080 (Keycloak Admin UI) -- **Master admin** — `admin` / `admin-dev-secret` -- **Tenant realm OIDC discovery** — http://localhost:8080/realms/learnstack/.well-known/openid-configuration -- **Hub realm OIDC discovery** — http://localhost:8080/realms/learnstack-hub/.well-known/openid-configuration - -## Dev credentials are dev credentials - -Every secret in this directory (master admin password, client secret, demo user -passwords) is **dev-only**. Production deployments load these from Vault via -`ISecretProvider` per [Standards 12 § Secrets Management](../../docs/standards/12-infrastructure.md) -and [Standards 20](../../docs/standards/20-infrastructure-stack.md). Do not reuse -any of these strings outside local Docker. - -## Two realms, zero cross-trust - -The realm separation is a **hard architectural invariant** per -[ADR-0004 Amendment 1](../../docs/decisions/0004-authentication-strategy.md#amendment-1--learnstack-hub-realm-for-operators-2026-05-18): - -- A `learnstack-hub` token MUST be rejected on every tenant-facing endpoint - (the gateway + the backend both check the `iss` claim against the realm URL). -- A `learnstack` token MUST be rejected on every `/api/internal/*` endpoint - (the Hub-internal contract gates on mTLS + signed JWT, not on user tokens). -- An operator account cannot also be a tenant user under the same identity, and - vice versa. - -The Phase 02b OIDC integration enforces this in code; the dev seed mirrors it -in data (no shared users, no shared client IDs). - -## Dev-only client posture - -The realm JSONs keep `fullScopeAllowed: true` on every client for dev -simplicity — service-account tokens (for `learnstack-api`) and user tokens -(for `learnstack-web` / `learnstack-hub-web`) carry every realm role -automatically, so demo flows work without per-client `defaultClientScopes` -provisioning. **Production realm provisioning** (Terraform / -keycloak-config-cli, not these JSONs) flips this: - -| Client | Type | Dev | Production target | -|--------|------|-----|-------------------| -| `learnstack-api` | confidential, service-account | `fullScopeAllowed: true`, `directAccessGrantsEnabled: false`, `webOrigins: []` | `fullScopeAllowed: false` + explicit `scopeMappings` (least-privilege) | -| `learnstack-web` | public, PKCE | `fullScopeAllowed: true` | `fullScopeAllowed: false` + explicit `defaultClientScopes` listing the tenant-* roles only | -| `learnstack-hub-web` | public, PKCE | `fullScopeAllowed: true` | `fullScopeAllowed: false` + explicit `defaultClientScopes` listing the hub-* roles only | - -These notes used to sit inline in each client's `description` field, but -Keycloak's `CLIENT.DESCRIPTION` column is `varchar(255)` and the long -inline notes broke realm import. The descriptions in the JSON now point -back to this section. - -## Dev-only realm posture (tenant realm) - -The `learnstack` realm JSON carries permissive defaults so the demo flows -work without operator intervention. Each of these defaults is **flipped** -by the production realm-provisioning flow (Terraform / keycloak-config-cli), -not by editing the dev JSON: - -| Attribute | Dev (`learnstack.json`) | Production target | Why dev value | -|-----------|------------------------|-------------------|---------------| -| `registrationAllowed` | `true` | `false` (invite-only) | Lets reviewers exercise the tenant sign-up flow against `localhost:3000` without seeding a user first. | -| `verifyEmail` | `false` | `true` | Dev SMTP is Mailpit; flipping verification on would gate every demo on visiting `localhost:8025`. | -| `accessTokenLifespan` | `1800` (matches `learnstack-hub`) | `900` recommended (≤ 15 min, refresh-token-driven) | Halves the round-trip churn of debug sessions; still short enough to surface refresh bugs. | -| `bruteForceProtected` | `true` (same as prod) | `true` | No dev relaxation. | - -The `learnstack-hub` realm is **already** at the stricter posture -(`registrationAllowed: false`, `verifyEmail: true`, `accessTokenLifespan: 1800`) -because operators are seeded out-of-band per -[ADR-0019](../../docs/decisions/0019-learnstack-hub.md). The tenant-realm -fields above are the only intentional dev/prod deltas. - -## MFA - -- `learnstack-hub` realm: `CONFIGURE_TOTP` declared as a required action so the - TOTP enrolment flow is visible in dev; production enforces MFA mandatory for - every operator per ADR-0004 Amendment 1. -- `learnstack` realm: MFA is **optional in dev**; tenant policy may make it - mandatory per-tenant in Phase 03. - -## How the realms are seeded - -Keycloak boots with `start-dev --import-realm`. The JSON files under -`realms/` are bind-mounted to `/opt/keycloak/data/import/` and consumed once -at first start. To re-seed: - -```bash -docker compose --env-file .env -f infra/compose/dev.yml down keycloak -docker compose --env-file .env -f infra/compose/dev.yml up -d keycloak -``` - -Re-import overwrites the realm only if the realm did NOT already exist (Keycloak -default behaviour). To force a clean re-seed of the realm itself, wipe the -Keycloak database first: - -```bash -# Stop Keycloak first: PostgreSQL refuses to drop a database that still has a -# connected client, and Keycloak holds a pool open for its whole lifetime. -docker compose --env-file .env -f infra/compose/dev.yml stop keycloak -docker compose --env-file .env -f infra/compose/dev.yml exec postgres \ - psql -U learnstack -d learnstack -c "DROP DATABASE keycloak;" -docker compose --env-file .env -f infra/compose/dev.yml exec postgres \ - psql -U learnstack -d learnstack -c "CREATE DATABASE keycloak OWNER learnstack;" -docker compose --env-file .env -f infra/compose/dev.yml up -d keycloak -``` - -## What does NOT live here - -- Production realm configuration — managed via terraform / keycloak-config-cli - per environment; the dev JSONs are not the source of truth for production. -- LearnStack-side identity domain (`User`, `Membership`, `Role`, `Permission`, - `Invitation`) — that's Phase 03, owned by `LearnStack.Modules.Identity`. The - realms here only define authentication; authorisation is application-side. -- OIDC code integration in the .NET API — Phase 02b wires `AddJwtBearer` and - the BFF callback handler. +The pinned development provider is Keycloak 26.6.2. Two realms remain isolated +under [ADR-0004](../../docs/decisions/0004-authentication-strategy.md) and +[ADR-0019](../../docs/decisions/0019-learnstack-hub.md). +[ADR-0067](../../docs/decisions/0067-development-tenant-access-token-issuance.md) +decides development issuance; +[Security 11](../../docs/standards/11-security.md#authentication) owns its ongoing +rule. The +[P02b-6 accepted package](../../docs/roadmap/phase-02b-events-auth.md#p02b-6-decision-package-2026-10-20) +retains the earlier approval and owns safe warm-realm convergence. +Authentication grants no application membership or permission; Phase 03 owns +those records. + +## Realms and accounts + +| Realm | Population | Clients | +|-------|------------|---------| +| `learnstack` | Tenant users; four declared development accounts | `learnstack-web`: confidential code/S256 BFF; `learnstack-api`: resource and legacy service account | +| `learnstack-hub` | Platform operators | `learnstack-hub-web`: public PKCE; unchanged by P02b-6 | + +[SeedData.IdentityUsers](../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) +owns actor UUIDs and the account/tenant association. The read-only Seeder +`--identity-manifest` projection opens no database. The realm export is checked +against it, including the host's default organization where applicable. + +- `demo-admin@tenant-a.test`, `demo-learner@tenant-a.test`: existing tenant-wide + accounts and passwords are preserved on upgrade. +- `demo-admin@tenant-b.test`, `demo-learner@tenant-b.test`: the second tenant's + default organization is included in its identity claims. +- Fresh development accounts use `demo-dev-secret`. Existing passwords, provider + account IDs, MFA, required actions and unrelated attributes are not reset. +- The four personas declare only the built-in `account/manage-account` + self-service capability in addition to their existing inert realm roles. + This permits owner credential/linked-account management; it grants no + LearnStack permission or membership. The + [approved adjustment](../../docs/roadmap/phase-02b-events-auth.md#account-console-self-service-capability--proposal-2026-10-21) + owns this explicit exception to role-assignment preservation. +- The operator account remains `demo-operator@learnstack.test`; its separate + realm is untouched. Neither realm's token is accepted by the other surface. + +## Local access and private sources + +- Admin console: ; local master defaults are + `admin` / `admin-dev-secret`. +- Tenant discovery: + . +- Operator discovery: + . +- `.env.example` declares a public development example for + `KEYCLOAK_WEB_CLIENT_SECRET`; newly scaffolded private files inherit that example. + File permissions do not make a published value confidential. Compose passes it + only to Keycloak; the import resolves its environment placeholder. The development + reconciler and the later P02b-7 BFF are its other consumers. The API, public + renderer and .NET Seeder never receive this secret. +- Older private `.env` files must add that key explicitly, matching an already + imported confidential client's secret. Convergence verifies that existing + secret; a mismatch requires operator correction rather than automatic rotation. + Conversion of the old public client installs + the selected private source once. +- Before `make dev`, `make seed` or `make demo`, prepare the selected private + file and preserve its secret across restarts. A missing key refuses before + container creation or reconciliation; neither supplies an automatic secret + fallback. See + [secret continuity and recovery](#secret-continuity-and-recovery). + +Compose and the reconciler read the selected file through one no-follow handle +and verify that it is a regular file owned by the current user, has one hard link +and has no group/other permission bits. A new Makefile-created `.env` is private; +existing files are never repaired silently. This prerequisite also applies to +lifecycle commands: an older `0644` file is refused before Docker. After verifying +an older path is your own regular, nonsymlink, single-link file, explicitly run +`chmod 600 .env` (or the selected private path). Do not print its contents or copy +it to a report. +Compose, seed and reconciliation report these file-property refusals as +`preflight/private_file`, with the same fixed ownership/permission instruction. + +Recognized `down`, `logs`, `ps`, `stop` and `config --services` invocations can +parse an older private file without the web key by using a process-only nonsecret +placeholder. This does not write the private file or install a provider secret. +Container-creating commands, full configuration output and unknown argument forms +retain the required-secret check. The private-file prerequisite still applies. + +These credentials are development-only. Production provisioning and rotation +belong to Phase 11 and [Infrastructure Standards](../../docs/standards/12-infrastructure.md#secrets-management). +Do not reuse local secrets in production or put them in command arguments, +URLs, logs or public configuration. + +## Secret continuity and recovery + +`KEYCLOAK_WEB_CLIENT_SECRET` is create-once local state, not a new value on every +seed. Before the first Keycloak import, set it in the selected private file. +Use the same value for Compose, reconciliation and the later BFF. The API, +public renderer and application Seeder never receive it. + +- `missing_web_secret`: add the key explicitly before starting the stack, matching + the existing imported client when present. A previously populated file is not + supplemented with a fallback automatically. +- `secret_conflict`: stop the affected local consumers and inspect the selected + confidential `learnstack-web` client privately in the Admin Console. Recover + its existing secret into the selected private source if that source was lost + or changed accidentally. Reconciliation will not overwrite the provider secret. +- If an import used the committed public development example, treat it as + exposed. The operator must explicitly rotate that local client secret in the + Admin Console and update the selected private source together; the reconciler + does not perform this rotation. Restart only the owned affected consumers, + then explicitly rerun convergence. + +Do not reset users, delete the client, drop provider data or enable a public +client to bypass the refusal. Never put the recovered/rotated secret on argv, +in logs, in a URL or in committed configuration. + +## Lock recovery + +One local owner protects each selected container/realm. The default lock is +under `os.tmpdir()/learnstack-realm-/`, named +`sha256( + "/realms/learnstack").lock`. This is separate +from the demo lifecycle's project lock. The parent is current-user-owned `0700`; +the exclusive regular lock file is `0600`. No age or guessed PID proves a lock +is stale, and the tool never reclaims one automatically. + +On `lock_busy`, first identify the exact selected project, container and realm, +and verify that every previous seed/demo/reconciler owner for that target has +finished and joined its children. Do not stop foreign processes. If cleanup is +uncertain, keep the lock and investigate; a timeout is not cleanup evidence. +Only after establishing that no owner remains may the local operator remove +that exact lock, after checking its directory, ownership, file type, permissions +and single-link status. Never remove the whole directory, glob locks or delete +a lock based only on elapsed time. Explicitly rerun convergence afterwards; +partial Admin REST writes remain durable and are re-read. + +## Tenant client contract + +`learnstack-web` permits authorization code with S256 PKCE and a server-held +secret. Password grants, implicit flow, service accounts and browser CORS origins +are disabled. Only these exact callbacks are registered: + +- `https://demo-english.learnstack.local:3000/api/auth/callback/keycloak` +- `https://demo-yoga.learnstack.local:3000/api/auth/callback/keycloak` + +Post-logout redirects are the corresponding exact HTTPS roots on port 3000, +joined with Keycloak's `##` attribute separator. No wildcard or localhost HTTP +callback remains. Host-file and trusted development TLS setup are still needed +for a browser; P02b-7 owns the callback/session implementation. Provider tests +capture the redirect without following it and do not claim a browser session. +Their isolated provider uses HTTPS with a generated fixture CA and normal +hostname verification; this does not change the selected Compose issuer or +install certificates in the developer's trust store. + +`learnstack-api` has no browser code flow or redirects. Its legacy service account +and secret are retained; its tokens cannot satisfy the API's user-token profile. +Both named clients attach `learnstack-tenant-api` as a default scope: + +- `user_id`, optional `tenant_id` and optional `organization_id` are scalar String + user-attribute claims in access and ID tokens. Profile view/edit permission for + all three attributes is admin-only; organization is protected too. +- The explicit `learnstack-api` audience is added to access tokens only. +- The export includes the actual pinned built-in scope definitions, especially + `basic` and its `sub` mapper. Listing a missing built-in name is insufficient + when an import supplies its own `clientScopes` collection. + +The configured tenant-realm access-token lifespan is 900 seconds, without +per-client overrides. [Security Standards](../../docs/standards/11-security.md#authentication) +owns the distinction between configured lifespan, observed timestamps and the +API acceptance bound. Previously issued tokens are not immediately revoked. +The Hub realm retains its own settings. + +Existing realm roles and `fullScopeAllowed: true` remain a development posture; +the API derives no membership, permission or platform authority from them. +The web client's retained roles scope can put the `account` audience and eligible +account roles into its access token. That token can authorize Keycloak owner +self-service, including ordinary profile updates; it is not restricted to the +LearnStack API. The configured issuance lifetime limits token duration, not its +capability. P02b-7 owns server-side BFF token custody; narrowing this already +approved development capability requires a separate decision. +The tenant export enables open registration with email as username, password +reset and remember-me, while email verification is disabled. It declares no +password policy or event-persistence/listener configuration. These are local +development settings; production provisioning owns its own hardened configuration. +Production client scope mapping, refresh rotation and MFA enforcement remain +Phase 11 work; Phase 03 owns tenant identity policy. Tenant MFA stays optional in +this development export; the operator realm keeps its declared TOTP action. + +The ordinary Account REST profile update does not establish an email/login rename: +Keycloak's [email condition](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java#L156-L187) +makes email read-only in that context under the current realm settings. A 204 can +therefore leave email unchanged. Separate required-action or administrator +changes use different paths. If a reserved login is changed, +reconciliation refuses ownership instead of adopting another account or resetting +credentials. Verify the existing provider ID and protected actor privately before +restoring an intended reserved login; never repair a conflict by changing actor IDs. + +## Fresh import and warm upgrade + +Keycloak starts with `start-dev --import-realm`, reading both committed exports +only when their realms do not already exist. Restarting a populated database +does not apply export changes. Dropping the database or replaying a whole import +is not an upgrade procedure. + +P02b-6 Step 1 supplies the executable fresh import; Step 2 supplies the bounded +shared Admin REST reconciler. Both completed their local review rounds. Step 3 +connects `make seed` and `make demo` to the same awaited operation after +application seed convergence. Its actual lifecycle qualification and reviews +are recorded in the [Step 3 delivery record](../../docs/roadmap/phase-02b-events-auth.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21). +The exact pre-P02b-6 export has a stock profile that hides identity values and +broader account-console assignments. The qualified correction handles these +prerequisites without inferring account ownership from a hidden projection. +[ADR-0068](../../docs/decisions/0068-development-warm-realm-qualification.md) +is Accepted — 2026-10-22 after verified review corrections under conditional +maintainer authority. Its three-step implementation and automatic-path proof +are tracked in the [acceptance record](../../docs/roadmap/phase-02b-events-auth.md#adr-0068-acceptance-and-implementation-2026-10-22). +The [first correction step](../../docs/roadmap/phase-02b-events-auth.md#adr-0068-step-1--qualified-stock-assignment-implementation-2026-10-22) +adds fixed historical graph qualification and assignment-only normalization, +with both independent review rounds complete. The +[second correction step](../../docs/roadmap/phase-02b-events-auth.md#adr-0068-step-2--owned-visibility-preparation-2026-10-22) +completes automatic preparation and fresh complete planning after both independent +review rounds. The +[third correction step](../../docs/roadmap/phase-02b-events-auth.md#adr-0068-step-3--complete-historical-path-qualification-2026-10-22) +completes local historical seed/demo, usable credential/MFA, signed API and stored +self-service proof after both review rounds. That record owns exact source-bound +hosted assignments and their evidence limits; merge remains separate. +Current identity-visibility guards remain active. Do not remove them, wipe the +database or silently normalize customized scopes by name. + +Direct .NET Seeder remains an application-data tool; its identity manifest is a +read-only declaration. Both combined entries build its fresh Release DLL and +keep private realm credentials out of that child. + +An older private environment must add `KEYCLOAK_WEB_CLIENT_SECRET` explicitly +before either combined entry starts stack or database work. Do not wipe the +provider database to upgrade. The tool reports fixed refusal reasons, keeps +partial Admin changes durable and requires an explicit verified rerun. The +[accepted account-console adjustment](../../docs/roadmap/phase-02b-events-auth.md#account-console-self-service-capability--proposal-2026-10-21) +is implemented; its actual stock-parity, warm-preservation, profile/API +qualification and both completed independent review rounds are recorded with +Step 4. Qualification applies only to the recorded source and scope; subsequent +corrections require their own validation and independent reviews. Merge remains +a separate maintainer action. + +Fresh import declares both stock `account` and `account-console` definitions, +including the stock role/composite and scope-mapping baseline. Warm reconciliation +does not replace these clients, role definitions or composites. It qualifies +their existing bounded capability and scope allowance before any mutation, +adds only a missing named capability for a managed persona and preserves every +prior mapping. An inherited effective capability needs no redundant grant. + +The console receives one fixed-account role emitter in access/introspection +tokens, retaining its stock S256, audience resolver and restricted full scope. +It emits no realm/all-client roles or ID/user-info/lightweight-token roles. +Incompatible role, scope or mapper customization refuses before writes. +Compatible existing emitters retain their identities. Because the console is +shared, its mapper may also restore self-service for nonmanifest users already +holding eligible account roles; their account data and grants stay unchanged. + +The accepted warm-upgrade contract preserves nonmanifest accounts, credentials, +provider IDs and all Hub state. Only known legacy seed attributes are migrated; +incompatible identities, effective mapper conflicts and private-source mismatch +refuse before ordinary writes. Independently observable conflicts refuse before +preparation too. Partial Admin REST changes are durable, without automatic +rollback. A later explicit run re-reads and resumes; a completed rerun makes no +mutating Admin calls. Use the packet's delivery records for implementation and +qualification status. + +Admin user responses are profile projections, not raw attribute storage. Before +interpreting a missing identity as absent, the reconciler requires an +admin-readable definition without a default or selector, or an explicitly +admin-readable unmanaged policy. External user-storage profiles and independently +detectable configuration conflicts refuse before any write. When definitions +are missing and the unmanaged policy does not expose them, the reconciler first +qualifies all identity-independent preconditions. It may then append only the +missing admin-only scalar definitions in one `prepare` PUT, preserving existing +definitions, ordered arrays and the unmanaged policy. A separate GET verifies +the complete profile before a fresh full plan reads the revealed identities. +An existing hidden, defaulted or selector-based protected definition refuses +without preparation. An exposed conflicting UUID is never overwritten. + +Preparation counts as a dispatched mutation even when its acknowledgement is +lost. Any later failure retains the fixed notice that profile metadata may +already be durable: inspect current realm state before an explicit rerun. No +automatic replay or rollback occurs. A separately planned ordinary profile PUT +is counted separately and requires the fresh full plan. Identity-dependent +conflicts may leave only preparation metadata; no ordinary mutation follows a +fresh-plan refusal. The shared five-second request and 120-second pass deadlines +are unchanged. Step 3 retains complete lifecycle and credential/token proof. + +Protected claims must come from user attributes. Aggregation and inherited +group identity values are incompatible with that contract and refuse before +writes. Safe ordinary groups and memberships are preserved. The bounded group +inventory includes nested groups. Unpaginated membership reads follow the +mapper's group stream; any exposed membership outside that ordinary inventory +refuses before writes. The pinned cached model excludes Keycloak Organization +groups from that stream, including retained memberships after the feature is +disabled. Its actual-token control is separate from the defensive refusal for +unknown membership IDs. This is separate from LearnStack's own organization +IDs. The named single-user import does not inherit realm default groups; +new-account membership is independently checked as empty. + +The entire projected change must also fit the existing bounded readers and +request bodies. Known overflow refuses before preparation. Payload growth revealed +only by preparation can still refuse the fresh plan, leaving that metadata durable +with no ordinary mutation. Every ordinary write requires the complete bounded plan. + +## What does not live here + +- Production realm configuration: Phase 11 provisioning owns it; these JSONs are + local fixtures, not a production source of truth. +- Application `User`, membership, role, permission and invitation state: Phase 03 + owns it in LearnStack; the provider export supplies authentication only. +- BFF cookie/session custody, CSRF, callback and logout implementation: P02b-7. + Full browser OIDC journeys belong to Phase 06. diff --git a/infra/keycloak/realms/learnstack.json b/infra/keycloak/realms/learnstack.json index 1a4f474f..9eb285e0 100644 --- a/infra/keycloak/realms/learnstack.json +++ b/infra/keycloak/realms/learnstack.json @@ -19,7 +19,7 @@ "quickLoginCheckMilliSeconds": 1000, "maxDeltaTimeSeconds": 43200, "failureFactor": 30, - "accessTokenLifespan": 1800, + "accessTokenLifespan": 900, "accessTokenLifespanForImplicitFlow": 900, "ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 36000, @@ -31,7 +31,10 @@ "actionTokenGeneratedByUserLifespan": 300, "sslRequired": "external", "internationalizationEnabled": true, - "supportedLocales": ["en", "tr"], + "supportedLocales": [ + "en", + "tr" + ], "defaultLocale": "en", "roles": { "realm": [ @@ -52,53 +55,245 @@ "description": "Learner — enrols in courses + attends sessions within a tenant.", "composite": false, "clientRole": false + }, + { + "name": "default-roles-learnstack", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "composites": { + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + } } - ] + ], + "client": { + "account": [ + { + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true + }, + { + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "clientRole": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + } + }, + { + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true + }, + { + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true + }, + { + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true + }, + { + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "clientRole": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + } + }, + { + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true + }, + { + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true + } + ] + } }, "clients": [ { "clientId": "learnstack-api", "name": "LearnStack API (confidential service)", - "description": "Backend .NET API. Confidential client; standard flow + service-account. ROPC disabled, webOrigins empty. Dev posture (fullScopeAllowed=true) documented in infra/keycloak/README.md.", + "description": "API resource and legacy dev service account; no browser flow. See infra/keycloak/README.md.", "enabled": true, "publicClient": false, "secret": "learnstack-api-dev-secret", - "standardFlowEnabled": true, + "standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": true, - "redirectUris": [ - "http://localhost:5080/*", - "http://localhost:3000/*" - ], + "redirectUris": [], "webOrigins": [], "protocol": "openid-connect", "attributes": { - "access.token.lifespan": "1800", "pkce.code.challenge.method": "S256" }, - "fullScopeAllowed": true + "fullScopeAllowed": true, + "defaultClientScopes": [ + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic", + "learnstack-tenant-api" + ], + "optionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ] }, { "clientId": "learnstack-web", - "name": "LearnStack Web (public PKCE)", - "description": "Next.js apps/web. Public client, PKCE-enforced, no secret. Dev posture (fullScopeAllowed=true) documented in infra/keycloak/README.md.", + "name": "LearnStack Web (confidential BFF, PKCE)", + "description": "Server-only code/S256 client. Private secret source; no browser secret. See infra/keycloak/README.md.", "enabled": true, - "publicClient": true, + "publicClient": false, "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "redirectUris": [ - "http://localhost:3000/*" + "https://demo-english.learnstack.local:3000/api/auth/callback/keycloak", + "https://demo-yoga.learnstack.local:3000/api/auth/callback/keycloak" ], - "webOrigins": ["http://localhost:3000"], + "webOrigins": [], "protocol": "openid-connect", "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "http://localhost:3000/*" + "post.logout.redirect.uris": "https://demo-english.learnstack.local:3000/##https://demo-yoga.learnstack.local:3000/" + }, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic", + "learnstack-tenant-api" + ], + "optionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_WEB_CLIENT_SECRET}" + }, + { + "clientId": "account", + "name": "${client_account}", + "enabled": true, + "alwaysDisplayInConsole": false, + "publicClient": true, + "clientAuthenticatorType": "client-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "fullScopeAllowed": false, + "protocol": "openid-connect", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/learnstack/account/", + "redirectUris": [ + "/realms/learnstack/account/*" + ], + "webOrigins": [], + "attributes": { + "post.logout.redirect.uris": "+" }, - "fullScopeAllowed": true + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [], + "optionalClientScopes": [], + "protocolMappers": [] + }, + { + "clientId": "account-console", + "name": "${client_account-console}", + "enabled": true, + "alwaysDisplayInConsole": false, + "publicClient": true, + "clientAuthenticatorType": "client-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "fullScopeAllowed": false, + "protocol": "openid-connect", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/learnstack/account/", + "redirectUris": [ + "/realms/learnstack/account/*" + ], + "webOrigins": [], + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [], + "optionalClientScopes": [], + "protocolMappers": [ + { + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "name": "learnstack-account-self-service-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "usermodel.clientRoleMapping.clientId": "account", + "usermodel.clientRoleMapping.rolePrefix": "", + "claim.name": "resource_access.account.roles", + "jsonType.label": "String", + "multivalued": "true", + "access.token.claim": "true", + "introspection.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "lightweight.claim": "false" + } + } + ] } ], "users": [ @@ -116,9 +311,21 @@ "temporary": false } ], - "realmRoles": ["tenant-admin"], + "realmRoles": [ + "tenant-admin" + ], "attributes": { - "tenant_id": ["tenant-a"] + "user_id": [ + "01930000-0000-7000-8000-00000000a001" + ], + "tenant_id": [ + "01930000-0000-7000-8000-000000000001" + ] + }, + "clientRoles": { + "account": [ + "manage-account" + ] } }, { @@ -135,9 +342,89 @@ "temporary": false } ], - "realmRoles": ["tenant-learner"], + "realmRoles": [ + "tenant-learner" + ], + "attributes": { + "user_id": [ + "01930000-0000-7000-8000-00000000a002" + ], + "tenant_id": [ + "01930000-0000-7000-8000-000000000001" + ] + }, + "clientRoles": { + "account": [ + "manage-account" + ] + } + }, + { + "username": "demo-admin@tenant-b.test", + "email": "demo-admin@tenant-b.test", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "demo-dev-secret", + "temporary": false + } + ], + "realmRoles": [ + "tenant-admin" + ], + "attributes": { + "user_id": [ + "01930000-0000-7000-8000-00000000b001" + ], + "tenant_id": [ + "01930000-0000-7000-8000-000000000002" + ], + "organization_id": [ + "01930000-0000-7000-8000-0000000000b1" + ] + }, + "clientRoles": { + "account": [ + "manage-account" + ] + } + }, + { + "username": "demo-learner@tenant-b.test", + "email": "demo-learner@tenant-b.test", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "Learner", + "credentials": [ + { + "type": "password", + "value": "demo-dev-secret", + "temporary": false + } + ], + "realmRoles": [ + "tenant-learner" + ], "attributes": { - "tenant_id": ["tenant-a"] + "user_id": [ + "01930000-0000-7000-8000-00000000b002" + ], + "tenant_id": [ + "01930000-0000-7000-8000-000000000002" + ], + "organization_id": [ + "01930000-0000-7000-8000-0000000000b1" + ] + }, + "clientRoles": { + "account": [ + "manage-account" + ] } } ], @@ -150,5 +437,755 @@ "ssl": "false", "starttls": "false", "auth": "false" + }, + "clientScopes": [ + { + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "learnstack-tenant-api", + "description": "Scalar application identity and access-only API audience.", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "user_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "user_id", + "claim.name": "user_id", + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "tenant_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "tenant_id", + "claim.name": "tenant_id", + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "organization_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "organization_id", + "claim.name": "organization_id", + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "learnstack-api-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "learnstack-api", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] + }, + { + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"user_id\",\"displayName\":\"user_id\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"tenant_id\",\"displayName\":\"tenant_id\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"organization_id\",\"displayName\":\"organization_id\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ] + }, + "defaultRole": { + "name": "default-roles-learnstack", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false + }, + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] } } diff --git a/scripts/ci-native-tests.py b/scripts/ci-native-tests.py index 0d82ef28..19f8367c 100644 --- a/scripts/ci-native-tests.py +++ b/scripts/ci-native-tests.py @@ -81,6 +81,13 @@ STARTUP_FAILURES = {'resource_controllers_unavailable', 'work_cgroup_not_empty', 'cgroup_kill_unavailable', 'startup_io_refused', 'startup_internal_refusal'} FAILURE_SOURCES = { + 'TenantRealmApiTests.WarmQualification.cs': 'realm_warm_qualification', + 'BackgroundJobPositiveSdkTests.CausalRestart.cs': 'positive_sdk_causal_restart', + 'BackgroundJobPositiveSdkTests.NativeSettlement.cs': 'positive_sdk_native_settlement', + 'BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs': 'positive_sdk_settlement_scheduling', + 'BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs': 'positive_sdk_settlement_prepare', + 'BackgroundJobRecurrenceStoreTests.NativeOwnership.cs': 'recurrence_native_ownership', + 'BackgroundJobRecurrenceStoreTests.Runtime.cs': 'recurrence_runtime', 'BackgroundJobRuntimeFacadeTests.AuxiliaryCleanup.cs': 'facade_auxiliary_cleanup', 'BackgroundJobRuntimeFacadeTests.Business.cs': 'facade_business', 'BackgroundJobRuntimeFacadeTests.CapacityAdmission.cs': 'facade_capacity_admission', @@ -124,13 +131,288 @@ 'setup initial-request initial-status initial-source initial-site initial-release watch-connect change ' 'source-barrier refresh-request refresh-status refresh-type refresh-source refresh-site refresh-release after' ).split()} +KEYCLOAK_FAILURE_CLASSES = { + 'TenantRealmApiTests', 'TenantRealmIssuanceTests', 'TenantRealmProfileProtectionTests', + 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests', +} +KEYCLOAK_FAILURE_STAGES = { + 'certificate', 'network', 'database', 'import_input', 'provider_start', + 'mapped_endpoint', 'loopback_bindings', 'discovery', + 'database_create_request', 'database_start', 'database_readiness', 'database_started', +} +KEYCLOAK_FAILURE_REASONS = { + 'container_exit', 'deadline', 'readiness_timeout', 'http_transport', 'argument', + 'invalid_state', 'unsupported_operation', 'missing_mapping', 'provider_error', + 'docker_bad_request', 'docker_access_denied', 'docker_not_found', 'docker_conflict', + 'docker_rate_limited', 'docker_server_error', 'docker_api_error', 'resource_reaper', + 'socket_error', 'filesystem_access_denied', 'filesystem_io', + 'docker_port_allocated', 'docker_port_bind', 'docker_port_listen', 'docker_port_proxy', + 'docker_network_error', 'docker_runtime_task', +} +KEYCLOAK_FAILURE_CLEANUP = {'completed', 'failed'} +FAILURE_EXCEPTION_LABELS = { + 'System.TimeoutException': 'timeout', + 'System.OperationCanceledException': 'cancellation', + 'System.Threading.Tasks.TaskCanceledException': 'cancellation', + 'System.InvalidOperationException': 'invalid_state', +} + + +def closed_exception_kind(raw): + """Project a recognized top-level adapter label, never a causal diagnosis.""" + # xUnit omits Xunit.Sdk type names from combined messages. Unlabelled + # assertion text cannot establish an exception kind and stays unclassified. + message = raw.get('ErrorMessage') + if not isinstance(message, str) or len(message) > 65536: + return None + label, separator, _ = message.partition(' : ') + return FAILURE_EXCEPTION_LABELS.get(label) if separator else None + + +SETTLEMENT_FAILURE_CASES = { + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Actual_Outcome_ACK_settles_renewal_before_its_held_cleanup_can_release_the_native_identity(knownFailure: False)', + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Actual_Outcome_ACK_settles_renewal_before_its_held_cleanup_can_release_the_native_identity(knownFailure: True)', + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Actual_stored_recovery_selection_repairs_the_early_finalize_hint_only_after_its_real_cleanup', +} + + +RECURRENCE_NATIVE_FAILURE_CASE = ( + 'LearnStack.Tests.Integration.Database.BackgroundJobRecurrenceStoreTests.' + 'A_held_actual_native_callback_preserves_durable_progress_and_both_sources_until_real_return' +) +RECURRENCE_NATIVE_BOUNDARIES = { + 'start', 'callback_entry', 'native_retained', 'execution', 'durable_progress', 'held_assertions', + 'applications', 'cancelled_stop', 'bridge_stop_publication', 'control_loops_publication', + 'recurrence_loop_publication', 'sibling_join', 'sibling_assertions', 'parent_pin', 'source_pin', + 'cleanup_stop', 'cleanup_completion', 'cleanup_thread', 'cleanup_sources', 'post_cleanup', +} +RECURRENCE_NATIVE_FIELDS = ( + 'callbacks', 'entered', 'returned', 'executed', 'thread', 'nativeOutstanding', 'nativeRetained', + 'sqlHealthy', 'applications', 'retainedApplications', 'pendingHistory', 'executions', 'pendingOutcomes', + 'bootstrap', 'pools', 'loops', 'controlRelay', 'recurrenceRelay', 'shared', 'bridgeLoop', 'bridgeStop', + 'controlCancellation', 'recurrenceLoop', 'control', 'recurrence', 'runtime', 'sourceDisposed', + 'applicationDisposed', 'sourceUnconfirmed', 'applicationUnconfirmed', +) + + +def closed_recurrence_native_details(raw): + """Preserve finite first-failure facts separately from later cleanup observations.""" + messages = raw.get('Messages') + if not isinstance(messages, list) or not 1 <= len(messages) <= 32: + return None + if any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages): + return None + if sum(len(item['Text']) for item in messages) > 65536: + return None + tasks = {'not_issued', 'unpublished', 'pending', 'succeeded', 'faulted', 'canceled', 'unavailable'} + counts = {'callbacks', 'applications', 'retainedApplications', 'pendingHistory', 'executions', 'pendingOutcomes'} + booleans = {'nativeOutstanding', 'nativeRetained', 'sqlHealthy', 'bootstrap', 'sourceDisposed', + 'applicationDisposed', 'sourceUnconfirmed', 'applicationUnconfirmed'} + result = {'stage': 'recurrence_native'} + for kind in ('failure', 'cleanup'): + marker = 'Recurrence native ' + kind + ';' + count = sum(item['Text'].count(marker) for item in messages) + if kind == 'cleanup' and count == 0: + continue + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if count != 1 or len(lines) != 1: + return None + fields = ('boundary', *RECURRENCE_NATIVE_FIELDS) if kind == 'failure' else RECURRENCE_NATIVE_FIELDS + pattern = re.escape(marker) + ';'.join(name + r'=([a-z_0-9,]{1,128})' for name in fields) + r'\.' + matched = re.fullmatch(pattern, lines[0]) + if matched is None: + return None + values = dict(zip(fields, matched.groups())) + for field, value in values.items(): + if field == 'boundary': + if value not in RECURRENCE_NATIVE_BOUNDARIES: + return None + elif field in counts: + if value == 'unavailable': + values[field] = None + elif value != 'out_of_range': + if not re.fullmatch(r'0|[1-9][0-9]?', value) or not 0 <= int(value) <= 64: + return None + values[field] = int(value) + elif field in booleans: + if value not in ('true', 'false', 'unavailable'): + return None + values[field] = None if value == 'unavailable' else value == 'true' + elif field == 'thread': + if value not in ('not_entered', 'alive', 'returned', 'unavailable'): + return None + elif field in ('pools', 'loops'): + if value in ('not_issued', 'unavailable') or field == 'loops' and value == 'unpublished': + continue + states = value.split(',') + if not 1 <= len(states) <= (8 if field == 'pools' else 3) or any(state not in tasks for state in states): + return None + values[field] = states + elif value not in tasks: + return None + if kind == 'failure': + result.update(values) + else: + result['cleanup'] = values + return result + + +def closed_settlement_failure_details(raw): + """Read the named case's xUnit output; its original exception stays untouched.""" + messages = raw.get('Messages') + if not isinstance(messages, list) or not 1 <= len(messages) <= 32: + return None + if any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages): + return None + if sum(len(item['Text']) for item in messages) > 65536: + return None + marker = 'Native settlement failure;' + if sum(item['Text'].count(marker) for item in messages) != 1: + return None + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if len(lines) != 1: + return None + fields = ('boundary', 'observedAttempts', 'snapshot', 'total', 'normal', 'finalize', + 'normalCounter', 'finalizeCounter', 'phase', 'terminal', 'generationDelta', + 'executions', 'outcome', 'finalized', 'originalCause', 'finalizeCause', 'renewalLosses', 'elapsedLosses') + pattern = re.escape(marker) + ';'.join(name + r'=([a-z_0-9-]{1,32})' for name in fields) + r'\.' + matched = re.fullmatch(pattern, lines[0]) + if matched is None: + return None + values = dict(zip(fields, matched.groups())) + enums = { + 'boundary': {'start', 'held_outcome', 'early_hint', 'held_count_read', 'held_count_assert', + 'deadline_armed', 'renewal_first', 'renewal_progress', 'renewal_second', 'timer_cleanup', + 'completion_wait', 'count_read', 'count_assert', 'normal_attempts', 'generation', + 'outcome', 'provider_counts', 'cleanup', 'post_cleanup', 'later_original', 'later_admission', + 'later_commit_held', 'later_refusal', 'later_ack', 'later_facts', 'later_cleanup', 'later_post_cleanup', + 'prepare_admission', 'prepare_commit_held', 'prepare_renewal_held', 'prepare_refusal', + 'prepare_ack', 'prepare_facts', 'prepare_cleanup', 'prepare_post_cleanup'}, + 'snapshot': {'available', 'missing', 'unavailable'}, + 'phase': {'unknown', 'other', 'pending', 'preparing', 'prepared', 'executing', 'reconciling', + 'finalize_pending', 'finalizing', 'terminal_pending', 'completed', 'quarantined'}, + 'terminal': {'unknown', 'none', 'other', 'external_retry_forbidden', 'permanent_failure', + 'retry_exhausted', 'finalize_refused', 'finalize_exhausted', 'retired_definition', 'invalid_envelope'}, + 'outcome': {'unknown', 'pending', 'succeeded', 'cancelled', 'failed'}, + 'originalCause': {'unknown', 'other', 'none', 'renewal_refused', 'elapsed'}, + 'finalizeCause': {'unknown', 'other', 'none', 'renewal_refused', 'elapsed'}, + } + if any(values[field] not in allowed for field, allowed in enums.items()): + return None + bounds = {'observedAttempts': (0, 12), 'total': (0, 12), 'normal': (0, 6), 'finalize': (0, 6), + 'normalCounter': (0, 6), 'finalizeCounter': (0, 6), 'generationDelta': (-12, 12), + 'executions': (0, 64), 'finalized': (0, 1), 'renewalLosses': (0, 64), 'elapsedLosses': (0, 64)} + result = {'stage': 'native_settlement', **values} + for field, (minimum, maximum) in bounds.items(): + value = values[field] + if value in ('unknown', 'out_of_range'): + result[field] = None if value == 'unknown' else value + elif re.fullmatch(r'0|[1-9][0-9]?|-[1-9][0-9]?', value) and minimum <= int(value) <= maximum: + result[field] = int(value) + else: + return None + snapshot_fields = ('total', 'normal', 'finalize', 'normalCounter', 'finalizeCounter', 'finalized') + if values['snapshot'] != 'available': + if any(values[field] != 'unknown' for field in (*snapshot_fields, 'phase', 'terminal', 'generationDelta')): + return None + elif any(values[field] == 'unknown' for field in (*snapshot_fields, 'phase', 'terminal')): + return None + if values['boundary'] in ('held_count_assert', 'count_assert') and values['observedAttempts'] == 'unknown': + return None + cleanup_marker = 'Native settlement cleanup;' + cleanup_count = sum(item['Text'].count(cleanup_marker) for item in messages) + if cleanup_count: + cleanup_lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if cleanup_marker in line] + if cleanup_count != 1 or len(cleanup_lines) != 1 or not values['boundary'].startswith(('later_', 'prepare_')): + return None + cleanup_fields = ('originalAdmission', 'finalizeAdmission', 'external', 'business', 'disposition', + 'callback', 'original', 'finalize', 'control', 'generation', 'executions', 'running', 'retained', 'privateWork') + cleanup_pattern = re.escape(cleanup_marker) + ';'.join(name + r'=([a-z_0-9]{1,16})' for name in cleanup_fields) + r'\.' + cleanup_match = re.fullmatch(cleanup_pattern, cleanup_lines[0]) + if cleanup_match is None: + return None + cleanup = dict(zip(cleanup_fields, cleanup_match.groups())) + if any(cleanup[field] not in ('joined', 'incomplete', 'not_issued') for field in (*cleanup_fields[:10], 'privateWork')): + return None + if any(cleanup[field] == 'not_issued' for field in ('control', 'generation', 'privateWork')): + return None + for field in ('executions', 'running', 'retained'): + value = cleanup[field] + if value == 'out_of_range': + continue + if not re.fullmatch(r'0|[1-9][0-9]?', value) or not 0 <= int(value) <= 64: + return None + cleanup[field] = int(value) + if cleanup['generation'] == 'joined' and (cleanup['running'] != 0 or cleanup['retained'] != 0): + return None + if cleanup['privateWork'] == 'joined' and cleanup['generation'] != 'joined': + return None + result['cleanup'] = cleanup + pacing_marker = 'Native settlement pacing;' + pacing_count = sum(item['Text'].count(pacing_marker) for item in messages) + if pacing_count: + pacing_lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if pacing_marker in line] + if pacing_count != 1 or len(pacing_lines) != 1 or values['boundary'].startswith(('later_', 'prepare_')): + return None + pacing_fields = ('lease', 'deadline', 'first', 'second', 'fault', 'cleanup') + pacing_pattern = re.escape(pacing_marker) + ';'.join(name + r'=([a-z_]{1,16})' for name in pacing_fields) + r'\.' + pacing_match = re.fullmatch(pacing_pattern, pacing_lines[0]) + if pacing_match is None: + return None + pacing = dict(zip(pacing_fields, pacing_match.groups())) + timer_states = {'not_issued', 'creating', 'armed', 'entered', 'returned', 'joined', 'cancelled', 'incomplete'} + if (pacing['lease'] not in {'unseen', 'waiting', 'released', 'aborted'} + or any(pacing[field] not in timer_states for field in ('deadline', 'first', 'second')) + or pacing['fault'] not in {'none', 'ambiguous', 'timeout', 'callback_failed', 'timer_failed'} + or pacing['cleanup'] not in {'not_started', 'joined', 'incomplete'}): + return None + result['pacing'] = pacing + return result def closed_failure_details(case, raw): """Decode only existing test-owned finite facts; raw error text never leaves this function.""" + if case['name'] == RECURRENCE_NATIVE_FAILURE_CASE: + return closed_recurrence_native_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] in SETTLEMENT_FAILURE_CASES: + if case['assembly'] != 'learnstack.tests.integration': + return None + details = closed_settlement_failure_details(raw) + if details and ('cleanup' in details or details['boundary'].startswith('prepare_')) and not case['name'].endswith('(knownFailure: True)'): + return None + if (details and 'knownFailure:' not in case['name'] + and (details['boundary'] == 'deadline_armed' + or details.get('pacing', {}).get('deadline', 'not_issued') != 'not_issued')): + return None + return details message = raw.get('ErrorMessage') if not isinstance(message, str): return None + if case['assembly'] == 'learnstack.tests.integration' and any( + case['name'].startswith('LearnStack.Tests.Integration.TenancyIdentity.' + name + '.') + for name in KEYCLOAK_FAILURE_CLASSES): + # xUnit preserves this test-owned sentence in each fixture failure's + # ErrorMessage. Do not infer startup from a 1 ms result or missing fixture. + marker = 'Keycloak fixture startup failed at ' + if len(message) > 65536 or message.count(marker) != 1: + return None + match = re.search(re.escape(marker) + r'([a-z_]{1,32})/([a-z_]{1,32}); ' + r'owned cleanup ([a-z_]{1,32})\.', message) + if (match and match[1] in KEYCLOAK_FAILURE_STAGES and match[2] in KEYCLOAK_FAILURE_REASONS + and match[3] in KEYCLOAK_FAILURE_CLEANUP): + return {'stage': match[1], 'reason': match[2], 'cleanup': match[3]} + return None message = message[:65536] prefix = 'LearnStack.Tests.Integration.Database.' if case['name'] == prefix + 'PublicAdmissionRenderingTests.Native_admission_preserves_wire_failures_request_lifetimes_and_browser_fallback': @@ -686,6 +968,9 @@ def results(self, payload): locations.add((code, line)) diagnostic = {'assembly': case['assembly'], 'id': case['id'], 'locations': [{'source': code, 'line': line} for code, line in sorted(locations)[:8]]} + exception_kind = closed_exception_kind(raw) + if exception_kind is not None: + diagnostic['exceptionKind'] = exception_kind details = closed_failure_details(case, raw) if details is not None: diagnostic['details'] = details diff --git a/scripts/ci-test-shard-results.py b/scripts/ci-test-shard-results.py index d36d0dd6..166280d3 100644 --- a/scripts/ci-test-shard-results.py +++ b/scripts/ci-test-shard-results.py @@ -44,6 +44,7 @@ UPLOAD_STEP = 'Upload immutable attempt evidence' BUILD_STEP = 'Build Release with warnings as errors' UPLOAD_ACTION = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' +FIXTURE_IMAGES = ('quay.io/keycloak/keycloak:26.6.2', 'postgres:18.4-alpine') def module(name, filename): @@ -58,6 +59,30 @@ def module(name, filename): Refusal, require = PLAN.Refusal, PLAN.require +def prepare_fixture_images(): + # Pull outside fixture startup, while charging every second to the same + # twenty-minute job. Never consume the execution owner's cleanup reserve. + require(sys.platform == 'linux' and os.environ.get('GITHUB_ACTIONS') == 'true' + and os.environ.get('RUNNER_ENVIRONMENT') == 'github-hosted' + and os.environ.get('GITHUB_JOB') == 'backend-integration-tests', 'fixture_setup_context_invalid') + started = os.environ.get('LEARNSTACK_CI_STARTED_AT', '') + require(re.fullmatch(r'[1-9][0-9]*', started), 'fixture_setup_clock_missing') + elapsed = time.time() - int(started) + available = OWNER.JOB_SECONDS - OWNER.RESERVE_SECONDS - elapsed + require(elapsed >= 0 and available > 0, 'fixture_setup_deadline_exhausted') + beginning = time.monotonic() + deadline = beginning + min(110, available) + images = [] + for image in FIXTURE_IMAGES: + image_started = time.monotonic() + OWNER.command([OWNER.DOCKER, 'pull', image], deadline, 'fixture_image_pull_failed', cap=55) + images.append({'image': image, 'pullSeconds': round(time.monotonic() - image_started, 3)}) + require(time.monotonic() < deadline, 'fixture_setup_deadline_exhausted') + print(json.dumps({'protocol': 1, 'fixtureImagePreparationSeconds': round(time.monotonic() - beginning, 3), + 'images': images}, sort_keys=True)) + return 0 + + def json_value(raw): try: return json.loads(raw, object_pairs_hook=PLAN.unique_object) @@ -139,6 +164,7 @@ def workflow_topology(documents, plan): jobs = workflow['jobs'] require(workflow.get('permissions') == {'contents': 'read'}, 'workflow_permissions_invalid') require(set(jobs) == {*REQUIRED, *JOB_IDS.values()}, 'workflow_jobs_invalid') + realm_node_gates(jobs['meta']) for scope, job_id in JOB_IDS.items(): job = jobs[job_id] require(job.get('runs-on') == 'ubuntu-24.04' and job.get('timeout-minutes') == 20 @@ -157,6 +183,12 @@ def workflow_topology(documents, plan): names = [step.get('name') for step in steps] require(len(names) == len(set(names)) and all(names.count(name) == 1 for name in (BUILD_STEP, EXECUTE_STEP, UPLOAD_STEP)), 'execution_gates_missing') + if scope == 'ordinary': + prepare_name = 'Prepare pinned provider fixture images within the whole-job budget' + prepare = [step for step in steps if step.get('name') == prepare_name] + require(len(prepare) == 1 and prepare[0].get('run') == 'python3 scripts/ci-test-shards.py prepare-fixtures' + and prepare[0].get('timeout-minutes') == 2 and 'if' not in prepare[0] + and names.index(prepare_name) < names.index(EXECUTE_STEP), 'fixture_image_gate_invalid') build, execute, upload = (steps[names.index(name)] for name in (BUILD_STEP, EXECUTE_STEP, UPLOAD_STEP)) mode = 'native-shard' if scope == 'native' else scope mode += '-${{ matrix.index }}' if scope in ('architecture', 'ordinary') else '' @@ -198,6 +230,24 @@ def workflow_topology(documents, plan): return jobs +def realm_node_gates(job): + """Keep the actual realm census and its counterexample owner mandatory.""" + require(not any(key in job for key in ('if', 'continue-on-error', 'needs')), + 'realm_node_gate_invalid') + steps = job.get('steps', []) + require(isinstance(steps, list) and all(isinstance(step, dict) for step in steps), + 'realm_node_gate_invalid') + for name, command in ( + ('Development realm transport, ownership and convergence controls', + 'python3 scripts/run-realm-node-tests.py'), + ('Realm Node executed-case and JUnit gate controls', + 'python3 scripts/test-realm-node-gate.py')): + matches = [step for step in steps if step.get('name') == name or step.get('run') == command] + require(len(matches) == 1 and matches[0].get('name') == name and matches[0].get('run') == command + and not any(key in matches[0] for key in ('if', 'continue-on-error', 'uses')), + 'realm_node_gate_invalid') + + def source_topology(plan): paths = sorted((ROOT / '.github/workflows').glob('*.y*ml')) require(0 < len(paths) <= 20, 'workflow_file_bound') @@ -316,11 +366,17 @@ def verify_jobs(jobs, context, plan, scope): def verify_artifact(metadata, context, owner, job): name = artifact_name(context, owner) - require(isinstance(metadata, dict) and metadata.get('total_count') == 1 - and isinstance(metadata.get('artifacts'), list) and len(metadata['artifacts']) == 1, - 'artifact_api_not_unique') + require(isinstance(metadata, dict) and type(metadata.get('total_count')) is int + and metadata['total_count'] >= 0 and isinstance(metadata.get('artifacts'), list) + and len(metadata['artifacts']) == metadata['total_count'], 'artifact_api_invalid') + # Re-running failed jobs can project old successful siblings into the new + # jobs API response. Only an immutable artifact with this exact attempt's + # name is evidence that the sibling actually executed this assignment. + require(metadata['total_count'] != 0, 'artifact_api_missing') + require(metadata['total_count'] == 1, 'artifact_api_not_unique') artifact = metadata['artifacts'][0] - require(positive(artifact.get('id')) and artifact.get('name') == name and artifact.get('expired') is False + require(isinstance(artifact, dict) and positive(artifact.get('id')) + and artifact.get('name') == name and artifact.get('expired') is False and positive(artifact.get('size_in_bytes')) and artifact['size_in_bytes'] <= MAX_ARCHIVE and isinstance(artifact.get('digest'), str) and re.fullmatch(r'sha256:[0-9a-f]{64}', artifact['digest']), 'artifact_api_invalid') diff --git a/scripts/ci-test-shards-plan.json b/scripts/ci-test-shards-plan.json index ad7b5881..bb6e89aa 100644 --- a/scripts/ci-test-shards-plan.json +++ b/scripts/ci-test-shards-plan.json @@ -84,6 +84,62 @@ "sourceSha": "d84ac3009f8ba84a339d91e023f7f6f5f9269cc0", "runId": "39417319998", "note": "Run attempt 2; owner architecture-5; PR head 00a7877e6b578c471fc52ae9b2c1e5d092e3f2f9; merge checkout d84ac3009f8ba84a339d91e023f7f6f5f9269cc0; artifact ID 12262804978. Exact completed hosted TRX case durations summed per actual assembly/selector in milliseconds (ceil, minimum 1); advisory only, not wall bounds." + }, + "p02b6-local-api": { + "kind": "local_trx", + "artifactSha256": "7fafa97c24ea554cb0a629b5c3c952942d1ee54b767a7a988f4b9c5a5e8eb02e", + "sourceSha": null, + "runId": null, + "note": "Complete corrected P02b-6 Step 4 local provider receipt, zero failures/skips. Source commit not embedded in TRX. Exact row durations summed per selector and scaled by historical ordinary ratio 2.271245 (ceil ms, minimum 1); advisory, not a wall bound. Fixture startup/discovery costs are not inferred from case durations; actual hosted fit remains required." + }, + "p02b6-local-account": { + "kind": "local_trx", + "artifactSha256": "a2b000ff5f9646ed7e4f03e20e74054a831e71410f863331892fbb3c0b30ca39", + "sourceSha": null, + "runId": null, + "note": "Complete corrected P02b-6 Step 4 local provider receipt, zero failures/skips. Source commit not embedded in TRX. Exact row durations summed per selector and scaled by historical ordinary ratio 2.271245 (ceil ms, minimum 1); advisory, not a wall bound. Fixture startup/discovery costs are not inferred from case durations; actual hosted fit remains required." + }, + "p02b6-local-profile": { + "kind": "local_trx", + "artifactSha256": "f470c252cbbfc908e1d4996278cf116ba2b78eb514755d729c57a6ae6f8a2aa7", + "sourceSha": null, + "runId": null, + "note": "Complete corrected P02b-6 Step 4 local provider receipt, zero failures/skips. Source commit not embedded in TRX. Exact row durations summed per selector and scaled by historical ordinary ratio 2.271245 (ceil ms, minimum 1); advisory, not a wall bound. Fixture startup/discovery costs are not inferred from case durations; actual hosted fit remains required." + }, + "adr68-local-preparation": { + "kind": "local_trx", + "artifactSha256": "b701d3166e5266dab904ae7e921a08034db5d2f6683d17c605bc625c8fa5dc83", + "sourceSha": null, + "runId": null, + "note": "Complete local provider receipt, all rows Passed/zero skips. Source commit absent from TRX; observed planner SHA256 8ef1d58c2774b531d88170ff6e04939bdd631a77d749527764744fdc832e94ed; other source hashes retained separately. Actual TestMethod groups sum case ms, scale 2.271245, ceil/min1; advisory, not wall bounds or final-head qualification." + }, + "adr68-local-mapper-correction": { + "kind": "local_trx", + "artifactSha256": "4497a991aab51936afb923cbd60aa6d01d9480a700211580f6a9fe0b183a1db8", + "sourceSha": null, + "runId": null, + "note": "Complete local provider receipt, all rows Passed/zero skips. Source commit absent from TRX; observed account policy SHA256 3cc2c01bdfbb64e2343bed2c757434ed6c81f25b93001860c1a34238d9b39dd4; other source hashes retained separately. Actual TestMethod groups sum case ms, scale 2.271245, ceil/min1; advisory, not wall bounds or final-head qualification." + }, + "adr68-hosted-baseline-ordinary-1": { + "kind": "hosted_trx", + "artifactSha256": "515636cb1318709236b068f05bc984385ae651c79a0fd5236aec36c62b51d5f4", + "sourceSha": "d55ae6fc37b794e482c42ebbdd49d3e59a9c3f0a", + "runId": "39699380813", + "note": "Run attempt 1; owner ordinary-1; PR head f714a5af96363d880e7e3d72a24ee2642a29cf47; artifact ID 12393016518; API archive SHA256 98c6174c249fa80ad3b53356a2efe0c764f500454de74dc429698766137a14a7. Sanitized TRX IDs bind actual discovery FullyQualifiedName metadata; complete passing owner. Exact summed row ms, ceil/min1; advisory, not wall bounds." + }, + "adr68-hosted-baseline-ordinary-4": { + "kind": "hosted_trx", + "artifactSha256": "7fcf4e254a1f35a96b59c187affdf345aa6400e60a13fc1b16712c3778717159", + "sourceSha": "d55ae6fc37b794e482c42ebbdd49d3e59a9c3f0a", + "runId": "39699380813", + "note": "Run attempt 1; owner ordinary-4; PR head f714a5af96363d880e7e3d72a24ee2642a29cf47; artifact ID 12392133270; API archive SHA256 d02f5a7ac79d62ce023ffde59d2989b7059be142925128f71cb6d129ebf714d6. Sanitized TRX IDs bind actual discovery FullyQualifiedName metadata; complete passing owner. Exact summed row ms, ceil/min1; advisory, not wall bounds." + }, + "adr68-local-warm-journey": { + "kind": "local_trx", + "artifactSha256": "3487a4510af4444908eaef908365e8cc4334b0dfc6a531b0af25276f49e657c9", + "sourceSha": null, + "runId": null, + "note": "Complete final refresh-presence-guard local journey Fact, 1/1 Passed and zero skips. Commit is not embedded in TRX. Separate seven-file source hash manifest SHA256 92cde32454dfb23f0421ce4e2d92ffe3c054281138cfe2d8a2ebe8c734960da3. Actual TestMethod row is 40205.7204 ms; local scale 2.271245, ceil/min1. Advisory, not a hosted wall bound or final-source CI qualification." } }, "weights": [ @@ -8102,9 +8158,294 @@ "method": "LearnStack.Tests.Integration.Database.WriteStoreConflictTests.A_uniqueness_violation_still_becomes_the_port_s_conflict_type", "milliseconds": 29, "source": "local-ordinary-scaled" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.A_genuine_Hub_issuer_token_is_rejected_without_changing_the_operator_or_Hub_client", + "milliseconds": 3665, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.A_genuine_ID_token_without_API_audience_is_401_on_account_and_public_routes", + "milliseconds": 7662, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.A_real_claimless_actor_retains_HostOnly_attribution_without_protected_read_authority", + "milliseconds": 2945, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.A_real_provider_organization_disagreement_refuses_and_records_the_trusted_host_organization", + "milliseconds": 3045, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.Anonymous_public_reads_and_masked_account_refusals_do_not_contact_an_unavailable_identity_endpoint", + "milliseconds": 3048, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.Every_seed_persona_reads_its_actual_account_site_under_the_matching_host_scope", + "milliseconds": 6681, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.Historical_automatic_upgrade_preserves_live_password_and_MFA_then_serves_signed_API_and_console_users", + "milliseconds": 91318, + "source": "adr68-local-warm-journey" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.Real_cross_tenant_tokens_refuse_before_configuration_and_audit_the_defended_host", + "milliseconds": 2684, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.The_real_backchannel_accepts_only_the_owned_CA_and_preserves_all_transport_bounds", + "milliseconds": 2615, + "source": "p02b6-local-api" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmExportTests.Export_controls_reject_unsafe_mapper_profile_client_and_identity_variants", + "milliseconds": 217, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmExportTests.The_committed_realm_matches_typed_identity_and_security_declarations", + "milliseconds": 12, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Actual_signed_tokens_detect_live_mapper_scope_audience_scalar_and_lifespan_mutants", + "milliseconds": 42829, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Authorization_refuses_a_missing_or_plain_challenge", + "milliseconds": 59, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Authorization_refuses_an_unregistered_callback", + "milliseconds": 592, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Code_exchange_refuses_a_missing_or_wrong_client_secret", + "milliseconds": 244, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Code_exchange_refuses_a_missing_or_wrong_verifier", + "milliseconds": 610, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Fixture_HTTPS_requires_its_owned_certificate_authority", + "milliseconds": 563, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Fresh_import_issues_the_API_profile_for_all_four_declared_identities", + "milliseconds": 1203, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Late_session_refresh_is_clamped_while_the_configured_access_lifespan_remains_exactly_900", + "milliseconds": 43202, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Live_import_has_exact_lifespan_callbacks_and_confidential_PKCE_configuration", + "milliseconds": 178, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests.Tenant_password_grant_stays_disabled", + "milliseconds": 17, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests.Ordinary_registration_works_and_injected_authority_never_becomes_new_account_data", + "milliseconds": 2161, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests.Same_seed_account_can_edit_ordinary_profile_but_cannot_see_write_or_delete_authority", + "milliseconds": 1789, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests.User_editable_profile_counterfactual_breaks_the_same_protection_predicate", + "milliseconds": 2261, + "source": "p02b6-local-profile" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Committed_fresh_import_is_already_converged_and_repeated_runs_have_zero_writes", + "milliseconds": 12843, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Group_carried_identity_requires_a_no_write_refusal", + "milliseconds": 222936, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Hidden_historical_actor_conflict_is_revealed_after_preparation_without_any_ordinary_write", + "milliseconds": 47653, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Hidden_stored_identity_refuses_before_ordinary_writes_and_preserves_the_conflicting_actor", + "milliseconds": 102474, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Historical_stock_upgrade_automatically_prepares_once_then_converges_with_seventeen_writes", + "milliseconds": 55935, + "source": "adr68-local-mapper-correction" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Identity_mapper_secret_and_duplicate_lookup_conflicts_refuse_before_the_first_write", + "milliseconds": 95073, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Interrupted_historical_preparation_counts_the_dispatched_write_and_requires_an_explicit_resume", + "milliseconds": 111989, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Legacy_upgrade_resumes_durable_partial_writes_and_preserves_accounts_and_unrelated_state", + "milliseconds": 250858, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Missing_protected_definitions_do_not_allow_preparation_before_an_independent_secret_conflict", + "milliseconds": 49365, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Missing_protected_definitions_do_not_allow_preparation_for_a_customized_stock_scope_lookalike", + "milliseconds": 46986, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Partial_admin_view_only_definition_is_preserved_by_preparation_then_normalized_by_a_distinct_ordinary_write", + "milliseconds": 55312, + "source": "adr68-local-mapper-correction" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Projected_identity_profile_configuration_refuses_before_any_pending_write", + "milliseconds": 93561, + "source": "adr68-local-preparation" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Readback_failure_is_nonzero_and_a_new_explicit_pass_verifies_the_committed_result", + "milliseconds": 64288, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Retained_cached_organization_groups_do_not_override_strict_user_identity", + "milliseconds": 54861, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Uninterrupted_legacy_upgrade_preserves_nonmanifest_actor_and_account_state", + "milliseconds": 58888, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests.Unrelated_nested_groups_converge_and_preserve_membership_and_strict_token_identity", + "milliseconds": 50216, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmSelfServiceTests.Effective_inherited_capability_and_a_compatible_existing_mapper_need_no_redundant_additions", + "milliseconds": 123811, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmSelfServiceTests.Explicit_fresh_builtins_preserve_stock_bootstrap_and_add_only_the_approved_mapper_and_four_grants", + "milliseconds": 45264, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmSelfServiceTests.Shared_mapper_restores_an_already_eligible_nonmanifest_account_without_changing_its_data_or_grants", + "milliseconds": 49972, + "source": "p02b6-local-account" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmSelfServiceTests.Unsafe_account_customization_refuses_before_any_pending_write_and_preserves_the_customization", + "milliseconds": 246337, + "source": "adr68-local-mapper-correction" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmStockBaselineTests.Historical_24705a7a_bootstrap_matches_the_fixed_26_6_2_scope_role_and_mapper_baseline", + "milliseconds": 98022, + "source": "adr68-hosted-baseline-ordinary-1" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmStockBaselineTests.Historical_stock_ordinary_reconciliation_detaches_only_three_console_edges_and_then_writes_zero", + "milliseconds": 102868, + "source": "adr68-hosted-baseline-ordinary-4" } ], "sharedFixtureClasses": [ + "LearnStack.Tests.Integration.ApiHandlerCompositionTests", + "LearnStack.Tests.Integration.ApiVersioningHttpTests", + "LearnStack.Tests.Integration.AssertionRecorderCompositionTests", + "LearnStack.Tests.Integration.AuthenticatedRateLimitTests", + "LearnStack.Tests.Integration.AuthorityCeilingHttpTests", + "LearnStack.Tests.Integration.CompositionRootCatalogueTests", + "LearnStack.Tests.Integration.CrossCuttingFoundationHttpTests", + "LearnStack.Tests.Integration.Database.AccountSiteHttpTests", + "LearnStack.Tests.Integration.Database.AccountSiteQueryTests", "LearnStack.Tests.Integration.Database.AmbientUnitOfWorkTests", "LearnStack.Tests.Integration.Database.AuditCaptureWiringTests", "LearnStack.Tests.Integration.Database.AuditConfigServiceTests", @@ -8130,11 +8471,13 @@ "LearnStack.Tests.Integration.Database.BackgroundJobSubmissionTests", "LearnStack.Tests.Integration.Database.CourseContentAccessMigrationTests", "LearnStack.Tests.Integration.Database.CustomizationCatalogTests", + "LearnStack.Tests.Integration.Database.CustomizationContentTypeQuotaTests", "LearnStack.Tests.Integration.Database.CustomizationDefaultDeliveryTests", "LearnStack.Tests.Integration.Database.CustomizationDefaultInitializationTests", "LearnStack.Tests.Integration.Database.CustomizationProjectionTests", "LearnStack.Tests.Integration.Database.CustomizationPublicationConcurrencyTests", "LearnStack.Tests.Integration.Database.CustomizationSchemaTests", + "LearnStack.Tests.Integration.Database.DatabaseRoleTests", "LearnStack.Tests.Integration.Database.DeliveryDispatchStoreTests", "LearnStack.Tests.Integration.Database.DeliveryExecutionTests", "LearnStack.Tests.Integration.Database.DeliveryProcessorTests", @@ -8146,18 +8489,22 @@ "LearnStack.Tests.Integration.Database.EducationPersistenceTests", "LearnStack.Tests.Integration.Database.EducationStructureTests", "LearnStack.Tests.Integration.Database.EducationWriterTests", + "LearnStack.Tests.Integration.Database.EntitlementExpiryReversalTests", "LearnStack.Tests.Integration.Database.EventDeliveryMigrationTests", "LearnStack.Tests.Integration.Database.FeatureFlagsTests", "LearnStack.Tests.Integration.Database.HangfireGenerationTests", "LearnStack.Tests.Integration.Database.HangfirePoolOwnerTests", + "LearnStack.Tests.Integration.Database.HangfireStorageTests", "LearnStack.Tests.Integration.Database.HostResolutionTests", "LearnStack.Tests.Integration.Database.InboxGuardTests", "LearnStack.Tests.Integration.Database.KillswitchOverlayTests", + "LearnStack.Tests.Integration.Database.MigrationRollbackTests", "LearnStack.Tests.Integration.Database.OrgScopeMigrationTests", "LearnStack.Tests.Integration.Database.OrgScopeSchemaTests", "LearnStack.Tests.Integration.Database.OrganizationScopeValidatorTests", "LearnStack.Tests.Integration.Database.OutboxDispatchMigrationTests", "LearnStack.Tests.Integration.Database.OutboxDispatchStoreTests", + "LearnStack.Tests.Integration.Database.OutboxDispatcherRoleGuardTests", "LearnStack.Tests.Integration.Database.OutboxProcessorTests", "LearnStack.Tests.Integration.Database.OutboxProducerTests", "LearnStack.Tests.Integration.Database.P02d2FoundationTests", @@ -8165,21 +8512,47 @@ "LearnStack.Tests.Integration.Database.PlatformSchemaTests", "LearnStack.Tests.Integration.Database.PoolHangfireStorageParityTests", "LearnStack.Tests.Integration.Database.ProducerExecutionTests", + "LearnStack.Tests.Integration.Database.PublicAdmissionRenderingTests", + "LearnStack.Tests.Integration.Database.PublicEducationHttpTests", + "LearnStack.Tests.Integration.Database.PublicEducationStoreTests", + "LearnStack.Tests.Integration.Database.PublicLocalLaunchTests", "LearnStack.Tests.Integration.Database.PublicLocaleEvolutionHttpTests", "LearnStack.Tests.Integration.Database.PublicReadTransactionTests", + "LearnStack.Tests.Integration.Database.PublicServerRenderingTests", + "LearnStack.Tests.Integration.Database.PublicSiteHttpTests", "LearnStack.Tests.Integration.Database.SeederTests", "LearnStack.Tests.Integration.Database.TenancySchemaTests", "LearnStack.Tests.Integration.Database.TenancyWriterTests", "LearnStack.Tests.Integration.Database.TenantAssertionAuditTests", + "LearnStack.Tests.Integration.Database.TenantAuthenticationRejectionAuditTests", "LearnStack.Tests.Integration.Database.TenantContextGuardTests", "LearnStack.Tests.Integration.Database.TenantCreatedProducerTests", + "LearnStack.Tests.Integration.Database.TenantIsolationHttpTests", "LearnStack.Tests.Integration.Database.TenantLocaleDefaultTests", "LearnStack.Tests.Integration.Database.TenantLocaleEnabledMigrationTests", "LearnStack.Tests.Integration.Database.TenantProvisioningTests", "LearnStack.Tests.Integration.Database.TenantSettingsAccessorTests", + "LearnStack.Tests.Integration.Database.TrustedVisitorHttpTests", "LearnStack.Tests.Integration.Database.UnitOfWorkCommitCleanupTests", "LearnStack.Tests.Integration.Database.UnitOfWorkTests", - "LearnStack.Tests.Integration.Database.WriteStoreConflictTests" + "LearnStack.Tests.Integration.Database.WriteStoreConflictTests", + "LearnStack.Tests.Integration.ErrorShapeHttpTests", + "LearnStack.Tests.Integration.FoundationPortResolutionTests", + "LearnStack.Tests.Integration.HostClassificationHttpTests", + "LearnStack.Tests.Integration.IdempotencyHttpTests", + "LearnStack.Tests.Integration.IdempotentEndpointConventionTests", + "LearnStack.Tests.Integration.RateLimitForwardedForTests", + "LearnStack.Tests.Integration.RateLimitingHttpTests", + "LearnStack.Tests.Integration.SmokeTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmSelfServiceTests", + "LearnStack.Tests.Integration.TenantAssertionDimensionTests", + "LearnStack.Tests.Integration.TenantAssertionHttpTests", + "LearnStack.Tests.Integration.TenantWideOrganizationAssertionTests", + "LearnStack.Tests.Integration.VersionedRouteEnforcementTests" ], "fixedCosts": { "basePreTestMs": [ diff --git a/scripts/ci-test-shards.py b/scripts/ci-test-shards.py index c957dd03..835dcaad 100644 --- a/scripts/ci-test-shards.py +++ b/scripts/ci-test-shards.py @@ -293,8 +293,114 @@ def groups(owners): return True +def fixture_class_census(root=ROOT): + """Explicit class fixtures and members of fixture-bearing collections. + + Recognize this repository's top-level classes, file-scoped namespaces and + xUnit .Name collection references. Mask comments/literals; bind attributes + and interfaces to one declaration. Unsupported recognized fixture markers + and collection-attribute aliases refuse. This observes no starts/costs. + """ + ignored = re.compile(r'//[^\n]*|/\*[\s\S]*?\*/|\$*(?P"{3,})[\s\S]*?(?P=raw)' + r'|@"(?:""|[^"])*"|\$?"(?:\\[\s\S]|[^"\\])*"|\'(?:\\.|[^\'\\])*\'') + declaration = re.compile(r'(?m)^[ \t]*(?P(?:\[[^\[\]]*\][ \t\r\n]*)*)' + r'(?:(?:public|internal|private|protected|sealed|partial|abstract|static|new|file|unsafe)\s+)*' + r'\bclass\s+(?P[A-Za-z_][A-Za-z0-9_]*)(?P
[^{};]*)(?:\{|;)') + marker = re.compile(r'\b(?:IClassFixture|ICollectionFixture)\s*<' + r'|\[[^\[\]]*\bCollection(?:Definition)?(?:Attribute)?\s*\(') + attribute = r'(?:^|\[|,)\s*(?:global\s*::\s*)?(?:Xunit\s*\.\s*)?%s(?:Attribute)?\s*\(([^()]*)\)' + classes = {} + for path in (root / 'backend/tests/LearnStack.Tests.Integration').rglob('*.cs'): + if {'bin', 'obj'} & set(path.relative_to(root).parts): + continue + source = ignored.sub(lambda match: re.sub(r'[^\n]', ' ', match[0]), path.read_text()) + # Attribute aliases hide the Collection marker, including when a + # separate global-using file supplies the alias. They need explicit + # support before entering the source census. + require(not re.search(r'\busing\s+[^;=]+\s*=\s*(?:[A-Za-z_]\w*\s*::\s*)?' + r'(?:[A-Za-z_]\w*\s*\.\s*)*Collection(?:Definition)?(?:Attribute)?\s*;', source), + 'fixture_attribute_alias_unsupported') + markers = list(marker.finditer(source)) + if not markers: + continue + declared = list(declaration.finditer(source)) + require(all(any(item.start() <= found.start() < item.end() for item in declared) + for found in markers), 'fixture_declaration_unsupported') + namespaces = re.findall(r'^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;', source, re.MULTILINE) + require(len(namespaces) == 1, 'fixture_namespace_invalid') + namespace = namespaces[0] + imports = re.findall(r'^\s*using\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;', source, re.MULTILINE) + for item in declared: + if not marker.search(item[0]): + continue + # Fixture declarations are top-level. A nested or inherited form + # must gain an executing control before entering this source census. + prefix = source[:item.start()] + require(prefix.count('{') == prefix.count('}'), 'fixture_declaration_unsupported') + name = namespace + '.' + item['name'] + require(name not in classes, 'fixture_declaration_ambiguous') + header = re.fullmatch(r'\s*(?:\([^{};]*\))?\s*(?::(?P[^{};]*))?\s*', item['header']) + require(header is not None, 'fixture_declaration_unsupported') + bases = header['bases'] or '' + # All current explicit fixture arguments are simple/qualified types. + interfaces = re.findall(r'(?:^|,)\s*(?:global\s*::\s*)?(?:Xunit\s*\.\s*)?' + r'(IClassFixture|ICollectionFixture)\s*<\s*[A-Za-z_][A-Za-z0-9_.]*\s*>\s*(?=,|$)', bases) + require(len(interfaces) == len(re.findall(r'\bI(?:Class|Collection)Fixture\s*<', item['header'])), + 'fixture_declaration_unsupported') + definitions = re.findall(attribute % 'CollectionDefinition', item['attributes']) + references = re.findall(attribute % 'Collection', item['attributes']) + require(len(definitions) + len(references) == len(re.findall( + r'\bCollection(?:Definition)?(?:Attribute)?\s*\(', item['attributes'])), + 'fixture_attribute_unsupported') + require(len(definitions) <= 1 and len(references) <= 1, 'fixture_attribute_ambiguous') + classes[name] = {'namespace': namespace, 'imports': imports, + 'definition': bool(definitions), 'classFixture': 'IClassFixture' in interfaces, + 'collectionFixture': 'ICollectionFixture' in interfaces, + 'reference': re.sub(r'\s', '', references[0]) if references else None} + definitions = {name: item for name, item in classes.items() if item['definition']} + result = set() + for name, item in classes.items(): + fixture = item['classFixture'] + reference = item['reference'] + if reference is not None: + require(re.fullmatch(r'(?:global::)?[A-Za-z_][A-Za-z0-9_.]*\.Name', reference), + 'fixture_collection_reference_invalid') + relative = reference[:-len('.Name')].removeprefix('global::') + namespaces = item['namespace'].split('.') + candidates = [relative] if reference.startswith('global::') else [ + '.'.join(namespaces[:length]) + '.' + relative for length in range(len(namespaces), 0, -1)] + candidates += [relative] + [namespace + '.' + relative for namespace in item['imports']] + matches = list(dict.fromkeys(candidate for candidate in candidates if candidate in definitions)) + require(len(matches) == 1, 'fixture_collection_unresolved') + fixture = fixture or definitions[matches[0]]['collectionFixture'] + if fixture and not item['definition']: + result.add(name) + require(result, 'fixture_class_census_empty') + return sorted(result) + + +def assignment_diff(manifest, before, after): + """Compare two reviewed plans on exactly the same complete case manifest.""" + result = {'manifestDigest': manifest_digest(manifest), 'beforePlanDigest': digest(before), + 'afterPlanDigest': digest(after), 'layouts': {}} + for attempt in ('1', '2'): + first, second = (assign(manifest, plan, attempt)['owners'] for plan in (before, after)) + def owners(value): + return {case_key(case): owner for owner, cases in value.items() for case in cases} + previous, current = owners(first), owners(second) + changes = [{'assembly': case['assembly'], 'id': case['id'], 'method': case['method'], + 'before': previous[case_key(case)], 'after': current[case_key(case)]} + for case in sorted(manifest['allTests'], key=case_key) + if previous[case_key(case)] != current[case_key(case)]] + result['layouts'][variant(attempt)] = {'beforeAssignmentDigest': digest(first), + 'afterAssignmentDigest': digest(second), 'movedCases': len(changes), + 'movedMethods': len({method_key(case) for case in changes}), + 'changesDigest': digest(changes), 'changes': changes} + return result + + def main(): - if len(sys.argv) != 2 or sys.argv[1] not in ('topology', 'aggregate-backend', 'aggregate-integration'): + if len(sys.argv) != 2 or sys.argv[1] not in ('topology', 'prepare-fixtures', 'aggregate-backend', 'aggregate-integration'): print('{"protocol":1,"error":"mode_refused"}') return 1 result = None @@ -306,6 +412,8 @@ def main(): result.source_topology(load_plan()) print('{"protocol":1,"topology":"complete"}') return 0 + if sys.argv[1] == 'prepare-fixtures': + return result.prepare_fixture_images() return result.aggregate(sys.argv[1].removeprefix('aggregate-')) except Exception as failure: # Known owners expose only closed refusals. An unexpected import/parser diff --git a/scripts/compose.d.mts b/scripts/compose.d.mts index 223ed256..1cefd221 100644 --- a/scripts/compose.d.mts +++ b/scripts/compose.d.mts @@ -1,8 +1,11 @@ export const repositoryRoot: string; +export function readComposeEnvironmentFile(path: string): string; +export function composeSelectionFailure(error: unknown): string | undefined; export function composeSelection( mode: string, environment?: Partial, root?: string, + commandArgs?: readonly string[], ): { readonly mode: string; readonly project: string; diff --git a/scripts/compose.mjs b/scripts/compose.mjs index 130d8053..70381220 100644 --- a/scripts/compose.mjs +++ b/scripts/compose.mjs @@ -1,28 +1,133 @@ import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { closeSync, constants, existsSync, fstatSync, openSync, readSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { RealmError, realmFailureNotice, refuse } from './development-realm-errors.mjs'; export const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); +const selectionMessages = Object.freeze({ + mode: 'Unsupported Compose mode', + project: 'Invalid Compose project', + missing: 'Compose environment file is missing; run make public-env', + reserved: 'Reserved COMPOSE_* controls cannot appear in the selected environment', +}); +class ComposeSelectionError extends Error { + constructor(code) { + super(selectionMessages[code]); + this.code = code; + } +} + +/** Only locally classified selection failures may expose a specific notice. */ +export function composeSelectionFailure(error) { + return error instanceof ComposeSelectionError ? selectionMessages[error.code] : undefined; +} + +/** Unknown argv stays strict; these operations cannot install a client secret. */ +function lifecycleOnly(args) { + if (!Array.isArray(args) || args.some(value => typeof value !== 'string')) return false; + let index = 0; + while (args[index] === '--profile') { + if (!/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*|\*)$/.test(args[index + 1] ?? '')) return false; + index += 2; + } + const command = args[index++]; + if (command === 'config') return args.length === index + 1 && args[index] === '--services'; + const flagsByCommand = { + down: ['-v', '--volumes', '--remove-orphans'], + logs: ['-f', '--follow', '-t', '--timestamps', '--no-color', '--no-log-prefix'], + ps: ['-a', '--all', '-q', '--quiet', '--services', '--no-trunc'], + stop: [], + }; + if (!Object.hasOwn(flagsByCommand, command)) return false; + const flags = flagsByCommand[command]; + const values = { + down: { '-t': /^\d+$/, '--timeout': /^\d+$/ }, + logs: { '--tail': /^(?:all|\d+)$/, '--since': /^[^-\r\n\0][^\r\n\0]*$/, '--until': /^[^-\r\n\0][^\r\n\0]*$/ }, + ps: { '--format': /^(?:json|table)$/, '--status': /^(?:paused|restarting|removing|running|dead|created|exited)$/ }, + stop: { '-t': /^\d+$/, '--timeout': /^\d+$/ }, + }[command]; + for (; index < args.length; index++) { + const argument = args[index]; + if (flags.includes(argument)) continue; + if (/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(argument)) continue; + const separator = argument.indexOf('='); + const option = separator === -1 ? argument : argument.slice(0, separator); + if (!Object.hasOwn(values, option)) return false; + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (typeof value !== 'string' || !values[option].test(value)) return false; + } + return true; +} + +/** Qualify the selected private source before its first content read. */ +export function readComposeEnvironmentFile(path) { + const maximumBytes = 256 * 1024; + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const state = fstatSync(descriptor); + if (!state.isFile() || state.uid !== process.getuid() || state.nlink !== 1 + || (state.mode & 0o077) !== 0) + refuse('preflight', 'private_file'); + if (state.size > maximumBytes) refuse('preflight', 'configuration'); + // Read at most one byte beyond the cap, even if an owned file grows after + // fstat. O_NONBLOCK and the type check prevent a FIFO from stalling selection. + const bytes = Buffer.alloc(maximumBytes + 1); + let size = 0; + while (size < bytes.length) { + const count = readSync(descriptor, bytes, size, bytes.length - size, null); + if (count === 0) break; + size += count; + } + if (size > maximumBytes) refuse('preflight', 'configuration'); + const source = bytes.subarray(0, size).toString('utf8'); + if (Buffer.byteLength(source) > maximumBytes) refuse('preflight', 'configuration'); + return source; + } catch (error) { + if (error instanceof RealmError) throw error; + refuse('preflight', error?.code === 'ELOOP' ? 'private_file' : 'configuration'); + } + finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } + catch { refuse('cleanup', 'cleanup'); } + } + } +} + +/** Docker prerequisites only; the selected file owns service configuration. */ +export function composeProcessEnvironment(environment = process.env) { + const prerequisites = ['PATH', 'HOME', 'TMPDIR', 'APPDATA', 'USERPROFILE', + 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'DOCKER_CONFIG', 'DOCKER_CONTEXT', + 'DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH', + 'DOCKER_DEFAULT_PLATFORM', 'DOCKER_API_VERSION']; + return Object.fromEntries(prerequisites.filter(key => environment[key] !== undefined) + .map(key => [key, environment[key]])); +} + /** One explicit selection, shared by start, readiness, seeding and teardown. */ -export function composeSelection(mode, environment = process.env, root = repositoryRoot) { - if (!['dev', 'e2e'].includes(mode)) throw new Error('Unsupported Compose mode'); +export function composeSelection(mode, environment = process.env, root = repositoryRoot, commandArgs = []) { + if (!['dev', 'e2e'].includes(mode)) throw new ComposeSelectionError('mode'); const project = environment.LEARNSTACK_COMPOSE_PROJECT ?? `learnstack-${mode}`; - if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(project)) throw new Error('Invalid Compose project'); + if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(project)) throw new ComposeSelectionError('project'); const file = resolve(root, environment.LEARNSTACK_COMPOSE_ENV_FILE ?? '.env'); if (!existsSync(file)) - throw new Error('Compose environment file is missing; run make public-env'); - const source = readFileSync(file, 'utf8'); + throw new ComposeSelectionError('missing'); + const source = readComposeEnvironmentFile(file); // Docker also reads its own controls from --env-file, after this process's // environment has been sanitized. Reject every supported assignment spelling. if (/^\s*(?:export\s+)?COMPOSE_[A-Za-z0-9_]*\s*(?:[=:]|$)/im.test(source)) - throw new Error('Reserved COMPOSE_* controls cannot appear in the selected environment'); - const env = { ...environment }; - // Explicit selection cannot be widened by an inherited profile/file/project. - for (const key of Object.keys(env)) if (/^COMPOSE_/i.test(key)) delete env[key]; + throw new ComposeSelectionError('reserved'); + // Compose reads selected configuration from the private file. It needs Docker + // process prerequisites, not the caller's Keycloak, application or BFF secrets. + const env = composeProcessEnvironment(environment); // Compose's process environment otherwise silently overrides --env-file. for (const match of source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=/gm)) delete env[match[1]]; + // This process-only value never reaches a container-creating command or the + // private source. Full config output also remains strict. + if (lifecycleOnly(commandArgs)) env.KEYCLOAK_WEB_CLIENT_SECRET = 'learnstack-lifecycle-placeholder'; const files = [resolve(root, 'infra/compose/dev.yml')]; if (mode === 'e2e') files.push(resolve(root, 'infra/compose/e2e.yml')); return Object.freeze({ @@ -44,12 +149,16 @@ export function composeSelection(mode, environment = process.env, root = reposit if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { + const commandArgs = process.argv.slice(3); const selected = composeSelection( process.argv[2] === 'selected' ? (process.env.LEARNSTACK_COMPOSE_MODE ?? 'dev') : process.argv[2], + process.env, + repositoryRoot, + commandArgs, ); - const child = spawn('docker', [...selected.args, ...process.argv.slice(3)], { + const child = spawn('docker', [...selected.args, ...commandArgs], { cwd: selected.cwd, env: selected.env, stdio: 'inherit', @@ -62,8 +171,9 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur process.exitCode = code ?? 1; }); for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal)); - } catch { - console.error('Invalid Compose selection or missing private environment'); + } catch (error) { + console.error(error instanceof RealmError ? realmFailureNotice(error) + : composeSelectionFailure(error) ?? 'Invalid Compose selection or missing private environment'); process.exitCode = 1; } } diff --git a/scripts/demo-database.mjs b/scripts/demo-database.mjs index 49bca807..c5554a9f 100644 --- a/scripts/demo-database.mjs +++ b/scripts/demo-database.mjs @@ -102,7 +102,12 @@ export function validateDemoDatabaseConfiguration(values) { /** Every Seeder mode receives only its own credential and process prerequisites. */ export function publicSeederEnvironment(applicationConnectionString, base = process.env) { const environment = {}; - for (const key of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA']) + for (const key of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', + 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'NUGET_PACKAGES', 'NUGET_HTTP_CACHE_PATH', 'COREPACK_HOME', 'PNPM_HOME', 'XDG_CACHE_HOME', + 'NPM_CONFIG_CACHE', 'npm_config_cache', 'NPM_CONFIG_STORE_DIR', 'npm_config_store_dir']) if (base[key] !== undefined) environment[key] = base[key]; if (applicationConnectionString !== undefined) environment.ConnectionStrings__Default = applicationConnectionString; diff --git a/scripts/demo.mjs b/scripts/demo.mjs index fc56fab4..3cf1e187 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -14,7 +14,7 @@ import { resolve, join } from 'node:path'; import { getCACertificates } from 'node:tls'; import { setTimeout as pause } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; -import { composeSelection, repositoryRoot } from './compose.mjs'; +import { composeProcessEnvironment, composeSelection, repositoryRoot } from './compose.mjs'; import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; import { demoRequest, vacant } from './demo-http.mjs'; import { defaultServices, waitCompose } from './wait-compose.mjs'; @@ -22,6 +22,9 @@ import { publicDemoApiEnvironment, publicWebEnvironment } from './public-runtime import { apiCompletionObserver } from './demo-observation.mjs'; import { publicSeederEnvironment, validateDemoDatabaseConfiguration } from './demo-database.mjs'; import { demoSource } from './demo-source.mjs'; +import { RealmError, realmFailureNotice } from './development-realm-errors.mjs'; +import { readRealmEnvironmentFile, realmEnvironment, validateLocalDockerContext } from './development-realm-owner.mjs'; +import { reconcileDevelopmentRealm } from './reconcile-development-realm.mjs'; const appRoot = join(repositoryRoot, 'frontend/apps/web'); const backendRoot = join(repositoryRoot, 'backend'); @@ -30,6 +33,14 @@ const seederDll = join( 'src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll', ); +class DemoSetupCancellation extends Error { + constructor(error) { + const notice = error instanceof RealmError && error.preparationDispatched === true + ? '\n' + realmFailureNotice(error) : ''; + super('demo: setup cancelled' + notice); + } +} + /** Only the owned host's structured lifetime event establishes listener readiness. */ export function isApiListenerEvent(line, origin) { try { @@ -75,12 +86,13 @@ export async function disposeDemoResources({ processes, finish, teardown, remove /** Only fixed local diagnostics may leave the launcher boundary. */ export function demoFailureMessage(error) { - return error instanceof DemoCommandError || - (error instanceof Error && + if (error instanceof RealmError) return realmFailureNotice(error); + if (error instanceof DemoCommandError) return `${error.message} (${error.reason})`; + return error instanceof Error && (error.message === 'Local demo database configuration is invalid' || /^(?:Use make demo|Port \d+ is occupied|Another demo owns|TLS leaf|Seed hosts|Environment disagrees|CI requires)/.test( error.message, - ))) + )) ? error.message : 'demo: startup/proof failed; inspect prerequisites, TLS, configuration and selected stack'; } @@ -110,7 +122,9 @@ export async function startDemo(mode) { const run = (command, args, label, options = {}) => processes.run(command, args, { cwd: repositoryRoot, - env: process.env, + // Build and setup tools need process prerequisites, not private realm or + // dispatcher credentials. Compose and runtime children have exact owners. + env: publicSeederEnvironment(), label, ...options, }); @@ -211,6 +225,8 @@ export async function startDemo(mode) { return Math.min(bound, workflowDeadline - Date.now()); }); } + if (mode === 'local' && existsSync(rootEnv)) + realmEnvironment(await readRealmEnvironmentFile(rootEnv)); mkdirSync(join(repositoryRoot, '.data'), { recursive: true }); try { mkdirSync(lock, { mode: 0o700 }); @@ -231,8 +247,12 @@ export async function startDemo(mode) { ]) await run(command, args, `Required ${command}`, { cwd: command === 'dotnet' ? backendRoot : repositoryRoot, + ...(command === 'docker' ? { env: composeProcessEnvironment() } : {}), timeout: 15000, }); + const dockerEnvironment = composeProcessEnvironment(); + validateLocalDockerContext(dockerEnvironment, JSON.parse(await run('docker', ['context', 'inspect'], + 'Local Docker context', { env: dockerEnvironment, capture: true, timeout: 5000 }))); privateRoot = mkdtempSync(join(tmpdir(), 'learnstack-demo-')); // Git/hook configuration is untouched. The frontend lock and pinned SDK/tool // manifests are the same dependency inputs used by required checks. @@ -300,6 +320,7 @@ export async function startDemo(mode) { .map(([key, value]) => `${key}=${value}`) .join('\n') + '\n'; writeFileSync(rootEnv, ownedEnvironment, { flag: 'wx', mode: 0o600 }); + realmEnvironment(ownedEnvironment); await run( 'openssl', [ @@ -328,9 +349,12 @@ export async function startDemo(mode) { flag: 'wx', mode: 0o600, }); + realmEnvironment(await readRealmEnvironmentFile(rootEnv)); await run('node', ['scripts/public-local.mjs', 'prepare'], 'Private local preparation'); } - const rawValues = parseLocalEnvironment(readFileSync(rootEnv, 'utf8')); + const privateSource = await readRealmEnvironmentFile(rootEnv); + realmEnvironment(privateSource); + const rawValues = parseLocalEnvironment(privateSource); for (const [key, value] of Object.entries(rawValues)) if (process.env[key] !== undefined && process.env[key] !== value) throw new Error('Environment disagrees with private root configuration'); @@ -374,6 +398,9 @@ export async function startDemo(mode) { LEARNSTACK_COMPOSE_ENV_FILE: rootEnv, }; selected = composeSelection(mode === 'ci' ? 'e2e' : 'dev', composeEnvironment); + // Selection can remove inherited Docker controls assigned in the private file. + validateLocalDockerContext(selected.env, JSON.parse(await run('docker', ['context', 'inspect'], + 'Selected Docker context', { env: selected.env, capture: true, timeout: 5000 }))); const composeModel = JSON.parse( await compose(['config', '--format', 'json'], { capture: true, @@ -402,7 +429,10 @@ export async function startDemo(mode) { await waitCompose(selected, processes, controller.signal, true); await run('make', ['migrate'], 'All migration chains', { env: { - ...process.env, + ...publicSeederEnvironment(), + LEARNSTACK_COMPOSE_PROJECT: selected.project, + LEARNSTACK_COMPOSE_ENV_FILE: selected.file, + LEARNSTACK_COMPOSE_MODE: selected.mode, ConnectionStrings__Migration: rawValues.ConnectionStrings__Migration, }, timeout: 300000, @@ -413,6 +443,7 @@ export async function startDemo(mode) { env: publicSeederEnvironment(rawValues.ConnectionStrings__Default), timeout: 180000, }); + await reconcileDevelopmentRealm({ selected, signal: controller.signal }); if (mode === 'ci') { await compose(['stop', 'keycloak'], { timeout: 20000 }); const rows = await compose(['ps', '--status', 'running', '--format', '{{.Service}}'], { @@ -474,7 +505,8 @@ export async function startDemo(mode) { const readinessDeadline = Date.now() + 60000; while (!observation.apiReady || !observation.webReady) { processes.assertActive(); - if (Date.now() >= readinessDeadline) throw new Error('Owned API/web startup deadline'); + if (Date.now() >= readinessDeadline) + throw new DemoCommandError('Owned API/web startup', undefined, undefined, { reason: 'deadline' }); await pause(100, undefined, { signal: controller.signal }); } for (const lane of manifest) { @@ -520,8 +552,12 @@ export async function startDemo(mode) { try { await dispose(); } catch { - throw new DemoCommandError('Cleanup could not be confirmed; owned private files retained'); + if (error instanceof RealmError && error.preparationDispatched === true) + throw new RealmError('cleanup', 'cleanup', true); + throw new DemoCommandError('Cleanup could not be confirmed; owned private files retained', + undefined, undefined, { reason: 'cleanup' }); } + if (mode === 'local' && signalRequested) throw new DemoSetupCancellation(error); throw error; } } @@ -548,7 +584,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur ]); } } catch (error) { - if (runtime?.mode !== 'local' || !runtime.signalRequested()) { + if (error instanceof DemoSetupCancellation) { + console.error(error.message); + process.exitCode = 130; + } else if (runtime?.mode !== 'local' || !runtime.signalRequested()) { // Labels are fixed locally; never echo provider, configuration or build output. console.error(demoFailureMessage(error)); process.exitCode = 1; diff --git a/scripts/development-realm-account.mjs b/scripts/development-realm-account.mjs new file mode 100644 index 00000000..ac8c371a --- /dev/null +++ b/scripts/development-realm-account.mjs @@ -0,0 +1,260 @@ +import { isDeepStrictEqual as equal } from 'node:util'; +import { refuse } from './development-realm-errors.mjs'; +import { accountMapperRepresentation, accountMapperCompatibility, validateAccountMappers } from './development-realm-mappers.mjs'; +import { readStockConsoleGraph, qualifyStockConsoleGraph } from './development-realm-stock-console.mjs'; + +const encode = encodeURIComponent; +const copy = value => structuredClone(value); +const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); +const ordered = values => [...values].sort((left, right) => left.id.localeCompare(right.id)); +const allowedNames = ['manage-account', 'manage-account-links', 'view-groups']; +const mapperName = 'learnstack-account-self-service-roles'; + +function list(value, stage) { + if (!Array.isArray(value) || value.length > 200 + || value.some(item => !item || typeof item.id !== 'string' || !item.id) + || new Set(value.map(item => item.id)).size !== value.length) refuse(stage, 'unsupported'); + return value; +} + +function sameRole(actual, expected) { + return actual?.id === expected.id && actual.name === expected.name + && actual.containerId === expected.containerId && actual.clientRole === true + && actual.composite === expected.composite; +} + +async function client(admin, name, stage) { + const found = list(await admin.get(`clients?clientId=${encode(name)}&max=2`), stage); + if (found.length !== 1 || found[0].clientId !== name) refuse(stage, 'unsupported'); + const value = await admin.get(`clients/${encode(found[0].id)}`); + if (value.id !== found[0].id || value.clientId !== name || value.protocol !== 'openid-connect' + || value.enabled !== true || value.publicClient !== true || value.fullScopeAllowed !== false + || value.consentRequired === true || value.rootUrl !== '${authBaseUrl}' + || value.baseUrl !== '/realms/learnstack/account/' + || !equal(value.redirectUris, ['/realms/learnstack/account/*']) || !equal(value.webOrigins, []) + || value.attributes?.['post.logout.redirect.uris'] !== '+' + || value.bearerOnly === true || value.standardFlowEnabled !== true || value.implicitFlowEnabled !== false + || value.directAccessGrantsEnabled !== false || value.serviceAccountsEnabled !== false + || (value.clientAuthenticatorType && value.clientAuthenticatorType !== 'client-secret')) refuse(stage, 'unsupported'); + if (name === 'account-console' && value.attributes?.['pkce.code.challenge.method'] !== 'S256') refuse(stage, 'unsupported'); + return value; +} + +async function snapshot(admin, stage) { + const account = await client(admin, 'account', stage); + const console = await client(admin, 'account-console', stage); + if (account.id === console.id) refuse(stage, 'unsupported'); + const roles = new Map(); + const children = new Map(); + for (const name of allowedNames) { + const role = await admin.get(`clients/${encode(account.id)}/roles/${encode(name)}`); + if (!role?.id || role.name !== name || role.containerId !== account.id || role.clientRole !== true + || role.composite !== (name === 'manage-account')) refuse(stage, 'unsupported'); + roles.set(name, role); + children.set(name, list(await admin.get(`roles-by-id/${encode(role.id)}/composites`), stage)); + } + if (new Set([...roles.values()].map(role => role.id)).size !== roles.size + || children.get('manage-account').length !== 1 + || !sameRole(children.get('manage-account')[0], roles.get('manage-account-links')) + || children.get('manage-account-links').length || children.get('view-groups').length) refuse(stage, 'unsupported'); + const base = `clients/${encode(console.id)}`; + const defaults = list(await admin.get(`${base}/default-client-scopes`), stage); + const optional = list(await admin.get(`${base}/optional-client-scopes`), stage); + const scopes = [...defaults, ...optional]; + if (new Set(scopes.map(scope => scope.id)).size !== scopes.length) refuse(stage, 'unsupported'); + // Assignment reads contain only id/name summaries. Qualify the independently + // fetched representation before trusting its protocol or following its graph. + const scopeDefinitions = new Map(); + for (const summary of scopes) { + if (typeof summary.name !== 'string' || !summary.name) refuse(stage, 'unsupported'); + const scope = await admin.get(`client-scopes/${encode(summary.id)}`); + if (!scope || scope.id !== summary.id || scope.name !== summary.name + || scope.protocol !== 'openid-connect') refuse(stage, 'unsupported'); + scopeDefinitions.set(scope.id, scope); + } + const directMappers = list(await admin.get(`${base}/protocol-mappers/models`), stage); + const scopeMappers = new Map(); + const allowances = new Map(); + for (const path of [base, ...scopes.map(scope => `client-scopes/${encode(scope.id)}`)]) { + const mappings = await admin.get(`${path}/scope-mappings`); + if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings) + || (mappings.clientMappings !== undefined && (!mappings.clientMappings + || typeof mappings.clientMappings !== 'object' || Array.isArray(mappings.clientMappings))) + || Object.keys(mappings.clientMappings ?? {}).length > 200) refuse(stage, 'unsupported'); + list(mappings.realmMappings ?? [], stage); + for (const mapping of Object.values(mappings.clientMappings ?? {})) { + if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping) + || typeof mapping.id !== 'string' || !mapping.id || typeof mapping.client !== 'string' + || !mapping.client) refuse(stage, 'unsupported'); + list(mapping.mappings, stage); + } + allowances.set(path, mappings); + if (path !== base) scopeMappers.set(path, list(await admin.get(`${path}/protocol-mappers/models`), stage)); + } + return { account, console, roles, children, defaults, optional, directMappers, scopeDefinitions, scopeMappers, allowances }; +} + +function compatibleAllowances(graph) { + for (const mappings of graph.allowances.values()) { + if ((mappings.realmMappings ?? []).length) return false; + for (const [name, mapping] of Object.entries(mappings.clientMappings ?? {})) { + if (name !== 'account' || mapping.id !== graph.account.id || mapping.client !== 'account' + || mapping.mappings.some(role => !allowedNames.some(name => sameRole(role, graph.roles.get(name))))) return false; + } + } + // The fixed emitter needs the approved capability in the default flow. + return [`clients/${encode(graph.console.id)}`, ...graph.defaults.map(scope => `client-scopes/${encode(scope.id)}`)] + .flatMap(path => graph.allowances.get(path).clientMappings?.account?.mappings ?? []) + .some(role => sameRole(role, graph.roles.get('manage-account'))); +} + +function mapperState(graph, canonical, stage) { + const inherited = [...graph.scopeMappers.values()].flat(); + const all = accountMapperCompatibility([...graph.directMappers, ...inherited], canonical, stage); + const defaults = graph.defaults.flatMap(scope => graph.scopeMappers.get(`client-scopes/${encode(scope.id)}`)); + const defaultCarrier = accountMapperCompatibility([...graph.directMappers, ...defaults], canonical, stage); + return { all, defaultCarrier }; +} + +function remainingGraph(before, assignments) { + const scopes = [...assignments.defaults, ...assignments.optional]; + const paths = new Set(scopes.map(scope => `client-scopes/${encode(scope.id)}`)); + paths.add(`clients/${encode(before.console.id)}`); + return { ...before, defaults: assignments.defaults, optional: assignments.optional, + console: { ...before.console, + ...(Object.hasOwn(before.console, 'defaultClientScopes') ? { defaultClientScopes: assignments.defaults.map(scope => scope.name) } : {}), + ...(Object.hasOwn(before.console, 'optionalClientScopes') ? { optionalClientScopes: assignments.optional.map(scope => scope.name) } : {}) }, + scopeDefinitions: new Map([...before.scopeDefinitions].filter(([id]) => scopes.some(scope => scope.id === id))), + scopeMappers: new Map([...before.scopeMappers].filter(([path]) => paths.has(path))), + allowances: new Map([...before.allowances].filter(([path]) => paths.has(path))) }; +} + +async function userAccess(admin, userId, accountId, stage) { + const base = `users/${encode(userId)}/role-mappings/clients/${encode(accountId)}`; + return { direct: list(await admin.get(base), stage), + effective: list(await admin.get(`${base}/composite?briefRepresentation=true`), stage) }; +} + +function withGrant(mappings, account, role) { + const result = copy(mappings); + result.clientMappings ??= {}; + result.clientMappings.account ??= { id: account.id, client: 'account', mappings: [] }; + result.clientMappings.account.mappings.push(copy(role)); + return result; +} + +export function exactCreatedAccountAccess(mappings, account, role) { + const clients = mappings.clientMappings; + return clients && Object.keys(clients).length === 1 && clients.account?.id === account.id + && clients.account.client === 'account' && clients.account.mappings?.length === 1 + && sameRole(clients.account.mappings[0], role); +} + +function canonicalMappings(mappings) { + return { ...mappings, + ...(mappings.realmMappings ? { realmMappings: ordered(mappings.realmMappings) } : {}), + ...(mappings.clientMappings ? { clientMappings: Object.fromEntries(Object.entries(mappings.clientMappings) + .map(([name, mapping]) => [name, { ...mapping, mappings: ordered(mapping.mappings) }])) } : {}) }; +} + +/** Qualifies three stock assignment removals, one mapper and four missing grants. */ +export async function planAccountSelfService({ admin, desired, manifest, candidateUsers, add, preflightResponse, changedScopeIds }) { + const declaredConsole = desired.clients.find(value => value.clientId === 'account-console'); + const canonical = declaredConsole?.protocolMappers?.find(mapper => mapper.name === mapperName); + if (!canonical || canonical.protocolMapper !== 'oidc-usermodel-client-role-mapper') refuse('plan', 'unsupported'); + let before = await snapshot(admin, 'plan'); + let stock; + // Assignment normalization never repairs a direct mapper. Keep its existing + // refusal code; the complete graph may obtain its sole resolver via a scope. + if (accountMapperCompatibility(before.directMappers, canonical, 'plan', { requireResolver: false }) === undefined) + refuse('plan', 'mapper_conflict'); + const compatible = mapperState(before, canonical, 'plan'); + if (!compatibleAllowances(before) || compatible.all === undefined || compatible.defaultCarrier === undefined) { + const graph = await readStockConsoleGraph({ admin, existingAccountGraph: before }); + stock = qualifyStockConsoleGraph({ graph, canonicalAccountMapper: canonical }); + // Normalization owns assignments only: an ordinary repair must not change + // a definition used to establish this fixed stock baseline. + if ([...graph.scopes.values()].some(scope => changedScopeIds.has(scope.definition.id))) refuse('plan', 'unsupported'); + before = remainingGraph(before, stock.remainingAssignments); + for (const binding of stock.detachments) add('detach_stock_console_scope', () => admin.detachStockConsoleAssignment(binding)); + } + if (!compatibleAllowances(before)) refuse('plan', 'unsupported'); + const role = omit(before.roles.get('manage-account'), ['attributes']); + const inheritedRoles = before.children.get('manage-account').map(value => omit(value, ['attributes'])); + const inheritedMappers = [...before.scopeMappers.values()].flat(); + const hasMapper = validateAccountMappers([...before.directMappers, ...inheritedMappers], canonical); + const defaultMappers = before.defaults.flatMap(scope => before.scopeMappers.get(`client-scopes/${encode(scope.id)}`)); + // The console requests openid alone. An emitter present only in an optional + // scope cannot carry that flow, and adding another would conflict when selected. + const hasDefaultMapper = validateAccountMappers([...before.directMappers, ...defaultMappers], canonical); + if (hasMapper && !hasDefaultMapper) + refuse('plan', 'unsupported'); + if (!hasMapper && before.directMappers.some(mapper => mapper.name === mapperName)) refuse('plan', 'mapper_conflict'); + const expectedMappers = hasMapper ? before.directMappers : [...before.directMappers, copy(canonical)]; + if (expectedMappers.length > 200) refuse('plan', 'unsupported'); + preflightResponse(expectedMappers.map(mapper => ({ ...mapper, id: mapper.id ?? '0'.repeat(36) }))); + preflightResponse({ ...before.console, protocolMappers: expectedMappers.map(mapper => ({ ...mapper, id: mapper.id ?? '0'.repeat(36) })) }); + if (!hasMapper) add('create_account_mapper', async () => { + const result = await admin.post(`clients/${encode(before.console.id)}/protocol-mappers/models`, copy(canonical)); + if (result.status !== 201 && result.status !== 409) refuse('apply', 'status'); + const actual = list(await admin.get(`clients/${encode(before.console.id)}/protocol-mappers/models`), 'apply'); + const found = actual.filter(mapper => mapper.name === canonical.name); + if (found.length !== 1 || !equal(accountMapperRepresentation(omit(found[0], ['id'])), accountMapperRepresentation(canonical))) + refuse('apply', 'mapper_conflict'); + }, canonical); + const expected = new Map(); + for (const declaration of manifest.users) { + const original = candidateUsers.get(declaration.username); + if (!original) continue; + const access = await userAccess(admin, original.user.id, before.account.id, 'plan'); + const mapped = original.roles.clientMappings?.account; + if (mapped && (mapped.id !== before.account.id || mapped.client !== 'account')) refuse('plan', 'unsupported'); + if (!equal(ordered(mapped?.mappings ?? []), ordered(access.direct))) refuse('plan', 'identity_conflict'); + for (const actual of [...access.direct, ...access.effective]) + if ((actual.id === role.id || actual.name === role.name) && !sameRole(actual, role)) refuse('plan', 'unsupported'); + const missing = !access.effective.some(actual => sameRole(actual, role)); + if (missing && access.direct.some(actual => sameRole(actual, role))) refuse('plan', 'unsupported'); + if (!missing && !inheritedRoles.every(child => access.effective.some(actual => sameRole(actual, child)))) + refuse('plan', 'unsupported'); + const mappings = missing ? withGrant(original.roles, before.account, role) : copy(original.roles); + const direct = missing ? [...access.direct, role] : access.direct; + const effective = missing ? [...access.effective, role, ...inheritedRoles + .filter(child => !access.effective.some(actual => actual.id === child.id))] : access.effective; + list(direct, 'plan'); + list(effective, 'plan'); + for (const value of [mappings, direct, effective]) preflightResponse(value); + expected.set(declaration.username, { mappings, direct, effective }); + if (missing) add('grant_account_self_service', async () => { + const result = await admin.post(`users/${encode(original.user.id)}/role-mappings/clients/${encode(before.account.id)}`, [copy(role)]); + if (result.status !== 204) refuse('apply', 'status'); + }, [role]); + } + return { account: before.account, role, verify: async afterUsers => { + const check = condition => { if (!condition) refuse('readback', 'readback_conflict'); }; + const after = await snapshot(admin, 'readback'); + if (stock) await stock.verify({ admin, existingAccountGraph: after }); + check(compatibleAllowances(after)); + check(equal(omit(after.console, ['protocolMappers']), omit(before.console, ['protocolMappers']))); + for (const key of ['account', 'roles', 'children', 'defaults', 'optional', 'scopeMappers', 'allowances']) check(equal(after[key], before[key])); + check(equal(ordered(after.directMappers).map(mapper => accountMapperRepresentation(omit(mapper, ['id']))).sort(byName), + expectedMappers.map(mapper => accountMapperRepresentation(omit(mapper, ['id']))).sort(byName))); + for (const mapper of before.directMappers) check(after.directMappers.some(actual => equal(actual, mapper))); + check(validateAccountMappers([...after.directMappers, ...after.scopeMappers.values()].flat(), canonical, 'readback')); + for (const declaration of manifest.users) { + const actual = afterUsers.get(declaration.username); + check(Boolean(actual)); + const access = await userAccess(admin, actual.user.id, after.account.id, 'readback'); + check(access.effective.some(value => sameRole(value, role))); + const original = expected.get(declaration.username); + if (original) { + check(equal(canonicalMappings(actual.roles), canonicalMappings(original.mappings))); + check(equal(ordered(access.direct), ordered(original.direct))); + check(equal(ordered(access.effective), ordered(original.effective))); + } else check(exactCreatedAccountAccess(actual.roles, after.account, role) + && equal(ordered(access.direct), ordered([role])) + && equal(ordered(access.effective), ordered([role, ...inheritedRoles]))); + } + } }; +} + +function byName(left, right) { return left.name.localeCompare(right.name); } diff --git a/scripts/development-realm-core.mjs b/scripts/development-realm-core.mjs new file mode 100644 index 00000000..7b41b048 --- /dev/null +++ b/scripts/development-realm-core.mjs @@ -0,0 +1,147 @@ +import { buildRealmPlan, buildRealmPreparation } from './development-realm-plan.mjs'; +import { stockConsoleDetachmentPath } from './development-realm-stock-console.mjs'; +import { realmJson, realmRequest } from './development-realm-http.mjs'; +import { RealmError, refuse } from './development-realm-errors.mjs'; + +export const realmPassMilliseconds = 120000; + +/** Internal core for the owned launcher and isolated provider tests; no CLI overrides. */ +export async function reconcileRealm({ baseUrl, expectedIssuer, adminUsername, adminPassword, + webSecret, manifest, desired, signal, ca, transport = realmRequest }) { + let base; + try { base = new URL(baseUrl); } + catch { refuse('preflight', 'configuration'); } + if (!['localhost', '127.0.0.1', '[::1]'].includes(base.hostname) + || base.pathname !== '/' || base.search || base.hash || base.username || base.password + || !['http:', 'https:'].includes(base.protocol) + || expectedIssuer !== new URL('realms/learnstack', base).href + || !adminUsername || !adminPassword || !webSecret) + refuse('preflight', 'configuration'); + const deadline = AbortSignal.timeout(realmPassMilliseconds); + const active = signal ? AbortSignal.any([signal, deadline]) : deadline; + let mutations = 0; + let preparationDispatched = false; + let stage = 'discovery'; + const request = async (path, options = {}, dispatched) => { + if (active.aborted) refuse(options.stage, signal?.aborted ? 'cancelled' : 'deadline'); + let result; + try { + const url = new URL(path, base); + const requestOptions = { ...options, signal: active, ca }; + dispatched?.(); + result = await transport(url, requestOptions); + } catch (error) { + if (active.aborted) refuse(options.stage, signal?.aborted ? 'cancelled' : 'deadline'); + throw error; + } + if (active.aborted) refuse(options.stage, signal?.aborted ? 'cancelled' : 'deadline'); + return result; + }; + try { + const discovery = await request('realms/learnstack/.well-known/openid-configuration', { stage: 'discovery' }); + if (discovery.status !== 200 || document(discovery, 'discovery').issuer !== expectedIssuer) + refuse('discovery', 'configuration'); + stage = 'authentication'; + let token; + let refreshToken; + let expiresAt = 0; + let refreshes = 0; + const authenticate = async form => { + // Reserve the complete exchange budget before the token expires. Refresh + // is proactive and bounded; no failed Admin request is ever replayed. + const started = performance.now(); + const authentication = await request('realms/master/protocol/openid-connect/token', { + method: 'POST', stage: 'authentication', form: { client_id: 'admin-cli', ...form }, + }); + if (authentication.status !== 200) refuse('authentication', 'status'); + const value = document(authentication, 'authentication'); + if (typeof value.access_token !== 'string' || !value.access_token || /[\r\n]/.test(value.access_token) + || !Number.isSafeInteger(value.expires_in) || value.expires_in <= 5 + || typeof value.refresh_token !== 'string' || !value.refresh_token || /[\r\n]/.test(value.refresh_token)) + refuse('authentication', 'response'); + token = value.access_token; + refreshToken = value.refresh_token; + expiresAt = started + value.expires_in * 1000 - 5000; + }; + await authenticate({ grant_type: 'password', username: adminUsername, password: adminPassword }); + const authenticatedRequest = async (path, options, dispatched) => { + if (performance.now() >= expiresAt) { + if (refreshes >= 2) refuse('authentication', 'deadline'); + refreshes++; + await authenticate({ grant_type: 'refresh_token', refresh_token: refreshToken }); + } + return request(path, { ...options, bearer: token }, dispatched); + }; + stage = 'version'; + const version = await authenticatedRequest('admin/serverinfo', { stage: 'version' }); + if (version.status !== 200 || document(version, 'version').systemInfo?.version !== '26.6.2') + refuse('version', 'unsupported'); + stage = 'plan'; + const admin = { + get: async (path) => { + const result = await authenticatedRequest(adminPath(path), { stage }); + if (result.status !== 200) refuse(stage, 'status'); + return realmJson(result, stage); + }, + put: async (path, body) => { + const result = await authenticatedRequest(adminPath(path), { method: 'PUT', json: body, stage }, () => { mutations++; }); + if (result.status < 200 || result.status >= 300) refuse(stage, 'status'); + return result.status; + }, + // This is deliberately not a generic DELETE API. Only an opaque binding + // minted by the complete stock-graph qualifier can name one assignment. + detachStockConsoleAssignment: async binding => { + const path = stockConsoleDetachmentPath(binding, stage); + const result = await authenticatedRequest(adminPath(path), { method: 'DELETE', stage }, () => { mutations++; }); + if (result.status !== 204) refuse(stage, 'status'); + }, + post: async (path, body) => { + const result = await authenticatedRequest(adminPath(path), { method: 'POST', json: body, stage }, () => { mutations++; }); + if ((result.status < 200 || result.status >= 300) && result.status !== 409) refuse(stage, 'status'); + return { status: result.status, ...(path === 'partialImport' && result.status === 200 ? { body: document(result, stage) } : {}) }; + }, + }; + const preparation = await buildRealmPreparation({ admin, desired, manifest, webSecret }); + if (preparation) { + stage = 'prepare'; + const response = await authenticatedRequest(adminPath('users/profile'), { + method: 'PUT', json: preparation.profile, stage, + }, () => { preparationDispatched = true; mutations++; }); + if (response.status !== 200) refuse(stage, 'status'); + document(response, stage); + await preparation.verify(await admin.get('users/profile')); + stage = 'plan'; + } + // Preparation's projections and actions never cross this full fresh read. + const plan = await buildRealmPlan({ admin, desired, manifest, webSecret }); + stage = 'apply'; + for (const action of plan.actions) { + if (active.aborted) refuse(stage, signal?.aborted ? 'cancelled' : 'deadline'); + await action.apply(); + } + stage = 'readback'; + await plan.verify(); + const remaining = await buildRealmPlan({ admin, desired, manifest, webSecret }); + if (remaining.actions.length !== 0) refuse(stage, 'readback_conflict'); + return Object.freeze({ mutations, ...(preparationDispatched ? { preparationDispatched: true } : {}) }); + } catch (error) { + if (error instanceof RealmError) { + if (preparationDispatched && error.preparationDispatched !== true) + throw new RealmError(error.stage, error.reason, true); + throw error; + } + throw new RealmError(stage, 'response', preparationDispatched); + } +} + +function document(result, stage) { + const value = realmJson(result, stage); + if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(stage, 'response'); + return value; +} + +function adminPath(path) { + if (typeof path !== 'string' || path.startsWith('/') || /[\r\n#\\]/.test(path) + || path.split(/[/?]/).includes('..')) refuse('plan', 'configuration'); + return `admin/realms/learnstack/${path}`; +} diff --git a/scripts/development-realm-core.test.mjs b/scripts/development-realm-core.test.mjs new file mode 100644 index 00000000..6e886169 --- /dev/null +++ b/scripts/development-realm-core.test.mjs @@ -0,0 +1,852 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import http from 'node:http'; +import test from 'node:test'; +import { reconcileRealm } from './development-realm-core.mjs'; +import { RealmError, realmFailure, realmFailureNotice } from './development-realm-errors.mjs'; +import { desired, manifest, secret, provider, stockProvider, providerTransport } from './development-realm-test-fixture.mjs'; + +const privateCanary = 'synthetic-private-token-and-password-41ae'; + +async function fixture(t, { discovery, discoveryBody, authentication, version, desired = {}, manifest = {} } = {}) { + const paths = []; + const sockets = new Set(); + let base; + const server = http.createServer((request, response) => { + paths.push([request.method, request.url]); + response.setHeader('Content-Type', 'application/json'); + if (request.url === '/realms/learnstack/.well-known/openid-configuration') { + response.end(discoveryBody ?? JSON.stringify({ issuer: discovery ?? new URL('realms/learnstack', base).href })); + } else if (request.url === '/realms/master/protocol/openid-connect/token') { + request.resume(); + response.statusCode = authentication?.status ?? 200; + response.end(authentication?.body ?? JSON.stringify({ access_token: privateCanary, refresh_token: privateCanary, expires_in: 60 })); + } else if (request.url === '/admin/serverinfo') { + assert.equal(request.headers.authorization, 'Bearer ' + privateCanary); + response.statusCode = version?.status ?? 200; + response.end(version?.body ?? JSON.stringify({ systemInfo: { version: 'unsupported-fixture-version' } })); + } else { + response.statusCode = 500; + response.end('{}'); + } + }); + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + t.after(async () => { + const closed = new Promise(resolve => server.close(resolve)); + for (const socket of sockets) socket.destroy(); + await closed; + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + base = new URL(`http://127.0.0.1:${server.address().port}/`); + return { paths, invoke: () => reconcileRealm({ baseUrl: base.href, + expectedIssuer: new URL('realms/learnstack', base).href, + adminUsername: 'synthetic-admin', adminPassword: privateCanary, + webSecret: privateCanary, manifest, desired, + }) }; +} + +function refusal(stage, reason) { + return error => { + assert.ok(error instanceof RealmError); + assert.equal(realmFailure(error), `realm: ${stage}/${reason}`); + assert.ok(!error.stack.includes(privateCanary)); + return true; + }; +} + +const memoryInput = transport => ({ baseUrl: 'http://localhost:8080/', + expectedIssuer: 'http://localhost:8080/realms/learnstack', adminUsername: 'synthetic-admin', + adminPassword: privateCanary, webSecret: secret, desired: structuredClone(desired), + manifest: structuredClone(manifest), transport }); + +test('the actual core applies one mutation, verifies full state, and makes an explicit next pass read only', async () => { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 1 }); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(state.writes.length, 1); + assert.equal(recording.requests.filter(request => request.path === '/admin/realms/learnstack/' && !request.method).length, 6); +}); + +for (const stage of ['apply', 'readback']) { + test(`an unexpected ${stage} fault preserves its closed stage and a committed mutation resumes from fresh state`, async () => { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + let failed = false; + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (!failed && options.stage === stage) { failed = true; throw new Error(privateCanary); } + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal(stage, 'response')); + assert.equal(state.state.realm.accessTokenLifespan, 900); + assert.equal(state.writes.length, 1); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(state.writes.length, 1, 'the uncertain completed write is never automatically replayed'); + }); +} + +test('invalid endpoint syntax is a closed preflight failure before any transport call', async () => { + let calls = 0; + const input = memoryInput(async () => { calls++; }); + input.baseUrl = 'http://['; + await assert.rejects(reconcileRealm(input), refusal('preflight', 'configuration')); + assert.equal(calls, 0); +}); + +test('valid but forbidden administrator destinations refuse before even a discovery transport call', async () => { + for (const baseUrl of [ + 'http://provider.fixture.invalid/', 'http://127.0.0.2/', 'http://localhost.fixture.invalid/', + 'http://[::ffff:127.0.0.1]/', 'ftp://127.0.0.1/', 'http://127.0.0.1/proxy/', + 'http://127.0.0.1/?target=foreign', 'http://127.0.0.1/#fragment', + 'http://synthetic-user@127.0.0.1/', 'http://:synthetic-password@127.0.0.1/', + ]) { + let calls = 0; + const input = memoryInput(async () => { calls++; throw new Error('forbidden destination reached'); }); + input.baseUrl = baseUrl; + input.expectedIssuer = new URL('realms/learnstack', baseUrl).href; + await assert.rejects(reconcileRealm(input), refusal('preflight', 'configuration')); + assert.equal(calls, 0); + } +}); + +test('issuer identity is exact before credential use while every supported loopback host remains usable', async () => { + for (const expectedIssuer of [ + 'http://localhost:8080/realms/learnstack/', 'http://127.0.0.1:8080/realms/learnstack', + 'http://localhost:8081/realms/learnstack', 'https://localhost:8080/realms/learnstack', + 'http://localhost:8080/realms/learnstack-hub', 'http://localhost:8080/realms/learnstack?extra=true', + ]) { + let calls = 0; + const input = memoryInput(async () => { calls++; }); + input.expectedIssuer = expectedIssuer; + await assert.rejects(reconcileRealm(input), refusal('preflight', 'configuration')); + assert.equal(calls, 0); + } + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + const recording = providerTransport(provider()); + const input = memoryInput(recording.transport); + input.baseUrl = `http://${host}:8080/`; + input.expectedIssuer = new URL('realms/learnstack', input.baseUrl).href; + assert.deepEqual(await reconcileRealm(input), { mutations: 0 }); + assert.ok(recording.requests.some(request => request.path === '/realms/master/protocol/openid-connect/token')); + } +}); + +test('password and bearer custody use only the selected fixed master and tenant Admin destinations', async () => { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + const observed = []; + const input = memoryInput(async (url, options) => { + observed.push({ url: new URL(url), options: structuredClone({ ...options, signal: undefined }) }); + return recording.transport(url, options); + }); + assert.deepEqual(await reconcileRealm(input), { mutations: 1 }); + const passwords = observed.filter(({ options }) => options.form?.grant_type === 'password'); + assert.equal(passwords.length, 1); + for (const { url, options } of observed) { + assert.equal(url.origin, new URL(input.baseUrl).origin); + assert.equal(url.username + url.password + url.hash, ''); + assert.ok(!url.href.includes(privateCanary)); + if (options.form) { + assert.equal(url.pathname, '/realms/master/protocol/openid-connect/token'); + assert.equal(url.search, ''); + assert.equal(options.method, 'POST'); + assert.deepEqual(options.form, { client_id: 'admin-cli', grant_type: 'password', + username: input.adminUsername, password: input.adminPassword }); + assert.equal(options.bearer, undefined); + } else if (options.bearer) { + assert.ok(url.pathname === '/admin/serverinfo' || url.pathname.startsWith('/admin/realms/learnstack/')); + assert.ok(!JSON.stringify(options.json ?? {}).includes(privateCanary)); + } else { + assert.equal(url.pathname, '/realms/learnstack/.well-known/openid-configuration'); + assert.equal(options.json, undefined); + } + } +}); + +test('Admin read status and malformed successful JSON refuse before a pending mutation', async () => { + for (const [status, bytes, reason] of [ + [204, '{}', 'status'], [403, '{}', 'status'], [500, '{}', 'status'], + [200, '{', 'response'], [200, '', 'response'], [200, 'null', 'response'], [200, '[]', 'response'], + ]) { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + let refusals = 0; + const transport = async (url, options) => { + if (url.pathname === '/admin/realms/learnstack/') { + refusals++; + return { status, bytes: Buffer.from(bytes) }; + } + return recording.transport(url, options); + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('plan', reason)); + assert.equal(refusals, 1); + assert.deepEqual(state.writes, []); + assert.equal(state.state.realm.accessTokenLifespan, 1800); + } +}); + +test('failed mutation statuses and malformed import receipts remain failures even after the provider committed', async () => { + for (const [kind, status, bytes, reason] of [ + ['put', 403, '{}', 'status'], ['put', 409, '{}', 'status'], ['put', 500, '{}', 'status'], + ['post', 500, '{}', 'status'], ['post', 201, '{}', 'status'], + ['post', 200, '{', 'response'], ['post', 200, '[]', 'response'], + ]) { + const state = provider(); + if (kind === 'put') state.state.realm.accessTokenLifespan = 1800; + else state.state.users.pop(); + const recording = providerTransport(state); + let injected = 0; + const transport = async (url, options) => { + const result = await recording.transport(url, options); + if (options.stage === 'apply' && options.method === (kind === 'put' ? 'PUT' : 'POST')) { + injected++; + return { status, bytes: Buffer.from(bytes) }; + } + return result; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('apply', reason)); + assert.equal(injected, 1); + assert.equal(state.writes.length, 1, 'a failed mutation response is never replayed'); + assert.equal(recording.requests.some(request => request.stage === 'readback'), false); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(state.writes.length, 1, 'a later explicit run qualifies durable state instead of repeating the write'); + } +}); + +test('successful status cannot replace independent readback when the provider silently loses a mutation', async () => { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + let writes = 0; + const transport = async (url, options) => { + if (options.method === 'PUT') { writes++; return { status: 204, bytes: Buffer.alloc(0) }; } + return recording.transport(url, options); + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('readback', 'readback_conflict')); + assert.equal(writes, 1); + assert.equal(state.state.realm.accessTokenLifespan, 1800); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 1 }); +}); + +for (const stage of ['discovery', 'authentication', 'version']) { + test(`a valid JSON null ${stage} document is rejected at that same stage`, async t => { + const f = await fixture(t, stage === 'discovery' ? { discoveryBody: 'null' } + : stage === 'authentication' ? { authentication: { body: 'null' } } : { version: { body: 'null' } }); + await assert.rejects(f.invoke(), refusal(stage, 'response')); + }); +} + +test('short-lived master credentials proactively refresh before Admin reads without replaying an exchange', async t => { + const state = provider(); + const recording = providerTransport(state); + let now = 0; + let validUntil = 60000; + t.mock.method(performance, 'now', () => now); + const transport = async (url, options) => { + if (url.pathname.startsWith('/admin/') && now >= validUntil) + return { status: 401, bytes: Buffer.from('{}') }; + const result = await recording.transport(url, options); + if (options.form?.grant_type === 'refresh_token') validUntil = now + 60000; + if (url.pathname === '/admin/serverinfo') now = 61000; + return result; + }; + assert.deepEqual(await reconcileRealm(memoryInput(transport)), { mutations: 0 }); + assert.deepEqual(recording.requests.filter(request => request.path.endsWith('/token')) + .map(request => request.form.grant_type), ['password', 'refresh_token']); + assert.equal(recording.requests.filter(request => request.path === '/admin/serverinfo').length, 1); + assert.equal(state.writes.length, 0); +}); + +test('an Admin 401 is never replayed and refresh failure never reaches subsequent Admin work', async t => { + t.mock.method(performance, 'now', () => now); + let now = 0; + for (const failure of ['admin', 'refresh']) { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + let failures = 0; + now = 0; + const transport = async (url, options) => { + if ((failure === 'admin' && url.pathname === '/admin/realms/learnstack/') + || (failure === 'refresh' && options.form?.grant_type === 'refresh_token')) { + failures++; + return { status: 401, bytes: Buffer.from('{}') }; + } + const result = await recording.transport(url, options); + if (failure === 'refresh' && url.pathname === '/admin/serverinfo') now = 56000; + return result; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal(failure === 'admin' ? 'plan' : 'authentication', 'status')); + assert.equal(failures, 1); + assert.equal(state.writes.length, 0); + assert.equal(recording.requests.filter(request => request.path.endsWith('/token')).length, 1); + } +}); + +test('refresh is bounded to two rotations within the same pass and cannot form a token-request loop', async t => { + const state = provider(); + const recording = providerTransport(state); + let now = 0; + t.mock.method(performance, 'now', () => now); + const transport = async (url, options) => { + const result = await recording.transport(url, options); + if (url.pathname.startsWith('/admin/')) now += 56000; + return result; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('authentication', 'deadline')); + assert.equal(recording.requests.filter(request => request.path.endsWith('/token')).length, 3); + assert.equal(state.writes.length, 0); +}); + +test('the 120-second whole-pass deadline closes work across individually successful exchanges and a new pass resumes', async t => { + const state = provider(); state.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(state); + const deadline = new AbortController(); + const allocations = []; + t.mock.method(AbortSignal, 'timeout', milliseconds => { allocations.push(milliseconds); return deadline.signal; }); + let observed = false; + const transport = async (url, options) => { + const result = await recording.transport(url, options); + if (options.stage === 'apply') { observed = true; deadline.abort(); } + return result; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('apply', 'deadline')); + assert.deepEqual(allocations, [120000], 'a larger whole-pass deadline must fail this proof'); + assert.equal(observed, true); + assert.equal(state.writes.length, 1); + assert.equal(recording.requests.some(request => request.stage === 'readback'), false); + t.mock.restoreAll(); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(state.writes.length, 1); +}); + +test('a 999-user census still refuses at the whole-pass deadline under cumulative provider latency', async t => { + const state = provider(); + while (state.state.users.length < 999) { + const index = state.state.users.length; + state.state.users.push({ id: `retained-${index}`, username: `retained-${index}`, attributes: {} }); + } + const recording = providerTransport(state); + const deadline = new AbortController(); + let now = 0; + let budget; + t.mock.method(performance, 'now', () => now); + t.mock.method(AbortSignal, 'timeout', milliseconds => { budget = milliseconds; return deadline.signal; }); + const transport = async (url, options) => { + const result = await recording.transport(url, options); + // Each successful 40ms exchange is independently below the 5s HTTP cap. + now += 40; + if (now >= budget) deadline.abort(); + return result; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('readback', 'deadline')); + assert.equal(budget, 120000); + assert.equal(now, 120000); + assert.equal(recording.requests.length, 3000); + assert.ok(state.reads.filter(path => path.includes('/groups?')).length > 999); + assert.deepEqual(state.writes, []); +}); + +for (const reason of ['cancelled', 'deadline']) { + for (const outcome of ['reject', 'resolve']) { + test(`${reason} remains correctly classified when the transport ${outcome}s after abort`, async t => { + const parent = new AbortController(); + const deadline = new AbortController(); + t.mock.method(AbortSignal, 'timeout', () => deadline.signal); + let calls = 0; + await assert.rejects(reconcileRealm({ + baseUrl: 'http://localhost:8080/', expectedIssuer: 'http://localhost:8080/realms/learnstack', + adminUsername: 'synthetic-admin', adminPassword: privateCanary, webSecret: privateCanary, + desired: {}, manifest: {}, signal: parent.signal, + transport: async (_url, { signal, stage }) => { + calls++; + if (reason === 'cancelled') parent.abort(); + deadline.abort(); + assert.equal(signal.aborted, true); + if (outcome === 'reject') throw new RealmError(stage, 'cancelled'); + return { status: 200, bytes: Buffer.from('{}') }; + }, + }), refusal('discovery', reason)); + assert.equal(calls, 1); + }); + } +} + +test('a transport refusal without an active abort is propagated unchanged', async () => { + const failure = new RealmError('discovery', 'transport'); + await assert.rejects(reconcileRealm({ + baseUrl: 'http://localhost:8080/', expectedIssuer: 'http://localhost:8080/realms/learnstack', + adminUsername: 'synthetic-admin', adminPassword: privateCanary, webSecret: privateCanary, + desired: {}, manifest: {}, transport: async () => { throw failure; }, + }), error => error === failure); +}); + +test('discovery disagreement refuses before exposing administrator credentials', async t => { + const f = await fixture(t, { discovery: 'https://foreign.fixture.invalid/realms/learnstack' }); + await assert.rejects(f.invoke(), refusal('discovery', 'configuration')); + assert.deepEqual(f.paths, [['GET', '/realms/learnstack/.well-known/openid-configuration']]); +}); + +for (const [name, authentication, reason] of [ + ['rejected credentials', { status: 401, body: privateCanary }, 'status'], + ['malformed credential response', { body: '{' + privateCanary }, 'response'], + ['missing access token', { body: '{}' }, 'response'], + ['nonstring access token', { body: '{"access_token":123}' }, 'response'], +]) { + test(`${name} fails once without version, planning or mutation`, async t => { + const f = await fixture(t, { authentication }); + await assert.rejects(f.invoke(), refusal('authentication', reason)); + assert.deepEqual(f.paths.map(value => value[0]), ['GET', 'POST']); + assert.equal(f.paths.length, 2); + }); +} + +for (const [name, version, reason] of [ + ['wrong server version', undefined, 'unsupported'], + ['missing version document', { body: '{}' }, 'unsupported'], + ['malformed full version document', { body: '{"systemInfo":{"version":"26.6.2"},' }, 'response'], + ['rejected version read', { status: 403, body: privateCanary }, 'unsupported'], + ['version response above accepted cap', { body: ' '.repeat(512 * 1024 + 1) }, 'body_limit'], + ['large malformed version document', { body: '{"systemInfo":{"version":"26.6.2"},' + ' '.repeat(300 * 1024) }, 'response'], + ['large wrong version document', { body: JSON.stringify({ systemInfo: { version: 'wrong' }, padding: ' '.repeat(300 * 1024) }) }, 'unsupported'], +]) { + test(`${name} refuses before any realm-state read or mutation`, async t => { + const f = await fixture(t, { version }); + await assert.rejects(f.invoke(), refusal('version', reason)); + assert.deepEqual(f.paths, [ + ['GET', '/realms/learnstack/.well-known/openid-configuration'], + ['POST', '/realms/master/protocol/openid-connect/token'], + ['GET', '/admin/serverinfo'], + ]); + }); +} + +test('an exact 512 KiB version document reaches planning only after the complete valid read', async t => { + const base = JSON.stringify({ systemInfo: { version: '26.6.2' }, padding: '' }); + const body = JSON.stringify({ systemInfo: { version: '26.6.2' }, padding: ' '.repeat(512 * 1024 - Buffer.byteLength(base)) }); + assert.equal(Buffer.byteLength(body), 512 * 1024); + const desired = JSON.parse(await readFile(new URL('../infra/keycloak/realms/learnstack.json', import.meta.url), 'utf8')); + const manifest = { users: desired.users.map(user => ({ username: user.username, email: user.email, + firstName: user.firstName, lastName: user.lastName, + realmRole: user.realmRoles[0], + userId: user.attributes.user_id[0], tenantId: user.attributes.tenant_id[0], + organizationId: user.attributes.organization_id?.[0] ?? null, + accountCapability: { clientId: 'account', role: 'manage-account' } })) }; + const f = await fixture(t, { version: { body }, desired, manifest }); + // This deliberately has no realm state: reaching the first read witnesses + // accepted version admission without inventing a successful reconciliation. + await assert.rejects(f.invoke(), refusal('plan', 'status')); + assert.equal(f.paths.length, 4); + assert.deepEqual(f.paths[2], ['GET', '/admin/serverinfo']); + assert.deepEqual(f.paths[3], ['GET', '/admin/realms/learnstack/users/profile']); +}); + + +test('the actual core removes only three qualified console assignments and converges without a marker', async () => { + const fixture = await stockProvider(); + const before = structuredClone(fixture.state); + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 4 }); + const removed = fixture.writes.filter(write => write.method === 'DELETE'); + assert.deepEqual(removed.map(write => write.path).sort(), [ + `${fixture.stock.base}/default-client-scopes/${fixture.stock.capture[fixture.stock.scopePath('roles')].id}`, + ...['microprofile-jwt', 'offline_access'].map(name => + `${fixture.stock.base}/optional-client-scopes/${fixture.stock.capture[fixture.stock.scopePath(name)].id}`), + ].sort()); + assert.equal(fixture.writes.filter(write => write.method === 'POST').length, 1); + assert.ok(fixture.writes.filter(write => write.method === 'POST') + .every(write => write.path === `${fixture.stock.base}/protocol-mappers/models`)); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const expectedConsole = before.clients.find(client => client.clientId === 'account-console'); + expectedConsole.defaultClientScopes = expectedConsole.defaultClientScopes.filter(name => name !== 'roles'); + expectedConsole.optionalClientScopes = expectedConsole.optionalClientScopes.filter(name => !['microprofile-jwt', 'offline_access'].includes(name)); + expectedConsole.protocolMappers.push(structuredClone(console.protocolMappers.at(-1))); + assert.deepEqual(fixture.state, before, 'global definitions, other assignments, users and grants are preserved'); + assert.equal(console.defaultClientScopes.length, 5); + assert.equal(console.optionalClientScopes.length, 3); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(fixture.writes.length, 4); +}); + +test('partial assignment deletion commits are never replayed and explicit reruns requalify the remaining edges', async () => { + for (const outcome of [200, 201, 202, 409, 500, 'lost_ack', 'cancelled']) { + for (const ordinal of [1, 2, 3]) { + const fixture = await stockProvider(); + const recording = providerTransport(fixture); + const stop = new AbortController(); + let seen = 0; + let failedPath; + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (options.method === 'DELETE' && ++seen === ordinal) { + failedPath = url.pathname; + if (outcome === 'lost_ack') throw new Error(privateCanary); + if (outcome === 'cancelled') { stop.abort(); return response; } + return { status: outcome, bytes: Buffer.alloc(0) }; + } + return response; + }; + const input = { ...memoryInput(transport), signal: stop.signal }; + const reason = outcome === 'lost_ack' ? 'response' : outcome === 'cancelled' ? 'cancelled' : 'status'; + await assert.rejects(reconcileRealm(input), refusal('apply', reason)); + assert.equal(fixture.writes.length, ordinal, 'the owner stops at the exact uncertain durable prefix'); + assert.equal(recording.requests.some(request => request.stage === 'readback'), false); + const firstFreshRead = fixture.reads.length; + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 4 - ordinal }); + assert.equal(recording.requests.filter(request => request.method === 'DELETE' + && request.path === failedPath).length, 1, 'the durable deletion is never replayed'); + assert.equal(recording.requests.filter(request => request.method === 'DELETE').length, 3); + assert.equal(new Set(recording.requests.filter(request => request.method === 'DELETE').map(request => request.path)).size, 3); + assert.equal(fixture.reads.slice(firstFreshRead).some(path => path.startsWith('roles-by-id/') + && !path.endsWith('/composites')), ordinal < 3, + 'only a remaining incompatible edge enters stock qualification; the final edge hands off to compatible mode'); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + assert.equal(console.defaultClientScopes.length, 5); + assert.equal(console.optionalClientScopes.length, 3); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(fixture.writes.length, 4); + } + } +}); + +test('every incompatible subset hands its last deletion to compatible preservation on the next pass', async () => { + for (let mask = 1; mask < 8; mask++) { + const fixture = await stockProvider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + if (!(mask & 1)) console.defaultClientScopes = console.defaultClientScopes.filter(name => name !== 'roles'); + console.optionalClientScopes = console.optionalClientScopes.filter(name => + (name !== 'microprofile-jwt' || mask & 2) && (name !== 'offline_access' || mask & 4)); + const remaining = [1, 2, 4].filter(bit => mask & bit).length; + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: remaining + 1 }); + assert.equal(fixture.writes.filter(write => write.method === 'DELETE').length, remaining); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(console.defaultClientScopes.length, 5); + assert.equal(console.optionalClientScopes.length, 3); + } +}); + +test('a compatible customized graph wins before the stock-name exception and keeps every assignment', async () => { + const fixture = await stockProvider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + console.optionalClientScopes = console.optionalClientScopes.filter(name => !['microprofile-jwt', 'offline_access'].includes(name)); + const roles = fixture.state.scopes.find(scope => scope.name === 'roles'); + roles.description = 'retained compatible customization'; + roles.protocolMappers = roles.protocolMappers.filter(mapper => mapper.protocolMapper === 'oidc-audience-resolve-mapper'); + console.protocolMappers = []; + const before = structuredClone(roles); + const defaults = structuredClone(console.defaultClientScopes); + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 1 }); + assert.equal(fixture.writes.some(write => write.method === 'DELETE'), false); + assert.deepEqual(roles, before); + assert.deepEqual(console.defaultClientScopes, defaults); + assert.equal(fixture.reads.some(path => path.startsWith('roles-by-id/') && !path.endsWith('/composites')), false, + 'the extra stock qualifier does not run for the compatible customized graph'); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); +}); + +test('stock graph transport or response failures never authorize any pending mutation', async () => { + for (const kind of ['transport', 'status', 'json']) { + const fixture = await stockProvider(); + fixture.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(fixture); + let reached = false; + const transport = async (url, options) => { + if (/\/roles-by-id\/[^/]+$/.test(url.pathname)) { + reached = true; + if (kind === 'transport') throw new Error(privateCanary); + return { status: kind === 'status' ? 503 : 200, bytes: Buffer.from(kind === 'json' ? '{' : '{}') }; + } + return recording.transport(url, options); + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('plan', kind === 'status' ? 'status' : 'response')); + assert.equal(reached, true); + assert.deepEqual(fixture.writes, []); + } +}); + +test('stock normalization refuses a pending ordinary repair of its unchanged global baseline', async () => { + const fixture = await stockProvider(); + const recording = providerTransport(fixture); + const input = memoryInput(recording.transport); + const basic = input.desired.clientScopes.find(scope => scope.name === 'basic'); + basic.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-sub-mapper').config['introspection.token.claim'] = 'false'; + await assert.rejects(reconcileRealm(input), refusal('plan', 'unsupported')); + assert.ok(fixture.reads.some(path => path.startsWith('roles-by-id/') && !path.endsWith('/composites')), + 'the independently qualified stock path is reached before rejecting the conflicting projected delta'); + assert.deepEqual(fixture.writes, []); +}); + +function preparationProvider() { + const fixture = provider(); + fixture.state.profile.attributes = fixture.state.profile.attributes + .filter(attribute => !['user_id', 'tenant_id', 'organization_id'].includes(attribute.name)); + delete fixture.state.profile.unmanagedAttributePolicy; + return fixture; +} + +const preparationNotice = 'realm: profile metadata may already be durable; inspect current realm state before an explicit rerun; see infra/keycloak/README.md'; + +function preparationFailure(stage, reason) { + return error => { + refusal(stage, reason)(error); + assert.equal(error.preparationDispatched, true); + assert.equal(realmFailureNotice(error), `realm: ${stage}/${reason}\n${preparationNotice}`); + return true; + }; +} + +test('hidden identity preparation is one fixed write, independently verified before full fresh planning and convergent on rerun', async () => { + const fixture = preparationProvider(); + const before = structuredClone(fixture.state); + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 1, preparationDispatched: true }); + const preparation = recording.requests.findIndex(request => request.stage === 'prepare' && request.method === 'PUT'); + assert.ok(preparation > 0); + assert.deepEqual(recording.requests.filter(request => request.method && !request.form) + .map(request => [request.stage, request.method, request.path]), + [['prepare', 'PUT', '/admin/realms/learnstack/users/profile']]); + assert.deepEqual(recording.requests.slice(preparation + 1, preparation + 3) + .map(request => [request.stage, request.path]), [ + ['prepare', '/admin/realms/learnstack/users/profile'], + ['plan', '/admin/realms/learnstack/'], + ]); + assert.deepEqual(fixture.state.profile.attributes.slice(0, before.profile.attributes.length), before.profile.attributes); + assert.equal(Object.hasOwn(fixture.state.profile, 'unmanagedAttributePolicy'), false); + before.profile = structuredClone(fixture.state.profile); + assert.deepEqual(fixture.state, before, 'metadata alone never changes existing user IDs, credentials or attributes'); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(fixture.writes.length, 1); +}); + +test('already readable unmanaged identities use the ordinary profile path without preparation progress', async () => { + for (const unmanagedAttributePolicy of ['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW']) { + const fixture = preparationProvider(); + fixture.state.profile.unmanagedAttributePolicy = unmanagedAttributePolicy; + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 1 }); + assert.equal(recording.requests.some(request => request.stage === 'prepare'), false); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + } +}); + +test('preparation and the independently authorized ordinary profile normalization remain two counted writes', async () => { + const fixture = preparationProvider(); + const attribute = structuredClone(provider().state.profile.attributes.find(attribute => attribute.name === 'user_id')); + attribute.permissions = { view: ['admin'], edit: [] }; + attribute.multivalued = true; + fixture.state.profile.attributes.push(attribute); + const recording = providerTransport(fixture); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 2, preparationDispatched: true }); + const writes = recording.requests.filter(request => request.method === 'PUT'); + assert.deepEqual(writes.map(request => [request.stage, request.path]), [ + ['prepare', '/admin/realms/learnstack/users/profile'], ['apply', '/admin/realms/learnstack/users/profile'], + ]); + assert.deepEqual(writes[0].json.attributes.find(value => value.name === 'user_id'), attribute); + assert.deepEqual(writes[1].json.attributes.find(value => value.name === 'user_id').permissions, + { view: ['admin'], edit: ['admin'] }); + const prepare = recording.requests.indexOf(writes[0]); + const apply = recording.requests.indexOf(writes[1]); + assert.ok(recording.requests.slice(prepare + 1, apply).some(request => request.stage === 'plan' + && request.path.startsWith('/admin/realms/learnstack/users?')), + 'the ordinary profile action comes only from a complete fresh snapshot'); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); +}); + +for (const [status, bytes, reason, description] of [ + [204, '', 'status', 'HTTP 204'], [201, '{}', 'status', 'HTTP 201'], + [403, '{}', 'status', 'HTTP 403'], [500, privateCanary, 'status', 'HTTP 500'], + [200, '{', 'response', 'malformed JSON'], [200, '', 'response', 'empty JSON'], + [200, 'null', 'response', 'null JSON'], [200, '[]', 'response', 'array JSON'], +]) { + test(`preparation ${description} refusal stops after its dispatched metadata call`, async () => { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + return options.stage === 'prepare' && options.method === 'PUT' + ? { status, bytes: Buffer.from(bytes) } : response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('prepare', reason)); + assert.equal(fixture.writes.length, 1); + assert.equal(recording.requests.filter(request => request.stage === 'prepare').length, 1); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + }); +} + +test('separate preparation readback rejects any full-profile field or ordered-array drift before ordinary work', async () => { + for (const change of [ + profile => { profile.attributes.reverse(); }, + profile => { profile.groups = [{ name: 'unexpected-group' }]; }, + profile => { profile.unmanagedAttributePolicy = 'ADMIN_VIEW'; }, + profile => { profile.attributes.at(-1).annotations = { unexpected: privateCanary }; }, + ]) { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (options.stage === 'prepare' && !options.method) { + const profile = JSON.parse(response.bytes); + change(profile); + return { status: 200, bytes: Buffer.from(JSON.stringify(profile)) }; + } + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('prepare', 'readback_conflict')); + assert.equal(fixture.writes.length, 1); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); + } +}); + +test('a preparation acknowledgement loss or cancellation retains durable progress without replay or ordinary writes', async () => { + for (const outcome of ['lost_ack', 'cancelled']) { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const parent = new AbortController(); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (options.stage === 'prepare' && options.method === 'PUT') { + if (outcome === 'lost_ack') throw new Error(privateCanary); + parent.abort(new Error(privateCanary)); + } + return response; + }; + const input = { ...memoryInput(transport), signal: parent.signal }; + await assert.rejects(reconcileRealm(input), preparationFailure('prepare', outcome === 'lost_ack' ? 'response' : 'cancelled')); + assert.equal(fixture.writes.length, 1); + assert.equal(recording.requests.filter(request => request.stage === 'prepare').length, 1); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + } +}); + +test('whole-pass timeout after preparation dispatch preserves closed deadline progress', async t => { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const deadline = new AbortController(); + t.mock.method(AbortSignal, 'timeout', () => deadline.signal); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (options.stage === 'prepare' && options.method === 'PUT') deadline.abort(new Error(privateCanary)); + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('prepare', 'deadline')); + assert.equal(fixture.writes.length, 1); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); +}); + +test('newly exposed actor conflict and identity-independent drift leave only preparation durable before a fresh-plan refusal', async () => { + for (const kind of ['hidden_actor', 'realm_drift']) { + const fixture = preparationProvider(); + if (kind === 'hidden_actor') fixture.state.users[0].attributes.user_id = ['01910000-0000-7000-8000-00000000ffff']; + fixture.state.realm.accessTokenLifespan = 1800; + const before = structuredClone(fixture.state); + const recording = providerTransport(fixture); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (kind === 'realm_drift' && options.stage === 'prepare' && options.method === 'PUT') + fixture.state.realm.enabled = false; + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('plan', kind === 'hidden_actor' ? 'identity_conflict' : 'unsupported')); + assert.deepEqual(fixture.state.users, before.users); + assert.equal(fixture.state.realm.accessTokenLifespan, 1800, 'the earlier qualified ordinary repair was discarded'); + assert.equal(fixture.writes.length, 1); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); + } +}); + +test('preparation progress begins only after proactive credential refresh and pre-dispatch cancellation admission', async t => { + const probe = providerTransport(preparationProvider()); + await reconcileRealm(memoryInput(probe.transport)); + const handoff = probe.requests.findIndex(request => request.stage === 'prepare' && request.method === 'PUT'); + assert.ok(handoff > 0); + let now = 0; + t.mock.method(performance, 'now', () => now); + for (const outcome of ['refresh_failure', 'cancelled']) { + now = 0; + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const stop = new AbortController(); + let calls = 0; + let refreshes = 0; + const transport = async (url, options) => { + calls++; + if (options.form?.grant_type === 'refresh_token') { + refreshes++; + return { status: 401, bytes: Buffer.from('{}') }; + } + const response = await recording.transport(url, options); + if (calls === handoff) { + if (outcome === 'refresh_failure') now = 56000; + else stop.abort(new Error(privateCanary)); + } + return response; + }; + await assert.rejects(reconcileRealm({ ...memoryInput(transport), signal: stop.signal }), error => { + refusal(outcome === 'refresh_failure' ? 'authentication' : 'plan', outcome === 'refresh_failure' ? 'status' : 'cancelled')(error); + assert.equal(error.preparationDispatched, undefined); + assert.equal(realmFailureNotice(error).includes(preparationNotice), false); + return true; + }); + assert.equal(refreshes, outcome === 'refresh_failure' ? 1 : 0); + assert.equal(fixture.writes.length, 0); + assert.equal(recording.requests.some(request => request.stage === 'prepare'), false); + } +}); + +test('preparation readback HTTP or JSON faults retain metadata progress and prevent fresh planning', async () => { + for (const [status, bytes, reason] of [[403, '{}', 'status'], [500, privateCanary, 'status'], [200, '{', 'response']]) { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + return options.stage === 'prepare' && !options.method ? { status, bytes: Buffer.from(bytes) } : response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('prepare', reason)); + const preparation = recording.requests.findIndex(request => request.stage === 'prepare'); + assert.equal(recording.requests.slice(preparation + 1).some(request => request.stage === 'plan'), false); + assert.equal(fixture.writes.length, 1); + } +}); + +test('uncertain preparation transport handoff never reports an undispatched pass even without a receipt or stored mutation', async () => { + const fixture = preparationProvider(); + const recording = providerTransport(fixture); + let dispatches = 0; + const transport = async (url, options) => { + if (options.stage === 'prepare' && options.method === 'PUT') { + dispatches++; + throw new RealmError('prepare', 'transport'); + } + return recording.transport(url, options); + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure('prepare', 'transport')); + assert.equal(dispatches, 1); + assert.equal(fixture.writes.length, 0); + assert.equal(recording.requests.some(request => request.stage === 'apply'), false); +}); + +for (const stage of ['apply', 'readback']) { + test(`a later ${stage} failure preserves earlier dispatched preparation and explicit reruns use current state`, async () => { + const fixture = preparationProvider(); + fixture.state.realm.accessTokenLifespan = 1800; + const recording = providerTransport(fixture); + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (options.stage === stage) throw new Error(privateCanary); + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), preparationFailure(stage, 'response')); + assert.equal(fixture.writes.length, 2); + assert.equal(fixture.state.realm.accessTokenLifespan, 900); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); + assert.equal(fixture.writes.length, 2); + }); +} diff --git a/scripts/development-realm-errors.mjs b/scripts/development-realm-errors.mjs new file mode 100644 index 00000000..73bcdc84 --- /dev/null +++ b/scripts/development-realm-errors.mjs @@ -0,0 +1,47 @@ +const stages = new Set([ + 'preflight', 'discovery', 'authentication', 'version', 'manifest', 'lock', + 'plan', 'prepare', 'apply', 'readback', 'cleanup', +]); +const reasons = new Set([ + 'configuration', 'identity_conflict', 'mapper_conflict', 'secret_conflict', + 'unsupported', 'response', 'status', 'redirect', 'deadline', 'cancelled', + 'transport', 'lock_busy', 'body_limit', 'readback_conflict', 'cleanup', + 'missing_web_secret', 'private_file', +]); + +/** Neither upstream objects nor arbitrary caller text can become a diagnostic. */ +export class RealmError extends Error { + constructor(stage, reason, preparationDispatched = false) { + const safeStage = stages.has(stage) ? stage : 'preflight'; + const safeReason = reasons.has(reason) ? reason : 'configuration'; + super(`realm: ${safeStage}/${safeReason}`); + this.stage = safeStage; + this.reason = safeReason; + if (preparationDispatched === true) this.preparationDispatched = true; + } +} + +export function refuse(stage, reason) { + throw new RealmError(stage, reason); +} + +export function realmFailure(error) { + return error instanceof RealmError ? error.message : 'realm: preflight/configuration'; +} + +/** The three development entry points share the same closed setup instruction. */ +export function realmFailureNotice(error) { + let message = realmFailure(error); + if (!(error instanceof RealmError)) return message; + if (error.reason === 'private_file') + message += '\nrealm: selected private environment must be a regular, single-link file owned by the current user with owner-only permissions; use chmod 600 on that file; see infra/keycloak/README.md'; + else if (error.reason === 'missing_web_secret') + message += '\nrealm: add KEYCLOAK_WEB_CLIENT_SECRET explicitly to the selected private file; see infra/keycloak/README.md'; + else if (error.reason === 'secret_conflict') + message += '\nrealm: retain the confidential client secret in the selected private file; see infra/keycloak/README.md#secret-continuity-and-recovery'; + else if (error.reason === 'lock_busy') + message += '\nrealm: verify the previous owner has stopped before removing its private lock; see infra/keycloak/README.md#lock-recovery'; + if (error.preparationDispatched === true) + message += '\nrealm: profile metadata may already be durable; inspect current realm state before an explicit rerun; see infra/keycloak/README.md'; + return message; +} diff --git a/scripts/development-realm-groups.mjs b/scripts/development-realm-groups.mjs new file mode 100644 index 00000000..35ca0525 --- /dev/null +++ b/scripts/development-realm-groups.mjs @@ -0,0 +1,60 @@ +import { refuse } from './development-realm-errors.mjs'; +import { identityClaims } from './development-realm-mappers.mjs'; + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const groupLimit = 200; + +function boundedGroups(value, stage) { + if (!Array.isArray(value) || value.length > groupLimit) refuse(stage, 'unsupported'); + const ids = new Set(); + for (const group of value) { + if (!object(group) || typeof group.id !== 'string' || !group.id || ids.has(group.id)) refuse(stage, 'unsupported'); + ids.add(group.id); + } + return value; +} + +export async function readUserGroupIds(admin, userId, stage) { + // Intentionally omit search/first/max to use the mapper's provider stream. + // Pinned JPA pagination hides organization groups; the cached adapter itself + // filters them from both mapper and REST reads. Any nonordinary IDs exposed + // by the provider remain unqualified. Bytes, time and array size stay bounded. + const assigned = boundedGroups(await admin.get(`users/${encodeURIComponent(userId)}/groups?briefRepresentation=true`), stage); + return new Set(assigned.map(group => group.id)); +} + +/** Qualify every possible inherited identity source without modifying groups. */ +export async function readRealmGroups(admin, users, stage) { + const count = await admin.get('groups/count'); + if (!object(count) || !Number.isSafeInteger(count.count) || count.count < 0 || count.count > groupLimit) + refuse(stage, 'unsupported'); + // An encoded space survives REST's empty-string-to-null conversion, then + // GroupsResource trims it to an empty contains-search over all descendants. + // Disabling hierarchy avoids hidden recursive expansion. + const values = boundedGroups(await admin.get('groups?search=%20&exact=false&first=0&max=201&briefRepresentation=false&populateHierarchy=false&subGroupsCount=false'), stage); + if (values.length !== count.count) refuse(stage, 'unsupported'); + const groups = new Map(values.map(group => [group.id, group])); + for (const group of groups.values()) { + if (typeof group.name !== 'string' || !group.name + || (group.parentId != null && (typeof group.parentId !== 'string' || !group.parentId))) refuse(stage, 'unsupported'); + const attributes = group.attributes ?? {}; + if (!object(attributes)) refuse(stage, 'unsupported'); + for (const name of identityClaims) + if (Object.hasOwn(attributes, name) && (!Array.isArray(attributes[name]) || attributes[name].length !== 0)) + refuse(stage, 'identity_conflict'); + const ancestors = new Set([group.id]); + let parentId = group.parentId; + while (parentId != null) { + if (!groups.has(parentId) || ancestors.has(parentId)) refuse(stage, 'unsupported'); + ancestors.add(parentId); + parentId = groups.get(parentId).parentId; + } + } + const memberships = new Map(); + for (const user of users) { + const assigned = await readUserGroupIds(admin, user.id, stage); + for (const groupId of assigned) if (!groups.has(groupId)) refuse(stage, 'unsupported'); + memberships.set(user.id, assigned); + } + return { groups, memberships }; +} diff --git a/scripts/development-realm-http.mjs b/scripts/development-realm-http.mjs new file mode 100644 index 00000000..fac47445 --- /dev/null +++ b/scripts/development-realm-http.mjs @@ -0,0 +1,105 @@ +import http from 'node:http'; +import https from 'node:https'; +import { RealmError, refuse } from './development-realm-errors.mjs'; + +export const realmHttpMilliseconds = 5000; +export const realmBodyBytes = 256 * 1024; +const serverInformationBodyBytes = 512 * 1024; + +/** Each exchange owns its request, response and agent until actual close. */ +export function realmRequest(url, { method = 'GET', json, form, bearer, signal, ca, stage = 'plan' } = {}) { + if (!(url instanceof URL) || !['http:', 'https:'].includes(url.protocol) + || url.username || url.password || url.hash || (json !== undefined && form !== undefined)) + refuse(stage, 'configuration'); + if (signal?.aborted) refuse(stage, 'cancelled'); + const body = json !== undefined ? Buffer.from(JSON.stringify(json)) + : form !== undefined ? Buffer.from(new URLSearchParams(form).toString()) : undefined; + if (body && body.length > realmBodyBytes) refuse(stage, 'body_limit'); + const secure = url.protocol === 'https:'; + const agent = secure ? new https.Agent({ keepAlive: false, ca, rejectUnauthorized: true }) + : new http.Agent({ keepAlive: false }); + return new Promise((resolve, reject) => { + let request; + let response; + let failure; + let ended = false; + let requestClosed = false; + let responseClosed = false; + let size = 0; + const chunks = []; + let timer; + const finish = () => { + if (!requestClosed || (response && !responseClosed)) return; + clearTimeout(timer); + signal?.removeEventListener('abort', cancelled); + agent.destroy(); + if (failure) reject(failure); + else if (!ended) reject(new RealmError(stage, 'transport')); + else if (size > 0 && !/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/i.test(response.headers['content-type'] ?? '')) + reject(new RealmError(stage, 'response')); + else resolve({ status: response.statusCode, bytes: Buffer.concat(chunks) }); + }; + const fail = (reason) => { + failure ??= new RealmError(stage, reason); + response?.destroy(); + request?.destroy(); + }; + const cancelled = () => fail('cancelled'); + try { + request = (secure ? https : http).request(url, { + method, agent, + // Retain the issuer/Host and normal TLS hostname checks without allowing + // a workstation DNS/hosts override to send private setup credentials away. + ...(url.hostname === 'localhost' ? { family: 4, lookup: (_host, options, callback) => { + if (options.all) callback(null, [{ address: '127.0.0.1', family: 4 }]); + else callback(null, '127.0.0.1', 4); + } } : {}), + headers: { + Accept: 'application/json', 'Accept-Encoding': 'identity', + ...(body ? { 'Content-Type': form ? 'application/x-www-form-urlencoded' : 'application/json', + 'Content-Length': String(body.length) } : {}), + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + }, + }); + request.once('response', (value) => { + response = value; + value.once('close', () => { responseClosed = true; finish(); }); + value.once('error', () => fail('transport')); + value.once('aborted', () => fail('transport')); + if (value.statusCode >= 300 && value.statusCode < 400) return fail('redirect'); + if (value.headers['content-encoding'] && value.headers['content-encoding'] !== 'identity') + return fail('response'); + // The accepted exception belongs to this exact authenticated successful + // read, never a caller-supplied limit or a general Admin REST allowance. + const responseBytes = method === 'GET' && url.pathname === '/admin/serverinfo' + && !url.search && json === undefined && form === undefined + && typeof bearer === 'string' && bearer.length > 0 && !/\s/.test(bearer) + && value.statusCode === 200 ? serverInformationBodyBytes : realmBodyBytes; + value.on('data', (chunk) => { + size += chunk.length; + if (size > responseBytes) fail('body_limit'); + else chunks.push(chunk); + }); + value.once('end', () => { ended = true; }); + }); + request.once('error', () => fail('transport')); + request.once('close', () => { requestClosed = true; finish(); }); + timer = setTimeout(() => fail('deadline'), realmHttpMilliseconds); + signal?.addEventListener('abort', cancelled, { once: true }); + if (signal?.aborted) cancelled(); + request.end(body); + } catch { + failure = new RealmError(stage, 'transport'); + if (request) request.destroy(); + else { + requestClosed = true; + finish(); + } + } + }); +} + +export function realmJson(result, stage) { + try { return JSON.parse(result.bytes.toString('utf8')); } + catch { refuse(stage, 'response'); } +} diff --git a/scripts/development-realm-http.test.mjs b/scripts/development-realm-http.test.mjs new file mode 100644 index 00000000..71d4a065 --- /dev/null +++ b/scripts/development-realm-http.test.mjs @@ -0,0 +1,437 @@ +import assert from 'node:assert/strict'; +import dns from 'node:dns'; +import { once } from 'node:events'; +import http from 'node:http'; +import test from 'node:test'; +import { gzipSync } from 'node:zlib'; +import { RealmError, realmFailure } from './development-realm-errors.mjs'; +import { + realmBodyBytes, + realmHttpMilliseconds, + realmJson, + realmRequest, +} from './development-realm-http.mjs'; + +const canary = 'synthetic-private-canary-7fa6'; + +async function endpoint(t, handler) { + const sockets = new Set(); + const socketClosures = []; + const requests = []; + const server = http.createServer((request, response) => { + requests.push(request); + response.setHeader('Content-Type', 'application/json'); + handler(request, response); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socketClosures.push(new Promise(resolve => socket.once('close', () => { + sockets.delete(socket); + resolve(); + }))); + }); + t.after(async () => { + const closed = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + for (const socket of sockets) socket.destroy(); + await closed; + await Promise.all(socketClosures); + assert.equal(sockets.size, 0); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { + url: new URL(`http://127.0.0.1:${server.address().port}/`), + requests, + sockets, + }; +} + +function refusal(stage, reason) { + return (error) => { + assert.ok(error instanceof RealmError); + assert.equal(error.stage, stage); + assert.equal(error.reason, reason); + assert.equal(realmFailure(error), `realm: ${stage}/${reason}`); + assert.ok(!JSON.stringify(error).includes(canary)); + assert.ok(!error.message.includes(canary)); + assert.ok(!error.stack.includes(canary)); + return true; + }; +} + +function observeExchanges(t, onResponse) { + const original = http.request; + const exchanges = []; + http.request = (...args) => { + const request = original(...args); + const exchange = { request }; + const destroyAgent = request.agent.destroy.bind(request.agent); + request.agent.destroy = () => { + exchange.agentDestroyed = true; + destroyAgent(); + }; + exchanges.push(exchange); + request.once('socket', (socket) => { exchange.socket = socket; }); + request.once('response', (response) => { + exchange.response = response; + onResponse?.(); + }); + return request; + }; + t.after(() => { http.request = original; }); + return exchanges; +} + +function assertClosed(exchanges) { + assert.equal(exchanges.length, 1, 'a refused exchange must never retry'); + const { request, response, socket } = exchanges[0]; + assert.equal(exchanges[0].agentDestroyed, true); + assert.equal(request.closed, true); + assert.equal(request.destroyed, true); + assert.equal(socket.destroyed, true); + if (response) { + assert.equal(response.closed, true); + assert.equal(response.destroyed, true); + } + assert.equal(Object.values(request.agent.sockets).flat().length, 0); + assert.equal(Object.values(request.agent.freeSockets).flat().length, 0); +} + +async function remoteClosure(promise) { + let timer; + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('remote socket must close after local cancellation')), 1000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +test('successful JSON and form requests use the actual bounded identity transport', async (t) => { + const bodies = []; + const f = await endpoint(t, (request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.once('end', () => { + bodies.push(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Encoding': 'identity' }); + response.end('{"ok":true}'); + }); + }); + for (const options of [ + { json: { label: 'owned' } }, + { form: { username: 'synthetic-admin', password: canary } }, + ]) { + const result = await realmRequest(f.url, { method: 'POST', bearer: canary, ...options }); + assert.equal(result.status, 200); + assert.deepEqual(realmJson(result, 'plan'), { ok: true }); + } + assert.deepEqual(bodies, [ + '{"label":"owned"}', + `username=synthetic-admin&password=${canary}`, + ]); + assert.equal(f.requests.length, 2); + for (const request of f.requests) { + assert.equal(request.method, 'POST'); + assert.equal(request.headers['accept-encoding'], 'identity'); + assert.equal(request.headers.authorization, `Bearer ${canary}`); + assert.ok(!request.url.includes(canary)); + } + assert.equal(f.requests[0].headers['content-type'], 'application/json'); + assert.equal(f.requests[1].headers['content-type'], 'application/x-www-form-urlencoded'); +}); + +for (const contentType of ['text/html', 'text/plain', 'application/octet-stream', 'application/jsonx']) { + test(`a successful non-JSON ${contentType} response is refused after actual exchange cleanup`, async t => { + const exchanges = observeExchanges(t); + const f = await endpoint(t, (_request, response) => { + response.setHeader('Content-Type', contentType); + response.end('{}'); + }); + await assert.rejects(realmRequest(f.url), refusal('plan', 'response')); + assertClosed(exchanges); + assert.equal(f.requests.length, 1); + }); +} + +test('localhost connects only to pinned IPv4 loopback without system DNS and retains its Host', async (t) => { + let host; + const f = await endpoint(t, (request, response) => { + host = request.headers.host; + response.end('{"issuer":"localhost"}'); + }); + const url = new URL(f.url); + url.hostname = 'localhost'; + const originalLookup = dns.lookup; + const originalRequest = http.request; + let systemLookups = 0; + let requestUrl; + const lookups = []; + let peer; + dns.lookup = (...args) => { + systemLookups++; + args.at(-1)(new Error('system DNS must not select the issuer address')); + }; + http.request = (target, options) => { + requestUrl = target.href; + const suppliedLookup = options.lookup; + const observed = typeof suppliedLookup === 'function' ? { + ...options, + lookup: (hostname, lookupOptions, callback) => suppliedLookup(hostname, lookupOptions, (...result) => { + lookups.push({ hostname, result }); + callback(...result); + }), + } : options; + const request = originalRequest(target, observed); + request.once('socket', (socket) => { + socket.once('connect', () => { + peer = { address: socket.remoteAddress, family: socket.remoteFamily, port: socket.remotePort }; + }); + }); + return request; + }; + try { + const result = await realmRequest(url, { bearer: canary }); + assert.deepEqual(realmJson(result, 'plan'), { issuer: 'localhost' }); + assert.equal(result.status, 200); + assert.equal(systemLookups, 0); + assert.equal(requestUrl, url.href); + assert.equal(host, `localhost:${f.url.port}`); + assert.deepEqual(peer, { address: '127.0.0.1', family: 'IPv4', port: Number(f.url.port) }); + assert.equal(lookups.length, 1); + assert.equal(lookups[0].hostname, 'localhost'); + const [error, address, family] = lookups[0].result; + assert.equal(error, null); + if (Array.isArray(address)) assert.deepEqual(address, [{ address: '127.0.0.1', family: 4 }]); + else { + assert.equal(address, '127.0.0.1'); + assert.equal(family, 4); + } + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0].headers.authorization, `Bearer ${canary}`); + } finally { + dns.lookup = originalLookup; + http.request = originalRequest; + } +}); + +for (const stage of ['discovery', 'authentication', 'plan', 'apply']) { + test(`${stage} refuses redirects without following or forwarding credentials`, async (t) => { + const destination = await endpoint(t, (_request, response) => response.end('{}')); + const f = await endpoint(t, (_request, response) => { + response.writeHead(302, { Location: new URL(canary, destination.url).href }); + response.end(canary); + }); + await assert.rejects( + realmRequest(f.url, { stage, method: 'POST', form: { password: canary }, bearer: canary }), + refusal(stage, 'redirect'), + ); + assert.equal(f.requests.length, 1); + assert.equal(destination.requests.length, 0); + assert.equal(destination.sockets.size, 0); + }); +} + +test('a response at 256 KiB succeeds and one byte above the bound is refused', async (t) => { + assert.equal(realmBodyBytes, 256 * 1024); + const exact = Buffer.from(`"${'x'.repeat(256 * 1024 - 2)}"`); + const f = await endpoint(t, (request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(request.url === '/exact' ? exact : Buffer.concat([exact, Buffer.from(' ')])); + }); + const result = await realmRequest(new URL('exact', f.url)); + assert.equal(result.bytes.length, 256 * 1024); + assert.equal(realmJson(result, 'plan').length, 256 * 1024 - 2); + await assert.rejects(realmRequest(new URL('over', f.url)), refusal('plan', 'body_limit')); + assert.equal(f.requests.length, 2); +}); + +test('chunked wire bytes cannot evade the response bound without Content-Length', async (t) => { + const f = await endpoint(t, (_request, response) => { + response.writeHead(200); + response.write(Buffer.alloc(128 * 1024, 32)); + response.write(Buffer.alloc(128 * 1024, 32)); + response.end(canary); + }); + await assert.rejects(realmRequest(f.url), refusal('plan', 'body_limit')); + assert.equal(f.requests.length, 1); +}); + +test('only the fixed authenticated successful server-information read accepts exactly 512 KiB', async (t) => { + const exact = Buffer.from(JSON.stringify({ systemInfo: { version: '26.6.2' }, padding: '' })); + const body = Buffer.concat([exact, Buffer.alloc(512 * 1024 - exact.length, 32)]); + const f = await endpoint(t, (_request, response) => response.end(body)); + const result = await realmRequest(new URL('admin/serverinfo', f.url), { bearer: canary, stage: 'version' }); + assert.equal(result.bytes.length, 512 * 1024); + assert.equal(realmJson(result, 'version').systemInfo.version, '26.6.2'); + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0].headers.authorization, `Bearer ${canary}`); +}); + +test('server information refuses 512 KiB plus one byte and closes the actual exchange', async (t) => { + const exchanges = observeExchanges(t); + const f = await endpoint(t, (_request, response) => { + response.writeHead(200); + response.write(Buffer.alloc(256 * 1024, 32)); + response.end(Buffer.alloc(256 * 1024 + 1, 32)); + }); + await assert.rejects(realmRequest(new URL('admin/serverinfo', f.url), { bearer: canary, stage: 'version' }), + refusal('version', 'body_limit')); + assertClosed(exchanges); +}); + +for (const [name, path, options, status] of [ + ['ordinary path', 'admin/realms/learnstack', { bearer: canary }, 200], + ['full client scope', 'admin/realms/learnstack/client-scopes/retained-scope', { bearer: canary }, 200], + ['path suffix', 'admin/serverinfo/', { bearer: canary }, 200], + ['encoded path', 'admin/%73erverinfo', { bearer: canary }, 200], + ['query', 'admin/serverinfo?fields=systemInfo.version', { bearer: canary }, 200], + ['missing bearer', 'admin/serverinfo', {}, 200], + ['empty bearer', 'admin/serverinfo', { bearer: '' }, 200], + ['POST', 'admin/serverinfo', { method: 'POST', bearer: canary }, 200], + ['GET with JSON body', 'admin/serverinfo', { json: {}, bearer: canary }, 200], + ['GET with empty form', 'admin/serverinfo', { form: {}, bearer: canary }, 200], + ['unauthorized', 'admin/serverinfo', { bearer: canary }, 401], + ['forbidden', 'admin/serverinfo', { bearer: canary }, 403], + ['not found', 'admin/serverinfo', { bearer: canary }, 404], + ['partial content', 'admin/serverinfo', { bearer: canary }, 206], + ['upstream failure', 'admin/serverinfo', { bearer: canary }, 500], +]) { + test(`the ${name} case retains 256 KiB despite a version stage or caller-supplied larger limit`, async (t) => { + const f = await endpoint(t, (request, response) => { + request.resume(); + response.writeHead(status); + response.end(Buffer.alloc(256 * 1024 + 1, 32)); + }); + await assert.rejects(realmRequest(new URL(path, f.url), { + stage: 'version', responseBytes: 1024 * 1024, ...options, + }), refusal('version', 'body_limit')); + assert.equal(f.requests.length, 1); + }); +} + +test('the server-information exception cannot widen any outbound body', async (t) => { + const f = await endpoint(t, (_request, response) => response.end('{}')); + for (const payload of [ + { json: { value: 'x'.repeat(256 * 1024) } }, + { form: { value: 'x'.repeat(256 * 1024) } }, + ]) assert.throws(() => realmRequest(new URL('admin/serverinfo', f.url), { + bearer: canary, stage: 'version', ...payload, + }), refusal('version', 'body_limit')); + assert.equal(f.requests.length, 0); +}); + +for (const encoding of ['gzip', 'br', 'deflate', 'gzip, identity']) { + test(`${encoding} is refused so compression cannot widen the decoded response bound`, async (t) => { + const bomb = gzipSync(Buffer.alloc(256 * 1024 + 1, 32)); + assert.ok(bomb.length < 256 * 1024); + const f = await endpoint(t, (_request, response) => { + response.writeHead(200, { 'Content-Encoding': encoding }); + response.end(bomb); + }); + await assert.rejects(realmRequest(f.url), refusal('plan', 'response')); + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0].headers['accept-encoding'], 'identity'); + }); +} + +test('malformed upstream JSON becomes only the closed stage and reason', async (t) => { + const f = await endpoint(t, (_request, response) => response.end(`{"token":"${canary}"`)); + const result = await realmRequest(f.url, { stage: 'authentication' }); + assert.throws(() => realmJson(result, 'authentication'), refusal('authentication', 'response')); + assert.equal(f.requests.length, 1); +}); + +test('oversized outbound JSON and forms are refused before connecting', async (t) => { + const f = await endpoint(t, (_request, response) => response.end('{}')); + for (const options of [ + { json: { value: 'x'.repeat(256 * 1024) } }, + { form: { password: 'x'.repeat(256 * 1024) } }, + ]) assert.throws(() => realmRequest(f.url, options), refusal('plan', 'body_limit')); + assert.equal(f.requests.length, 0); + assert.equal(f.sockets.size, 0); +}); + +test('pre-aborted calls and invalid credential-bearing URLs never connect', async (t) => { + const f = await endpoint(t, (_request, response) => response.end('{}')); + const credentials = new URL(f.url); + credentials.username = canary; + credentials.password = canary; + assert.throws(() => realmRequest(credentials), refusal('plan', 'configuration')); + assert.throws( + () => realmRequest(f.url, { signal: AbortSignal.abort(new Error(canary)) }), + refusal('plan', 'cancelled'), + ); + assert.equal(f.requests.length, 0); + assert.equal(f.sockets.size, 0); +}); + +test('an abort closes owned HTTP resources before settling and the server observes closure', async (t) => { + const controller = new AbortController(); + const exchanges = observeExchanges(t, () => queueMicrotask(() => controller.abort(new Error(canary)))); + let closed; + const f = await endpoint(t, (request, response) => { + closed = new Promise((resolve) => request.socket.once('close', resolve)); + response.writeHead(200); + response.write('{'); + }); + await assert.rejects(realmRequest(f.url, { signal: controller.signal }), refusal('plan', 'cancelled')); + assert.throws(() => realmRequest(f.url, { signal: controller.signal }), refusal('plan', 'cancelled')); + assert.ok(exchanges[0].response, 'the abort must exercise an active response body'); + assertClosed(exchanges); + await remoteClosure(closed); + assert.equal(f.requests.length, 1); + assert.equal(f.sockets.size, 0); +}); + +for (const streamBody of [false, true]) { + test(`the absolute five-second deadline closes a ${streamBody ? 'trickling' : 'silent'} response`, async (t) => { + const exchanges = observeExchanges(t); + assert.equal(realmHttpMilliseconds, 5000); + let closed; + let writes = 0; + const f = await endpoint(t, (request, response) => { + closed = new Promise((resolve) => request.socket.once('close', resolve)); + if (streamBody) { + response.writeHead(200); + response.write('{'); + const interval = setInterval(() => { writes++; response.write(' '); }, 50); + request.socket.once('close', () => clearInterval(interval)); + t.after(() => clearInterval(interval)); + } + }); + const started = performance.now(); + await assert.rejects(realmRequest(f.url), refusal('plan', 'deadline')); + const elapsed = performance.now() - started; + assert.ok(elapsed >= 4800 && elapsed < 7500, 'deadline must be absolute and bounded'); + assertClosed(exchanges); + await remoteClosure(closed); + assert.equal(f.requests.length, 1); + assert.equal(f.sockets.size, 0); + if (streamBody) assert.ok(writes >= 5, 'a progressing response must not reset the deadline'); + }); +} + +test('a severed connection produces a closed transport failure with no retry', async (t) => { + const f = await endpoint(t, (request) => request.socket.destroy(new Error(canary))); + await assert.rejects(realmRequest(f.url, { bearer: canary }), refusal('plan', 'transport')); + assert.equal(f.requests.length, 1); +}); + +test('diagnostics refuse arbitrary upstream exceptions, credentials, claims and URLs', () => { + for (const value of [ + new Error(`http://user:${canary}@example.invalid/token?access_token=${canary}`), + { message: canary, body: canary, access_token: canary }, + canary, + null, + ]) assert.equal(realmFailure(value), 'realm: preflight/configuration'); + const error = new RealmError(canary, canary); + assert.ok(refusal('preflight', 'configuration')(error)); + assert.equal(realmFailure(new RealmError('cleanup', 'cleanup')), 'realm: cleanup/cleanup'); +}); diff --git a/scripts/development-realm-mappers.mjs b/scripts/development-realm-mappers.mjs new file mode 100644 index 00000000..16836d48 --- /dev/null +++ b/scripts/development-realm-mappers.mjs @@ -0,0 +1,132 @@ +import { refuse } from './development-realm-errors.mjs'; + +export const identityClaims = Object.freeze(['user_id', 'tenant_id', 'organization_id']); +const reservedClaims = new Set(['iss', 'sub', 'aud', 'azp', 'typ', 'scope', 'iat', 'exp', 'nbf']); +const knownTypes = new Set([ + 'oidc-acr-mapper', 'oidc-address-mapper', 'oidc-allowed-origins-mapper', + 'oidc-audience-mapper', 'oidc-audience-resolve-mapper', 'oidc-full-name-mapper', + 'oidc-organization-membership-mapper', 'oidc-sub-mapper', 'oidc-usermodel-attribute-mapper', + 'oidc-usermodel-client-role-mapper', 'oidc-usermodel-property-mapper', + 'oidc-usermodel-realm-role-mapper', 'oidc-usersessionmodel-note-mapper', +]); + +// Match pinned 26.6.2 JsonUtils.splitClaimPath: a backslash escapes any +// character; an unescaped dot ends a nonempty component. A leading dot is literal. +export function claimRoot(claim) { + if (typeof claim !== 'string') return ''; + const first = /^((\\.|[^\\.])+?)\./.exec(claim); + return (first ? first[1] : claim).replace(/\\(.)/g, '$1'); +} + +export function validateEffectiveMappers(mappers, stage = 'plan') { + const fail = () => refuse(stage, 'mapper_conflict'); + for (const mapper of mappers) { + if (!mapper || mapper.protocol !== 'openid-connect' || !knownTypes.has(mapper.protocolMapper)) fail(); + const config = mapper.config ?? {}; + const root = claimRoot(config['claim.name']); + if (reservedClaims.has(root)) fail(); + if (mapper.protocolMapper === 'oidc-usermodel-client-role-mapper' && root.includes('${client_id}')) fail(); + if (identityClaims.includes(root)) { + if (mapper.protocolMapper !== 'oidc-usermodel-attribute-mapper' + || (config['aggregate.attrs'] !== undefined && config['aggregate.attrs'] !== 'false') + || config['claim.name'] !== root || config['user.attribute'] !== root + || config['jsonType.label'] !== 'String' || config.multivalued !== 'false' + || config['access.token.claim'] !== 'true' || config['id.token.claim'] !== 'true' + || config['userinfo.token.claim'] !== 'false') fail(); + } + } + for (const claim of identityClaims) + if (mappers.filter(mapper => claimRoot(mapper.config?.['claim.name']) === claim).length !== 1) fail(); + for (const type of ['oidc-sub-mapper', 'oidc-audience-mapper', 'oidc-audience-resolve-mapper']) + if (mappers.filter(mapper => mapper.protocolMapper === type).length !== 1) fail(); + const subject = mappers.find(mapper => mapper.protocolMapper === 'oidc-sub-mapper'); + if (subject.config?.['access.token.claim'] !== 'true') fail(); + const audience = mappers.find(mapper => mapper.protocolMapper === 'oidc-audience-mapper'); + if (audience.config?.['included.client.audience'] !== 'learnstack-api' + || audience.config?.['access.token.claim'] !== 'true' + || audience.config?.['id.token.claim'] !== 'false') fail(); + const resolver = mappers.find(mapper => mapper.protocolMapper === 'oidc-audience-resolve-mapper'); + if (resolver.config?.['access.token.claim'] !== 'true' || resolver.config?.['id.token.claim'] === 'true') fail(); +} + +// The owned names identify repairable mappings; an unexpected implementation is +// an administrator customization, rather than permission to replace its code. +export function mergeOwnedMappers(current, desired, stage = 'plan') { + const names = new Set(); + for (const mapper of current) { + if (!mapper || typeof mapper.name !== 'string' || names.has(mapper.name) + || typeof mapper.id !== 'string' || !mapper.id) refuse(stage, 'mapper_conflict'); + names.add(mapper.name); + } + return [ + ...current.filter(mapper => !desired.some(value => value.name === mapper.name)), + ...desired.map(value => { + const existing = current.find(mapper => mapper.name === value.name); + if (!existing) return structuredClone(value); + if (existing.protocolMapper !== value.protocolMapper || existing.protocol !== value.protocol) + refuse(stage, 'mapper_conflict'); + // Never let a desired config overlay hide an administrator's unsafe + // aggregation setting before effective mapper validation runs. + if (identityClaims.includes(claimRoot(value.config?.['claim.name'])) + && existing.config?.['aggregate.attrs'] !== undefined && existing.config['aggregate.attrs'] !== 'false') + refuse(stage, 'mapper_conflict'); + return { ...existing, ...value, id: existing.id, config: { ...existing.config, ...value.config } }; + }), + ]; +} + +// Pinned RepresentationToModel removes empty config strings on import/create +// and effective-model reads. This one absent optional setting means no prefix +// in AbstractUserRoleMappingMapper; every other setting remains significant. +export function accountMapperRepresentation(mapper) { + const prefix = 'usermodel.clientRoleMapping.rolePrefix'; + if (mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper' + || mapper.config?.['usermodel.clientRoleMapping.clientId'] !== 'account' + || mapper.config?.['claim.name'] !== 'resource_access.account.roles' + || mapper.config?.[prefix] !== '') return mapper; + return { ...mapper, config: Object.fromEntries(Object.entries(mapper.config).filter(([key]) => key !== prefix)) }; +} + +// The shared console may emit account roles, but no realm/all-client role +// carrier or implicit role/audience transformer is qualified by a claim name. +export function accountMapperCompatibility(mappers, canonical, stage = 'plan', { requireResolver = true } = {}) { + const fail = () => refuse(stage, 'mapper_conflict'); + const configBoundary = { + 'usermodel.clientRoleMapping.clientId': 'account', 'usermodel.clientRoleMapping.rolePrefix': '', + 'claim.name': 'resource_access.account.roles', 'jsonType.label': 'String', multivalued: 'true', + 'access.token.claim': 'true', 'introspection.token.claim': 'true', 'id.token.claim': 'false', + 'userinfo.token.claim': 'false', 'lightweight.claim': 'false', + }; + if (Object.keys(canonical.config ?? {}).length !== Object.keys(configBoundary).length + || Object.entries(configBoundary).some(([key, value]) => canonical.config[key] !== value)) fail(); + let carriers = 0; + let resolvers = 0; + for (const mapper of mappers) { + if (!mapper || mapper.protocol !== 'openid-connect' || !knownTypes.has(mapper.protocolMapper)) return undefined; + const config = accountMapperRepresentation(mapper).config ?? {}; + if (mapper.protocolMapper === 'oidc-usermodel-client-role-mapper') { + if (Object.keys(config).some(key => !Object.hasOwn(canonical.config, key)) + || Object.entries(canonical.config).some(([key, value]) => + key === 'usermodel.clientRoleMapping.rolePrefix' ? config[key] !== undefined : config[key] !== value)) return undefined; + carriers++; + } else if (mapper.protocolMapper === 'oidc-audience-resolve-mapper') { + if (Object.keys(config).some(key => !['access.token.claim', 'introspection.token.claim', 'id.token.claim', 'lightweight.claim'].includes(key)) + || ['access.token.claim', 'introspection.token.claim'].some(key => config[key] !== undefined && config[key] !== 'true') + || ['id.token.claim', 'lightweight.claim'].some(key => config[key] !== undefined && config[key] !== 'false')) return undefined; + resolvers++; + } else { + if (['oidc-audience-mapper', 'oidc-usermodel-realm-role-mapper'].includes(mapper.protocolMapper) + || ['resource_access', 'realm_access', ...reservedClaims].includes(claimRoot(config['claim.name']))) return undefined; + } + } + if ((requireResolver ? resolvers !== 1 : resolvers > 1) || carriers > 1) return undefined; + return carriers === 1; +} + +// A malformed declaration always refuses; only a well-read incompatible graph +// can select the independently qualified stock-normalization path. +export function validateAccountMappers(mappers, canonical, stage = 'plan') { + const compatible = accountMapperCompatibility(mappers, canonical, stage); + if (compatible === undefined) refuse(stage, 'mapper_conflict'); + return compatible; +} diff --git a/scripts/development-realm-owner.mjs b/scripts/development-realm-owner.mjs new file mode 100644 index 00000000..30a3c6e8 --- /dev/null +++ b/scripts/development-realm-owner.mjs @@ -0,0 +1,119 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseEnv } from 'node:util'; +import { readComposeEnvironmentFile } from './compose.mjs'; +import { refuse } from './development-realm-errors.mjs'; + +const privateKeys = ['KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD', 'KEYCLOAK_WEB_CLIENT_SECRET']; + +/** Read through one no-follow handle; private credentials never come from a shared file. */ +export async function readRealmEnvironmentFile(path) { + return readComposeEnvironmentFile(path); +} + +export function isLocalDockerEndpoint(value) { + if (typeof value !== 'string' || !value.startsWith('unix:///')) return false; + try { + const endpoint = new URL(value); + return endpoint.protocol === 'unix:' && endpoint.host === '' && endpoint.pathname.length > 1 + && !endpoint.search && !endpoint.hash; + } catch { return false; } +} + +/** Check the effective engine before any stack start, migration or credential use. */ +export function validateLocalDockerContext(environment, context) { + const endpoint = context?.[0]?.Endpoints?.docker?.Host; + if (!Array.isArray(context) || context.length !== 1 || !isLocalDockerEndpoint(endpoint) + || (environment.DOCKER_HOST !== undefined && environment.DOCKER_HOST !== endpoint) + || environment.DOCKER_TLS_VERIFY || environment.DOCKER_CERT_PATH) + refuse('preflight', 'configuration'); +} + +/** Selected private file is data; inherited credentials never silently override it. */ +export function realmEnvironment(source, ambient = process.env) { + if (typeof source !== 'string' || Buffer.byteLength(source) > 256 * 1024 || source.includes('\0')) + refuse('preflight', 'configuration'); + const values = parseEnv(source); + for (const key of privateKeys) { + const assignments = [...source.matchAll(new RegExp(`^${key}=`, 'gm'))]; + const aliases = [...source.matchAll(new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=`, 'gm'))]; + if (assignments.length !== 1 || aliases.length !== 1 || !values[key] || /[\r\n\0]/.test(values[key])) + refuse('preflight', key === 'KEYCLOAK_WEB_CLIENT_SECRET' ? 'missing_web_secret' : 'configuration'); + if (ambient[key] !== undefined && ambient[key] !== values[key]) refuse('preflight', 'configuration'); + } + return Object.freeze(Object.fromEntries(privateKeys.map((key) => [key, values[key]]))); +} + +/** One exclusive local owner, never reclaimed from age or a guessed process state. */ +export async function acquireRealmLock(identity, directory = join(tmpdir(), `learnstack-realm-${process.getuid()}`)) { + if (typeof identity !== 'string' || !identity || Buffer.byteLength(identity) > 4096) + refuse('lock', 'configuration'); + await mkdir(directory, { mode: 0o700 }).catch((error) => { + if (error.code !== 'EEXIST') refuse('lock', 'configuration'); + }); + const parent = await lstat(directory); + if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid() + || (parent.mode & 0o777) !== 0o700) refuse('lock', 'configuration'); + const path = join(directory, createHash('sha256').update(identity).digest('hex') + '.lock'); + let handle; + try { handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); } + catch (error) { refuse('lock', error.code === 'EEXIST' ? 'lock_busy' : 'configuration'); } + let nonce; + let owned; + try { + try { + nonce = randomUUID(); + await handle.writeFile(nonce, 'utf8'); + owned = await handle.stat(); + } finally { await handle.close(); } + } catch { + await unlink(path).catch(() => refuse('cleanup', 'cleanup')); + refuse('lock', 'configuration'); + } + let released = false; + return Object.freeze({ + release: async () => { + if (released) refuse('cleanup', 'cleanup'); + const current = await lstat(path); + if (!current.isFile() || current.isSymbolicLink() || current.dev !== owned.dev + || current.ino !== owned.ino || current.nlink !== 1 || current.uid !== owned.uid + || current.size !== Buffer.byteLength(nonce) || (current.mode & 0o777) !== 0o600 + || await readFile(path, 'utf8') !== nonce) refuse('cleanup', 'cleanup'); + await unlink(path); + released = true; + }, + }); +} + +/** Only the selected running Compose service may receive the private admin credential. */ +export function validateRealmTarget(selected, model, containers, inspected, context, values) { + validateLocalDockerContext(selected.env ?? {}, context); + const service = model?.services?.keycloak; + const image = 'quay.io/keycloak/keycloak:26.6.2'; + if (model?.name !== selected.project || service?.image !== image + || !Array.isArray(containers) || containers.length !== 1 || typeof containers[0] !== 'string' + || !/^[a-f0-9]{64}$/.test(containers[0]) || inspected?.Id !== containers[0] + || inspected?.Config?.Image !== image || inspected?.State?.Running !== true + || inspected?.Config?.Labels?.['com.docker.compose.project'] !== selected.project + || inspected?.Config?.Labels?.['com.docker.compose.service'] !== 'keycloak' + || !Array.isArray(context) || context.length !== 1 + || !isLocalDockerEndpoint(context[0]?.Endpoints?.docker?.Host)) + refuse('preflight', 'configuration'); + const ports = inspected.NetworkSettings?.Ports?.['8080/tcp']; + if (!Array.isArray(ports) || ports.length !== 1 || ports[0].HostIp !== '127.0.0.1' + || ports[0].HostPort !== '8080') refuse('preflight', 'configuration'); + const environment = inspected.Config.Env; + if (!Array.isArray(environment) || environment.some(entry => typeof entry !== 'string')) + refuse('preflight', 'configuration'); + for (const key of privateKeys) { + const entries = environment.filter((entry) => entry.startsWith(key + '=')); + if (entries.length !== 1 || entries[0] !== `${key}=${values[key]}` + || service.environment?.[key] !== values[key]) refuse('preflight', 'configuration'); + } + return Object.freeze({ baseUrl: 'http://localhost:8080/', + expectedIssuer: 'http://localhost:8080/realms/learnstack', + lockIdentity: containers[0] + '/realms/learnstack' }); +} diff --git a/scripts/development-realm-owner.test.mjs b/scripts/development-realm-owner.test.mjs new file mode 100644 index 00000000..9d58a599 --- /dev/null +++ b/scripts/development-realm-owner.test.mjs @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmod, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rm, symlink, unlink, utimes, writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { RealmError, realmFailure } from './development-realm-errors.mjs'; +import { + acquireRealmLock, readRealmEnvironmentFile, realmEnvironment, validateLocalDockerContext, validateRealmTarget, +} from './development-realm-owner.mjs'; + +const keys = ['KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD', 'KEYCLOAK_WEB_CLIENT_SECRET']; +const values = { + KEYCLOAK_ADMIN: 'synthetic-admin', + KEYCLOAK_ADMIN_PASSWORD: 'synthetic-private-password-82b1', + KEYCLOAK_WEB_CLIENT_SECRET: 'synthetic-private-web-secret-82b1', +}; +const source = keys.map((key) => `${key}=${values[key]}`).join('\n'); + +function refusal(stage = 'preflight', reason = 'configuration') { + return (error) => { + assert.ok(error instanceof RealmError); + assert.equal(realmFailure(error), `realm: ${stage}/${reason}`); + for (const value of Object.values(values)) assert.ok(!error.message.includes(value)); + return true; + }; +} + +function target() { + const id = 'a'.repeat(64); + return { + selected: { project: 'selected-development' }, + model: { + name: 'selected-development', + services: { + keycloak: { image: 'quay.io/keycloak/keycloak:26.6.2', environment: { ...values } }, + }, + }, + containers: [id], + inspected: { + Id: id, + Config: { + Image: 'quay.io/keycloak/keycloak:26.6.2', + Labels: { + 'com.docker.compose.project': 'selected-development', + 'com.docker.compose.service': 'keycloak', + }, + Env: keys.map((key) => `${key}=${values[key]}`), + }, + State: { Running: true }, + NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '127.0.0.1', HostPort: '8080' }] } }, + }, + context: [{ Endpoints: { docker: { Host: 'unix:///var/run/docker.sock' } } }], + }; +} + +function validate(f) { + return validateRealmTarget(f.selected, f.model, f.containers, f.inspected, f.context, values); +} + +async function directory(t) { + const path = await mkdtemp(join(tmpdir(), 'learnstack-realm-owner-test-')); + await chmod(path, 0o700); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} + +test('private environment is read from a regular owner-only file without changing it', async t => { + const root = await directory(t); + const file = join(root, 'private.env'); + await writeFile(file, source, { mode: 0o600 }); + assert.equal(await readRealmEnvironmentFile(file), source); + assert.equal((await lstat(file)).mode & 0o777, 0o600); + assert.equal(await readFile(file, 'utf8'), source); +}); + +for (const mode of [0o644, 0o620, 0o604]) { + test(`private environment refuses shared mode ${mode.toString(8)} without repairing it`, async t => { + const root = await directory(t); + const file = join(root, 'private.env'); + await writeFile(file, source, { mode }); + await chmod(file, mode); + await assert.rejects(readRealmEnvironmentFile(file), refusal('preflight', 'private_file')); + assert.equal((await lstat(file)).mode & 0o777, mode); + assert.equal(await readFile(file, 'utf8'), source); + const result = spawnSync(process.execPath, + [fileURLToPath(new URL('./compose.mjs', import.meta.url)), 'dev', 'config'], { + env: { PATH: root, LEARNSTACK_COMPOSE_ENV_FILE: file }, + encoding: 'utf8', timeout: 5000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + for (const text of ['realm: preflight/private_file', 'regular', 'single-link', + 'current user', 'owner-only', 'chmod 600']) assert.ok(result.stderr.includes(text)); + for (const value of [file, ...Object.values(values)]) assert.ok(!result.stderr.includes(value)); + }); +} + +test('private environment refuses symlinks, multiple links, directories and oversized files', async t => { + const root = await directory(t); + const file = join(root, 'private.env'); + await writeFile(file, source, { mode: 0o600 }); + const alias = join(root, 'alias'); + await symlink(file, alias); + await assert.rejects(readRealmEnvironmentFile(alias), refusal('preflight', 'private_file')); + await unlink(alias); + await link(file, alias); + await assert.rejects(readRealmEnvironmentFile(file), refusal('preflight', 'private_file')); + await unlink(alias); + await assert.rejects(readRealmEnvironmentFile(root), refusal('preflight', 'private_file')); + await writeFile(file, 'x'.repeat(256 * 1024 + 1)); + await assert.rejects(readRealmEnvironmentFile(file), refusal()); +}); + +test('private environment refuses a different owner before using its content', async t => { + const root = await directory(t); + const file = join(root, 'private.env'); + await writeFile(file, source, { mode: 0o600 }); + const uid = process.getuid(); + t.mock.method(process, 'getuid', () => uid + 1); + await assert.rejects(readRealmEnvironmentFile(file), refusal('preflight', 'private_file')); +}); + +test('effective Docker host must agree with the inspected local context before stack work', () => { + const context = target().context; + assert.doesNotThrow(() => validateLocalDockerContext({}, context)); + assert.doesNotThrow(() => validateLocalDockerContext({ DOCKER_HOST: 'unix:///var/run/docker.sock' }, context)); + for (const environment of [ + { DOCKER_HOST: 'tcp://remote.invalid:2375' }, + { DOCKER_HOST: 'unix:///different-engine.sock' }, + { DOCKER_TLS_VERIFY: '1' }, { DOCKER_CERT_PATH: '/remote-certs' }, + ]) assert.throws(() => validateLocalDockerContext(environment, context), refusal()); +}); + +test('the selected environment supplies exact private values and permits matching inheritance', () => { + assert.deepEqual(realmEnvironment(source, {}), values); + assert.deepEqual(realmEnvironment(source, { ...values, FOREIGN_SETTING: 'preserved' }), values); + assert.ok(Object.isFrozen(realmEnvironment(source, {}))); +}); + +test('private dotenv values remain literal data with no shell or variable evaluation', () => { + const literal = '$(printf should-not-execute) `printf should-not-execute` ${HOME} $HOME ; value'; + const parsed = realmEnvironment(source.replace(values.KEYCLOAK_ADMIN_PASSWORD, `"${literal}"`), {}); + assert.equal(parsed.KEYCLOAK_ADMIN_PASSWORD, literal); +}); + +for (const key of keys) { + test(`${key} refuses inherited disagreement, missing values and duplicate assignments`, () => { + assert.throws(() => realmEnvironment(source, { [key]: 'different' }), refusal()); + assert.throws( + () => realmEnvironment(source.split('\n').filter((line) => !line.startsWith(key + '=')).join('\n'), {}), + refusal('preflight', key === 'KEYCLOAK_WEB_CLIENT_SECRET' ? 'missing_web_secret' : 'configuration'), + ); + assert.throws( + () => realmEnvironment(`${source}\n${key}=duplicate`, {}), + refusal('preflight', key === 'KEYCLOAK_WEB_CLIENT_SECRET' ? 'missing_web_secret' : 'configuration'), + ); + }); +} + +test('dotenv export and whitespace aliases cannot conceal duplicate private assignments', () => { + for (const assignment of [ + 'export KEYCLOAK_ADMIN_PASSWORD=conflicting', + ' KEYCLOAK_ADMIN_PASSWORD=conflicting', + 'KEYCLOAK_ADMIN_PASSWORD = conflicting', + '\texport\tKEYCLOAK_ADMIN_PASSWORD = conflicting', + ]) assert.throws(() => realmEnvironment(`${source}\n${assignment}`, {}), refusal()); + for (const assignment of [ + 'export KEYCLOAK_ADMIN_PASSWORD=single', + ' KEYCLOAK_ADMIN_PASSWORD=single', + ]) assert.throws( + () => realmEnvironment(source.replace(`KEYCLOAK_ADMIN_PASSWORD=${values.KEYCLOAK_ADMIN_PASSWORD}`, assignment), {}), + refusal(), + ); +}); + +test('multiline, NUL and oversized environment data is refused', () => { + for (const invalid of [ + `${source}\nOTHER=\0`, + source.replace(values.KEYCLOAK_ADMIN_PASSWORD, '"first\nsecond"'), + `${source}\nOTHER=${'x'.repeat(256 * 1024)}`, + ]) assert.throws(() => realmEnvironment(invalid, {}), refusal()); +}); + +test('the exact selected pinned running local target supplies the fixed realm identity', () => { + assert.deepEqual(validate(target()), { + baseUrl: 'http://localhost:8080/', + expectedIssuer: 'http://localhost:8080/realms/learnstack', + lockIdentity: `${'a'.repeat(64)}/realms/learnstack`, + }); + assert.ok(Object.isFrozen(validate(target()))); +}); + +const mismatches = [ + ['selected project', (f) => { f.selected.project = 'foreign'; }], + ['model project', (f) => { f.model.name = 'foreign'; }], + ['service missing', (f) => { delete f.model.services.keycloak; }], + ['service image', (f) => { f.model.services.keycloak.image = 'quay.io/keycloak/keycloak:latest'; }], + ['container image', (f) => { f.inspected.Config.Image = 'quay.io/keycloak/keycloak:26.6.1'; }], + ['container missing', (f) => { f.containers = []; }], + ['multiple containers', (f) => { f.containers.push('b'.repeat(64)); }], + ['short container identifier', (f) => { f.containers = ['a'.repeat(12)]; }], + ['container identifier disagreement', (f) => { f.inspected.Id = 'b'.repeat(64); }], + ['stopped container', (f) => { f.inspected.State.Running = false; }], + ['project label', (f) => { f.inspected.Config.Labels['com.docker.compose.project'] = 'foreign'; }], + ['service label', (f) => { f.inspected.Config.Labels['com.docker.compose.service'] = 'foreign'; }], + ['public binding', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'][0].HostIp = '0.0.0.0'; }], + ['foreign address binding', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'][0].HostIp = '192.0.2.15'; }], + ['hostname binding', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'][0].HostIp = 'localhost'; }], + ['ambiguous IPv6 binding', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'][0].HostIp = '::'; }], + ['foreign port', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'][0].HostPort = '8081'; }], + ['second binding', (f) => { f.inspected.NetworkSettings.Ports['8080/tcp'].push({ HostIp: '::', HostPort: '8080' }); }], + ['missing binding', (f) => { delete f.inspected.NetworkSettings.Ports['8080/tcp']; }], + ['remote TCP context', (f) => { f.context[0].Endpoints.docker.Host = 'tcp://remote.invalid:2375'; }], + ['remote SSH context', (f) => { f.context[0].Endpoints.docker.Host = 'ssh://remote.invalid'; }], + ['empty Unix socket context', (f) => { f.context[0].Endpoints.docker.Host = 'unix://'; }], + ['relative Unix socket context', (f) => { f.context[0].Endpoints.docker.Host = 'unix://relative/docker.sock'; }], + ['malformed context host', (f) => { f.context[0].Endpoints.docker.Host = { host: 'unix:///tmp/socket' }; }], + ['multiple contexts', (f) => { f.context.push(structuredClone(f.context[0])); }], + ['missing context', (f) => { f.context = []; }], + ['missing private environment', (f) => { delete f.inspected.Config.Env; }], + ['malformed private environment', (f) => { f.inspected.Config.Env.push(null); }], +]; +for (const [name, mutate] of mismatches) { + test(`${name} is refused before authentication`, () => { + const f = target(); + mutate(f); + assert.throws(() => validate(f), refusal()); + }); +} + +for (const key of keys) { + test(`${key} must agree with both selected model and actual container exactly once`, () => { + for (const mutate of [ + (f) => { f.model.services.keycloak.environment[key] = 'different'; }, + (f) => { f.inspected.Config.Env = f.inspected.Config.Env.filter((entry) => !entry.startsWith(key + '=')); }, + (f) => { f.inspected.Config.Env.push(`${key}=different`); }, + (f) => { f.inspected.Config.Env = f.inspected.Config.Env.map((entry) => entry.startsWith(key + '=') ? `${key}=different` : entry); }, + ]) { + const f = target(); + mutate(f); + assert.throws(() => validate(f), refusal()); + } + }); +} + +test('a second lock owner is refused and successful release permits a new owner', async (t) => { + const parent = await directory(t); + const path = join(parent, 'private-locks'); + const owner = await acquireRealmLock('selected-container/realms/learnstack', path); + assert.equal((await lstat(path)).mode & 0o777, 0o700); + assert.equal((await lstat(path)).uid, process.getuid()); + const files = await readdir(path); + assert.equal(files.length, 1); + assert.equal((await lstat(join(path, files[0]))).mode & 0o777, 0o600); + await assert.rejects(acquireRealmLock('selected-container/realms/learnstack', path), refusal('lock', 'lock_busy')); + await owner.release(); + assert.deepEqual(await readdir(path), []); + await assert.rejects(owner.release(), refusal('cleanup', 'cleanup')); + const next = await acquireRealmLock('selected-container/realms/learnstack', path); + await next.release(); +}); + +test('an arbitrarily old lock is never reclaimed from elapsed age', async (t) => { + const path = await directory(t); + const identity = 'selected-container/realms/learnstack'; + const name = createHash('sha256').update(identity).digest('hex') + '.lock'; + const marker = join(path, name); + await writeFile(marker, 'foreign-owner'); + await utimes(marker, new Date(0), new Date(0)); + await assert.rejects(acquireRealmLock(identity, path), refusal('lock', 'lock_busy')); + assert.equal(await readFile(marker, 'utf8'), 'foreign-owner'); +}); + +test('different target identities have independent local owners', async (t) => { + const path = await directory(t); + const first = await acquireRealmLock('one/realms/learnstack', path); + const second = await acquireRealmLock('two/realms/learnstack', path); + assert.equal((await readdir(path)).length, 2); + await first.release(); + await second.release(); +}); + +for (const operation of ['writeFile', 'stat', 'close']) { + test(`failed lock ${operation} closes the handle and removes the exclusive marker`, async t => { + const path = await directory(t); + const probe = join(path, 'handle-prototype'); + const handle = await open(probe, 'wx'); + const prototype = Object.getPrototypeOf(handle); + const method = operation === 'close' ? 'writeFile' : operation; + const original = prototype[method]; + await handle.close(); + await unlink(probe); + let failedHandle; + const failure = t.mock.method(prototype, method, async function(...args) { + failedHandle = this; + if (operation === 'close') { + const close = this.close; + this.close = async () => { + await close.call(this); + throw new Error('synthetic lock close failure'); + }; + return original.apply(this, args); + } + if (operation === 'writeFile') await original.call(this, 'partial nonce'); + throw new Error('synthetic lock initialization failure'); + }); + await assert.rejects(acquireRealmLock('selected', path), refusal('lock', 'configuration')); + assert.equal(failedHandle.fd, -1); + assert.deepEqual(await readdir(path), []); + failure.mock.restore(); + const next = await acquireRealmLock('selected', path); + await next.release(); + }); +} + +test('permissive, non-directory and symlink lock directories are refused', async (t) => { + const parent = await directory(t); + const permissive = join(parent, 'permissive'); + await mkdir(permissive, { mode: 0o755 }); + await chmod(permissive, 0o755); + const file = join(parent, 'file'); + await writeFile(file, 'foreign'); + const linked = join(parent, 'linked'); + await symlink(parent, linked); + for (const path of [permissive, file, linked]) { + await assert.rejects(acquireRealmLock('selected', path), refusal('lock', 'configuration')); + } + assert.equal(await readFile(file, 'utf8'), 'foreign'); +}); + +for (const replacement of ['file', 'symlink', 'contents']) { + test(`release refuses ${replacement} replacement and preserves the foreign marker`, async (t) => { + const path = await directory(t); + const owner = await acquireRealmLock('selected', path); + const marker = join(path, (await readdir(path))[0]); + const foreign = join(path, 'foreign'); + await writeFile(foreign, 'foreign-owner'); + if (replacement === 'contents') await writeFile(marker, 'foreign-owner'); + else { + await unlink(marker); + if (replacement === 'file') await writeFile(marker, 'foreign-owner'); + else await symlink(foreign, marker); + } + await assert.rejects(owner.release(), refusal('cleanup', 'cleanup')); + assert.equal(await readFile(marker, 'utf8'), 'foreign-owner'); + assert.equal(await readFile(foreign, 'utf8'), 'foreign-owner'); + }); +} diff --git a/scripts/development-realm-plan.mjs b/scripts/development-realm-plan.mjs new file mode 100644 index 00000000..5e5ef710 --- /dev/null +++ b/scripts/development-realm-plan.mjs @@ -0,0 +1,757 @@ +import { isDeepStrictEqual } from 'node:util'; +import { refuse } from './development-realm-errors.mjs'; +import { identityClaims, mergeOwnedMappers, validateEffectiveMappers } from './development-realm-mappers.mjs'; +import { readRealmGroups, readUserGroupIds } from './development-realm-groups.mjs'; +import { exactCreatedAccountAccess, planAccountSelfService } from './development-realm-account.mjs'; + +const managedScope = 'learnstack-tenant-api'; +const namedClients = ['learnstack-web', 'learnstack-api']; +const pageSize = 100; +const pageLimit = 10; +const bodyLimit = 256 * 1024; +const generatedIdBytes = '0'.repeat(36); +// New-user Admin DTO fields (timestamp, flags, credential type names and access +// booleans) are provider-generated. Reserve more than their pinned fixed shape; +// profile metadata and the UUID are budgeted separately below. +const newUserResponseReserve = 1024; +const profileAttributeFields = new Set(['name', 'displayName', 'validations', 'annotations', + 'required', 'permissions', 'selector', 'group', 'multivalued', 'defaultValue']); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const actorUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ownClientFields = ['publicClient', 'clientAuthenticatorType', 'standardFlowEnabled', + 'implicitFlowEnabled', 'directAccessGrantsEnabled', 'serviceAccountsEnabled', 'redirectUris', 'webOrigins']; +const secretRotationAttributes = ['client.secret.expiration.time', 'client.secret.remaining.expiration.time', + 'client.secret.rotated', 'client.secret.rotated.creation.time', 'client.secret.rotated.expiration.time']; +const encode = encodeURIComponent; +const copy = value => structuredClone(value); +const equal = isDeepStrictEqual; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); + +function requireObject(value, stage) { + if (!object(value)) refuse(stage, 'response'); + return value; +} + +function array(value, stage, limit = 200) { + if (!Array.isArray(value) || value.length > limit) refuse(stage, 'unsupported'); + return value; +} + +function unique(values, key, stage, reason = 'unsupported') { + const seen = new Set(); + for (const value of values) { + if (!object(value) || typeof value[key] !== 'string' || !value[key] || seen.has(value[key])) refuse(stage, reason); + seen.add(value[key]); + } + return values; +} + +function preflightBody(body) { + if (body === undefined) return; + if (Buffer.byteLength(JSON.stringify(body), 'utf8') > bodyLimit) refuse('plan', 'body_limit'); +} + +const jsonBytes = value => Buffer.byteLength(JSON.stringify(value), 'utf8'); + +function preflightResponse(value, reserve = 0) { + if (jsonBytes(value) + reserve > bodyLimit) refuse('plan', 'body_limit'); +} + +function validateProjectedResponses({ before, profile, scopes, mappers, clients, assignments, users, desired, manifest, webSecret, identityReserve = new Map() }) { + preflightResponse({ ...before.realm, accessTokenLifespan: 900 }); + preflightResponse(profile); + // These UUID placeholders are used only for byte accounting. Provider IDs + // in the plan/apply/readback state remain exclusively authoritative reads. + const projectedScopes = new Map([...scopes].map(([name, scope]) => [name, { + ...scope, id: scope.id ?? generatedIdBytes, + ...(mappers.has(name) ? { protocolMappers: mappers.get(name).map(mapper => ({ ...mapper, id: mapper.id ?? generatedIdBytes })) } : {}), + }])); + preflightResponse([...projectedScopes.values()]); + for (const values of mappers.values()) + preflightResponse(values.map(mapper => ({ ...mapper, id: mapper.id ?? generatedIdBytes }))); + for (const name of namedClients) { + const assigned = assignments.get(name); + preflightResponse({ ...clients.get(name), defaultClientScopes: [...assigned.defaults], optionalClientScopes: [...assigned.optional] }); + preflightResponse({ type: 'secret', value: name === 'learnstack-web' ? webSecret : before.clients.get(name).secret }); + for (const kind of ['defaults', 'optional']) + preflightResponse([...assigned[kind]].map(scopeName => projectedScopes.get(scopeName))); + } + // Full user listings repeat profile metadata in every row. Count the whole + // projected profile plus REST field/implicit-validator overhead, including + // when the current realm has no ordinary-user metadata template. Counts are + // independent ceilings: this conservative byte budget can bind earlier. + const validators = profile.attributes.reduce((count, attribute) => count + Object.keys(attribute.validations ?? {}).length, 0); + const metadataGrowth = Math.max(0, jsonBytes(profile) - jsonBytes(before.profile)) + + (equal(profile, before.profile) ? 0 : identityClaims.length * 128); + const observedMetadata = before.users.filter(user => !user.serviceAccountClientId && object(user.userProfileMetadata)) + .map(user => jsonBytes(user.userProfileMetadata)); + const metadataFieldBytes = Buffer.byteLength(',"userProfileMetadata":'); + const metadataBytes = metadataFieldBytes + (observedMetadata.length ? Math.max(...observedMetadata) + metadataGrowth + : jsonBytes(profile) + profile.attributes.length * 128 + validators * 32 + 256); + const projectedUsers = before.users.map(user => ({ + value: omit({ ...user, ...users.get(user.username) }, ['userProfileMetadata']), + reserve: (user.userProfileMetadata ? metadataFieldBytes + jsonBytes(user.userProfileMetadata) + metadataGrowth : metadataBytes) + + (identityReserve.get(user.id) ?? 0), + })); + for (const declaration of manifest.users) { + const existing = before.candidates.get(declaration.username); + if (existing) preflightResponse(users.get(declaration.username), identityReserve.get(existing.user.id) ?? 0); + else { + const wanted = desired.users.find(user => user.username === declaration.username); + const projected = { ...omit(wanted, ['credentials', 'realmRoles', 'clientRoles']), id: generatedIdBytes }; + const reserve = newUserResponseReserve + metadataBytes; + preflightResponse(projected, reserve); + projectedUsers.push({ value: projected, reserve }); + } + } + // Insertions can move page boundaries. Budget the largest possible 100 + // records, including JSON array punctuation, without relying on ordering. + const largestPage = projectedUsers.map(({ value, reserve }) => jsonBytes(value) + reserve) + .sort((left, right) => right - left).slice(0, pageSize); + if (2 + Math.max(0, largestPage.length - 1) + largestPage.reduce((sum, size) => sum + size, 0) > bodyLimit) + refuse('plan', 'body_limit'); +} + +function adminCanEdit(attribute) { + if (['username', 'email'].includes(attribute.name) + && (attribute.permissions == null + || ((attribute.permissions.view?.length ?? 0) === 0 && (attribute.permissions.edit?.length ?? 0) === 0))) return true; + return Array.isArray(attribute.permissions?.edit) && attribute.permissions.edit.includes('admin'); +} + +function adminCanRead(attribute) { + // Pinned USER_API view permission is the union of view and edit roles. + return adminCanEdit(attribute) + || (Array.isArray(attribute.permissions?.view) && attribute.permissions.view.includes('admin')); +} + +function plainAttributeProjection(attribute) { + return attribute.defaultValue == null && attribute.selector == null + && Object.keys(attribute).every(key => profileAttributeFields.has(key)); +} + +function readableIdentityClaims(profile) { + return identityClaims.filter(name => { + const attribute = profile.attributes.find(value => value.name === name); + return attribute ? adminCanRead(attribute) && plainAttributeProjection(attribute) + : ['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'].includes(profile.unmanagedAttributePolicy); + }); +} + +function validateIdentityVisibility(profile, stage) { + unique(array(profile.attributes, stage), 'name', stage); + if (profile.groups !== undefined) unique(array(profile.groups, stage), 'name', stage); + // Admin responses are projections. Absence is authoritative only for claims + // exposed by an ordinary definition or an admin-readable unmanaged policy. + if (readableIdentityClaims(profile).length !== identityClaims.length) refuse(stage, 'identity_conflict'); +} + +function validateAttributePreservation(profile, creating = false) { + // A full attribute PUT can persist a synthesized default or remove a + // writable value hidden by an unqualified projection. Readonly hidden + // attributes are preserved by the provider and need no policy expansion. + for (const attribute of profile.attributes) + if ((adminCanEdit(attribute) || (creating && adminCanRead(attribute))) + && (!adminCanRead(attribute) || !plainAttributeProjection(attribute))) + refuse('plan', 'unsupported'); +} + +function uuidValue(value) { + return typeof value === 'string' && uuid.test(value) + && value !== '00000000-0000-0000-0000-000000000000'; +} + +function legacyDeclaration(declaration) { + return declaration.legacyTenantAttribute === 'tenant-a' && declaration.organizationId === null; +} + +function qualifiedExistingActor(user, declaration, stage, readableClaims = identityClaims) { + if (!readableClaims.includes('user_id')) return; + const actor = user.attributes?.user_id; + if (Array.isArray(actor) && actor.length === 1 && typeof actor[0] === 'string' + && actor[0].toLowerCase() === declaration.userId.toLowerCase()) return; + // A public registration matching a reserved username is not a seed identity. + // Only the two historical accounts have this explicit migration discriminator. + if ((actor === undefined || (Array.isArray(actor) && actor.length === 0)) && legacyDeclaration(declaration)) { + if (!readableClaims.includes('tenant_id') || equal(user.attributes?.tenant_id, ['tenant-a'])) return; + } + refuse(stage, 'identity_conflict'); +} + +function validateIdentityAttribute(name, current, wanted, declaration, stage) { + if (current === undefined || (Array.isArray(current) && current.length === 0)) return; + if (!Array.isArray(current) || current.length !== 1 || typeof current[0] !== 'string') + refuse(stage, 'identity_conflict'); + if (name === 'tenant_id' && legacyDeclaration(declaration) && current[0] === 'tenant-a') return; + if (wanted === null || !uuidValue(current[0]) || current[0].toLowerCase() !== wanted.toLowerCase()) + refuse(stage, 'identity_conflict'); +} + +function identityAttributes(user, declaration, stage) { + const attributes = user.attributes === undefined ? {} : requireObject(user.attributes, stage); + const merged = copy(attributes); + const expected = { user_id: declaration.userId, tenant_id: declaration.tenantId, + organization_id: declaration.organizationId }; + for (const name of identityClaims) { + const current = attributes[name]; + const wanted = expected[name]; + validateIdentityAttribute(name, current, wanted, declaration, stage); + if (current === undefined || (Array.isArray(current) && current.length === 0)) { + if (wanted !== null) merged[name] = [wanted]; + continue; + } + if (name === 'tenant_id' && legacyDeclaration(declaration) && current[0] === 'tenant-a') { + merged[name] = [wanted]; + continue; + } + } + return merged; +} + +function validateInputs(desired, manifest, webSecret) { + if (!object(desired) || desired.realm !== 'learnstack' || desired.accessTokenLifespan !== 900 + || !Array.isArray(desired.users) || desired.users.length !== 4 + || !object(manifest) || !Array.isArray(manifest.users) || manifest.users.length !== 4 + || typeof webSecret !== 'string' || !webSecret) refuse('plan', 'unsupported'); + unique(manifest.users, 'username', 'plan', 'identity_conflict'); + unique(desired.users, 'username', 'plan', 'identity_conflict'); + // SeedIdentityManifest qualifies the two historical personas by declaration. + // Tooling validates its bounded, tenant-wide projection without branching on + // institution names, hosts or persona usernames. + const legacy = manifest.users.filter(user => user.legacyTenantAttribute != null); + if (legacy.length !== 0 && (legacy.length !== 2 + || new Set(legacy.map(user => user.tenantId)).size !== 1 + || !equal(new Set(legacy.map(user => user.realmRole)), new Set(['tenant-admin', 'tenant-learner'])))) + refuse('plan', 'identity_conflict'); + const actors = new Set(); + const scopes = new Set(manifest.users.flatMap(user => [user.tenantId, user.organizationId]) + .filter(value => typeof value === 'string').map(value => value.toLowerCase())); + for (const declaration of manifest.users) { + if (!uuidValue(declaration.userId) || !actorUuid.test(declaration.userId) || !uuidValue(declaration.tenantId) + || (declaration.organizationId !== null && !uuidValue(declaration.organizationId)) + || (declaration.organizationId !== null && declaration.organizationId.toLowerCase() === declaration.tenantId.toLowerCase()) + || (declaration.legacyTenantAttribute != null && !legacyDeclaration(declaration)) + || scopes.has(declaration.userId.toLowerCase()) + || actors.has(declaration.userId.toLowerCase())) refuse('plan', 'identity_conflict'); + actors.add(declaration.userId.toLowerCase()); + const exports = desired.users.filter(user => user.username === declaration.username); + if (!equal(declaration.accountCapability, { clientId: 'account', role: 'manage-account' }) + || exports.length !== 1 || exports[0].email !== declaration.email + || exports[0].firstName !== declaration.firstName || exports[0].lastName !== declaration.lastName + || !['tenant-admin', 'tenant-learner'].includes(declaration.realmRole) + || !equal(exports[0].realmRoles, [declaration.realmRole]) + || exports[0].enabled !== true || exports[0].emailVerified !== true + || !Array.isArray(exports[0].credentials) || exports[0].credentials.length !== 1 + || !object(exports[0].credentials[0]) + || exports[0].credentials[0].type !== 'password' || exports[0].credentials[0].temporary !== false + || typeof exports[0].credentials[0].value !== 'string' || !exports[0].credentials[0].value + || !equal(exports[0].clientRoles, { account: ['manage-account'] }) + || !equal(identityAttributes(exports[0], declaration, 'plan'), exports[0].attributes)) + refuse('plan', 'identity_conflict'); + } +} + +async function allUsers(admin, stage) { + const users = []; + for (let page = 0; page < pageLimit; page += 1) { + // exact=false selects the pinned provider's filtered listing, which includes + // service accounts; the unfiltered users endpoint silently excludes them. + const values = array(await admin.get(`users?first=${page * pageSize}&max=${pageSize}&briefRepresentation=false&exact=false`), stage, pageSize); + users.push(...values); + unique(users, 'id', stage, 'identity_conflict'); + if (values.length < pageSize) { + const names = new Set(); + for (const user of users) { + // External storage may decorate profile metadata and hide protected + // values independently of the realm configuration. Its actor census + // cannot be qualified through these bounded Admin REST projections. + if (user.federationLink || user.id.startsWith('f:')) refuse(stage, 'identity_conflict'); + if (typeof user.username !== 'string' || names.has(user.username.toLowerCase())) refuse(stage, 'identity_conflict'); + names.add(user.username.toLowerCase()); + } + return users; + } + } + refuse(stage, 'unsupported'); +} + +async function readClient(admin, name, stage) { + const matches = array(await admin.get(`clients?clientId=${encode(name)}&max=2`), stage, 2); + if (matches.length !== 1 || matches[0].clientId !== name || !matches[0].id) refuse(stage, 'unsupported'); + const client = requireObject(await admin.get(`clients/${encode(matches[0].id)}`), stage); + if (client.id !== matches[0].id || client.clientId !== name || client.protocol !== 'openid-connect' + || client.enabled !== true || client.consentRequired === true + || client.bearerOnly === true || (client.clientAuthenticatorType && client.clientAuthenticatorType !== 'client-secret')) + refuse(stage, 'unsupported'); + const defaults = unique(array(await admin.get(`clients/${encode(client.id)}/default-client-scopes`), stage), 'id', stage); + const optional = unique(array(await admin.get(`clients/${encode(client.id)}/optional-client-scopes`), stage), 'id', stage); + const mappers = unique(array(await admin.get(`clients/${encode(client.id)}/protocol-mappers/models`), stage), 'id', stage, 'mapper_conflict'); + let secret; + if (name === 'learnstack-api' || client.publicClient !== true) { + const credential = requireObject(await admin.get(`clients/${encode(client.id)}/client-secret`), stage); + if (typeof credential.value !== 'string' || !credential.value) refuse(stage, 'secret_conflict'); + secret = credential.value; + } + return { client, defaults, optional, mappers, secret }; +} + +async function snapshot(admin, desired, manifest, stage, { preparationProfile, preparing = false } = {}) { + const realm = requireObject(await admin.get(''), stage); + if (realm.realm !== 'learnstack' || realm.enabled !== true) refuse(stage, 'unsupported'); + // A preparation probe can reuse only its actual profile read. It never + // substitutes the proposed configuration or a fabricated account projection. + const profile = preparationProfile ?? requireObject(await admin.get('users/profile'), stage); + if (!preparing) validateIdentityVisibility(profile, stage); + const readableClaims = readableIdentityClaims(profile); + const scopes = unique(array(await admin.get('client-scopes'), stage), 'name', stage, 'mapper_conflict'); + unique(scopes, 'id', stage, 'mapper_conflict'); + const scopeByName = new Map(scopes.map(scope => [scope.name, scope])); + const clients = new Map(); + for (const name of namedClients) clients.set(name, await readClient(admin, name, stage)); + const effectiveNames = new Set([managedScope, 'basic']); + for (const name of namedClients) { + const client = desired.clients.find(value => value.clientId === name); + for (const scopeName of [...client.defaultClientScopes, ...client.optionalClientScopes]) effectiveNames.add(scopeName); + } + for (const { defaults, optional } of clients.values()) { + for (const assigned of [...defaults, ...optional]) { + const scope = scopeByName.get(assigned.name); + if (!scope || scope.id !== assigned.id || scope.protocol !== 'openid-connect') refuse(stage, 'unsupported'); + effectiveNames.add(assigned.name); + } + } + const scopeMappers = new Map(); + for (const name of effectiveNames) { + const scope = scopeByName.get(name); + if (scope) scopeMappers.set(name, unique(array(await admin.get(`client-scopes/${encode(scope.id)}/protocol-mappers/models`), stage), 'id', stage, 'mapper_conflict')); + } + const users = await allUsers(admin, stage); + const candidates = new Map(); + for (const declaration of manifest.users) { + const matched = users.filter(user => user.username.toLowerCase() === declaration.username.toLowerCase()); + // A profile projection can hide the stored username: pinned email-as- + // username normalization substitutes email even during Admin REST reads. + // Require exact lookup and census agreement before classifying an account + // as missing; never resolve its ownership through an alternate email. + const exact = array(await admin.get(`users?username=${encode(declaration.username)}&exact=true&max=2&briefRepresentation=false`), stage, 2); + if (!matched.length && !exact.length) continue; + if (matched.length !== 1 || exact.length !== 1 || matched[0].username !== declaration.username + || exact[0].username !== declaration.username || exact[0].id !== matched[0].id) + refuse(stage, 'identity_conflict'); + const user = requireObject(await admin.get(`users/${encode(matched[0].id)}`), stage); + if (user.id !== matched[0].id || user.username !== declaration.username + || user.federationLink || user.serviceAccountClientId) refuse(stage, 'identity_conflict'); + qualifiedExistingActor(user, declaration, stage, readableClaims); + const credentials = array(await admin.get(`users/${encode(user.id)}/credentials`), stage); + const roles = requireObject(await admin.get(`users/${encode(user.id)}/role-mappings`), stage); + candidates.set(declaration.username, { user, credentials, roles }); + } + return { realm, profile, scopes, scopeByName, scopeMappers, clients, users, candidates, readableClaims }; +} + +function mergedProfile(current, desired) { + const attributes = unique(array(current.attributes, 'plan'), 'name', 'plan'); + if (current.groups !== undefined) unique(array(current.groups, 'plan'), 'name', 'plan'); + const merged = copy(current); + merged.attributes = attributes.map(attribute => copy(attribute)); + const declared = JSON.parse(desired.components['org.keycloak.userprofile.UserProfileProvider'][0] + .config['kc.user.profile.config'][0]); + for (const name of identityClaims) { + const canonical = declared.attributes.find(attribute => attribute.name === name); + const index = merged.attributes.findIndex(attribute => attribute.name === name); + if (index === -1) merged.attributes.push(copy(canonical)); + else { + merged.attributes[index] = { ...merged.attributes[index], permissions: copy(canonical.permissions), multivalued: false }; + delete merged.attributes[index].required; + } + } + return merged; +} + +function mergedClient(current, wanted, webSecret) { + const body = {}; + for (const name of ownClientFields) { + if (name in wanted) body[name] = copy(wanted[name]); + } + if (Array.isArray(current.redirectUris) && Array.isArray(body.redirectUris) + && new Set(current.redirectUris).size === current.redirectUris.length + && equal([...current.redirectUris].sort(), [...body.redirectUris].sort())) + body.redirectUris = copy(current.redirectUris); + body.attributes = { ...(current.attributes ?? {}), ...wanted.attributes }; + delete body.attributes['access.token.lifespan']; + if (wanted.clientId === 'learnstack-web' && current.publicClient === true) body.secret = webSecret; + return body; +} + +function clientPatch(current, expected) { + const patch = {}; + for (const field of ownClientFields) + if (field in expected && !equal(current[field], expected[field])) patch[field] = copy(expected[field]); + if ('secret' in expected) patch.secret = expected.secret; + const attributes = Object.fromEntries(Object.entries(expected.attributes) + .filter(([name, value]) => !equal(current.attributes?.[name], value))); + // Client attributes merge in the pinned Admin REST implementation. An + // explicit null removes this owned override; omission leaves it in force. + if (Object.hasOwn(current.attributes ?? {}, 'access.token.lifespan')) attributes['access.token.lifespan'] = null; + if (Object.keys(attributes).length) patch.attributes = attributes; + if (Object.keys(patch).length) { + // Omitting an empty origins list makes the pinned provider derive browser + // origins from redirects. Preserve this owned server-only value explicitly. + patch.webOrigins = copy(expected.webOrigins); + // ClientResource disables an existing resource server when this flag is + // omitted. Preserve its current state, including unrelated settings. + patch.authorizationServicesEnabled = current.authorizationServicesEnabled === true; + } + return patch; +} + +function matchesFields(actual, expected) { + return Object.entries(expected).every(([key, value]) => equal(actual[key], value)); +} + +async function exactNewUser(admin, declaration, wanted, stage) { + const values = array(await admin.get(`users?username=${encode(declaration.username)}&exact=true&max=2&briefRepresentation=false`), stage, 2); + if (values.length !== 1 || values[0].username !== declaration.username || !values[0].id) refuse(stage, 'identity_conflict'); + const user = requireObject(await admin.get(`users/${encode(values[0].id)}`), stage); + const expected = omit(wanted, ['credentials', 'realmRoles', 'clientRoles']); + if (!matchesFields(user, expected) || !equal(identityAttributes(user, declaration, stage), user.attributes)) + refuse(stage, 'identity_conflict'); + if (user.requiredActions !== undefined && !equal(user.requiredActions, [])) refuse(stage, 'identity_conflict'); + return user; +} + +function exactCreatedAccess(roles, credentials, roleId, account) { + return Array.isArray(roles.realmMappings) && roles.realmMappings.length === 1 && roles.realmMappings[0].id === roleId + && exactCreatedAccountAccess(roles, account.account, account.role) + && credentials.length === 1 && credentials[0].type === 'password'; +} + +/** A complete read-only preflight. No ordinary mutation escapes before identity qualification. */ +export async function buildRealmPlan({ admin, desired, manifest, webSecret }) { + validateInputs(desired, manifest, webSecret); + const before = await snapshot(admin, desired, manifest, 'plan'); + return assemblePlan({ admin, desired, manifest, webSecret, before }); +} + +// Only this internal path defers checks needing newly visible protected values. +// Every other qualifier is shared with the ordinary planner. Provisional actions +// never escape: the caller receives only a profile and its exact verification. +export async function buildRealmPreparation({ admin, desired, manifest, webSecret }) { + validateInputs(desired, manifest, webSecret); + const current = requireObject(await admin.get('users/profile'), 'prepare'); + const attributes = unique(array(current.attributes, 'prepare'), 'name', 'prepare'); + if (current.groups !== undefined) unique(array(current.groups, 'prepare'), 'name', 'prepare'); + const missing = identityClaims.filter(name => !attributes.some(attribute => attribute.name === name)); + if (!missing.length || ['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'].includes(current.unmanagedAttributePolicy)) return; + for (const attribute of attributes.filter(value => identityClaims.includes(value.name))) + if (!adminCanRead(attribute) || !plainAttributeProjection(attribute)) refuse('prepare', 'identity_conflict'); + const declared = JSON.parse(desired.components['org.keycloak.userprofile.UserProfileProvider'][0] + .config['kc.user.profile.config'][0]); + const expected = copy(current); + for (const name of missing) { + const definitions = array(declared.attributes, 'prepare').filter(attribute => attribute?.name === name); + const definition = definitions[0]; + if (definitions.length !== 1 || !plainAttributeProjection(definition) || definition.multivalued !== false + || !equal(definition.permissions, { view: ['admin'], edit: ['admin'] })) refuse('prepare', 'identity_conflict'); + expected.attributes.push(copy(definition)); + } + array(expected.attributes, 'prepare'); + preflightBody(expected); + preflightResponse(expected); + const before = await snapshot(admin, desired, manifest, 'plan', { preparationProfile: current, preparing: true }); + await assemblePlan({ admin, desired, manifest, webSecret, before, preparationOnly: true }); + return Object.freeze({ profile: copy(expected), verify: actual => { + if (!equal(actual, expected)) refuse('prepare', 'readback_conflict'); + } }); +} + +async function assemblePlan({ admin, desired, manifest, webSecret, before, preparationOnly = false }) { + const actions = []; + const add = (label, apply, body) => { + preflightBody(body); + actions.push({ label, apply }); + }; + const desiredScopes = new Map(desired.clientScopes.map(scope => [scope.name, scope])); + const expectedScopes = new Map(before.scopes.map(scope => [scope.name, copy(scope)])); + const expectedMappers = new Map(before.scopeMappers); + const changedScopeIds = new Set(); + const requiredScopeNames = new Set(); + for (const name of namedClients) { + const wanted = desired.clients.find(client => client.clientId === name); + for (const scopeName of [...wanted.defaultClientScopes, ...wanted.optionalClientScopes]) requiredScopeNames.add(scopeName); + } + // Scope references are filled only after a successful creation/readback. The + // plan itself holds no synthetic provider IDs. + const scopeIds = new Map(before.scopes.map(scope => [scope.name, scope.id])); + for (const name of requiredScopeNames) { + const wanted = desiredScopes.get(name); + if (!wanted) refuse('plan', 'unsupported'); + const current = before.scopeByName.get(name); + if (current && current.protocol !== wanted.protocol) refuse('plan', 'mapper_conflict'); + if (!current) { + expectedScopes.set(name, copy(wanted)); + expectedMappers.set(name, copy(wanted.protocolMappers ?? [])); + add('create_scope', async () => { + const result = await admin.post('client-scopes', copy(wanted)); + if (result.status !== 201 && result.status !== 409) refuse('apply', 'status'); + const found = array(await admin.get('client-scopes'), 'apply').filter(scope => scope.name === name); + if (found.length !== 1 || !found[0].id || !matchesFields(found[0], omit(wanted, ['protocolMappers']))) refuse('apply', 'mapper_conflict'); + const actualMappers = array(await admin.get(`client-scopes/${encode(found[0].id)}/protocol-mappers/models`), 'apply'); + if (!equal(actualMappers.map(mapper => omit(mapper, ['id'])).sort(byName), + (wanted.protocolMappers ?? []).map(mapper => omit(mapper, ['id'])).sort(byName))) refuse('apply', 'mapper_conflict'); + scopeIds.set(name, found[0].id); + }, wanted); + } else if (name === managedScope || name === 'basic') { + const currentMappers = before.scopeMappers.get(name) ?? []; + const merged = mergeOwnedMappers(currentMappers, wanted.protocolMappers, 'plan'); + expectedMappers.set(name, merged); + for (const mapper of merged) { + const previous = currentMappers.find(value => value.name === mapper.name); + if (equal(previous, mapper)) continue; + changedScopeIds.add(current.id); + if (previous) add('update_mapper', () => admin.put(`client-scopes/${encode(current.id)}/protocol-mappers/models/${encode(previous.id)}`, copy(mapper)), mapper); + else add('create_mapper', async () => { + const result = await admin.post(`client-scopes/${encode(current.id)}/protocol-mappers/models`, copy(mapper)); + if (result.status !== 201 && result.status !== 409) refuse('apply', 'status'); + const values = array(await admin.get(`client-scopes/${encode(current.id)}/protocol-mappers/models`), 'apply'); + const found = values.filter(value => value.name === mapper.name); + if (found.length !== 1 || !matchesFields(found[0], mapper)) refuse('apply', 'mapper_conflict'); + }, mapper); + } + if (name === managedScope) { + const attributes = { ...current.attributes, ...wanted.attributes }; + // Pinned ClientScopeResource validates name even for an attribute-only + // update. Carry the unchanged identity/protocol without replaying the + // full representation, mappers or unrelated descriptive fields. + const body = { name: current.name, protocol: current.protocol, attributes }; + expectedScopes.set(name, { ...current, ...body }); + if (!equal(current.attributes, attributes)) add('update_scope', () => admin.put(`client-scopes/${encode(current.id)}`, copy(body)), body); + } + } + } + const profile = mergedProfile(before.profile, desired); + if (!equal(profile, before.profile)) add('update_profile', () => admin.put('users/profile', copy(profile)), profile); + if (before.realm.accessTokenLifespan !== 900) add('update_lifespan', () => admin.put('', { accessTokenLifespan: 900 }), { accessTokenLifespan: 900 }); + const expectedClients = new Map(); + const expectedAssignments = new Map(); + for (const name of namedClients) { + const baseline = before.clients.get(name); + const wanted = desired.clients.find(client => client.clientId === name); + // ClientResource can create/delete nonmanifest service accounts or move its + // scope as a side effect. Such transitions need explicit operator handling. + if (baseline.client.serviceAccountsEnabled !== wanted.serviceAccountsEnabled) + refuse('plan', 'unsupported'); + if (name === 'learnstack-web' && baseline.client.publicClient !== true && baseline.secret !== webSecret) refuse('plan', 'secret_conflict'); + const body = mergedClient(baseline.client, wanted, webSecret); + expectedClients.set(name, { ...baseline.client, ...omit(body, ['secret']) }); + const patch = clientPatch(baseline.client, body); + if (Object.keys(patch).length) { + // Stock ClientResource PUT clears configured expiry/rotation state. + // Ordinary client.secret.creation.time is not rotation state and stays. + if (secretRotationAttributes.some(attribute => Object.hasOwn(baseline.client.attributes ?? {}, attribute))) + refuse('plan', 'unsupported'); + if (baseline.client.serviceAccountsEnabled === false + && (before.users.some(user => user.serviceAccountClientId === name) + || baseline.defaults.some(scope => scope.name === 'service_account'))) + refuse('plan', 'unsupported'); + add('update_client', () => admin.put(`clients/${encode(baseline.client.id)}`, copy(patch)), patch); + } + const defaults = new Set(baseline.defaults.map(scope => scope.name)); + const optional = new Set(baseline.optional.map(scope => scope.name)); + for (const scopeName of wanted.defaultClientScopes) { + if (optional.has(scopeName)) refuse('plan', 'unsupported'); + defaults.add(scopeName); + if (!baseline.defaults.some(scope => scope.name === scopeName)) + add('attach_default_scope', () => admin.put(`clients/${encode(baseline.client.id)}/default-client-scopes/${encode(scopeIds.get(scopeName))}`)); + } + for (const scopeName of wanted.optionalClientScopes) { + if (defaults.has(scopeName)) continue; + optional.add(scopeName); + if (!baseline.optional.some(scope => scope.name === scopeName)) + add('attach_optional_scope', () => admin.put(`clients/${encode(baseline.client.id)}/optional-client-scopes/${encode(scopeIds.get(scopeName))}`)); + } + expectedAssignments.set(name, { defaults, optional }); + const effective = [...new Set([...defaults, ...optional])].flatMap(scopeName => { + const mappers = expectedMappers.get(scopeName); + if (!mappers) refuse('plan', 'unsupported'); + return mappers; + }); + validateEffectiveMappers([...baseline.mappers, ...effective]); + } + // Mapper qualification precedes inherited-source reads, so an unsafe + // aggregation customization always refuses without being silently repaired. + const beforeGroups = await readRealmGroups(admin, before.users, 'plan'); + const account = await planAccountSelfService({ admin, desired, manifest, candidateUsers: before.candidates, + add, preflightResponse, changedScopeIds }); + // A manifest actor may not already belong to a differently named account. + for (const user of before.readableClaims.includes('user_id') ? before.users : []) { + const values = user.attributes?.user_id; + if (!Array.isArray(values)) continue; + for (const value of values) { + const declaration = manifest.users.find(entry => typeof value === 'string' && entry.userId.toLowerCase() === value.toLowerCase()); + if (declaration && user.username !== declaration.username) refuse('plan', 'identity_conflict'); + } + } + const expectedUsers = new Map(); + const identityReserve = new Map(); + for (const declaration of manifest.users) { + const existing = before.candidates.get(declaration.username); + if (existing) { + if (preparationOnly) { + // A partial profile does not hide every claim. Apply the same guards to + // every already-readable value before any exceptional metadata write. + const observed = existing.user.attributes === undefined ? {} : requireObject(existing.user.attributes, 'plan'); + const values = { user_id: declaration.userId, tenant_id: declaration.tenantId, + organization_id: declaration.organizationId }; + for (const name of before.readableClaims) + validateIdentityAttribute(name, observed[name], values[name], declaration, 'plan'); + validateAttributePreservation(before.profile); + // Budget observed account data plus every prospective canonical identity + // byte. These numbers grant no identity/ownership or mutation authority; + // newly visible stored values still require a fresh complete plan. + const canonical = { user_id: [declaration.userId], tenant_id: [declaration.tenantId], + ...(declaration.organizationId === null ? {} : { organization_id: [declaration.organizationId] }) }; + const reserve = jsonBytes({ attributes: canonical }); + identityReserve.set(existing.user.id, reserve); + expectedUsers.set(declaration.username, existing.user); + const fields = Object.fromEntries(['username', 'email', 'firstName', 'lastName'] + .filter(key => key in existing.user).map(key => [key, existing.user[key]])); + if (jsonBytes({ ...fields, attributes: observed }) + reserve > bodyLimit) refuse('plan', 'body_limit'); + continue; + } + const attributes = identityAttributes(existing.user, declaration, 'plan'); + expectedUsers.set(declaration.username, { ...existing.user, attributes }); + if (!equal(attributes, existing.user.attributes ?? {})) { + validateAttributePreservation(before.profile); + // A supplied attribute map replaces profile values in 26.6.2. Include + // unchanged ordinary profile fields, but no credentials/actions/enabled. + const body = { attributes, ...Object.fromEntries(['username', 'email', 'firstName', 'lastName'] + .filter(key => key in existing.user).map(key => [key, existing.user[key]])) }; + add('update_user_attributes', () => admin.put(`users/${encode(existing.user.id)}`, copy(body)), body); + } + } else { + validateAttributePreservation(before.profile, true); + if (before.users.some(user => typeof user.email === 'string' && user.email.toLowerCase() === declaration.email.toLowerCase())) refuse('plan', 'identity_conflict'); + const wanted = copy(desired.users.find(user => user.username === declaration.username)); + if (!equal(wanted.realmRoles, [declaration.realmRole])) refuse('plan', 'unsupported'); + const role = requireObject(await admin.get(`roles/${encode(declaration.realmRole)}`), 'plan'); + if (!['tenant-admin', 'tenant-learner'].includes(declaration.realmRole) + || !role.id || role.name !== declaration.realmRole || role.composite !== false || role.clientRole !== false) + refuse('plan', 'unsupported'); + const representation = Object.fromEntries(['username', 'email', 'emailVerified', 'enabled', + 'firstName', 'lastName', 'credentials', 'realmRoles', 'clientRoles', 'attributes'].map(key => [key, copy(wanted[key])])); + const body = { ifResourceExists: 'FAIL', users: [representation] }; + add('create_user', async () => { + // Pinned RealmAdminResource.partialImport wraps one named user in its + // own transaction. Unlike POST users, this creates credentials and the + // declared inert role and account capability atomically. FAIL never overwrites. + const result = await admin.post('partialImport', copy(body)); + // Only ADDED proves that this transaction installed the declared + // credential. An exact-looking concurrent account cannot prove it. + if (result.status === 409) refuse('apply', 'identity_conflict'); + if (result.status !== 200) refuse('apply', 'status'); + const receipt = result.body; + const added = receipt?.results; + if (!object(receipt) || receipt.added !== 1 || receipt.overwritten !== 0 || receipt.skipped !== 0 + || receipt.errorMessage != null || !Array.isArray(added) || added.length !== 1 + || !object(added[0]) + || added[0].action !== 'ADDED' || added[0].resourceType !== 'USER' + || added[0].resourceName !== declaration.username || typeof added[0].id !== 'string' || !added[0].id) + refuse('apply', 'identity_conflict'); + const created = await exactNewUser(admin, declaration, wanted, 'apply'); + if (created.id !== added[0].id) refuse('apply', 'identity_conflict'); + const roles = requireObject(await admin.get(`users/${encode(created.id)}/role-mappings`), 'apply'); + const credentials = array(await admin.get(`users/${encode(created.id)}/credentials`), 'apply'); + const assigned = await readUserGroupIds(admin, created.id, 'apply'); + if (!exactCreatedAccess(roles, credentials, role.id, account) || assigned.size !== 0) refuse('apply', 'identity_conflict'); + expectedUsers.set(declaration.username, created); + }, body); + } + } + + // Every resulting collection must fit the same bounded readers used by + // apply/readback and a subsequent explicit run, before any action escapes. + array([...expectedScopes.values()], 'plan'); + array(profile.attributes, 'plan'); + if (profile.groups !== undefined) array(profile.groups, 'plan'); + for (const mappers of expectedMappers.values()) array(mappers, 'plan'); + for (const { defaults, optional } of expectedAssignments.values()) { + array([...defaults], 'plan'); + array([...optional], 'plan'); + } + if (before.users.length + manifest.users.filter(entry => !before.candidates.has(entry.username)).length + >= pageSize * pageLimit) refuse('plan', 'unsupported'); + validateProjectedResponses({ before, profile, scopes: expectedScopes, mappers: expectedMappers, + clients: expectedClients, assignments: expectedAssignments, users: expectedUsers, desired, manifest, webSecret, identityReserve }); + + if (preparationOnly) return; + + return { actions, verify: async () => { + const after = await snapshot(admin, desired, manifest, 'readback'); + await account.verify(after.candidates); + const afterGroups = await readRealmGroups(admin, after.users, 'readback'); + const check = condition => { if (!condition) refuse('readback', 'readback_conflict'); }; + check(after.realm.accessTokenLifespan === 900 + && equal(omit(after.realm, ['accessTokenLifespan']), omit(before.realm, ['accessTokenLifespan']))); + check(equal(after.profile, profile)); + check(equal(afterGroups.groups, beforeGroups.groups)); + for (const [userId, assigned] of beforeGroups.memberships) + check(equal(afterGroups.memberships.get(userId), assigned)); + for (const [name, expected] of expectedScopes) { + const actual = after.scopeByName.get(name); + check(Boolean(actual) && matchesFields(actual, omit(expected, ['id', 'protocolMappers']))); + if (before.scopeByName.has(name)) check(actual.id === before.scopeByName.get(name).id); + if (expectedMappers.has(name)) { + const actualMappers = after.scopeMappers.get(name) ?? []; + check(equal(actualMappers.map(mapper => omit(mapper, ['id'])).sort(byName), + expectedMappers.get(name).map(mapper => omit(mapper, ['id'])).sort(byName))); + for (const original of before.scopeMappers.get(name) ?? []) + check(actualMappers.some(mapper => mapper.name === original.name && mapper.id === original.id)); + } + } + for (const name of namedClients) { + const actual = after.clients.get(name); + const baseline = before.clients.get(name); + const wanted = desired.clients.find(client => client.clientId === name); + check(equal(omit(actual.client, ['secret', 'defaultClientScopes', 'optionalClientScopes']), + omit(expectedClients.get(name), ['secret', 'defaultClientScopes', 'optionalClientScopes']))); + check(actual.secret === (name === 'learnstack-web' ? webSecret : baseline.secret)); + check(equal(actual.mappers, baseline.mappers)); + for (const [kind, required] of [['defaults', wanted.defaultClientScopes], ['optional', wanted.optionalClientScopes]]) { + const names = new Set(actual[kind].map(scope => scope.name)); + check(equal(names, expectedAssignments.get(name)[kind])); + check(baseline[kind].every(scope => actual[kind].some(value => value.id === scope.id))); + check(required.every(scopeName => names.has(scopeName) + || (kind === 'optional' && actual.defaults.some(scope => scope.name === scopeName)))); + } + } + for (const previous of before.users) { + const current = after.users.find(user => user.id === previous.id); + check(Boolean(current)); + // Full list rows include metadata derived from the realm profile. Its + // owned definition changes are verified independently above; every + // stored account field remains part of this preservation comparison. + if (!before.candidates.has(previous.username)) + check(equal(omit(current, ['userProfileMetadata']), omit(previous, ['userProfileMetadata']))); + } + check(after.users.length === before.users.length + manifest.users.filter(entry => !before.candidates.has(entry.username)).length); + for (const declaration of manifest.users) { + const actual = after.candidates.get(declaration.username); + const original = before.candidates.get(declaration.username); + check(Boolean(actual) && expectedUsers.has(declaration.username)); + check(equal(actual.user, expectedUsers.get(declaration.username))); + check(equal(identityAttributes(actual.user, declaration, 'readback'), actual.user.attributes)); + if (original) check(equal(actual.credentials, original.credentials)); + else { + // Atomic addUser(false,false) skips default roles AND default groups. + check(afterGroups.memberships.get(actual.user.id)?.size === 0); + const role = actual.roles.realmMappings?.find(value => value.name === declaration.realmRole); + check(Boolean(role) && role.composite === false && role.clientRole === false + && exactCreatedAccess(actual.roles, actual.credentials, role.id, account)); + } + } + } }; +} + +function byName(left, right) { return left.name.localeCompare(right.name); } diff --git a/scripts/development-realm-plan.test.mjs b/scripts/development-realm-plan.test.mjs new file mode 100644 index 00000000..af5915c0 --- /dev/null +++ b/scripts/development-realm-plan.test.mjs @@ -0,0 +1,1568 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRealmPlan } from './development-realm-plan.mjs'; +import { desired, manifest, secret, provider } from './development-realm-test-fixture.mjs'; +import { accountMapperRepresentation, claimRoot, validateAccountMappers, validateEffectiveMappers } from './development-realm-mappers.mjs'; + +const clone = value => structuredClone(value); +const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); + +const plan = fixture => buildRealmPlan({ admin: fixture.admin, desired: clone(desired), manifest: clone(manifest), webSecret: secret }); +const apply = async change => { for (const action of change.actions) await action.apply(); await change.verify(); }; + +for (const [name, mutate, reason = 'identity_conflict'] of [ + ['wrong realm', input => { input.desired.realm = 'foreign'; }, 'unsupported'], + ['wrong lifespan', input => { input.desired.accessTokenLifespan = 1800; }, 'unsupported'], + ['missing exported users', input => { input.desired.users = null; }, 'unsupported'], + ['wrong exported count', input => { input.desired.users.pop(); }, 'unsupported'], + ['missing manifest users', input => { input.manifest.users = null; }, 'unsupported'], + ['wrong manifest count', input => { input.manifest.users.pop(); }, 'unsupported'], + ['empty secret', input => { input.webSecret = ''; }, 'unsupported'], + ['nonstring secret', input => { input.webSecret = 123; }, 'unsupported'], + ['duplicate username', input => { input.manifest.users[1].username = input.manifest.users[0].username; }], + ['invalid actor UUID', input => { input.manifest.users[0].userId = 'invalid'; }], + ['reserved actor UUID', input => { input.manifest.users[0].userId = '00000000-0000-0000-0000-000000000000'; }], + ['non-v7 actor UUID', input => { input.manifest.users[0].userId = input.manifest.users[0].userId.replace('-7000-', '-4000-'); }], + ['duplicate actor', input => { input.manifest.users[1].userId = input.manifest.users[0].userId; }], + ['invalid tenant UUID', input => { input.manifest.users[0].tenantId = 'invalid'; }], + ['invalid organization UUID', input => { input.manifest.users[2].organizationId = 'invalid'; }], + ['tenant organization collision', input => { input.manifest.users[2].organizationId = input.manifest.users[2].tenantId; }], + ['actor scope collision', input => { input.manifest.users[0].userId = input.manifest.users[2].organizationId; }], + ['wrong legacy literal', input => { input.manifest.users[0].legacyTenantAttribute = 'another-legacy'; }], + ['Yoga legacy migration', input => { input.manifest.users[2].legacyTenantAttribute = 'tenant-a'; }], + ['legacy organization', input => { input.manifest.users[0].organizationId = input.manifest.users[2].organizationId; }], + ['wrong account capability', input => { input.manifest.users[0].accountCapability.role = 'view-profile'; }], + ['missing exported persona', input => { input.desired.users[0].username = 'other'; }], + ['duplicate exported persona', input => { input.desired.users[1].username = input.desired.users[0].username; }], + ['wrong email', input => { input.desired.users[0].email = 'other@fixture.invalid'; }], + ['wrong first name', input => { input.desired.users[0].firstName = 'other'; }], + ['wrong last name', input => { input.desired.users[0].lastName = 'other'; }], + ['disabled declaration', input => { input.desired.users[0].enabled = false; }], + ['unverified email', input => { input.desired.users[0].emailVerified = false; }], + ['missing credentials', input => { input.desired.users[0].credentials = []; }], + ['null credential', input => { input.desired.users[0].credentials = [null]; }], + ['multiple credentials', input => { input.desired.users[0].credentials.push(clone(input.desired.users[0].credentials[0])); }], + ['wrong credential type', input => { input.desired.users[0].credentials[0].type = 'otp'; }], + ['temporary credential', input => { input.desired.users[0].credentials[0].temporary = true; }], + ['empty credential', input => { input.desired.users[0].credentials[0].value = ''; }], + ['wrong declared role', input => { input.manifest.users[0].realmRole = 'unknown'; }], + ['wrong exported role', input => { input.desired.users[0].realmRoles = ['tenant-learner']; }], + ['wrong account grant', input => { input.desired.users[0].clientRoles.account = ['view-profile']; }], + ['wrong exported actor', input => { input.desired.users[0].attributes.user_id = [input.manifest.users[1].userId]; }], +]) { + test(`input preflight rejects ${name} before the first provider read`, async () => { + const fixture = provider(); + const input = { desired: clone(desired), manifest: clone(manifest), webSecret: secret, admin: fixture.admin }; + mutate(input); + await assert.rejects(buildRealmPlan(input), error => error.stage === 'plan' && error.reason === reason); + assert.deepEqual(fixture.reads, []); + assert.deepEqual(fixture.writes, []); + }); +} + +test('disabled realm or named client and consent drift refuse without repairing unrelated settings', async () => { + for (const resource of ['realm', 'learnstack-web', 'learnstack-api']) { + for (const field of resource === 'realm' ? ['enabled'] : ['enabled', 'consentRequired']) { + const fixture = provider(); + const value = resource === 'realm' ? fixture.state.realm : fixture.state.clients.find(client => client.clientId === resource); + value[field] = field !== 'enabled'; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.deepEqual(fixture.state, original); + assert.deepEqual(fixture.writes, []); + } + } +}); + +test('stock account callbacks, origins and consent must match the supported warm boundary', async () => { + for (const name of ['account', 'account-console']) for (const [field, value] of [ + ['redirectUris', ['https://foreign.fixture.invalid/*']], ['webOrigins', ['*']], + ['rootUrl', 'https://foreign.fixture.invalid/'], ['baseUrl', '/foreign/'], + ['consentRequired', true], ['attributes', { 'post.logout.redirect.uris': '*' }], + ]) { + const fixture = provider(); + const client = fixture.state.clients.find(client => client.clientId === name); + client[field] = field === 'attributes' ? { ...client.attributes, ...value } : clone(value); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported', `${name}/${field}`); + assert.deepEqual(fixture.writes, []); + assert.deepEqual(fixture.state, original); + } +}); + +test('matching reserved usernames without protected actor identity are not adopted from public registration', async () => { + for (const index of [0, 1, 2, 3]) for (const actor of [undefined, []]) { + const fixture = provider(); + if (actor === undefined) delete fixture.state.users[index].attributes.user_id; + else fixture.state.users[index].attributes.user_id = actor; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.deepEqual(fixture.writes, []); + assert.deepEqual(fixture.state, original); + } + const legacyFixture = provider(); legacy(legacyFixture); + await apply(await plan(legacyFixture)); + for (const declaration of manifest.users) assert.deepEqual(legacyFixture.state.users + .find(user => user.username === declaration.username).attributes.user_id, [declaration.userId]); +}); + +test('equivalent redirect registration order requires no write and preserves its existing representation', async () => { + const fixture = provider(); + fixture.state.clients.find(client => client.clientId === 'learnstack-web').redirectUris.reverse(); + const original = clone(fixture.state); + const change = await plan(fixture); + assert.deepEqual(change.actions, []); + await change.verify(); + assert.deepEqual(fixture.state, original); +}); + +test('unsafe owned callbacks and password grants are repaired independently without changing retained client state', async () => { + for (const name of ['learnstack-web', 'learnstack-api']) { + for (const [field, unsafe] of [['redirectUris', ['http://localhost:3000/*', 'https://foreign.fixture.invalid/*']], + ['directAccessGrantsEnabled', true]]) { + for (const loseField of [false, true]) { + const fixture = provider(); + const client = fixture.state.clients.find(value => value.clientId === name); + client[field] = clone(unsafe); + client.description = 'retained client description'; + client.attributes['retained.fixture.setting'] = 'retained'; + const before = clone(client); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_client']); + assert.deepEqual(fixture.writes, []); + const put = fixture.admin.put; + fixture.admin.put = async (path, body) => put(path, loseField ? omit(body, [field]) : body); + if (loseField) { + await assert.rejects(apply(change), error => error.stage === 'readback' && error.reason === 'readback_conflict'); + assert.deepEqual(client[field], unsafe); + } else { + await apply(change); + assert.deepEqual(client[field], desired.clients.find(value => value.clientId === name)[field]); + assert.deepEqual(omit(client, [field, 'authorizationServicesEnabled']), omit(before, [field, 'authorizationServicesEnabled'])); + assert.equal((await plan(fixture)).actions.length, 0); + assert.equal(fixture.writes.length, 1); + } + assert.equal(fixture.writes[0].path, `clients/${client.id}`); + } + } + } +}); + +test('exact username lookups distinguish retained near matches during both existing-user and new-user reconciliation', async () => { + for (const creating of [false, true]) { + const fixture = provider(); + const declaration = manifest.users.at(-1); + if (creating) fixture.state.users.pop(); + const near = { id: 'retained-near-match', username: `prefix-${declaration.username}-suffix`, + enabled: false, attributes: {}, credentials: [], roles: {} }; + fixture.state.users.unshift(near); + const lookup = `users?username=${encodeURIComponent(declaration.username)}&max=2&briefRepresentation=false`; + const contains = await fixture.admin.get(lookup); + assert.equal(contains.length, creating ? 1 : 2, 'the independent provider model must expose the contains-match distinction'); + const exact = await fixture.admin.get(lookup + '&exact=true'); + assert.equal(exact.length, creating ? 0 : 1); + const before = clone(near); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), creating ? ['create_user'] : []); + await apply(change); + assert.deepEqual(near, before); + assert.equal(fixture.state.users.filter(user => user.username === declaration.username).length, 1); + assert.equal((await plan(fixture)).actions.length, 0); + } +}); + +test('full filtered census exposes actor collisions on both ordinary and service accounts before pending writes', async () => { + for (const serviceAccount of [false, true]) { + const fixture = provider(); + fixture.state.realm.accessTokenLifespan = 1800; + const other = { id: 'retained-collision', username: 'retained-collision', enabled: false, + attributes: { user_id: [manifest.users[0].userId] }, credentials: [], roles: {}, + ...(serviceAccount ? { serviceAccountClientId: 'learnstack-api' } : {}) }; + fixture.state.users.push(other); + const path = 'users?first=0&max=100'; + const unfiltered = await fixture.admin.get(path); + assert.equal(unfiltered.some(user => user.id === other.id), !serviceAccount); + const brief = await fixture.admin.get(path + '&exact=false&briefRepresentation=true'); + assert.equal(brief.find(user => user.id === other.id).attributes, undefined); + const full = await fixture.admin.get(path + '&exact=false&briefRepresentation=false'); + assert.deepEqual(full.find(user => user.id === other.id).attributes.user_id, [manifest.users[0].userId]); + assert.deepEqual(await fixture.admin.get(path + '&exact=false'), full, + 'omitting explicit brief=false is equivalent in the pinned provider and is not a defect'); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.deepEqual(fixture.writes, []); + delete other.attributes.user_id; + const before = clone(other); + await apply(await plan(fixture)); + assert.deepEqual(other, before); + assert.equal((await plan(fixture)).actions.length, 0); + } +}); + +test('successful user import receipts cannot conceal independently corrupted created account data or access', async () => { + const corruptions = [ + user => { user.username = 'different-created-user'; }, + user => { user.email = 'different@fixture.invalid'; }, + user => { user.enabled = false; }, + user => { user.emailVerified = false; }, + user => { user.firstName = 'different'; }, + user => { user.lastName = 'different'; }, + user => { user.attributes.user_id = [manifest.users[0].userId]; }, + user => { user.attributes.tenant_id = [manifest.users[0].tenantId]; }, + user => { user.requiredActions = ['UPDATE_PASSWORD']; }, + user => { user.roles.realmMappings[0].id = 'different-role-id'; }, + user => { user.roles.clientMappings.account.mappings = []; }, + user => { user.credentials = []; }, + user => { user.credentials[0].type = 'otp'; }, + user => { user.credentials.push({ id: 'extra-credential', type: 'password' }); }, + ]; + for (const corrupt of corruptions) { + const fixture = provider(); fixture.state.users.pop(); + const post = fixture.admin.post; + fixture.admin.post = async (path, body) => { + const response = await post(path, body); + assert.equal(path, 'partialImport'); + corrupt(fixture.state.users.at(-1)); + return response; + }; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['create_user']); + await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 1); + assert.equal(fixture.state.users.length, 4, 'refusal preserves the uncertain durable account instead of deleting or retrying it'); + } +}); + +test('readback rechecks newly created accounts after their successful immediate import verification', async () => { + for (const corrupt of [ + user => { user.firstName = 'changed-after-creation'; }, + user => { user.attributes.organization_id = [manifest.users[0].tenantId]; }, + user => { user.requiredActions = ['UPDATE_PASSWORD']; }, + user => { user.roles.realmMappings.push({ id: 'extra-role', name: 'extra', composite: false, clientRole: false }); }, + user => { user.credentials[0].type = 'otp'; }, + ]) { + const fixture = provider(); fixture.state.users.pop(); + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + await change.verify(); + corrupt(fixture.state.users.at(-1)); + await assert.rejects(change.verify(), error => error.stage === 'readback'); + assert.equal(fixture.writes.length, 1, 'readback never repairs a mismatching account behind the operator'); + } +}); + +test('warm owned scope attributes and mapper destinations converge and independently verify', async () => { + const fixture = provider(); + const scope = fixture.state.scopes.find(value => value.name === 'learnstack-tenant-api'); + scope.attributes['include.in.token.scope'] = 'false'; + scope.attributes.retained = 'preserved'; + scope.protocolMappers[0].config['userinfo.token.claim'] = 'true'; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_mapper', 'update_scope']); + await apply(change); + assert.equal(scope.attributes.retained, 'preserved'); + assert.equal(scope.attributes['include.in.token.scope'], 'true'); + assert.equal(scope.protocolMappers[0].config['userinfo.token.claim'], 'false'); + assert.equal((await plan(fixture)).actions.length, 0); +}); + +test('scope repair carries the bounded provider envelope and an omitted-name control cannot mutate state', async () => { + for (const omitName of [false, true]) { + const fixture = provider(); + const scope = fixture.state.scopes.find(value => value.name === 'learnstack-tenant-api'); + scope.attributes['include.in.token.scope'] = 'false'; + scope.attributes.retained = 'preserved'; + scope.description = 'retained administrator description'; + const original = clone(scope); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_scope']); + const put = fixture.admin.put; + let sent; + fixture.admin.put = (path, body) => { + sent = clone(body); + if (omitName) delete body.name; + return put(path, body); + }; + if (omitName) { + await assert.rejects(apply(change), /pinned scope update requires unchanged name/); + assert.deepEqual(scope, original); + } else { + await apply(change); + assert.equal(scope.description, original.description); + assert.equal(scope.attributes.retained, 'preserved'); + assert.equal((await plan(fixture)).actions.length, 0); + } + assert.deepEqual(Object.keys(sent).sort(), ['attributes', 'name', 'protocol']); + assert.equal(sent.name, original.name); + assert.equal(sent.protocol, original.protocol); + } +}); + +test('user creation requires the exact successful ADDED receipt bound to its new provider ID', async () => { + for (const mutate of [ + response => { delete response.body; }, response => { response.body.added = 0; }, + response => { response.body.overwritten = 1; }, response => { response.body.skipped = 1; }, + response => { response.body.results[0].action = 'SKIPPED'; }, + response => { response.body.results[0].resourceType = 'CLIENT'; }, + response => { response.body.results[0].resourceName = 'other'; }, + response => { response.body.results[0].id = 'different-provider-id'; }, + response => { response.body.results.push(clone(response.body.results[0])); }, + response => { response.body.results = [null]; }, + ]) { + const fixture = provider(); fixture.state.users.pop(); + const post = fixture.admin.post; + fixture.admin.post = async (path, body) => { const response = await post(path, body); mutate(response); return response; }; + const change = await plan(fixture); + await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 1); + assert.equal(fixture.writes[0].body.ifResourceExists, 'FAIL'); + } +}); + +test('identity and audience mapper destinations reject independent unsafe configurations', () => { + const effective = desired.clientScopes.filter(scope => ['basic', 'roles', 'learnstack-tenant-api'].includes(scope.name)) + .flatMap(scope => scope.protocolMappers ?? []); + validateEffectiveMappers(effective); + for (const [type, field, value] of [ + ['oidc-usermodel-attribute-mapper', 'access.token.claim', 'false'], + ['oidc-usermodel-attribute-mapper', 'id.token.claim', 'false'], + ['oidc-usermodel-attribute-mapper', 'userinfo.token.claim', 'true'], + ['oidc-usermodel-attribute-mapper', 'jsonType.label', 'long'], + ['oidc-usermodel-attribute-mapper', 'multivalued', 'true'], + ['oidc-audience-mapper', 'id.token.claim', 'true'], + ['oidc-audience-mapper', 'access.token.claim', 'false'], + ['oidc-audience-resolve-mapper', 'id.token.claim', 'true'], + ['oidc-sub-mapper', 'access.token.claim', 'false'], + ]) { + const values = clone(effective); + values.find(mapper => mapper.protocolMapper === type).config[field] = value; + assert.throws(() => validateEffectiveMappers(values), error => error.reason === 'mapper_conflict', `${type}/${field}`); + } +}); + +test('concurrent scope and mapper creation never admits extra provider configuration', async () => { + for (const resource of ['scope', 'mapper']) { + const fixture = provider(); + const scope = fixture.state.scopes.find(scope => scope.name === 'basic'); + if (resource === 'scope') { + fixture.state.scopes = fixture.state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api'); + for (const client of fixture.state.clients) client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + } else scope.protocolMappers = scope.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-sub-mapper'); + const post = fixture.admin.post; + fixture.admin.post = async (path, body) => { + await post(path, body); + if (resource === 'scope') fixture.state.scopes.at(-1).attributes.unexpected = 'not-admitted'; + else scope.protocolMappers.at(-1).config.unexpected = 'not-admitted'; + return { status: 409 }; + }; + const change = await plan(fixture); + await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'mapper_conflict'); + assert.equal(fixture.writes.length, 1); + } +}); + +function legacy(fixture) { + const { state } = fixture; + state.realm.accessTokenLifespan = 1800; + state.profile.attributes = state.profile.attributes.filter(attribute => !['user_id', 'tenant_id', 'organization_id'].includes(attribute.name)); + state.profile.unmanagedAttributePolicy = 'ADMIN_EDIT'; + state.profile.attributes.push({ name: 'retained', permissions: { view: ['admin'], edit: ['admin'] } }); + state.profile.groups.push({ name: 'retained', displayHeader: 'Retained group' }); + state.scopes = state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api'); + for (const client of state.clients.filter(client => ['learnstack-web', 'learnstack-api'].includes(client.clientId))) { + client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + client.attributes['access.token.lifespan'] = '1800'; + client.attributes['retained-setting'] = 'retained'; + if (client.clientId === 'learnstack-web') { client.publicClient = true; client.secret = 'unused-public-secret'; } + } + state.clients.find(client => client.clientId === 'account-console').protocolMappers = + state.clients.find(client => client.clientId === 'account-console').protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper'); + state.users = state.users.slice(0, 2); + for (const [index, user] of state.users.entries()) { + user.attributes = { tenant_id: [manifest.users[index].legacyTenantAttribute], retained: ['untouched'] }; + user.enabled = false; + user.requiredActions = ['UPDATE_PASSWORD']; + user.credentials.push({ id: `mfa-${index}`, type: 'otp', credentialData: 'retained MFA' }); + user.firstName = 'Retained'; + delete user.roles.clientMappings; + } + state.users.push({ id: 'unrelated-account', username: 'unrelated-account', enabled: true, + attributes: { retained: ['untouched'] }, credentials: [], roles: {} }); +} + +test('fresh desired state has zero actions and independently verifies', async () => { + const fixture = provider(); + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + assert.equal(fixture.writes.length, 0); + await change.verify(); + assert.equal(fixture.writes.length, 0); +}); + +test('pinned mapper storage omits an empty account role prefix while qualification accepts precisely empty or absent', async () => { + const canonical = desired.clients.find(client => client.clientId === 'account-console').protocolMappers + .find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper'); + const prefix = 'usermodel.clientRoleMapping.rolePrefix'; + assert.equal(canonical.config[prefix], ''); + const fixture = provider(); + const mappers = fixture.state.clients.find(client => client.clientId === 'account-console').protocolMappers; + const stored = mappers.find(mapper => mapper.protocolMapper === canonical.protocolMapper); + assert.equal(Object.hasOwn(stored.config, prefix), false, 'provider state is not an echo of the export'); + assert.deepEqual(omit(stored, ['id']), accountMapperRepresentation(canonical)); + for (const value of [undefined, '']) { + if (value === undefined) delete stored.config[prefix]; + else stored.config[prefix] = value; + const before = clone(fixture.state); + assert.equal(validateAccountMappers(mappers, canonical), true); + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + await change.verify(); + assert.deepEqual(fixture.state, before); + } +}); + +test('account prefix normalization never drops unknown configuration or supplies another missing required field', async () => { + const canonical = desired.clients.find(client => client.clientId === 'account-console').protocolMappers + .find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper'); + const mutations = Object.keys(canonical.config).filter(key => key !== 'usermodel.clientRoleMapping.rolePrefix') + .map(key => config => { delete config[key]; }); + for (const value of [null, ' ', 'unexpected-', false]) mutations.push(config => { config['usermodel.clientRoleMapping.rolePrefix'] = value; }); + mutations.push(config => { config['unqualified.empty.setting'] = ''; }); + for (const mutate of mutations) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const mapper = fixture.state.clients.find(client => client.clientId === 'account-console').protocolMappers + .find(mapper => mapper.protocolMapper === canonical.protocolMapper); + mutate(mapper.config); + const before = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && ['mapper_conflict', 'unsupported'].includes(error.reason)); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, before); + } +}); + +test('new account mapper readback accepts only the pinned empty-prefix omission, including one exact creation conflict check', async () => { + for (const status of [201, 409]) for (const corrupt of [false, true]) { + const fixture = provider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + console.protocolMappers = console.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper'); + const originalPost = fixture.admin.post; + fixture.admin.post = async (path, body) => { + await originalPost(path, body); + if (corrupt) console.protocolMappers.at(-1).config['id.token.claim'] = 'true'; + return { status }; + }; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['create_account_mapper']); + if (corrupt) await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'mapper_conflict'); + else { + await apply(change); + assert.equal(Object.hasOwn(console.protocolMappers.at(-1).config, 'usermodel.clientRoleMapping.rolePrefix'), false); + assert.equal((await plan(fixture)).actions.length, 0); + } + assert.equal(fixture.writes.length, 1); + } +}); + +test('normalization of the intended addition does not weaken raw preservation of an existing mapper', async () => { + const fixture = provider(); + const mapper = fixture.state.clients.find(client => client.clientId === 'account-console').protocolMappers + .find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper'); + mapper.config['usermodel.clientRoleMapping.rolePrefix'] = ''; + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + delete mapper.config['usermodel.clientRoleMapping.rolePrefix']; + await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'readback_conflict'); + assert.equal(fixture.writes.length, 0); +}); + +test('account capability is explicit manifest data and agrees with the exact export grant', async () => { + for (const variant of ['missing', 'other_role', 'extra_grant', 'default_role']) { + const fixture = provider(); + const declaration = clone(manifest); + const exported = clone(desired); + if (variant === 'missing') delete declaration.users[0].accountCapability; + if (variant === 'other_role') declaration.users[0].accountCapability.role = 'view-profile'; + if (variant === 'extra_grant') exported.users[0].clientRoles.account.push('view-profile'); + if (variant === 'default_role') exported.users[0].clientRoles = {}; + await assert.rejects(buildRealmPlan({ admin: fixture.admin, desired: exported, manifest: declaration, webSecret: secret }), + error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 0); + } +}); + +test('inherited account capability and a compatible differently named fixed mapper need no new grant or mapper', async () => { + for (const source of ['group', 'composite']) { + const fixture = provider(); + const user = fixture.state.users[0]; + const role = fixture.state.accountRoles.find(role => role.name === 'manage-account'); + delete user.roles.clientMappings; + if (source === 'group') { + fixture.state.groups.push({ id: 'eligible-group', name: 'Eligible', attributes: {}, clientRoles: { account: ['manage-account'] } }); + fixture.state.memberships[user.id] = ['eligible-group']; + } else fixture.state.roleChildren[user.roles.realmMappings[0].id] = [role]; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + console.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper').name = 'retained-compatible-name'; + const before = clone(fixture.state); + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + await change.verify(); + assert.deepEqual(fixture.state, before); + } +}); + +test('an existing fixed emitter must be effective without selecting an optional console scope', async () => { + for (const kind of ['defaultClientScopes', 'optionalClientScopes']) { + const fixture = provider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const scope = fixture.state.scopes.find(scope => scope.name === 'phone'); + scope.protocolMappers.push(console.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper')); + console.protocolMappers = console.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper'); + console[kind].push(scope.name); + if (kind === 'optionalClientScopes') await assert.rejects(plan(fixture), error => error.reason === 'unsupported'); + else { + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + await change.verify(); + } + assert.equal(fixture.writes.length, 0); + } +}); + +test('account scope id/name summaries resolve full representations and preserve compatible custom graphs', async () => { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const account = fixture.state.clients.find(client => client.clientId === 'account'); + const emitter = console.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper'); + emitter.name = 'retained-compatible-emitter'; + console.protocolMappers = console.protocolMappers.filter(mapper => mapper !== emitter); + const custom = ['default', 'optional'].map(kind => ({ id: `retained-${kind}`, name: `retained-${kind}`, + protocol: 'openid-connect', attributes: { retained: `custom ${kind} setting` }, + protocolMappers: kind === 'default' ? [emitter] : [{ id: 'retained-name-mapper', name: 'retained-name-mapper', + protocol: 'openid-connect', protocolMapper: 'oidc-full-name-mapper', config: {} }] })); + fixture.state.scopes.push(...custom); + console.defaultClientScopes = [custom[0].name]; + console.optionalClientScopes = [custom[1].name]; + fixture.state.scopeMappings[`client-scopes/${custom[0].id}`] = fixture.state.scopeMappings[`clients/${console.id}`]; + fixture.state.scopeMappings[`clients/${console.id}`] = {}; + fixture.state.scopeMappings[`client-scopes/${custom[1].id}`] = { clientMappings: { + account: { id: account.id, client: 'account', mappings: [fixture.state.accountRoles.find(role => role.name === 'view-groups')] }, + } }; + for (const kind of ['default', 'optional']) { + const summaries = await fixture.admin.get(`clients/${console.id}/${kind}-client-scopes`); + assert.deepEqual(summaries, [{ id: `retained-${kind}`, name: `retained-${kind}` }]); + } + const original = clone(fixture.state); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_lifespan']); + assert.equal(fixture.writes.length, 0); + await apply(change); + original.realm.accessTokenLifespan = 900; + assert.deepEqual(fixture.state, original); + for (const scope of custom) assert.equal(fixture.reads.filter(path => path === `client-scopes/${scope.id}`).length, 2, + 'preflight and independent readback each resolve the full scope'); + assert.equal((await plan(fixture)).actions.length, 0); +}); + +test('account scope qualification independently binds full representation id, name and protocol before pending writes', async () => { + for (const kind of ['default', 'optional']) for (const replacement of [ + null, { id: undefined }, { name: undefined }, { id: 'foreign-scope' }, { name: 'foreign-scope' }, + { protocol: 'saml' }, { protocol: undefined }, + ]) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const scope = fixture.state.scopes.find(scope => scope.name === 'phone'); + console.defaultClientScopes = kind === 'default' ? [scope.name] : []; + console.optionalClientScopes = kind === 'optional' ? [scope.name] : []; + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const value = await originalGet(path); + return path === `client-scopes/${scope.id}` ? replacement === null ? null : { ...value, ...replacement } : value; + }; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } +}); + +test('account scope summary names, duplicate ids and per-assignment count bounds refuse before full scope reads', async () => { + for (const kind of ['default', 'optional']) for (const variant of ['missing_name', 'empty_name', 'numeric_name', 'duplicate_id', 'over_count', 'shared_id']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const scope = fixture.state.scopes.find(scope => scope.name === 'phone'); + console.defaultClientScopes = []; + console.optionalClientScopes = []; + const summary = { id: scope.id, name: scope.name }; + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const value = await originalGet(path); + if (path === `clients/${console.id}/${kind}-client-scopes`) { + if (variant === 'missing_name') return [omit(summary, ['name'])]; + if (variant === 'empty_name') return [{ ...summary, name: '' }]; + if (variant === 'numeric_name') return [{ ...summary, name: 3 }]; + if (variant === 'duplicate_id') return [summary, { ...summary, name: 'other' }]; + if (variant === 'over_count') return Array.from({ length: 201 }, (_, index) => ({ id: `scope-${index}`, name: `scope-${index}` })); + } + return variant === 'shared_id' && path.startsWith(`clients/${console.id}/`) + && path.endsWith('-client-scopes') ? [summary] : value; + }; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.reads.some(path => path === `client-scopes/${scope.id}`), false); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } +}); + +test('independent account scope readback resolves and requalifies each full representation', async () => { + for (const kind of ['default', 'optional']) for (const replacement of [ + { id: 'foreign-scope' }, { name: 'foreign-scope' }, { protocol: 'saml' }, + ]) { + const fixture = provider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const scope = fixture.state.scopes.find(scope => scope.name === 'phone'); + console.defaultClientScopes = kind === 'default' ? [scope.name] : []; + console.optionalClientScopes = kind === 'optional' ? [scope.name] : []; + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const value = await originalGet(path); + return path === `client-scopes/${scope.id}` ? { ...value, ...replacement } : value; + }; + await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'unsupported'); + assert.equal(fixture.reads.filter(path => path === `client-scopes/${scope.id}`).length, 2); + assert.equal(fixture.writes.length, 0); + } +}); + +test('unsafe direct console mappers retain mapper_conflict before stock-assignment qualification', async () => { + const clean = provider(); + clean.state.realm.accessTokenLifespan = 1800; + await apply(await plan(clean)); + assert.equal((await plan(clean)).actions.length, 0); + for (const [protocolMapper, config] of [ + ['oidc-usermodel-realm-role-mapper', { 'claim.name': 'realm_access.roles', 'jsonType.label': 'String', multivalued: 'true', 'access.token.claim': 'true' }], + ['oidc-hardcoded-role-mapper', { role: 'account.view-profile' }], + ]) { + const fixture = provider(); + fixture.state.realm.accessTokenLifespan = 1800; + fixture.state.clients.find(client => client.clientId === 'account-console').protocolMappers.push({ + id: 'unsafe-direct', name: 'unsafe-direct', protocol: 'openid-connect', protocolMapper, config, + }); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'mapper_conflict'); + assert.deepEqual(fixture.state, original); + assert.deepEqual(fixture.writes, []); + } +}); + +test('a compatible console can inherit its sole resolver while the complete graph still rejects duplicate resolvers', async () => { + const fixture = provider(); + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const resolver = console.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-audience-resolve-mapper'); + console.protocolMappers = console.protocolMappers.filter(mapper => mapper !== resolver); + const scope = { id: 'console-resolver-scope', name: 'console-resolver-scope', + protocol: 'openid-connect', protocolMappers: [resolver] }; + fixture.state.scopes.push(scope); + console.defaultClientScopes = [scope.name]; + const original = clone(fixture.state); + const change = await plan(fixture); + assert.deepEqual(change.actions, []); + await change.verify(); + assert.deepEqual(fixture.state, original); + console.protocolMappers.push({ ...clone(resolver), id: 'duplicate-resolver' }); + await assert.rejects(plan(fixture), error => error.stage === 'plan'); + assert.deepEqual(fixture.writes, []); +}); + +test('unsafe account composites, direct/default/optional scope allowances and implicit mappers refuse before pending writes', async () => { + const variants = [ + fixture => { fixture.state.accountRoles.find(role => role.name === 'manage-account').containerId = 'foreign'; }, + fixture => { fixture.state.accountRoles.find(role => role.name === 'manage-account-links').composite = true; }, + fixture => { fixture.state.roleChildren[fixture.state.accountRoles.find(role => role.name === 'manage-account').id].push(fixture.state.roles[0]); }, + fixture => { fixture.state.roleChildren[fixture.state.accountRoles.find(role => role.name === 'manage-account-links').id].push(fixture.state.roles[0]); }, + fixture => { fixture.state.clients.find(client => client.clientId === 'account-console').fullScopeAllowed = true; }, + fixture => { fixture.state.clients.find(client => client.clientId === 'account-console').attributes['pkce.code.challenge.method'] = 'plain'; }, + ]; + for (const location of ['direct', 'default', 'optional']) for (const unsafe of ['realm', 'foreign', 'wider', 'realm-emitter', 'audience', 'transform', 'dynamic-client', 'claim-prefix']) + variants.push(fixture => { + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + const scope = fixture.state.scopes.find(scope => scope.name === 'phone'); + if (location !== 'direct') console[location === 'default' ? 'defaultClientScopes' : 'optionalClientScopes'] = [scope.name]; + const path = location === 'direct' ? `clients/${console.id}` : `client-scopes/${scope.id}`; + if (['realm', 'foreign', 'wider'].includes(unsafe)) { + fixture.state.scopeMappings[path] ??= {}; + const mappings = fixture.state.scopeMappings[path]; + if (unsafe === 'realm') mappings.realmMappings = [fixture.state.roles[0]]; + else if (unsafe === 'foreign') mappings.clientMappings = { foreign: { id: 'foreign', client: 'foreign', mappings: [] } }; + else mappings.clientMappings = { account: { id: fixture.state.clients.find(client => client.clientId === 'account').id, + client: 'account', mappings: [fixture.state.accountRoles.find(role => role.name === 'view-profile')] } }; + } else { + const mapper = clone(console.protocolMappers.find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper')); + mapper.id = 'unsafe-mapper'; mapper.name = 'retained-unsafe-mapper'; + if (unsafe === 'realm-emitter') mapper.protocolMapper = 'oidc-usermodel-realm-role-mapper'; + if (unsafe === 'audience') { mapper.protocolMapper = 'oidc-audience-mapper'; mapper.config = { 'included.client.audience': 'other' }; } + if (unsafe === 'transform') { mapper.protocolMapper = 'oidc-hardcoded-role-mapper'; mapper.config = { role: 'account.manage-account' }; } + if (unsafe === 'dynamic-client') delete mapper.config['usermodel.clientRoleMapping.clientId']; + if (unsafe === 'claim-prefix') mapper.config['usermodel.clientRoleMapping.rolePrefix'] = 'wider-'; + (location === 'direct' ? console : scope).protocolMappers.push(mapper); + } + }); + for (const mutate of variants) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + mutate(fixture); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' + && ['unsupported', 'mapper_conflict'].includes(error.reason)); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } + const clean = provider(); clean.state.realm.accessTokenLifespan = 1800; + await apply(await plan(clean)); + assert.equal((await plan(clean)).actions.length, 0); +}); + +test('account additions preserve all prior mappings and mapper IDs; independent readback rejects corruption', async () => { + for (const tamper of [null, 'mapper_id', 'extra_grant', 'missing_prior_role', 'console_setting', 'effective']) { + const fixture = provider(); + const user = fixture.state.users[0]; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + delete user.roles.clientMappings; + console.protocolMappers = console.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper'); + const originalMapper = clone(console.protocolMappers[0]); + const originalRoles = clone(user.roles.realmMappings); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['create_account_mapper', 'grant_account_self_service']); + for (const action of change.actions) await action.apply(); + if (tamper === 'mapper_id') console.protocolMappers[0].id = 'replaced'; + if (tamper === 'extra_grant') user.roles.clientMappings.account.mappings.push(fixture.state.accountRoles.find(role => role.name === 'view-profile')); + if (tamper === 'missing_prior_role') user.roles.realmMappings = []; + if (tamper === 'console_setting') console.redirectUris = ['https://changed.invalid/*']; + if (tamper === 'effective') { + const originalGet = fixture.admin.get; + fixture.admin.get = async path => path.includes('/composite?') ? [] : originalGet(path); + } + if (tamper) await assert.rejects(change.verify(), error => error.stage === 'readback'); + else { + await change.verify(); + assert.deepEqual(console.protocolMappers[0], originalMapper); + assert.deepEqual(user.roles.realmMappings, originalRoles); + assert.equal((await plan(fixture)).actions.length, 0); + assert.ok(fixture.writes.every(write => write.method === 'POST')); + } + } +}); + +test('account mapper additions preflight response counts and bytes before any earlier pending write', async () => { + for (const oversized of ['count', 'bytes']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const console = fixture.state.clients.find(client => client.clientId === 'account-console'); + console.protocolMappers = console.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-usermodel-client-role-mapper'); + const safe = { name: 'retained', id: 'retained', protocol: 'openid-connect', protocolMapper: 'oidc-full-name-mapper', config: {} }; + if (oversized === 'count') for (let index = 0; index < 199; index++) console.protocolMappers.push({ ...safe, id: `safe-${index}`, name: `safe-${index}` }); + else console.protocolMappers.push({ ...safe, retained: 'x'.repeat(256 * 1024) }); + await assert.rejects(plan(fixture), error => error.stage === 'plan' + && error.reason === (oversized === 'count' ? 'unsupported' : 'body_limit')); + assert.equal(fixture.writes.length, 0); + } +}); + +test('warm realm preflight is read only; merge preserves accounts, credentials, IDs and unrelated state', async () => { + const fixture = provider(); legacy(fixture); + const original = clone(fixture.state); + const change = await plan(fixture); + assert.ok(change.actions.length > 0); + assert.equal(fixture.writes.length, 0); + await apply(change); + assert.equal((await plan(fixture)).actions.length, 0); + assert.equal((await plan(fixture)).actions.length, 0); + assert.equal(fixture.state.users[0].id, original.users[0].id); + assert.deepEqual(fixture.state.users[0].credentials, original.users[0].credentials); + assert.deepEqual(omit(fixture.state.users[0].roles, ['clientMappings']), original.users[0].roles); + assert.deepEqual(fixture.state.users[0].roles.clientMappings.account.mappings.map(role => role.name), ['manage-account']); + assert.equal(fixture.state.users[0].enabled, false); + assert.equal(fixture.state.users[0].firstName, 'Retained'); + assert.deepEqual(fixture.state.users[0].requiredActions, ['UPDATE_PASSWORD']); + assert.deepEqual(fixture.state.users[0].attributes.retained, ['untouched']); + assert.equal(fixture.state.profile.unmanagedAttributePolicy, 'ADMIN_EDIT'); + assert.deepEqual(fixture.state.profile.groups, original.profile.groups); + assert.equal(fixture.state.clients.find(client => client.clientId === 'learnstack-api').secret, + original.clients.find(client => client.clientId === 'learnstack-api').secret); + for (const write of fixture.writes.filter(write => write.path === `users/${original.users[0].id}`)) { + assert.deepEqual(Object.keys(write.body).sort(), ['attributes', 'email', 'firstName', 'lastName', 'username']); + } +}); + +test('matching upper-case canonical D UUIDs retain their exact representation', async () => { + const fixture = provider(); + for (const user of fixture.state.users) for (const values of Object.values(user.attributes)) values[0] = values[0].toUpperCase(); + assert.equal((await plan(fixture)).actions.length, 0); +}); + +test('safe unrelated scopes and mappers survive while owned mapper/profile fields converge', async () => { + const fixture = provider(); + const safe = { id: 'unrelated-mapper', name: 'unrelated', protocol: 'openid-connect', + protocolMapper: 'oidc-usermodel-attribute-mapper', config: { 'claim.name': 'retained', 'user.attribute': 'retained' } }; + fixture.state.scopes.push({ id: 'unrelated-scope', name: 'unrelated', protocol: 'openid-connect', + attributes: { retained: 'value' }, protocolMappers: [clone(safe)] }); + for (const client of fixture.state.clients.filter(client => ['learnstack-web', 'learnstack-api'].includes(client.clientId))) { + client.defaultClientScopes.push('unrelated'); + client.protocolMappers.push({ ...clone(safe), id: `direct-${client.id}` }); + } + const owned = fixture.state.scopes.find(scope => scope.name === 'learnstack-tenant-api'); + owned.attributes.retained = 'value'; + owned.protocolMappers[0].config['userinfo.token.claim'] = 'true'; + fixture.state.profile.attributes.find(attribute => attribute.name === 'organization_id').permissions.edit = ['user', 'admin']; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_mapper', 'update_profile']); + await apply(change); + assert.equal((await plan(fixture)).actions.length, 0); + assert.equal(owned.attributes.retained, 'value'); + assert.deepEqual(fixture.state.scopes.at(-1).protocolMappers, [safe]); +}); + +test('client override deletion requires a tombstone under actual attribute merge semantics', async () => { + for (const dropTombstone of [false, true]) { + const fixture = provider(); + for (const client of fixture.state.clients.filter(client => ['learnstack-web', 'learnstack-api'].includes(client.clientId))) { + client.attributes['access.token.lifespan'] = '900'; + client.attributes.retained = 'untouched'; + } + const originalPut = fixture.admin.put; + fixture.admin.put = (path, body) => { + const sent = clone(body); + if (dropTombstone && sent?.attributes) delete sent.attributes['access.token.lifespan']; + return originalPut(path, sent); + }; + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + if (dropTombstone) { + await assert.rejects(change.verify(), error => error.stage === 'readback'); + assert.equal((await plan(fixture)).actions.length, 2); + } else { + await change.verify(); + assert.equal((await plan(fixture)).actions.length, 0); + for (const client of fixture.state.clients.filter(client => ['learnstack-web', 'learnstack-api'].includes(client.clientId))) { + assert.equal(Object.hasOwn(client.attributes, 'access.token.lifespan'), false); + assert.equal(client.attributes.retained, 'untouched'); + } + assert.ok(fixture.writes.every(write => write.body.attributes['access.token.lifespan'] === null)); + } + } +}); + +test('a client PUT preserves existing authorization and avoids reattaching the service account scope', async () => { + const fixture = provider(); + const api = fixture.state.clients.find(client => client.clientId === 'learnstack-api'); + api.authorizationServicesEnabled = true; + api.attributes['access.token.lifespan'] = '1800'; + const scopes = clone(api.defaultClientScopes); + const change = await plan(fixture); + await apply(change); + assert.equal(api.authorizationServicesEnabled, true); + assert.deepEqual(api.defaultClientScopes, scopes); + const update = fixture.writes.find(write => write.path === `clients/${api.id}`); + assert.equal(update.body.authorizationServicesEnabled, true); + assert.equal(Object.hasOwn(update.body, 'serviceAccountsEnabled'), false); + assert.deepEqual(Object.keys(update.body).sort(), ['attributes', 'authorizationServicesEnabled', 'webOrigins']); +}); + +test('empty server-only origins remain explicit on client PUT; omission is rejected by independent readback', async () => { + for (const omitOrigins of [false, true]) { + const fixture = provider(); + const web = fixture.state.clients.find(client => client.clientId === 'learnstack-web'); + web.attributes['access.token.lifespan'] = '1800'; + const originalPut = fixture.admin.put; + fixture.admin.put = (path, body) => { + const sent = clone(body); + if (omitOrigins && path === `clients/${web.id}`) delete sent.webOrigins; + return originalPut(path, sent); + }; + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + if (omitOrigins) { + assert.ok(web.webOrigins.length > 0, 'the provider derives browser origins from the configured redirects'); + await assert.rejects(change.verify(), error => error.stage === 'readback'); + } else { + await change.verify(); + assert.deepEqual(web.webOrigins, []); + assert.deepEqual(fixture.writes[0].body.webOrigins, []); + assert.equal((await plan(fixture)).actions.length, 0); + } + } +}); + +test('service account transitions and orphan cleanup refuse before a pending realm write', async () => { + for (const variant of ['enable_web', 'disable_api', 'orphan_web']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const web = fixture.state.clients.find(client => client.clientId === 'learnstack-web'); + const api = fixture.state.clients.find(client => client.clientId === 'learnstack-api'); + if (variant === 'enable_web') web.serviceAccountsEnabled = true; + else if (variant === 'disable_api') api.serviceAccountsEnabled = false; + else { + web.attributes['access.token.lifespan'] = '1800'; + fixture.state.users.push({ id: 'orphan-provider-user', username: 'orphan-provider-user', + serviceAccountClientId: web.clientId, attributes: {} }); + } + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + } +}); + +test('ordinary secret creation time is preserved; a needed client PUT refuses configured rotation state up front', async () => { + const ordinary = provider(); + const api = ordinary.state.clients.find(client => client.clientId === 'learnstack-api'); + api.attributes['client.secret.creation.time'] = '1700000000'; + api.attributes['access.token.lifespan'] = '1800'; + await apply(await plan(ordinary)); + assert.equal(api.attributes['client.secret.creation.time'], '1700000000'); + assert.equal((await plan(ordinary)).actions.length, 0); + for (const attribute of ['client.secret.expiration.time', 'client.secret.remaining.expiration.time', + 'client.secret.rotated', 'client.secret.rotated.creation.time', 'client.secret.rotated.expiration.time']) { + const fixture = provider(); + const current = fixture.state.clients.find(client => client.clientId === 'learnstack-api'); + current.attributes[attribute] = '1900000000'; + assert.equal((await plan(fixture)).actions.length, 0, 'no-op reads preserve existing rotation configuration'); + fixture.state.realm.accessTokenLifespan = 1800; + current.attributes['access.token.lifespan'] = '1800'; + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + assert.equal(current.attributes[attribute], '1900000000'); + } +}); + +test('identity, mapper and secret conflicts all refuse before any write', async () => { + const mutations = [ + [fixture => { fixture.state.users[0].attributes.user_id = ['malformed']; }, 'identity_conflict'], + [fixture => { fixture.state.users[0].attributes.user_id = [`{${manifest.users[0].userId}}`]; }, 'identity_conflict'], + [fixture => { fixture.state.users[0].attributes.tenant_id.push(manifest.users[0].tenantId); }, 'identity_conflict'], + [fixture => { fixture.state.users[0].attributes.organization_id = [manifest.users[2].organizationId]; }, 'identity_conflict'], + [fixture => { fixture.state.users.push({ ...clone(fixture.state.users[0]), id: 'duplicate-account' }); }, 'identity_conflict'], + [fixture => { fixture.state.users.push({ id: 'collision', username: 'unrelated', attributes: { user_id: [manifest.users[0].userId] } }); }, 'identity_conflict'], + [fixture => { fixture.state.clients[0].protocolMappers.push({ ...clone(desired.clientScopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers[0]), id: 'conflicting-direct' }); }, 'mapper_conflict'], + [fixture => { fixture.state.clients[0].protocolMappers.push({ id: 'unknown', name: 'unknown', protocol: 'openid-connect', protocolMapper: 'unqualified' }); }, 'mapper_conflict'], + [fixture => { fixture.state.clients.find(client => client.clientId === 'learnstack-web').secret = 'different-contract'; }, 'secret_conflict'], + [fixture => { fixture.state.users.pop(); fixture.state.roles.find(role => role.name === manifest.users.at(-1).realmRole).composite = true; }, 'unsupported'], + ]; + for (const [index, [mutate, reason]] of mutations.entries()) { + const fixture = provider(); mutate(fixture); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === reason, `mutation ${index}`); + assert.equal(fixture.writes.length, 0); + } +}); + +test('census and exact username disagreement refuse before a pending owned write', async () => { + for (const inconsistent of ['projected_username', 'missing_exact', 'different_exact_id']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const result = await originalGet(path); + const exact = path.startsWith('users?username=') + && new URL(path, 'http://provider/').searchParams.get('username') === manifest.users[0].username; + if (inconsistent === 'projected_username' + && (path.startsWith('users?first=') || exact)) { + for (const user of result.filter(value => value.id === fixture.state.users[0].id)) user.username = 'changed-profile-projection'; + } + if (exact && inconsistent === 'missing_exact') return []; + if (exact && inconsistent === 'different_exact_id') result[0].id = 'disagreeing-provider-id'; + return result; + }; + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 0); + assert.equal(fixture.state.realm.accessTokenLifespan, 1800); + } +}); + +test('hidden or synthesized protected values refuse before writes, including the all-user actor census', async () => { + for (const variant of ['unmanaged_disabled', 'managed_hidden', 'default_value', 'selector', 'normalizer', 'hidden_duplicate_actor']) { + const fixture = provider(); legacy(fixture); + fixture.state.profile.unmanagedAttributePolicy = null; + fixture.state.users[0].attributes.user_id = [manifest.users[1].userId]; + if (variant !== 'unmanaged_disabled' && variant !== 'hidden_duplicate_actor') { + const definition = clone(provider().state.profile.attributes.find(attribute => attribute.name === 'user_id')); + fixture.state.profile.attributes.push(...clone(provider().state.profile.attributes.filter(attribute => + ['tenant_id', 'organization_id'].includes(attribute.name)))); + if (variant === 'managed_hidden') definition.permissions = { view: ['user'], edit: ['user'] }; + if (variant === 'default_value') definition.defaultValue = manifest.users[0].userId; + if (variant === 'selector') definition.selector = { scopes: ['profile'] }; + if (variant === 'normalizer') definition.normalizer = 'unqualified'; + fixture.state.profile.attributes.push(definition); + } + if (variant === 'hidden_duplicate_actor') { + delete fixture.state.users[0].attributes.user_id; + fixture.state.users.at(-1).attributes.user_id = [manifest.users[0].userId]; + } + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict', variant); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + assert.deepEqual(fixture.reads, ['', 'users/profile'], 'profile qualification precedes projected account reads'); + } +}); + +test('managed admin-readable values and explicit unmanaged admin policies remain supported', async () => { + for (const policy of ['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW']) { + const fixture = provider(); legacy(fixture); + fixture.state.profile.unmanagedAttributePolicy = policy; + await apply(await plan(fixture)); + assert.equal(fixture.state.profile.unmanagedAttributePolicy, policy); + assert.equal((await plan(fixture)).actions.length, 0); + } + const editOnly = provider(); + for (const definition of editOnly.state.profile.attributes.filter(attribute => + ['user_id', 'tenant_id', 'organization_id'].includes(attribute.name))) definition.permissions.view = []; + await apply(await plan(editOnly)); + assert.equal((await plan(editOnly)).actions.length, 0, 'pinned admin edit permission also grants view'); +}); + +test('the all-user actor census refuses unqualified external-storage profile metadata before writes', async () => { + for (const external of [{ id: 'retained-external', federationLink: 'retained-provider' }, { id: 'f:retained-provider:retained-external' }]) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + fixture.state.users.push({ ...external, username: 'retained-external', attributes: {} }); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } +}); + +test('existing attribute updates reject unrelated writable projection defaults and retain hidden readonly values', async () => { + for (const projection of [{ defaultValue: 'synthesized' }, { selector: { scopes: ['profile'] } }, { normalizer: 'unqualified' }]) { + const fixture = provider(); + fixture.state.realm.accessTokenLifespan = 1800; + delete fixture.state.users[0].attributes.tenant_id; + fixture.state.profile.attributes.push({ name: 'unrelated', permissions: { view: ['admin'], edit: ['admin'] }, ...projection }); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } + const readonly = provider(); + delete readonly.state.users[0].attributes.tenant_id; + readonly.state.users[0].attributes.unmanaged_hidden = ['retained']; + readonly.state.users[0].attributes.managed_hidden = ['retained']; + readonly.state.profile.attributes.push({ name: 'managed_hidden', permissions: { view: ['user'], edit: ['user'] } }); + await apply(await plan(readonly)); + assert.deepEqual(readonly.state.users[0].attributes.unmanaged_hidden, ['retained']); + assert.deepEqual(readonly.state.users[0].attributes.managed_hidden, ['retained']); + assert.equal(readonly.state.profile.unmanagedAttributePolicy, undefined); + const creating = provider(); + creating.state.realm.accessTokenLifespan = 1800; + creating.state.users.pop(); + creating.state.profile.attributes.push({ name: 'unrelated_default', defaultValue: 'synthesized', + permissions: { view: ['admin'], edit: [] } }); + const original = clone(creating.state); + await assert.rejects(plan(creating), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(creating.writes.length, 0, 'a new account must not acquire an unqualified readback projection after creation'); + assert.deepEqual(creating.state, original); +}); + +function fillScopes(fixture, count) { + while (fixture.state.scopes.length < count) { + const index = fixture.state.scopes.length; + fixture.state.scopes.push({ id: `retained-scope-${index}`, name: `retained-scope-${index}`, + protocol: 'openid-connect', attributes: {}, protocolMappers: [] }); + } +} + +test('projected scopes, profile definitions and mapper counts stay within the readback bound', async () => { + for (const resource of ['scope', 'profile', 'mapper']) for (const total of [200, 201]) { + const fixture = provider(); + fixture.state.realm.accessTokenLifespan = 1800; + if (resource === 'scope') { + fixture.state.scopes = fixture.state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api'); + for (const client of fixture.state.clients) + client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + fillScopes(fixture, total - 1); + } else if (resource === 'profile') { + fixture.state.profile.unmanagedAttributePolicy = 'ADMIN_EDIT'; + fixture.state.profile.attributes = fixture.state.profile.attributes.filter(attribute => + !['user_id', 'tenant_id', 'organization_id'].includes(attribute.name)); + while (fixture.state.profile.attributes.length < total - 3) + fixture.state.profile.attributes.push({ name: `retained-${fixture.state.profile.attributes.length}`, + permissions: { view: ['admin'], edit: ['admin'] } }); + } else { + const scope = fixture.state.scopes.find(value => value.name === 'basic'); + scope.protocolMappers = scope.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-sub-mapper'); + while (scope.protocolMappers.length < total - 1) { + const index = scope.protocolMappers.length; + scope.protocolMappers.push({ id: `retained-mapper-${index}`, name: `retained-mapper-${index}`, + protocol: 'openid-connect', protocolMapper: 'oidc-usermodel-attribute-mapper', + config: { 'claim.name': `retained_${index}`, 'user.attribute': `retained_${index}` } }); + } + } + const original = clone(fixture.state); + if (total === 201) { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported', resource); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } else { + await apply(await plan(fixture)); + assert.equal((await plan(fixture)).actions.length, 0, resource); + } + } +}); + +test('assignment and profile-group exact boundaries remain supported and excess groups refuse before writes', async () => { + const assigned = provider(); + const web = assigned.state.clients.find(client => client.clientId === 'learnstack-web'); + assigned.state.scopes = assigned.state.scopes.filter(scope => scope.protocol === 'openid-connect'); + fillScopes(assigned, 200); + web.defaultClientScopes = assigned.state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api').map(scope => scope.name); + web.optionalClientScopes = []; + await apply(await plan(assigned)); + assert.equal(web.defaultClientScopes.length, 200); + assert.equal((await plan(assigned)).actions.length, 0); + for (const count of [200, 201]) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + fixture.state.profile.groups = Array.from({ length: count }, (_, index) => ({ name: `retained-group-${index}` })); + if (count === 200) await apply(await plan(fixture)); + else { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + } + } +}); + +test('projected accounts allow 999 with readable full metadata pages and refuse 1000 before earlier writes', async () => { + for (const total of [999, 1000]) { + const fixture = provider(); + fixture.state.realm.accessTokenLifespan = 1800; + fixture.state.users = fixture.state.users.slice(0, 2); + while (fixture.state.users.length < total - 2) { + const index = fixture.state.users.length; + fixture.state.users.push({ id: `retained-user-${index}`, username: `retained-user-${index}`, attributes: {} }); + } + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const value = await originalGet(path); + if (path.startsWith('users?')) for (const user of value) user.userProfileMetadata = { + attributes: fixture.state.profile.attributes.map(attribute => ({ name: attribute.name, + displayName: attribute.displayName, required: false, readOnly: false, multivalued: false, + annotations: attribute.annotations ?? {}, validators: Object.fromEntries(Object.entries(attribute.validations ?? {}) + .map(([name, config]) => [name, { ...config, 'ignore.empty.value': true }])) })), + groups: clone(fixture.state.profile.groups), + }; + return value; + }; + capResponses(fixture); + const original = clone(fixture.state); + if (total === 1000) { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } else { + await apply(await plan(fixture)); + assert.equal(fixture.state.users.length, 999); + assert.equal((await plan(fixture)).actions.length, 0); + } + } +}); + +test('UTF-8 mutation bytes and repeated response metadata are both qualified before returning actions', async () => { + for (const desiredBodyBytes of [16 * 1024, 256 * 1024, 256 * 1024 + 1]) { + const fixture = provider(); + fixture.state.scopes = fixture.state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api'); + for (const client of fixture.state.clients) + client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + const changed = fixture.state.profile.attributes.find(attribute => attribute.name === 'user_id'); + changed.permissions.edit = ['user']; + changed.annotations = { retained: '' }; + const merged = clone(fixture.state.profile); + merged.attributes.find(attribute => attribute.name === 'user_id').permissions.edit = ['admin']; + const paddingBytes = desiredBodyBytes - Buffer.byteLength(JSON.stringify(merged), 'utf8'); + changed.annotations.retained = 'é'.repeat(Math.floor(paddingBytes / 2)) + 'x'.repeat(paddingBytes % 2); + const original = clone(fixture.state); + if (desiredBodyBytes > 16 * 1024) { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'body_limit'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } else { + await apply(await plan(fixture)); + const write = fixture.writes.find(value => value.path === 'users/profile'); + assert.equal(Buffer.byteLength(JSON.stringify(write.body), 'utf8'), desiredBodyBytes); + assert.equal((await plan(fixture)).actions.length, 0); + } + } +}); + +function capResponses(fixture) { + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const value = await originalGet(path); + assert.ok(Buffer.byteLength(JSON.stringify(value), 'utf8') <= 256 * 1024, 'fixture response exceeds the real transport limit'); + return value; + }; +} + +test('known projected user pages and scope inventories must fit the response cap before small valid mutations', async () => { + for (const resource of ['users', 'scopes']) for (const supported of [true, false]) { + const fixture = provider(); + const headroom = supported ? 16 * 1024 : 16; + const path = resource === 'users' ? 'users?first=0&max=100&briefRepresentation=false&exact=false' : 'client-scopes'; + if (resource === 'users') { + fixture.state.profile.unmanagedAttributePolicy = 'ADMIN_EDIT'; + fixture.state.users[0].attributes = { user_id: [manifest.users[0].userId] }; + let remaining = 256 * 1024 - headroom - Buffer.byteLength(JSON.stringify(await fixture.admin.get(path)), 'utf8'); + for (let index = 0; remaining > 0; index += 1) { + const name = `retained_${index}`; + // Keep individual values within the pinned default attribute length. + const punctuation = Buffer.byteLength(JSON.stringify({ [name]: [''] }), 'utf8') - 1; + const length = Math.min(2000, Math.max(0, remaining - punctuation)); + fixture.state.users[0].attributes[name] = ['x'.repeat(length)]; + remaining -= punctuation + length; + } + } else { + fixture.state.scopes = fixture.state.scopes.filter(scope => scope.name !== 'learnstack-tenant-api'); + for (const client of fixture.state.clients) + client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + fixture.state.scopes.push({ id: 'retained-large-scope', name: 'retained-large-scope', protocol: 'openid-connect', + attributes: { retained: '' }, protocolMappers: [] }); + fixture.state.scopes.at(-1).attributes.retained = 'x'.repeat(256 * 1024 - headroom + - Buffer.byteLength(JSON.stringify(await fixture.admin.get(path)), 'utf8')); + } + const original = clone(fixture.state); + capResponses(fixture); + if (supported) { + await apply(await plan(fixture)); + assert.equal((await plan(fixture)).actions.length, 0); + } else { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'body_limit', resource); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } + } +}); + +test('projected profile metadata growth is budgeted in every full user-list record', async () => { + const fixture = provider(); + fixture.state.profile.unmanagedAttributePolicy = 'ADMIN_EDIT'; + fixture.state.profile.attributes = fixture.state.profile.attributes.filter(attribute => + !['user_id', 'tenant_id', 'organization_id'].includes(attribute.name)); + const originalGet = fixture.admin.get; + let padding = ''; + fixture.admin.get = async path => { + const value = await originalGet(path); + if (path.startsWith('users?')) for (const user of value) user.userProfileMetadata = { + attributes: fixture.state.profile.attributes.map(attribute => ({ name: attribute.name, + displayName: attribute.displayName, required: false, readOnly: false, multivalued: false })), + groups: [], retained: padding, + }; + return value; + }; + const initialBytes = Buffer.byteLength(JSON.stringify(await fixture.admin.get('users?first=0&max=100&briefRepresentation=false&exact=false')), 'utf8'); + padding = 'x'.repeat(Math.floor((256 * 1024 - 128 - initialBytes) / fixture.state.users.length)); + const original = clone(fixture.state); + capResponses(fixture); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'body_limit'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); +}); + +test('new-user response estimates include profile metadata even without an existing ordinary-user template', async () => { + for (const largeMetadata of [false, true]) { + const fixture = provider(); + fixture.state.users = []; + fixture.state.profile.attributes[0].annotations = { retained: 'x'.repeat(largeMetadata ? 70 * 1024 : 0) }; + const original = clone(fixture.state); + capResponses(fixture); + if (largeMetadata) { + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'body_limit'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } else { + await apply(await plan(fixture)); + assert.equal(fixture.state.users.length, manifest.users.length); + assert.ok(fixture.state.users.every(user => user.id.startsWith('provider-')), 'budget UUIDs never become stored provider IDs'); + } + } +}); + +test('partial success is durable and an explicit next run converges', async () => { + const fixture = provider(); legacy(fixture); + const change = await plan(fixture); + await change.actions[0].apply(); + const resumed = await plan(fixture); + assert.ok(resumed.actions.length < change.actions.length); + await apply(resumed); + assert.equal((await plan(fixture)).actions.length, 0); +}); + +test('one named-user import atomically carries credential and inert role; lost response resumes without role mutation', async () => { + const fixture = provider(); fixture.state.users.pop(); + const originalPost = fixture.admin.post; + fixture.admin.post = async (path, body) => { + const response = await originalPost(path, body); + if (path === 'partialImport') throw new Error('lost response'); + return response; + }; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['create_user']); + await assert.rejects(change.actions[0].apply()); + const write = fixture.writes[0]; + assert.equal(write.path, 'partialImport'); + assert.equal(write.body.users[0].credentials[0].type, 'password'); + assert.deepEqual(write.body.users[0].realmRoles, [manifest.users.at(-1).realmRole]); + assert.deepEqual(write.body.users[0].clientRoles, { account: ['manage-account'] }); + assert.equal(Object.hasOwn(await fixture.admin.get(`users/${fixture.state.users.at(-1).id}`), 'clientRoles'), false); + assert.equal((await plan(fixture)).actions.length, 0); + assert.equal(fixture.writes.length, 1); +}); + +test('creation409 refuses an unproven raced credential without fresh-account adoption or retry', async () => { + for (const compatible of [true, false]) { + const fixture = provider(); fixture.state.users.pop(); + const originalPost = fixture.admin.post; + fixture.admin.post = async (path, body) => { + await originalPost(path, body); + fixture.state.users.at(-1).credentials[0].credentialData = 'different-raced-password'; + if (!compatible) fixture.state.users.at(-1).roles.realmMappings = []; + return { status: 409 }; + }; + const change = await plan(fixture); + const initialLookups = fixture.reads.filter(path => path.includes('username=')).length; + await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 1); + assert.equal(fixture.reads.filter(path => path.includes('username=')).length - initialLookups, 0); + assert.equal(fixture.writes[0].body.ifResourceExists, 'FAIL'); + assert.equal(fixture.state.users.at(-1).credentials[0].credentialData, 'different-raced-password'); + } +}); + +test('scope and mapper creation409 accept only exact fresh resources without retry', async () => { + for (const resource of ['scope', 'mapper']) { + const fixture = provider(); + const scope = fixture.state.scopes.find(value => value.name === 'basic'); + if (resource === 'scope') { + fixture.state.scopes = fixture.state.scopes.filter(value => value.name !== 'learnstack-tenant-api'); + for (const client of fixture.state.clients) client.defaultClientScopes = client.defaultClientScopes.filter(name => name !== 'learnstack-tenant-api'); + } else scope.protocolMappers = scope.protocolMappers.filter(mapper => mapper.protocolMapper !== 'oidc-sub-mapper'); + const originalPost = fixture.admin.post; + fixture.admin.post = async (path, body) => { await originalPost(path, body); return { status: 409 }; }; + const change = await plan(fixture); + await apply(change); + assert.equal(fixture.writes.filter(write => write.method === 'POST').length, 1); + assert.equal((await plan(fixture)).actions.length, 0); + } +}); + +test('unbounded user pages refuse during read-only preflight', async () => { + const paged = provider(); + const originalGet = paged.admin.get; + let calls = 0; + paged.admin.get = path => path.startsWith('users?first=') ? Promise.resolve(Array.from({ length: 100 }, (_, index) => ({ + id: `page-${calls}-${index}`, username: `page-${calls}-${index}`, + }))).finally(() => { calls += 1; }) : originalGet(path); + await assert.rejects(plan(paged), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(calls, 10); + assert.equal(paged.writes.length, 0); +}); + +test('readback detects preserved field and credential corruption', async () => { + for (const tamper of [ + fixture => { fixture.state.users[0].firstName = 'changed'; }, + fixture => { fixture.state.users[0].credentials[0].id = 'changed'; }, + fixture => { fixture.state.realm.displayName = 'changed'; }, + fixture => { fixture.state.profile.groups = []; }, + ]) { + const fixture = provider(); + const change = await plan(fixture); tamper(fixture); + await assert.rejects(change.verify(), error => error.stage === 'readback'); + } +}); + +test('pinned claim-path parsing refuses escaped/dynamic protected roots but keeps literal dotted keys and safe nested client roles', () => { + const mappers = desired.clientScopes.filter(scope => ['basic', 'roles', 'learnstack-tenant-api'].includes(scope.name)).flatMap(scope => scope.protocolMappers); + const identity = desired.clientScopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers[0]; + for (const destination of ['user_id.nested', 'user\\_id', 'tenant\\_id.nested', 'sub', 'aud']) { + const mapper = { ...clone(identity), name: 'extra', config: { ...identity.config, 'claim.name': destination } }; + assert.throws(() => validateEffectiveMappers([...mappers, mapper]), error => error.reason === 'mapper_conflict'); + } + assert.equal(claimRoot('user_id\\.nested'), 'user_id.nested'); + validateEffectiveMappers([...mappers, { ...clone(identity), name: 'literal', config: { ...identity.config, 'claim.name': 'user_id\\.nested' } }]); + const clientRole = clone(mappers.find(mapper => mapper.protocolMapper === 'oidc-usermodel-client-role-mapper')); + clientRole.config['claim.name'] = '${client_id}'; + assert.throws(() => validateEffectiveMappers([...mappers, clientRole])); + clientRole.config['claim.name'] = 'resource_access.${client_id}.roles'; + validateEffectiveMappers([...mappers, clientRole]); +}); + +test('protected attribute aggregation refuses before group inspection, including a desired config overlay', async () => { + for (const aggregate of ['true', 'TRUE', true, null, 'unqualified']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + const mapper = fixture.state.scopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers[0]; + mapper.config['aggregate.attrs'] = aggregate; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'mapper_conflict'); + assert.equal(fixture.writes.length, 0); + assert.equal(fixture.reads.some(path => path.startsWith('groups')), false); + assert.deepEqual(fixture.state, original); + } + const fixture = provider(); + fixture.state.scopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers[0].config['aggregate.attrs'] = 'true'; + const declared = clone(desired); + declared.clientScopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers[0].config['aggregate.attrs'] = 'false'; + await assert.rejects(buildRealmPlan({ admin: fixture.admin, desired: declared, manifest: clone(manifest), webSecret: secret }), + error => error.stage === 'plan' && error.reason === 'mapper_conflict'); + assert.equal(fixture.writes.length, 0); + const safe = provider(); + for (const mapper of safe.state.scopes.find(scope => scope.name === 'learnstack-tenant-api').protocolMappers) + mapper.config['aggregate.attrs'] = 'false'; + assert.equal((await plan(safe)).actions.length, 0); +}); + +function safeGroupHierarchy(fixture) { + fixture.state.groups = [ + { id: 'retained-parent', name: 'Retained parent', path: '/retained-parent', attributes: { retained: ['parent value'] }, realmRoles: ['retained-role'] }, + { id: 'retained-child', name: 'Retained child', parentId: 'retained-parent', path: '/retained-parent/retained-child', + attributes: { retained: ['child value'], organization_id: [] } }, + ]; + fixture.state.memberships[fixture.state.users[0].id] = ['retained-child']; +} + +test('safe nested group attributes, roles and membership IDs survive warm reconciliation', async () => { + const fixture = provider(); legacy(fixture); safeGroupHierarchy(fixture); + const groups = clone(fixture.state.groups); + const memberships = clone(fixture.state.memberships); + await apply(await plan(fixture)); + assert.deepEqual(fixture.state.groups, groups); + assert.deepEqual(fixture.state.memberships, memberships); + assert.equal((await plan(fixture)).actions.length, 0); + assert.ok(fixture.writes.every(write => !write.path.includes('/groups') && !write.path.startsWith('groups'))); + const groupReads = fixture.reads.filter(path => path.includes('/groups?')); + assert.ok(groupReads.length > 0); + for (const path of groupReads) { + const query = new URL(path, 'http://provider/').searchParams; + assert.equal(query.get('briefRepresentation'), 'true'); + assert.equal(query.has('first') || query.has('max') || query.has('search'), false); + } +}); + +test('protected group or ancestor values cannot supply claims when scalar user attributes are absent', async () => { + for (const claim of ['user_id', 'tenant_id', 'organization_id']) for (const value of [['inherited'], [''], 'malformed', null]) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; safeGroupHierarchy(fixture); + fixture.state.groups[0].attributes[claim] = value; + fixture.state.users.push({ id: 'retained-no-actor', username: 'retained-no-actor', attributes: {} }); + fixture.state.memberships['retained-no-actor'] = ['retained-child']; + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } +}); + +test('a provider-exposed nonordinary membership refuses while pagination would conceal it', async () => { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; + fixture.state.realm.organizationsEnabled = false; + fixture.state.groups.push({ id: 'retained-organization-group', name: 'Retained organization', type: 'organization', attributes: {} }); + fixture.state.memberships[fixture.state.users[0].id] = ['retained-organization-group']; + const base = `users/${fixture.state.users[0].id}/groups?briefRepresentation=true`; + assert.equal((await fixture.admin.get(`${base}&first=0&max=201`)).length, 0, 'the pinned paginated branch excludes organization groups'); + assert.equal((await fixture.admin.get(base)).length, 1, 'the mapper and intentional null/null lookup see retained organization groups'); + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); +}); + +test('encoded-space group search includes descendants that REST empty-search injection omits', async () => { + const fixture = provider(); safeGroupHierarchy(fixture); + const parameters = '&exact=false&first=0&max=201&briefRepresentation=false&populateHierarchy=false&subGroupsCount=false'; + assert.equal((await fixture.admin.get('groups/count')).count, 2); + const incomplete = await fixture.admin.get(`groups?search=${parameters}`); + assert.equal(incomplete.length, 1); + assert.equal(incomplete[0].parentId, undefined); + const complete = await fixture.admin.get(`groups?search=%20${parameters}`); + assert.equal(complete.length, 2); + assert.ok(complete.some(group => group.parentId === complete[0].id)); + const change = await plan(fixture); + assert.equal(change.actions.length, 0); + await change.verify(); + assert.equal(fixture.reads.filter(path => path.startsWith('groups?')).at(-1), `groups?search=%20${parameters}`); +}); + +test('group inventory completeness, parent closure and membership bounds qualify before pending writes', async () => { + for (const variant of ['duplicate_id', 'missing_parent', 'cycle', 'truncated', 'too_many_groups', 'too_many_memberships']) { + const fixture = provider(); fixture.state.realm.accessTokenLifespan = 1800; safeGroupHierarchy(fixture); + if (variant === 'duplicate_id') fixture.state.groups.push(clone(fixture.state.groups[0])); + if (variant === 'missing_parent') fixture.state.groups[0].parentId = 'unknown-parent'; + if (variant === 'cycle') fixture.state.groups[0].parentId = 'retained-child'; + if (variant === 'too_many_groups') fixture.state.groups = Array.from({ length: 201 }, (_, index) => ({ id: `retained-${index}`, name: `Retained ${index}`, attributes: {} })); + if (variant === 'truncated') { + const originalGet = fixture.admin.get; + fixture.admin.get = async path => { + const result = await originalGet(path); + return path.startsWith('groups?') ? result.slice(0, -1) : result; + }; + } + if (variant === 'too_many_memberships') { + const originalGet = fixture.admin.get; + fixture.admin.get = async path => path.includes('/groups?') + ? Array.from({ length: 201 }, (_, index) => ({ id: `membership-${index}` })) : originalGet(path); + } + const original = clone(fixture.state); + await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'unsupported', variant); + assert.equal(fixture.writes.length, 0); + assert.deepEqual(fixture.state, original); + } + const boundary = provider(); + boundary.state.groups = Array.from({ length: 200 }, (_, index) => ({ id: `retained-${index}`, name: `Retained ${index}`, attributes: {} })); + boundary.state.memberships[boundary.state.users[0].id] = boundary.state.groups.map(group => group.id); + const change = await plan(boundary); + assert.equal(change.actions.length, 0); + await change.verify(); +}); + +test('independent readback catches group changes, membership changes and an unexpected new-account membership', async () => { + for (const variant of ['group_attribute', 'existing_membership', 'new_membership']) { + const fixture = provider(); safeGroupHierarchy(fixture); + if (variant === 'new_membership') fixture.state.users.pop(); + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + if (variant === 'group_attribute') fixture.state.groups[0].attributes.retained = ['changed']; + if (variant === 'existing_membership') fixture.state.memberships[fixture.state.users[0].id] = ['retained-parent']; + if (variant === 'new_membership') fixture.state.memberships[fixture.state.users.at(-1).id] = ['retained-parent']; + await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'readback_conflict'); + } +}); + +test('a created or raced account with group membership refuses its exact fresh check without changing that membership', async () => { + for (const status of [200, 409]) { + const fixture = provider(); safeGroupHierarchy(fixture); fixture.state.users.pop(); + const originalPost = fixture.admin.post; + fixture.admin.post = async (path, body) => { + await originalPost(path, body); + fixture.state.memberships[fixture.state.users.at(-1).id] = ['retained-parent']; + return { status }; + }; + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['create_user']); + await assert.rejects(change.actions[0].apply(), error => error.stage === 'apply' && error.reason === 'identity_conflict'); + assert.equal(fixture.writes.length, 1); + assert.deepEqual(fixture.state.memberships[fixture.state.users.at(-1).id], ['retained-parent']); + } +}); + +test('profile-derived metadata may change for unrelated accounts while every stored field remains checked', async () => { + for (const tamper of [null, 'firstName', 'enabled', 'attributes']) { + const fixture = provider(); + fixture.state.profile.unmanagedAttributePolicy = 'ADMIN_EDIT'; + fixture.state.profile.attributes.find(attribute => attribute.name === 'organization_id').permissions.edit = ['user']; + fixture.state.users.push({ id: 'retained-account', username: 'retained-account', enabled: false, firstName: 'Retained', attributes: { retained: ['value'] } }); + const before = clone(fixture.state.users.at(-1)); + const beforeList = await fixture.admin.get('users?max=100&briefRepresentation=false'); + const change = await plan(fixture); + assert.deepEqual(change.actions.map(action => action.label), ['update_profile']); + for (const action of change.actions) await action.apply(); + const afterList = await fixture.admin.get('users?max=100&briefRepresentation=false'); + assert.notDeepEqual(afterList.at(-1).userProfileMetadata, beforeList.at(-1).userProfileMetadata); + if (tamper === 'firstName') fixture.state.users.at(-1).firstName = 'Changed'; + if (tamper === 'enabled') fixture.state.users.at(-1).enabled = true; + if (tamper === 'attributes') fixture.state.users.at(-1).attributes.retained = ['changed']; + if (tamper) await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'readback_conflict'); + else { + await change.verify(); + assert.deepEqual(fixture.state.users.at(-1), before); + } + } +}); diff --git a/scripts/development-realm-preparation.test.mjs b/scripts/development-realm-preparation.test.mjs new file mode 100644 index 00000000..8adef5b0 --- /dev/null +++ b/scripts/development-realm-preparation.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildRealmPlan, buildRealmPreparation } from './development-realm-plan.mjs'; +import { desired, manifest, secret, provider } from './development-realm-test-fixture.mjs'; + +const clone = value => structuredClone(value); +const claims = ['user_id', 'tenant_id', 'organization_id']; +const declared = JSON.parse(desired.components['org.keycloak.userprofile.UserProfileProvider'][0].config['kc.user.profile.config'][0]); +const input = fixture => ({ admin: fixture.admin, desired: clone(desired), manifest: clone(manifest), webSecret: secret }); +const probe = fixture => buildRealmPreparation(input(fixture)); +const refusal = (stage, reason) => error => error.stage === stage && error.reason === reason; + +function hiddenProfile(missing = claims) { + const fixture = provider(); + fixture.state.profile.attributes = fixture.state.profile.attributes.filter(attribute => !missing.includes(attribute.name)); + delete fixture.state.profile.unmanagedAttributePolicy; + return fixture; +} + +test('preparation returns only an exact absent-definition append and its independent verifier without applying provisional actions', async () => { + const fixture = hiddenProfile(); + fixture.state.profile.retained = { fields: ['first', 'second'] }; + const original = clone(fixture.state); + const preparation = await probe(fixture); + assert.deepEqual(Object.keys(preparation).sort(), ['profile', 'verify']); + const expected = clone(original.profile); + expected.attributes.push(...claims.map(name => clone(declared.attributes.find(attribute => attribute.name === name)))); + assert.deepEqual(preparation.profile, expected); + preparation.verify(clone(expected)); + assert.deepEqual(fixture.state, original); + assert.deepEqual(fixture.writes, []); +}); + +test('the exact missing-definition and unmanaged-visibility trigger keeps all already-readable paths unchanged', async () => { + for (const missing of [[], ['organization_id'], claims]) for (const policy of [undefined, null, 'DISABLED', 'ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW']) { + const fixture = hiddenProfile(missing); + if (policy !== undefined) fixture.state.profile.unmanagedAttributePolicy = policy; + const preparation = await probe(fixture); + const expected = missing.length > 0 && !['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'].includes(policy); + assert.equal(Boolean(preparation), expected, `${missing.length}/${policy}`); + if (!expected) assert.deepEqual(fixture.reads, ['users/profile']); + assert.deepEqual(fixture.writes, []); + } +}); + +test('a partial admin-view-only definition is preserved verbatim during preparation before separate ordinary normalization', async () => { + const fixture = hiddenProfile(['user_id', 'organization_id']); + const retained = fixture.state.profile.attributes.find(attribute => attribute.name === 'tenant_id'); + retained.permissions = { view: ['admin'], edit: [] }; + retained.multivalued = true; + retained.required = { roles: ['user'] }; + const expected = clone(retained); + const preparation = await probe(fixture); + assert.deepEqual(preparation.profile.attributes.find(attribute => attribute.name === 'tenant_id'), expected); + await fixture.admin.put('users/profile', preparation.profile); + const check = await fixture.admin.get('users/profile'); + preparation.verify(check); + const ordinary = await buildRealmPlan(input(fixture)); + assert.deepEqual(ordinary.actions.map(action => action.label), ['update_profile']); + assert.equal(fixture.writes.length, 1); + await ordinary.actions[0].apply(); + await ordinary.verify(); + assert.equal(fixture.writes.length, 2); + assert.deepEqual((await buildRealmPlan(input(fixture))).actions, []); +}); + +test('hidden defaulted selected or unqualified existing protected definitions cannot be repaired by preparation', async () => { + for (const variant of ['hidden', 'default', 'selector', 'unknown_field']) { + const fixture = hiddenProfile(['organization_id']); + const attribute = fixture.state.profile.attributes.find(attribute => attribute.name === 'user_id'); + if (variant === 'hidden') attribute.permissions = { view: ['user'], edit: ['user'] }; + if (variant === 'default') attribute.defaultValue = manifest.users[0].userId; + if (variant === 'selector') attribute.selector = {}; + if (variant === 'unknown_field') attribute.unsupported = false; + await assert.rejects(probe(fixture), refusal('prepare', 'identity_conflict')); + assert.deepEqual(fixture.reads, ['users/profile']); + assert.deepEqual(fixture.writes, []); + } +}); + +test('new definitions must remain the separately declared admin-only scalar profile rather than a wider repair', async () => { + for (const variant of ['view', 'edit', 'multivalued', 'default', 'selector', 'duplicate']) { + const fixture = hiddenProfile(); + const value = input(fixture); + const profile = clone(declared); + const attribute = profile.attributes.find(attribute => attribute.name === 'user_id'); + if (variant === 'view') attribute.permissions.view.push('user'); + if (variant === 'edit') attribute.permissions.edit.push('user'); + if (variant === 'multivalued') attribute.multivalued = true; + if (variant === 'default') attribute.defaultValue = manifest.users[0].userId; + if (variant === 'selector') attribute.selector = {}; + if (variant === 'duplicate') profile.attributes.push(clone(attribute)); + value.desired.components['org.keycloak.userprofile.UserProfileProvider'][0].config['kc.user.profile.config'][0] = JSON.stringify(profile); + await assert.rejects(buildRealmPreparation(value), refusal('prepare', 'identity_conflict')); + assert.deepEqual(fixture.writes, []); + } +}); + +test('a known ordinary account update cannot admit preparation before its shared profile-preservation guard', async () => { + const clean = hiddenProfile(['organization_id']); + clean.state.users[0].attributes.tenant_id = ['tenant-a']; + const preparation = await probe(clean); + await clean.admin.put('users/profile', preparation.profile); + const ordinary = await buildRealmPlan(input(clean)); + assert.deepEqual(ordinary.actions.map(action => action.label), ['update_user_attributes']); + await ordinary.actions[0].apply(); + await ordinary.verify(); + for (const projection of [{ defaultValue: 'synthesized' }, { selector: {} }]) { + const dirty = hiddenProfile(['organization_id']); + dirty.state.users[0].attributes.tenant_id = ['tenant-a']; + dirty.state.profile.attributes.push({ name: 'retained', + permissions: { view: ['admin'], edit: ['admin'] }, ...projection }); + await assert.rejects(probe(dirty), refusal('plan', 'unsupported')); + assert.deepEqual(dirty.writes, []); + } +}); + +test('an already-readable actor must qualify before preparation for another missing claim', async () => { + assert.ok(await probe(hiddenProfile(['organization_id']))); + for (const actor of [['01910000-0000-7000-8000-00000000ffff'], ['invalid'], null, []]) { + const fixture = hiddenProfile(['organization_id']); + // The Yoga identity has no legacy actor-absence exception. + fixture.state.users[2].attributes.user_id = actor; + await assert.rejects(probe(fixture), refusal('plan', 'identity_conflict')); + assert.deepEqual(fixture.writes, []); + } +}); + +test('readable legacy actor absence needs its readable legacy tenant before another claim can be prepared', async () => { + const clean = hiddenProfile(['organization_id']); + delete clean.state.users[0].attributes.user_id; + clean.state.users[0].attributes.tenant_id = ['tenant-a']; + assert.ok(await probe(clean)); + for (const tenant of [undefined, [manifest.users[0].tenantId], ['foreign']]) { + const fixture = hiddenProfile(['organization_id']); + delete fixture.state.users[0].attributes.user_id; + if (tenant === undefined) delete fixture.state.users[0].attributes.tenant_id; + else fixture.state.users[0].attributes.tenant_id = tenant; + await assert.rejects(probe(fixture), refusal('plan', 'identity_conflict')); + assert.deepEqual(fixture.writes, []); + } +}); + +test('readable tenant and organization disagreements refuse before preparation of a different claim', async () => { + for (const [missing, name, index] of [ + [['organization_id'], 'tenant_id', 0], + [['user_id'], 'organization_id', 2], + [['user_id'], 'organization_id', 0], + ]) { + assert.ok(await probe(hiddenProfile(missing))); + for (const value of [['01910000-0000-7000-8000-00000000ffff'], ['invalid'], ['one', 'two']]) { + const fixture = hiddenProfile(missing); + fixture.state.users[index].attributes[name] = value; + await assert.rejects(probe(fixture), refusal('plan', 'identity_conflict')); + assert.deepEqual(fixture.writes, []); + } + } +}); + +test('already-readable actor duplication on a differently named account refuses before preparation', async () => { + assert.ok(await probe(hiddenProfile(['organization_id']))); + const fixture = hiddenProfile(['organization_id']); + fixture.state.users.push({ id: 'unmanaged-existing', username: 'unmanaged-existing', + attributes: { user_id: [manifest.users[0].userId] } }); + await assert.rejects(probe(fixture), refusal('plan', 'identity_conflict')); + assert.deepEqual(fixture.writes, []); +}); + +test('only genuinely hidden identity conflicts wait for preparation and the fresh complete plan', async () => { + const fixture = hiddenProfile(['user_id', 'tenant_id']); + fixture.state.users[0].attributes.user_id = ['01910000-0000-7000-8000-00000000ffff']; + const preparation = await probe(fixture); + assert.deepEqual(fixture.writes, []); + await fixture.admin.put('users/profile', preparation.profile); + preparation.verify(await fixture.admin.get('users/profile')); + await assert.rejects(buildRealmPlan(input(fixture)), refusal('plan', 'identity_conflict')); + assert.equal(fixture.writes.length, 1); +}); + +const independentConflicts = [ + ['disabled realm', fixture => { fixture.state.realm.enabled = false; }, 'unsupported'], + ['disabled client', fixture => { fixture.state.clients.find(client => client.clientId === 'learnstack-web').enabled = false; }, 'unsupported'], + ['confidential secret', fixture => { fixture.state.clients.find(client => client.clientId === 'learnstack-web').secret = 'different-private-fixture'; }, 'secret_conflict'], + ['service-account transition', fixture => { fixture.state.clients.find(client => client.clientId === 'learnstack-api').serviceAccountsEnabled = false; }, 'unsupported'], + ['direct mapper', fixture => { fixture.state.clients.find(client => client.clientId === 'learnstack-web').protocolMappers.push({ id: 'unqualified', name: 'unqualified', protocol: 'openid-connect', protocolMapper: 'custom-unsafe', config: {} }); }, 'mapper_conflict'], + ['scope protocol', fixture => { fixture.state.scopes.find(scope => scope.name === 'phone').protocol = 'saml'; }, 'unsupported'], + ['console origin', fixture => { fixture.state.clients.find(client => client.clientId === 'account-console').rootUrl = 'https://foreign.fixture.invalid'; }, 'unsupported'], + ['account composite', fixture => { fixture.state.roleChildren[fixture.state.accountRoles.find(role => role.name === 'manage-account').id] = []; }, 'unsupported'], + ['external storage projection', fixture => { fixture.state.users[0].federationLink = 'external'; }, 'identity_conflict'], + ['group identity source', fixture => { fixture.state.groups.push({ id: 'group', name: 'group', attributes: { user_id: [manifest.users[0].userId] } }); }, 'identity_conflict'], + ['unknown membership source', fixture => { fixture.state.groups.push({ id: 'organization-group', name: 'organization-group', type: 'organization' }); fixture.state.memberships[fixture.state.users[0].id] = ['organization-group']; }, 'unsupported'], + ['new account email collision', fixture => { const user = fixture.state.users.pop(); fixture.state.users.push({ ...user, id: 'unowned', username: 'unowned', attributes: {} }); }, 'identity_conflict'], + ['new account role', fixture => { fixture.state.users.pop(); fixture.state.roles.find(role => role.name === manifest.users.at(-1).realmRole).composite = true; }, 'unsupported'], + ['scope collection limit', fixture => { fixture.state.scopes.push(...Array.from({ length: 201 }, (_, index) => ({ id: `extra-${index}`, name: `extra-${index}`, protocol: 'openid-connect' }))); }, 'unsupported'], + ['projected request capacity', fixture => { fixture.state.profile.attributes.push({ name: 'retained', permissions: { view: ['admin'], edit: ['admin'] } }); fixture.state.users[0].attributes.retained = ['x'.repeat(256 * 1024)]; }, 'body_limit'], +]; + +for (const [name, mutate, reason] of independentConflicts) { + test(`the same ${name} qualifier refuses before a preparation write and has a clean executable twin`, async () => { + const clean = hiddenProfile(); + assert.ok(await probe(clean)); + const fixture = hiddenProfile(); + mutate(fixture); + const original = clone(fixture.state); + await assert.rejects(probe(fixture), refusal('plan', reason)); + assert.deepEqual(fixture.state, original); + assert.deepEqual(fixture.writes, []); + }); +} + +test('projected metadata body and collection bounds bind before even a hidden-value preparation can write', async () => { + for (const variant of ['bytes', 'attributes']) { + const fixture = hiddenProfile(); + if (variant === 'bytes') fixture.state.profile.retained = 'x'.repeat(256 * 1024); + else fixture.state.profile.attributes.push(...Array.from({ length: 195 }, (_, index) => ({ name: `retained-${index}` }))); + await assert.rejects(probe(fixture), error => error.reason === (variant === 'bytes' ? 'body_limit' : 'unsupported')); + assert.deepEqual(fixture.writes, []); + } +}); + +test('preparation verifier rejects complete-field and ordered-array changes without trusting its exposed body object', async () => { + const fixture = hiddenProfile(); + const preparation = await probe(fixture); + const expected = clone(preparation.profile); + preparation.profile.attributes.reverse(); + assert.throws(() => preparation.verify(preparation.profile), refusal('prepare', 'readback_conflict')); + const changed = clone(expected); + changed.attributes.at(-1).permissions.view = ['admin', 'user']; + assert.throws(() => preparation.verify(changed), refusal('prepare', 'readback_conflict')); + preparation.verify(expected); +}); diff --git a/scripts/development-realm-stock-console-baseline.json b/scripts/development-realm-stock-console-baseline.json new file mode 100644 index 00000000..25b81f5c --- /dev/null +++ b/scripts/development-realm-stock-console-baseline.json @@ -0,0 +1,1393 @@ +{ + "provenance": { + "status": "Qualified historical 26.6.2 bootstrap expectation; automatic warm-upgrade qualification remains separate.", + "historicalCommit": "24705a7a", + "historicalExportSha256": "39659a4e505c8ad3c1b2963e69e58dadb725e4fb37b408dcf87214a2bb0d2d07", + "providerImage": "quay.io/keycloak/keycloak:26.6.2", + "authenticatedProviderVersion": "26.6.2", + "providerImageId": "sha256:f9ba7b2af90db8dc749a57ca9aedca51e840cb9224441ab546a968da941da900", + "providerRepoDigest": "quay.io/keycloak/keycloak@sha256:f9ba7b2af90db8dc749a57ca9aedca51e840cb9224441ab546a968da941da900", + "sourceCaptureSha256": "7a1ab028d61eb6c59ed6f7ba2f8740aec17e38b065a9ddaea06301ec4152c138", + "canonicalSha256": "fe38c56fdd99f2073a901e4b7261ca9f0f4d9f24c45e1effea1e69261781a476", + "extractionSha256": "8c696a918b3ec457718256abc498ca39b7099024671b10e120a1a277c68b5208", + "normalization": "Replace bound generated IDs only; preserve stored and effective mapper DTOs separately. Sort only set-like endpoint collections and object keys.", + "credentialTransformation": "Users, credentials, profile payload and the full realm representation are excluded from the canonical expectation; committed historical credentials are redacted, and the provider fixture supplies private test-only credentials in memory.", + "initialExpectedCaptureSha256": "183f1790db8e500a04f8fd16bde0f105455d63bc143b99d7dc8d04106b5d0881", + "normalizationModuleSha256": "14112aa561f3082b62b4ebaed8e646d8e5b3e32a916ef42e601067f80bc122b0", + "independentBindingEvidence": "Passing fresh redacted-fixture bootstrap proof captured both account assignment endpoints and all four scoped role-name, role-by-ID and composite reads; fixed canonical expectation matched unchanged.", + "historicalFixtureSha256": "ba9a6d6783d8849387e1a194f650a853f9596ade032a5b48eb3d19695ade7b1d", + "historicalFixtureTransformation": { + "operation": "Replace exactly three published example credential strings with committed redaction placeholders; private test credentials are injected only in memory.", + "jsonPointers": [ + "/clients/0/secret", + "/users/0/credentials/0/value", + "/users/1/credentials/0/value" + ], + "canonicalPatchSha256": "45b0138c93fee9d2d542931e7d9583b7baaabdd15d4d4458babf33ab384dfd2f", + "patchHashEncoding": "RFC 6902 replace operations sorted by JSON pointer; recursively sorted object keys; compact UTF-8 JSON plus LF." + }, + "providerPlatform": { + "os": "linux", + "architecture": "arm64" + }, + "imageIdentityScope": "Observed identity of the independently owned bootstrap fixture; not a universal cross-platform runtime image-ID guard." + }, + "canonical": { + "assignments": { + "default": [ + { + "id": "@scope/acr", + "name": "acr" + }, + { + "id": "@scope/basic", + "name": "basic" + }, + { + "id": "@scope/email", + "name": "email" + }, + { + "id": "@scope/profile", + "name": "profile" + }, + { + "id": "@scope/roles", + "name": "roles" + }, + { + "id": "@scope/web-origins", + "name": "web-origins" + } + ], + "optional": [ + { + "id": "@scope/address", + "name": "address" + }, + { + "id": "@scope/microprofile-jwt", + "name": "microprofile-jwt" + }, + { + "id": "@scope/offline_access", + "name": "offline_access" + }, + { + "id": "@scope/organization", + "name": "organization" + }, + { + "id": "@scope/phone", + "name": "phone" + } + ] + }, + "clients": { + "account": { + "access": { + "configure": true, + "manage": true, + "view": true + }, + "alwaysDisplayInConsole": false, + "attributes": { + "post.logout.redirect.uris": "+", + "realm_client": "false" + }, + "authenticationFlowBindingOverrides": {}, + "baseUrl": "/realms/learnstack/account/", + "bearerOnly": false, + "clientAuthenticatorType": "client-secret", + "clientId": "account", + "consentRequired": false, + "defaultClientScopes": [ + "acr", + "basic", + "email", + "profile", + "roles", + "web-origins" + ], + "directAccessGrantsEnabled": false, + "enabled": true, + "frontchannelLogout": false, + "fullScopeAllowed": false, + "id": "@client/account", + "implicitFlowEnabled": false, + "name": "${client_account}", + "nodeReRegistrationTimeout": 0, + "notBefore": 0, + "optionalClientScopes": [ + "address", + "microprofile-jwt", + "offline_access", + "organization", + "phone" + ], + "protocol": "openid-connect", + "publicClient": true, + "redirectUris": [ + "/realms/learnstack/account/*" + ], + "rootUrl": "${authBaseUrl}", + "serviceAccountsEnabled": false, + "standardFlowEnabled": true, + "surrogateAuthRequired": false, + "webOrigins": [] + }, + "account-console": { + "access": { + "configure": true, + "manage": true, + "view": true + }, + "alwaysDisplayInConsole": false, + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+", + "realm_client": "false" + }, + "authenticationFlowBindingOverrides": {}, + "baseUrl": "/realms/learnstack/account/", + "bearerOnly": false, + "clientAuthenticatorType": "client-secret", + "clientId": "account-console", + "consentRequired": false, + "defaultClientScopes": [ + "acr", + "basic", + "email", + "profile", + "roles", + "web-origins" + ], + "directAccessGrantsEnabled": false, + "enabled": true, + "frontchannelLogout": false, + "fullScopeAllowed": false, + "id": "@client/account-console", + "implicitFlowEnabled": false, + "name": "${client_account-console}", + "nodeReRegistrationTimeout": 0, + "notBefore": 0, + "optionalClientScopes": [ + "address", + "microprofile-jwt", + "offline_access", + "organization", + "phone" + ], + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": {}, + "consentRequired": false, + "id": "@mapper/client/account-console/audience resolve", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper" + } + ], + "publicClient": true, + "redirectUris": [ + "/realms/learnstack/account/*" + ], + "rootUrl": "${authBaseUrl}", + "serviceAccountsEnabled": false, + "standardFlowEnabled": true, + "surrogateAuthRequired": false, + "webOrigins": [] + } + }, + "directConsoleEffectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/client/account-console/audience resolve", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper" + } + ], + "directConsoleMappings": { + "clientMappings": { + "account": { + "client": "account", + "id": "@client/account", + "mappings": [ + { + "clientRole": true, + "composite": true, + "containerId": "@client/account", + "description": "${role_manage-account}", + "id": "@role/client/account/manage-account", + "name": "manage-account" + }, + { + "clientRole": true, + "composite": false, + "containerId": "@client/account", + "description": "${role_view-groups}", + "id": "@role/client/account/view-groups", + "name": "view-groups" + } + ] + } + } + }, + "providerVersion": "26.6.2", + "roles": { + "@role/client/account/manage-account": { + "composites": [ + { + "clientRole": true, + "composite": false, + "containerId": "@client/account", + "description": "${role_manage-account-links}", + "id": "@role/client/account/manage-account-links", + "name": "manage-account-links" + } + ], + "definition": { + "attributes": {}, + "clientRole": true, + "composite": true, + "containerId": "@client/account", + "description": "${role_manage-account}", + "id": "@role/client/account/manage-account", + "name": "manage-account" + } + }, + "@role/client/account/manage-account-links": { + "composites": [], + "definition": { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "@client/account", + "description": "${role_manage-account-links}", + "id": "@role/client/account/manage-account-links", + "name": "manage-account-links" + } + }, + "@role/client/account/view-groups": { + "composites": [], + "definition": { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "@client/account", + "description": "${role_view-groups}", + "id": "@role/client/account/view-groups", + "name": "view-groups" + } + }, + "@role/realm/offline_access": { + "composites": [], + "definition": { + "attributes": {}, + "clientRole": false, + "composite": false, + "containerId": "@realm/learnstack", + "description": "${role_offline-access}", + "id": "@role/realm/offline_access", + "name": "offline_access" + } + } + }, + "schema": 1, + "scopes": { + "acr": { + "allowances": {}, + "definition": { + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "false" + }, + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "id": "@scope/acr", + "name": "acr", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/acr/acr loa level", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/acr/acr loa level", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper" + } + ] + }, + "address": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true" + }, + "description": "OpenID Connect built-in scope: address", + "id": "@scope/address", + "name": "address", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "user.attribute.country": "country", + "user.attribute.formatted": "formatted", + "user.attribute.locality": "locality", + "user.attribute.postal_code": "postal_code", + "user.attribute.region": "region", + "user.attribute.street": "street", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/address/address", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "user.attribute.country": "country", + "user.attribute.formatted": "formatted", + "user.attribute.locality": "locality", + "user.attribute.postal_code": "postal_code", + "user.attribute.region": "region", + "user.attribute.street": "street", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/address/address", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper" + } + ] + }, + "basic": { + "allowances": {}, + "definition": { + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "false" + }, + "description": "OpenID Connect scope for add all basic claims to the token", + "id": "@scope/basic", + "name": "basic", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "auth_time", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "long", + "user.session.note": "AUTH_TIME" + }, + "consentRequired": false, + "id": "@mapper/scope/basic/auth_time", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper" + }, + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/basic/sub", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "auth_time", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "long", + "user.session.note": "AUTH_TIME", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/basic/auth_time", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper" + }, + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/basic/sub", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper" + } + ] + }, + "email": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true" + }, + "description": "OpenID Connect built-in scope: email", + "id": "@scope/email", + "name": "email", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "email", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "email", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/email/email", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "email_verified", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "boolean", + "user.attribute": "emailVerified", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/email/email verified", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "email", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "email", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/email/email", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "email_verified", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "boolean", + "user.attribute": "emailVerified", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/email/email verified", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper" + } + ] + }, + "microprofile-jwt": { + "allowances": {}, + "definition": { + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "true" + }, + "description": "Microprofile - JWT built-in scope", + "id": "@scope/microprofile-jwt", + "name": "microprofile-jwt", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "groups", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo" + }, + "consentRequired": false, + "id": "@mapper/scope/microprofile-jwt/groups", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "upn", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "username", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/microprofile-jwt/upn", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "groups", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/microprofile-jwt/groups", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "upn", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "username", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/microprofile-jwt/upn", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "offline_access": { + "allowances": { + "realmMappings": [ + { + "clientRole": false, + "composite": false, + "containerId": "@realm/learnstack", + "description": "${role_offline-access}", + "id": "@role/realm/offline_access", + "name": "offline_access" + } + ] + }, + "definition": { + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + }, + "description": "OpenID Connect built-in scope: offline_access", + "id": "@scope/offline_access", + "name": "offline_access", + "protocol": "openid-connect" + }, + "effectiveMappers": [] + }, + "organization": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true" + }, + "description": "Additional claims about the organization a subject belongs to", + "id": "@scope/organization", + "name": "organization", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "organization", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/organization/organization", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "organization", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/organization/organization", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper" + } + ] + }, + "phone": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true" + }, + "description": "OpenID Connect built-in scope: phone", + "id": "@scope/phone", + "name": "phone", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "phone_number", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "phoneNumber", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/phone/phone number", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "boolean", + "user.attribute": "phoneNumberVerified", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/phone/phone number verified", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "phone_number", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "phoneNumber", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/phone/phone number", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "boolean", + "user.attribute": "phoneNumberVerified", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/phone/phone number verified", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "profile": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true" + }, + "description": "OpenID Connect built-in scope: profile", + "id": "@scope/profile", + "name": "profile", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "birthdate", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "birthdate", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/birthdate", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "family_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "lastName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/family name", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/full name", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "gender", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "gender", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/gender", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "given_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "firstName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/given name", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "locale", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "locale", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/locale", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "middle_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "middleName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/middle name", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "nickname", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "nickname", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/nickname", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "picture", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "picture", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/picture", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "profile", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "profile", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/profile", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "updated_at", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "long", + "user.attribute": "updatedAt", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/updated at", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "preferred_username", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "username", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/username", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "website", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "website", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/website", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "zoneinfo", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "zoneinfo", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/zoneinfo", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "birthdate", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "birthdate", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/birthdate", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "family_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "lastName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/family name", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/full name", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "gender", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "gender", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/gender", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "given_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "firstName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/given name", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "locale", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "locale", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/locale", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "middle_name", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "middleName", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/middle name", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "nickname", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "nickname", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/nickname", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "picture", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "picture", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/picture", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "profile", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "profile", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/profile", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "updated_at", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "long", + "user.attribute": "updatedAt", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/updated at", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "preferred_username", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "username", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/username", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "website", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "website", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/website", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "zoneinfo", + "id.token.claim": "true", + "introspection.token.claim": "true", + "jsonType.label": "String", + "user.attribute": "zoneinfo", + "userinfo.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/profile/zoneinfo", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper" + } + ] + }, + "roles": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "false" + }, + "description": "OpenID Connect scope for add user roles to the access token", + "id": "@scope/roles", + "name": "roles", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/audience resolve", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/client roles", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/realm roles", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/audience resolve", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/client roles", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "introspection.token.claim": "true", + "jsonType.label": "String", + "multivalued": "true", + "user.attribute": "foo" + }, + "consentRequired": false, + "id": "@mapper/scope/roles/realm roles", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper" + } + ] + }, + "web-origins": { + "allowances": {}, + "definition": { + "attributes": { + "consent.screen.text": "", + "display.on.consent.screen": "false", + "include.in.token.scope": "false" + }, + "description": "OpenID Connect scope for add allowed web origins to the access token", + "id": "@scope/web-origins", + "name": "web-origins", + "protocol": "openid-connect", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/web-origins/allowed web origins", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper" + } + ] + }, + "effectiveMappers": [ + { + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + }, + "consentRequired": false, + "id": "@mapper/scope/web-origins/allowed web origins", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper" + } + ] + } + } + } +} diff --git a/scripts/development-realm-stock-console-test-fixture.mjs b/scripts/development-realm-stock-console-test-fixture.mjs new file mode 100644 index 00000000..78093ea4 --- /dev/null +++ b/scripts/development-realm-stock-console-test-fixture.mjs @@ -0,0 +1,92 @@ +import { readFile } from 'node:fs/promises'; +import { RealmError } from './development-realm-errors.mjs'; +import { readStockConsoleGraph, qualifyStockConsoleGraph } from './development-realm-stock-console.mjs'; + +// Test-only fixed historical observations; production entry points never import this module. +export const stockConsoleBaseline = JSON.parse(await readFile(new URL('./development-realm-stock-console-baseline.json', import.meta.url), 'utf8')); +const baseline = stockConsoleBaseline; +const declaration = JSON.parse(await readFile(new URL('../infra/keycloak/realms/learnstack.json', import.meta.url), 'utf8')); +// The current export supplies only the separately approved mapper declaration, never stock expectations. +export const canonicalAccountMapper = declaration.clients.find(client => client.clientId === 'account-console').protocolMappers + .find(mapper => mapper.name === 'learnstack-account-self-service-roles'); +const clone = value => structuredClone(value); + +export function stockConsoleProvider(prefix = 'provider') { + let serial = 0; + const ids = new Map(); + const materialize = value => { + if (typeof value === 'string' && value.startsWith('@')) { + if (!ids.has(value)) ids.set(value, `${prefix}-${serial += 1}`); + return ids.get(value); + } + if (Array.isArray(value)) return value.map(materialize); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, materialize(item)])); + return value; + }; + const data = materialize(clone(baseline.canonical)); + const account = data.clients.account; + const console = data.clients['account-console']; + const definitions = Object.values(data.scopes).map(value => value.definition); + const scopeByName = new Map(definitions.map(scope => [scope.name, scope])); + const capture = { '': { id: materialize('@realm/learnstack'), realm: 'learnstack' }, + clients: [clone(account), clone(console)], 'client-scopes': clone(definitions) }; + const scopePath = name => `client-scopes/${scopeByName.get(name).id}`; + for (const client of [account, console]) { + const path = `clients/${client.id}`; + capture[path] = clone(client); + for (const kind of ['default', 'optional']) capture[`${path}/${kind}-client-scopes`] = client[`${kind}ClientScopes`] + .map(name => ({ id: scopeByName.get(name).id, name })); + } + const base = `clients/${console.id}`; + capture[`${base}/protocol-mappers/models`] = clone(data.directConsoleEffectiveMappers); + capture[`${base}/scope-mappings`] = clone(data.directConsoleMappings); + for (const [name, value] of Object.entries(data.scopes)) { + const path = scopePath(name); + capture[path] = clone(value.definition); + capture[`${path}/protocol-mappers/models`] = clone(value.effectiveMappers); + capture[`${path}/scope-mappings`] = clone(value.allowances); + } + for (const value of Object.values(data.roles)) { + const role = value.definition; + const path = role.clientRole ? `clients/${account.id}/roles/${encodeURIComponent(role.name)}` : `roles/${encodeURIComponent(role.name)}`; + capture[path] = clone(role); + capture[`roles-by-id/${role.id}`] = clone(role); + capture[`roles-by-id/${role.id}/composites`] = clone(value.composites); + } + const reads = []; + const admin = { get: async path => { + reads.push(path); + if (!Object.hasOwn(capture, path)) throw new RealmError('plan', 'response'); + return clone(capture[path]); + } }; + const existing = () => ({ account: clone(capture[`clients/${account.id}`]), console: clone(capture[base]), + defaults: clone(capture[`${base}/default-client-scopes`]), optional: clone(capture[`${base}/optional-client-scopes`]), + directMappers: clone(capture[`${base}/protocol-mappers/models`]), allowances: new Map([[base, clone(capture[`${base}/scope-mappings`])]]) }); + const read = () => readStockConsoleGraph({ admin, existingAccountGraph: existing() }); + const qualify = async () => qualifyStockConsoleGraph({ graph: await read(), canonicalAccountMapper }); + const assignments = (defaults, optional) => { + for (const [kind, names] of [['default', defaults], ['optional', optional]]) { + capture[base][`${kind}ClientScopes`] = [...names]; + capture[`${base}/${kind}-client-scopes`] = names.map(name => ({ id: scopeByName.get(name)?.id ?? `unknown-${name}`, name })); + } + }; + const detach = plan => { + for (const binding of plan.detachments) { + const path = `${base}/${binding.category}-client-scopes`; + capture[path] = capture[path].filter(scope => scope.id !== binding.scopeId); + capture[base][`${binding.category}ClientScopes`] = capture[base][`${binding.category}ClientScopes`].filter(name => name !== binding.name); + } + }; + const addApproved = (name = canonicalAccountMapper.name) => { + const mapper = { ...clone(canonicalAccountMapper), id: 'approved-provider-mapper', name }; + delete mapper.config['usermodel.clientRoleMapping.rolePrefix']; + capture[base].protocolMappers.push(clone(mapper)); + capture[`${base}/protocol-mappers/models`].push(clone(mapper)); + return mapper; + }; + const changeScope = (name, mutate) => { + mutate(capture[scopePath(name)]); + capture['client-scopes'] = capture['client-scopes'].map(scope => scope.name === name ? clone(capture[scopePath(name)]) : scope); + }; + return { account, console, base, capture, scopePath, reads, admin, existing, read, qualify, assignments, detach, addApproved, changeScope }; +} diff --git a/scripts/development-realm-stock-console.mjs b/scripts/development-realm-stock-console.mjs new file mode 100644 index 00000000..05de247b --- /dev/null +++ b/scripts/development-realm-stock-console.mjs @@ -0,0 +1,318 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { isDeepStrictEqual as equal } from 'node:util'; +import { refuse } from './development-realm-errors.mjs'; +import { accountMapperRepresentation, validateAccountMappers } from './development-realm-mappers.mjs'; + +const baseline = JSON.parse(await readFile(new URL('./development-realm-stock-console-baseline.json', import.meta.url), 'utf8')); +const expected = baseline.canonical; +const encode = encodeURIComponent; +const copy = value => structuredClone(value); +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); +const byName = (left, right) => left.name.localeCompare(right.name); +const byId = (left, right) => left.id.localeCompare(right.id); +const namedMapper = 'learnstack-account-self-service-roles'; +const detachmentPaths = new WeakMap(); +const readGraphs = new WeakMap(); +const categories = ['default', 'optional']; +const incompatible = { roles: 'default', 'microprofile-jwt': 'optional', offline_access: 'optional' }; + +function check(condition, stage, reason = 'unsupported') { + if (!condition) refuse(stage, reason); +} + +function record(value, stage) { + check(object(value), stage); + return value; +} + +function id(value, stage) { + check(typeof value === 'string' && value.length > 0, stage); + return value; +} + +function list(value, stage, named = false) { + check(Array.isArray(value) && value.length <= 200, stage); + const ids = new Set(); + const names = new Set(); + for (const item of value) { + record(item, stage); + id(item.id, stage); + check(!ids.has(item.id), stage); + ids.add(item.id); + if (named) { + id(item.name, stage); + check(!names.has(item.name), stage); + names.add(item.name); + } + } + return value; +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!object(value)) return value; + return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])); +} + +function hash(value) { + return createHash('sha256').update(JSON.stringify(stable(value)) + '\n').digest('hex'); +} + +function assignmentNames(client, category, summaries, stage) { + const names = client[`${category}ClientScopes`]; + check(Array.isArray(names) && names.length <= 200 && names.every(name => typeof name === 'string' && name) + && new Set(names).size === names.length, stage); + check(equal([...names].sort(), summaries.map(scope => scope.name).sort()), stage); + check(summaries.every(scope => equal(Object.keys(scope).sort(), ['id', 'name'])), stage); +} + +function assignments(defaults, optional, stage) { + list(defaults, stage, true); + list(optional, stage, true); + const combined = [...defaults, ...optional]; + check(new Set(combined.map(scope => scope.id)).size === combined.length + && new Set(combined.map(scope => scope.name)).size === combined.length, stage); + return { default: defaults, optional }; +} + +function identityFields(mapper) { return omit(mapper, ['config']); } + +function bindMappers(stored, effective, owner, bindings, stage) { + list(stored, stage, true); + list(effective, stage, true); + check(equal([...stored].sort(byId).map(identityFields), [...effective].sort(byId).map(identityFields)), stage); + for (const mapper of stored) { + check(mapper.protocol === 'openid-connect' && typeof mapper.protocolMapper === 'string' + && object(mapper.config), stage); + const identity = `${owner}/${mapper.name}`; + check(!bindings.has(mapper.id) || bindings.get(mapper.id) === identity, stage); + bindings.set(mapper.id, identity); + } + for (const mapper of effective) check(object(mapper.config), stage); +} + +function requireClient(value, name, stage) { + record(value, stage); + id(value.id, stage); + check(value.clientId === name && value.protocol === 'openid-connect', stage); +} + +/** Read-only closure of the eleven pinned definitions and their referenced roles. */ +export async function readStockConsoleGraph({ admin, existingAccountGraph, stage = 'plan' }) { + check(admin && typeof admin.get === 'function', stage); + const before = record(existingAccountGraph, stage); + for (const key of ['scopeDefinitions', 'scopeMappers', 'allowances']) + check(before[key] === undefined || before[key] instanceof Map, stage); + requireClient(before.account, 'account', stage); + requireClient(before.console, 'account-console', stage); + check(before.account.id !== before.console.id, stage); + const realm = record(await admin.get(''), stage); + check(realm.realm === 'learnstack', stage); + id(realm.id, stage); + const consoleAssignments = assignments(before.defaults, before.optional, stage); + const accountAssignments = assignments( + await admin.get(`clients/${encode(before.account.id)}/default-client-scopes`), + await admin.get(`clients/${encode(before.account.id)}/optional-client-scopes`), stage); + for (const category of categories) { + assignmentNames(before.console, category, consoleAssignments[category], stage); + assignmentNames(before.account, category, accountAssignments[category], stage); + const allowed = new Set(expected.assignments[category].map(scope => scope.name)); + check(consoleAssignments[category].every(scope => allowed.has(scope.name)), stage); + check(equal(accountAssignments[category].map(scope => scope.name).sort(), [...allowed].sort()), stage); + } + const inventory = list(await admin.get('client-scopes'), stage, true); + const byScopeName = new Map(inventory.map(scope => [scope.name, scope])); + const scopes = new Map(); + const mapperBindings = new Map(); + const consolePath = `clients/${encode(before.console.id)}`; + const directMappers = list(before.directMappers, stage, true); + bindMappers(before.console.protocolMappers ?? [], directMappers, 'client/account-console', mapperBindings, stage); + const directMappings = record(before.allowances?.get(consolePath), stage); + for (const summary of [...accountAssignments.default, ...accountAssignments.optional]) { + const indexed = byScopeName.get(summary.name); + check(indexed?.id === summary.id, stage); + for (const assigned of [...consoleAssignments.default, ...consoleAssignments.optional].filter(value => value.name === summary.name)) + check(assigned.id === summary.id, stage); + const path = `client-scopes/${encode(summary.id)}`; + const definition = record(before.scopeDefinitions?.get(summary.id) ?? await admin.get(path), stage); + check(definition.id === summary.id && definition.name === summary.name && definition.protocol === 'openid-connect' + && equal(definition, indexed), stage); + const effectiveMappers = before.scopeMappers?.get(path) ?? await admin.get(`${path}/protocol-mappers/models`); + bindMappers(definition.protocolMappers ?? [], effectiveMappers, `scope/${summary.name}`, mapperBindings, stage); + const allowances = record(before.allowances?.get(path) ?? await admin.get(`${path}/scope-mappings`), stage); + scopes.set(summary.name, { definition: copy(definition), effectiveMappers: copy(effectiveMappers), allowances: copy(allowances) }); + } + const roles = new Map(); + const roleIds = new Set(); + const pending = [ + { name: 'offline_access', clientRole: false }, + ...['manage-account', 'manage-account-links', 'view-groups'].map(name => ({ name, clientRole: true })), + ]; + for (const reference of pending) { + const path = reference.clientRole ? `clients/${encode(before.account.id)}/roles/${encode(reference.name)}` : `roles/${encode(reference.name)}`; + const named = record(await admin.get(path), stage); + id(named.id, stage); + check(named.name === reference.name && named.clientRole === reference.clientRole + && named.containerId === (reference.clientRole ? before.account.id : realm.id) && !roleIds.has(named.id), stage); + const definition = record(await admin.get(`roles-by-id/${encode(named.id)}`), stage); + check(equal(definition, named), stage); + const composites = list(await admin.get(`roles-by-id/${encode(named.id)}/composites`), stage, true); + check(typeof definition.composite === 'boolean' && definition.composite === (composites.length > 0), stage); + roleIds.add(named.id); + roles.set(`${reference.clientRole ? 'client/account' : 'realm'}/${reference.name}`, + { definition: copy(definition), composites: copy(composites) }); + } + const graph = { realmId: realm.id, account: copy(before.account), console: copy(before.console), + accountAssignments: copy(accountAssignments), consoleAssignments: copy(consoleAssignments), + directMappers: copy(directMappers), directMappings: copy(directMappings), scopes, roles }; + readGraphs.set(graph, copy(graph)); + return graph; +} + +function canonicalize(graph, canonicalAccountMapper, stage) { + const roleById = new Map([...graph.roles.values()].map(role => [role.definition.id, role.definition])); + const clientIds = new Map([[graph.account.id, 'account'], [graph.console.id, 'account-console']]); + const role = value => { + record(value, stage); + const authoritative = roleById.get(value.id); + check(Boolean(authoritative), stage); + check(equal(value, authoritative) || equal(value, omit(authoritative, ['attributes'])), stage); + const result = copy(value); + const owner = authoritative.clientRole ? `client/${clientIds.get(authoritative.containerId)}` : 'realm'; + check(!authoritative.clientRole || clientIds.get(authoritative.containerId) === 'account', stage); + result.id = `@role/${owner}/${authoritative.name}`; + result.containerId = authoritative.clientRole ? `@client/${clientIds.get(authoritative.containerId)}` : '@realm/learnstack'; + return result; + }; + const mapper = (value, owner) => ({ ...copy(value), id: `@mapper/${owner}/${value.name}` }); + const mappers = (values, owner) => values.map(value => mapper(value, owner)).sort(byName); + const mappings = value => { + const result = copy(value); + if (Object.hasOwn(value, 'realmMappings')) result.realmMappings = list(value.realmMappings, stage, true).map(role).sort(byId); + if (Object.hasOwn(value, 'clientMappings')) { + record(value.clientMappings, stage); + check(Object.keys(value.clientMappings).length <= 200, stage); + result.clientMappings = Object.fromEntries(Object.entries(value.clientMappings).map(([name, mapping]) => { + record(mapping, stage); + check(name === 'account' && mapping.client === name && mapping.id === graph.account.id, stage); + return [name, { ...copy(mapping), id: '@client/account', mappings: list(mapping.mappings, stage, true).map(role).sort(byId) }]; + })); + } + return result; + }; + const approvedStored = (graph.console.protocolMappers ?? []).filter(value => value.name === namedMapper); + const approvedEffective = graph.directMappers.filter(value => value.name === namedMapper); + check(approvedStored.length <= 1 && approvedStored.length === approvedEffective.length, stage); + let approvedMapperDelta = null; + if (approvedStored.length) { + check(canonicalAccountMapper?.name === namedMapper, stage); + check(approvedStored[0].id === approvedEffective[0].id, stage); + for (const value of [...approvedStored, ...approvedEffective]) + check(equal(accountMapperRepresentation(omit(value, ['id'])), accountMapperRepresentation(canonicalAccountMapper)), stage); + approvedMapperDelta = { stored: copy(approvedStored[0]), effective: copy(approvedEffective[0]) }; + } + const result = { schema: 1, providerVersion: '26.6.2', clients: {}, assignments: {}, scopes: {}, directConsoleMappings: {}, roles: {} }; + for (const [name, source] of [['account', graph.account], ['account-console', graph.console]]) { + const current = { ...copy(source), id: `@client/${name}` }; + for (const category of categories) current[`${category}ClientScopes`] = [...source[`${category}ClientScopes`]].sort(); + if (Object.hasOwn(source, 'protocolMappers')) + current.protocolMappers = mappers(source.protocolMappers.filter(value => name !== 'account-console' || value.name !== namedMapper), `client/${name}`); + result.clients[name] = current; + } + result.directConsoleEffectiveMappers = mappers(graph.directMappers.filter(value => value.name !== namedMapper), 'client/account-console'); + for (const category of categories) result.assignments[category] = graph.consoleAssignments[category] + .map(value => ({ ...copy(value), id: `@scope/${value.name}` })).sort(byName); + for (const [name, scope] of graph.scopes) { + const definition = { ...copy(scope.definition), id: `@scope/${name}` }; + if (Object.hasOwn(definition, 'protocolMappers')) definition.protocolMappers = mappers(scope.definition.protocolMappers, `scope/${name}`); + result.scopes[name] = { definition, effectiveMappers: mappers(scope.effectiveMappers, `scope/${name}`), allowances: mappings(scope.allowances) }; + } + result.directConsoleMappings = mappings(graph.directMappings); + for (const value of graph.roles.values()) { + const definition = role(value.definition); + result.roles[definition.id] = { definition, composites: value.composites.map(role).sort(byId) }; + } + const expectedProjection = copy(expected); + for (const category of categories) { + const allowed = new Map(expected.assignments[category].map(value => [value.name, value])); + for (const value of result.assignments[category]) check(equal(value, allowed.get(value.name)), stage); + expectedProjection.assignments[category] = copy(result.assignments[category]); + expectedProjection.clients['account-console'][`${category}ClientScopes`] = result.assignments[category].map(value => value.name).sort(); + } + check(equal(result, expectedProjection), stage); + return { canonicalGraph: result, approvedMapperDelta }; +} + +function snapshot(graph, approved) { + const sortedMappings = value => ({ ...copy(value), + ...(Object.hasOwn(value, 'realmMappings') ? { realmMappings: [...value.realmMappings].sort(byId) } : {}), + ...(Object.hasOwn(value, 'clientMappings') ? { clientMappings: Object.fromEntries(Object.entries(value.clientMappings) + .map(([name, mapping]) => [name, { ...copy(mapping), mappings: [...mapping.mappings].sort(byId) }])) } : {}) }); + const client = value => ({ ...copy(value), + defaultClientScopes: [...value.defaultClientScopes].sort(), optionalClientScopes: [...value.optionalClientScopes].sort(), + ...(Object.hasOwn(value, 'protocolMappers') ? { protocolMappers: value.protocolMappers.filter(mapper => mapper.id !== approved?.stored.id).sort(byId) } : {}) }); + return { realmId: graph.realmId, account: client(graph.account), console: client(graph.console), + accountAssignments: Object.fromEntries(categories.map(category => [category, [...graph.accountAssignments[category]].sort(byId)])), + consoleAssignments: Object.fromEntries(categories.map(category => [category, [...graph.consoleAssignments[category]].sort(byId)])), + directMappers: graph.directMappers.filter(mapper => mapper.id !== approved?.effective.id).sort(byId), + directMappings: sortedMappings(graph.directMappings), + scopes: [...graph.scopes].sort(([left], [right]) => left.localeCompare(right)).map(([name, scope]) => [name, { + definition: { ...copy(scope.definition), ...(Object.hasOwn(scope.definition, 'protocolMappers') + ? { protocolMappers: [...scope.definition.protocolMappers].sort(byId) } : {}) }, + effectiveMappers: [...scope.effectiveMappers].sort(byId), allowances: sortedMappings(scope.allowances) }]), + roles: [...graph.roles].sort(([left], [right]) => left.localeCompare(right)).map(([name, role]) => [name, + { definition: role.definition, composites: [...role.composites].sort(byId) }]) }; +} + +/** The caller selects this only after a successfully read incompatible graph. */ +export function qualifyStockConsoleGraph({ graph, canonicalAccountMapper, stage = 'plan' }) { + check(object(graph) && readGraphs.has(graph), stage); + graph = readGraphs.get(graph); + check(canonicalAccountMapper?.name === namedMapper, stage); + canonicalAccountMapper = copy(canonicalAccountMapper); + check(hash(expected) === baseline.provenance.canonicalSha256, stage); + const normalized = canonicalize(graph, canonicalAccountMapper, stage); + const detachments = []; + const remainingAssignments = { defaults: [], optional: [] }; + for (const category of categories) for (const scope of graph.consoleAssignments[category]) { + if (incompatible[scope.name] === category) { + const binding = Object.freeze({ category, name: scope.name, consoleId: graph.console.id, scopeId: scope.id }); + detachmentPaths.set(binding, `clients/${encode(graph.console.id)}/${category}-client-scopes/${encode(scope.id)}`); + detachments.push(binding); + } else remainingAssignments[category === 'default' ? 'defaults' : 'optional'].push(copy(scope)); + } + check(detachments.length > 0 && detachments.length <= 3, stage); + const retained = [...remainingAssignments.defaults, ...remainingAssignments.optional]; + validateAccountMappers([...graph.directMappers, ...retained.flatMap(scope => graph.scopes.get(scope.name).effectiveMappers)], canonicalAccountMapper, stage); + const expectedSnapshot = snapshot(graph, normalized.approvedMapperDelta); + expectedSnapshot.consoleAssignments = { default: copy(remainingAssignments.defaults).sort(byId), optional: copy(remainingAssignments.optional).sort(byId) }; + expectedSnapshot.console.defaultClientScopes = remainingAssignments.defaults.map(scope => scope.name).sort(); + expectedSnapshot.console.optionalClientScopes = remainingAssignments.optional.map(scope => scope.name).sort(); + return { detachments: Object.freeze(detachments), remainingAssignments, approvedMapperDelta: copy(normalized.approvedMapperDelta), + canonicalSha256: baseline.provenance.canonicalSha256, canonicalGraph: normalized.canonicalGraph, + verify: async ({ admin, existingAccountGraph }) => { + const after = await readStockConsoleGraph({ admin, existingAccountGraph, stage: 'readback' }); + const current = canonicalize(after, canonicalAccountMapper, 'readback'); + if (normalized.approvedMapperDelta) check(equal(current.approvedMapperDelta, normalized.approvedMapperDelta), 'readback', 'readback_conflict'); + check(equal(snapshot(after, current.approvedMapperDelta), expectedSnapshot), 'readback', 'readback_conflict'); + } }; +} + +/** Only tokens minted by successful qualification can authorize a DELETE path. */ +export function stockConsoleDetachmentPath(binding, stage = 'apply') { + check(object(binding) && detachmentPaths.has(binding), stage); + return detachmentPaths.get(binding); +} + +/** Offline fixed-expectation comparison; it cannot mint detachment authority. */ +export function extractStockConsoleCanonicalGraph({ graph, stage = 'plan' }) { + check(object(graph) && readGraphs.has(graph), stage); + graph = readGraphs.get(graph); + check(hash(expected) === baseline.provenance.canonicalSha256, stage); + const result = canonicalize(graph, undefined, stage); + for (const category of categories) + check(equal(result.canonicalGraph.assignments[category], expected.assignments[category]), stage); + return result.canonicalGraph; +} diff --git a/scripts/development-realm-stock-console.test.mjs b/scripts/development-realm-stock-console.test.mjs new file mode 100644 index 00000000..dfc9e0ba --- /dev/null +++ b/scripts/development-realm-stock-console.test.mjs @@ -0,0 +1,298 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { RealmError } from './development-realm-errors.mjs'; +import { extractCapturedStockConsole } from './extract-development-realm-stock-console.mjs'; +import { readStockConsoleGraph, qualifyStockConsoleGraph, stockConsoleDetachmentPath, + extractStockConsoleCanonicalGraph } from './development-realm-stock-console.mjs'; + +import { stockConsoleProvider as fixture, stockConsoleBaseline as baseline, canonicalAccountMapper } from './development-realm-stock-console-test-fixture.mjs'; + +const clone = value => structuredClone(value); +const failure = stage => error => error instanceof RealmError && error.stage === stage && error.reason === 'unsupported'; + +test('the fixed historical graph binds eleven scopes and four roles and authorizes only three exact edges', async () => { + const f = fixture(); const original = clone(f.capture); + const plan = await f.qualify(); + assert.deepEqual(plan.detachments.map(value => `${value.category}/${value.name}`).sort(), + ['default/roles', 'optional/microprofile-jwt', 'optional/offline_access']); + for (const token of plan.detachments) assert.equal(stockConsoleDetachmentPath(token), + `${f.base}/${token.category}-client-scopes/${token.scopeId}`); + assert.equal(plan.remainingAssignments.defaults.length, 5); + assert.equal(plan.remainingAssignments.optional.length, 3); + assert.equal(plan.canonicalSha256, baseline.provenance.canonicalSha256); + assert.equal(f.reads.filter(path => path.startsWith('roles-by-id/') && !path.endsWith('/composites')).length, 4); + assert.deepEqual(f.capture, original); +}); + +test('different provider IDs produce the same independently bound canonical expectation', async () => { + const first = fixture('one'); const second = fixture('two'); + assert.notEqual(first.account.id, second.account.id); + assert.deepEqual(extractStockConsoleCanonicalGraph({ graph: await first.read() }), + extractStockConsoleCanonicalGraph({ graph: await second.read() })); +}); + +test('every nonempty incompatible subset detaches only present edges without reattaching missing compatible edges', async () => { + for (let mask = 1; mask < 8; mask++) { + const f = fixture(); + f.assignments(mask & 1 ? ['roles', 'basic'] : ['basic'], + [...(mask & 2 ? ['microprofile-jwt'] : []), ...(mask & 4 ? ['offline_access'] : [])]); + const plan = await f.qualify(); + assert.equal(plan.detachments.length, [1, 2, 4].filter(bit => mask & bit).length); + assert.deepEqual(plan.remainingAssignments.defaults.map(scope => scope.name), ['basic']); + assert.deepEqual(plan.remainingAssignments.optional, []); + f.detach(plan); + await plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }); + } +}); + +test('a graph with no incompatible edge cannot mint stock detachment authority', async () => { + const f = fixture(); f.assignments(['basic'], ['phone']); + await assert.rejects(f.qualify(), failure('plan')); +}); + +test('extra edges, wrong categories, duplicate IDs and summary substitutions refuse', async () => { + for (const variant of ['extra', 'category', 'duplicate', 'summary_id', 'summary_name', 'summary_field']) { + const f = fixture(); + if (variant === 'extra') f.assignments(['roles', 'custom'], []); + if (variant === 'category') f.assignments(['microprofile-jwt'], ['roles']); + const summaries = f.capture[`${f.base}/default-client-scopes`]; + if (variant === 'duplicate') summaries.push(clone(summaries[0])); + if (variant === 'summary_id') summaries[0].id = 'aliased'; + if (variant === 'summary_name') summaries[0].name = 'aliased'; + if (variant === 'summary_field') summaries[0].retained = 'not-stock'; + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('full scope identity and inventory bindings refuse independently substituted ID, name or protocol', async () => { + for (const field of ['id', 'name', 'protocol']) { + const f = fixture(); f.capture[f.scopePath('roles')][field] = 'aliased'; + await assert.rejects(f.qualify(), failure('plan')); + } +}); + +test('lookalike scope fields, attributes, stored config and effective config remain significant', async () => { + for (const variant of ['field', 'attribute', 'stored', 'effective']) { + const f = fixture(); + f.changeScope('roles', value => { + if (variant === 'field') value.unrecognized = false; + if (variant === 'attribute') value.attributes.custom = ''; + if (variant === 'stored') value.protocolMappers[0].config.custom = 'false'; + }); + if (variant === 'effective') f.capture[`${f.scopePath('roles')}/protocol-mappers/models`][0].config.custom = 'false'; + await assert.rejects(f.qualify(), failure('plan')); + } +}); + +test('stored and effective mapper DTOs are both retained rather than default-expanded or collapsed', async () => { + const f = fixture(); const plan = await f.qualify(); + assert.deepEqual(plan.canonicalGraph.clients['account-console'].protocolMappers[0].config, {}); + assert.deepEqual(plan.canonicalGraph.directConsoleEffectiveMappers[0].config, + { 'introspection.token.claim': 'true', 'access.token.claim': 'true' }); + f.capture[`${f.base}/protocol-mappers/models`][0].config = {}; + await assert.rejects(f.qualify(), failure('plan')); +}); + +test('mapper IDs bind both endpoint representations and cannot alias across owners', async () => { + for (const variant of ['wrong_id', 'wrong_name', 'wrong_type', 'wrong_protocol', 'alias']) { + const f = fixture(); const values = f.capture[`${f.scopePath('roles')}/protocol-mappers/models`]; + if (variant === 'wrong_id') values[0].id = 'aliased'; + if (variant === 'wrong_name') values[0].name = 'aliased'; + if (variant === 'wrong_type') values[0].protocolMapper = 'oidc-full-name-mapper'; + if (variant === 'wrong_protocol') values[0].protocol = 'saml'; + if (variant === 'alias') { + const shared = f.capture[`${f.base}/protocol-mappers/models`][0].id; + f.changeScope('roles', value => { value.protocolMappers[0].id = shared; }); values[0].id = shared; + } + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('roles require independent name, ID, container, attributes and complete composite binding', async () => { + for (const variant of ['named_id', 'by_id', 'container', 'attribute', 'extra_child', 'child_id', 'child_name']) { + const f = fixture(); const path = `clients/${f.account.id}/roles/manage-account`; + const named = f.capture[path]; const byId = `roles-by-id/${named.id}`; + if (variant === 'named_id') named.id = f.capture[`clients/${f.account.id}/roles/manage-account-links`].id; + if (variant === 'by_id') f.capture[byId].id = 'aliased'; + if (variant === 'container') named.containerId = f.console.id; + if (variant === 'attribute') { named.attributes.retained = ['changed']; f.capture[byId] = clone(named); } + if (variant === 'extra_child') f.capture[`${byId}/composites`].push({ id: 'unknown', name: 'unknown' }); + if (variant === 'child_id') f.capture[`${byId}/composites`][0].id = 'aliased'; + if (variant === 'child_name') f.capture[`${byId}/composites`][0].name = 'aliased'; + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('complete client fields and account assignment bindings reject stock lookalikes', async () => { + for (const variant of ['account_field', 'console_field', 'access', 'account_summary', 'account_edge']) { + const f = fixture(); + if (variant === 'account_field') f.capture[`clients/${f.account.id}`].unknown = false; + if (variant === 'console_field') f.capture[f.base].unknown = false; + if (variant === 'access') f.capture[f.base].access.configure = false; + if (variant === 'account_summary') f.capture[`clients/${f.account.id}/default-client-scopes`][0].id = 'aliased'; + if (variant === 'account_edge') f.capture[`clients/${f.account.id}`].defaultClientScopes.pop(); + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('unreferenced client, scope and role customization is outside the qualified closure', async () => { + const f = fixture(); + f.capture.clients.push({ id: 'unrelated-client', clientId: 'unrelated', secret: 'synthetic-private' }); + f.capture['client-scopes'].push({ id: 'unrelated-scope', name: 'unrelated', protocol: 'saml', attributes: { custom: true } }); + f.capture.roles = [{ id: 'unrelated-role', name: 'custom-role', attributes: { arbitrary: ['retained'] } }]; + const original = clone(f.capture); + await f.qualify(); + assert.deepEqual(f.capture, original); + assert.equal(f.reads.includes('roles'), false); +}); + +test('broader or substituted direct and scope allowances cannot qualify by historical names', async () => { + for (const variant of ['direct_unknown', 'direct_role', 'scope_client', 'scope_realm', 'scope_unknown']) { + const f = fixture(); const mapping = f.capture[`${f.base}/scope-mappings`]; + if (variant === 'direct_unknown') mapping.extra = []; + if (variant === 'direct_role') mapping.clientMappings.account.mappings[0].id = 'aliased'; + const path = `${f.scopePath('phone')}/scope-mappings`; + if (variant === 'scope_client') f.capture[path] = { clientMappings: { other: { id: 'other', client: 'other', mappings: [] } } }; + if (variant === 'scope_realm') f.capture[path] = { realmMappings: [clone(f.capture['roles/offline_access'])] }; + if (variant === 'scope_unknown') f.capture[path] = { custom: false }; + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('one separately approved canonical mapper is preserved with its existing provider identity', async () => { + const f = fixture(); const mapper = f.addApproved(); const plan = await f.qualify(); + assert.deepEqual(plan.approvedMapperDelta, { stored: mapper, effective: mapper }); + f.detach(plan); + await plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }); +}); + +test('unknown, renamed, duplicate and modified approved mapper lookalikes refuse stock fallback', async () => { + for (const variant of ['renamed', 'duplicate', 'config', 'field', 'stored', 'id']) { + const f = fixture(); f.addApproved(variant === 'renamed' ? 'custom-name' : undefined); + const effective = f.capture[`${f.base}/protocol-mappers/models`]; + if (variant === 'duplicate') f.addApproved(); + if (variant === 'config') effective.at(-1).config['userinfo.token.claim'] = 'true'; + if (variant === 'field') effective.at(-1).unknown = false; + if (variant === 'stored') f.capture[f.base].protocolMappers.at(-1).config.unrecognized = ''; + if (variant === 'id') effective.at(-1).id = 'different'; + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('readback accepts exactly the authorized edge delta and an independently bound approved mapper addition', async () => { + const f = fixture(); const plan = await f.qualify(); f.detach(plan); f.addApproved(); + await plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }); + await assert.rejects(f.qualify(), failure('plan')); +}); + +test('readback retains existing mapper IDs even when all semantic mapper fields still match', async () => { + const f = fixture(); const plan = await f.qualify(); f.detach(plan); + f.changeScope('roles', value => { value.protocolMappers[0].id = 'replaced-id'; }); + f.capture[`${f.scopePath('roles')}/protocol-mappers/models`][0].id = 'replaced-id'; + await assert.rejects(plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }), + error => error.stage === 'readback' && error.reason === 'readback_conflict'); +}); + +test('readback refuses reattached unsafe edges, removed compatible edges and changed account edges', async () => { + for (const variant of ['reattach', 'remove', 'account']) { + const f = fixture(); const plan = await f.qualify(); f.detach(plan); + if (variant === 'reattach') f.assignments([...f.capture[f.base].defaultClientScopes, 'roles'], f.capture[f.base].optionalClientScopes); + if (variant === 'remove') f.assignments(f.capture[f.base].defaultClientScopes.filter(name => name !== 'basic'), f.capture[f.base].optionalClientScopes); + if (variant === 'account') { + f.capture[`clients/${f.account.id}`].optionalClientScopes.pop(); + f.capture[`clients/${f.account.id}/optional-client-scopes`].pop(); + } + await assert.rejects(plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }), error => error.stage === 'readback'); + } +}); + +test('an existing approved mapper cannot be replaced, removed or modified during readback', async () => { + for (const variant of ['id', 'removed', 'config']) { + const f = fixture(); f.addApproved(); const plan = await f.qualify(); f.detach(plan); + for (const values of [f.capture[f.base].protocolMappers, f.capture[`${f.base}/protocol-mappers/models`]]) { + if (variant === 'id') values.at(-1).id = 'replaced'; + if (variant === 'removed') values.pop(); + if (variant === 'config') values.at(-1).config.unrecognized = ''; + } + await assert.rejects(plan.verify({ admin: f.admin, existingAccountGraph: f.existing() }), error => error.stage === 'readback'); + } +}); + +test('transport and arbitrary read exceptions propagate without stock fallback or replay', async () => { + for (const error of [new RealmError('plan', 'transport'), new Error('synthetic-private-canary')]) { + const f = fixture(); let calls = 0; + f.admin.get = async () => { calls++; throw error; }; + await assert.rejects(f.read(), actual => actual === error); + assert.equal(calls, 1); + } +}); + +test('all read collections retain the existing 200-entry bound', async () => { + for (const variant of ['inventory', 'mappers', 'composites', 'assignments']) { + const f = fixture(); const values = Array.from({ length: 201 }, (_, index) => ({ id: `extra-${index}`, name: `extra-${index}` })); + if (variant === 'inventory') { + f.capture['client-scopes'].push(...values.slice(0, 189)); + await f.qualify(); + f.capture['client-scopes'].push(values[189]); + } + if (variant === 'mappers') f.capture[`${f.scopePath('roles')}/protocol-mappers/models`] = values; + if (variant === 'composites') { + const role = f.capture[`clients/${f.account.id}/roles/manage-account`]; f.capture[`roles-by-id/${role.id}/composites`] = values; + } + if (variant === 'assignments') f.capture[`${f.base}/default-client-scopes`] = values; + await assert.rejects(f.qualify(), failure('plan'), variant); + } +}); + +test('capture extraction requires the previously missing independent assignment and role-name reads', async () => { + for (const variant of ['account_assignment', 'role_name']) { + const f = fixture(); + delete f.capture[variant === 'account_assignment' ? `clients/${f.account.id}/optional-client-scopes` : 'roles/offline_access']; + await assert.rejects(extractCapturedStockConsole(f.capture), error => error.stage === 'plan' && error.reason === 'response'); + } +}); + +test('offline extraction compares observed data to the fixed expectation without tokens or current desired data', async () => { + const f = fixture(); const output = await extractCapturedStockConsole(f.capture); + assert.deepEqual(output.canonical, baseline.canonical); + assert.equal(output.canonicalSha256, baseline.provenance.canonicalSha256); + assert.equal(output.bytes.includes('provider-'), false); + const graph = await f.read(); + assert.throws(() => stockConsoleDetachmentPath(extractStockConsoleCanonicalGraph({ graph })), failure('apply')); +}); + +test('detachment tokens cannot be forged, cloned or retargeted through a mutated returned graph', async () => { + const f = fixture(); const graph = await f.read(); + graph.console.id = 'foreign-console'; + const plan = qualifyStockConsoleGraph({ graph, canonicalAccountMapper }); + assert.ok(plan.detachments.every(value => value.consoleId === f.console.id)); + const token = plan.detachments[0]; + assert.throws(() => stockConsoleDetachmentPath(clone(token)), failure('apply')); + assert.throws(() => stockConsoleDetachmentPath(null), failure('apply')); + assert.throws(() => qualifyStockConsoleGraph({ graph: clone(graph), canonicalAccountMapper }), failure('plan')); + assert.equal(Object.isFrozen(token), true); +}); + +test('the offline CLI checks a private capture and cannot overwrite the committed expectation', async t => { + const temporary = await mkdtemp(join(tmpdir(), 'learnstack-stock-extract-')); + t.after(() => rm(temporary, { recursive: true, force: true })); + const input = join(temporary, 'capture.json'); await writeFile(input, JSON.stringify(fixture().capture), { mode: 0o600 }); + const script = 'scripts/extract-development-realm-stock-console.mjs'; + const checked = spawnSync(process.execPath, [script, '--check', input], { encoding: 'utf8' }); + assert.equal(checked.status, 0); + assert.equal(checked.stdout, `stock-console: fixed expectation matched; sha256=${baseline.provenance.canonicalSha256}\n`); + const refused = spawnSync(process.execPath, [script, '--candidate', input, 'scripts/development-realm-stock-console-baseline.json'], { encoding: 'utf8' }); + assert.equal(refused.status, 1); + assert.equal(refused.stderr, 'realm: preflight/configuration\n'); + const malformed = fixture().capture; + malformed['roles/offline_access'].name = 'synthetic-private-canary'; + await writeFile(input, JSON.stringify(malformed), { mode: 0o600 }); + const privateFailure = spawnSync(process.execPath, [script, '--check', input], { encoding: 'utf8' }); + assert.equal(privateFailure.status, 1); + assert.equal(privateFailure.stdout, ''); + assert.equal(privateFailure.stderr, 'realm: plan/unsupported\n'); +}); diff --git a/scripts/development-realm-test-fixture.mjs b/scripts/development-realm-test-fixture.mjs new file mode 100644 index 00000000..b0ddd2fa --- /dev/null +++ b/scripts/development-realm-test-fixture.mjs @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +// Test-only in-memory provider; production entry points never import this module. +export const desired = JSON.parse(await readFile(new URL('../infra/keycloak/realms/learnstack.json', import.meta.url), 'utf8')); +export const secret = 'private-test-source'; +const clone = value => structuredClone(value); +const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); +// Pinned RepresentationToModel.toModel/removeEmptyString, independently modeled +// for provider storage instead of echoing the declaration back to the planner. +const storedMapper = mapper => ({ ...clone(mapper), config: Object.fromEntries( + Object.entries(mapper.config ?? {}).filter(([, value]) => value !== null && value !== '')) }); +export const manifest = { users: desired.users.map((user, index) => ({ + username: user.username, email: user.email, firstName: user.firstName, lastName: user.lastName, + userId: user.attributes.user_id[0], tenantId: user.attributes.tenant_id[0], + organizationId: user.attributes.organization_id?.[0] ?? null, realmRole: user.realmRoles[0], + accountCapability: { clientId: 'account', role: 'manage-account' }, + legacyTenantAttribute: index < 2 ? 'tenant-a' : null, +})) }; + +export function provider() { + let serial = 0; + const id = () => `provider-${serial += 1}`; + const state = { + realm: { realm: desired.realm, enabled: true, id: id(), accessTokenLifespan: 900, displayName: 'retained realm' }, + profile: JSON.parse(desired.components['org.keycloak.userprofile.UserProfileProvider'][0].config['kc.user.profile.config'][0]), + scopes: desired.clientScopes.map(scope => ({ ...clone(scope), id: id(), + protocolMappers: (scope.protocolMappers ?? []).map(mapper => ({ ...storedMapper(mapper), id: id() })) })), + clients: desired.clients.map(client => ({ ...clone(client), id: id(), + protocolMappers: (client.protocolMappers ?? []).map(mapper => ({ ...storedMapper(mapper), id: id() })), + secret: client.clientId === 'learnstack-web' ? secret : client.secret })), + users: desired.users.map(user => ({ ...omit(clone(user), ['realmRoles', 'clientRoles', 'credentials']), id: id(), + requiredActions: [], credentials: [{ id: id(), type: 'password', credentialData: 'retained password metadata' }], + roles: { realmMappings: user.realmRoles.map(name => ({ id: id(), name, composite: false, clientRole: false })) } })), + roles: desired.roles.realm.map(role => ({ ...clone(role), id: id(), composite: false, clientRole: false })), + groups: [], memberships: {}, + }; + state.realm.defaultRole = { id: id(), name: `default-roles-${desired.realm}`, composite: true, clientRole: false }; + const account = state.clients.find(client => client.clientId === 'account'); + const console = state.clients.find(client => client.clientId === 'account-console'); + state.accountRoles = desired.roles.client.account.map(role => ({ ...omit(clone(role), ['composites']), + id: id(), containerId: account.id })); + state.roleChildren = Object.fromEntries(state.accountRoles.map(role => [role.id, + (desired.roles.client.account.find(value => value.name === role.name).composites?.client.account ?? []) + .map(name => state.accountRoles.find(value => value.name === name))])); + const clientMapping = names => ({ id: state.clients.find(client => client.clientId === 'account').id, client: 'account', + mappings: names.map(name => clone(state.accountRoles.find(role => role.name === name))) }); + for (const [index, user] of state.users.entries()) user.roles.clientMappings = { + account: clientMapping(desired.users[index].clientRoles.account), + }; + state.scopeMappings = { [`clients/${console.id}`]: { clientMappings: { + account: clientMapping(['manage-account', 'view-groups']), + } } }; + const effectiveAccountRoles = user => { + const values = new Map(); + const visit = role => { + if (values.has(role.id)) return; + values.set(role.id, role); + for (const child of state.roleChildren[role.id] ?? []) visit(child); + }; + for (const role of [...(user.roles?.clientMappings?.account?.mappings ?? []), ...(user.roles?.realmMappings ?? [])]) visit(role); + for (const groupId of state.memberships[user.id] ?? []) { + let group = state.groups.find(value => value.id === groupId); + while (group) { + for (const name of group.clientRoles?.account ?? []) visit(state.accountRoles.find(role => role.name === name)); + group = state.groups.find(value => value.id === group.parentId); + } + } + return [...values.values()].filter(role => role.containerId === state.clients.find(client => client.clientId === 'account').id); + }; + + const writes = []; + const reads = []; + const canEdit = attribute => attribute.permissions?.edit?.includes('admin') === true; + const canRead = attribute => canEdit(attribute) || attribute.permissions?.view?.includes('admin') === true; + const unmanagedReadable = () => ['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'].includes(state.profile.unmanagedAttributePolicy); + const projectUser = (user, includeMetadata = false) => { + const projected = omit(user, ['roles', 'credentials']); + projected.attributes = Object.fromEntries(Object.entries(user.attributes ?? {}).filter(([name]) => { + const attribute = state.profile.attributes.find(entry => entry.name === name); + return attribute ? canRead(attribute) : unmanagedReadable(); + })); + for (const attribute of state.profile.attributes) { + if (canRead(attribute) && !projected.attributes[attribute.name]?.length && attribute.defaultValue != null) + projected.attributes[attribute.name] = [attribute.defaultValue]; + } + if (includeMetadata) projected.userProfileMetadata = { + attributes: state.profile.attributes.filter(canRead).map(attribute => ({ name: attribute.name, + displayName: attribute.displayName, required: false, readOnly: !canEdit(attribute), + annotations: attribute.annotations ?? {}, validators: Object.fromEntries(Object.entries(attribute.validations ?? {}) + .map(([name, config]) => [name, { ...config, 'ignore.empty.value': true }])), multivalued: attribute.multivalued === true })), + groups: clone(state.profile.groups ?? []), + }; + return projected; + }; + const get = async path => { + reads.push(path); + const url = new URL(path, 'http://provider/'); + const parts = url.pathname.slice(1).split('/').map(decodeURIComponent); + let value; + if (path === '') value = state.realm; + else if (path === 'users/profile') value = state.profile; + else if (path === 'groups/count') value = { count: state.groups.filter(group => group.type !== 'organization').length }; + else if (parts[0] === 'groups') { + const search = url.searchParams.get('search'); + assert.ok(search === '' || search === ' '); + assert.equal(url.searchParams.get('populateHierarchy'), 'false'); + assert.equal(url.searchParams.get('briefRepresentation'), 'false'); + assert.equal(url.searchParams.get('max'), '201'); + let groups = state.groups.filter(group => group.type !== 'organization'); + // An empty REST query is injected as null and lists only top-level + // groups; a nonempty space reaches the trimmed contains-search instead. + if (search === '') groups = groups.filter(group => group.parentId == null); + value = groups.slice(0, 201).map(group => omit(group, ['type'])); + } + else if (parts[0] === 'users' && parts.length === 1) { + let users = state.users; + // Pinned UsersResource selects its filtered branch even for exact=false; + // the unfiltered branch excludes service accounts. Username matching is + // case-insensitive contains unless exact=true. Brief rows omit attributes. + // Missing briefRepresentation is full, so deleting explicit false is equivalent. + if (!url.searchParams.has('exact') && !url.searchParams.has('username')) + users = users.filter(user => !user.serviceAccountClientId); + if (url.searchParams.has('username')) { + const username = url.searchParams.get('username').toLowerCase(); + users = users.filter(user => url.searchParams.get('exact') === 'true' + ? user.username.toLowerCase() === username : user.username.toLowerCase().includes(username)); + } + const first = Number(url.searchParams.get('first') ?? 0); + value = users.slice(first, first + Number(url.searchParams.get('max') ?? 100)).map(user => + url.searchParams.get('briefRepresentation') === 'true' + ? Object.fromEntries(['id', 'username', 'email', 'firstName', 'lastName', 'enabled', 'emailVerified', 'createdTimestamp'] + .filter(key => Object.hasOwn(user, key)).map(key => [key, user[key]])) + : projectUser(user, true)); + } else if (parts[0] === 'users') { + const user = state.users.find(entry => entry.id === parts[1]); + if (!user) throw new Error('unknown fixture user'); + if (parts[2] === 'groups') { + let groups = (state.memberships[user.id] ?? []).map(groupId => state.groups.find(group => group.id === groupId)); + // Pinned null/null membership lookup sees all group types; supplying + // pagination switches to the realm-only branch and conceals org rows. + if (url.searchParams.has('first') || url.searchParams.has('max')) groups = groups.filter(group => group.type !== 'organization'); + value = groups.map(group => omit(group, ['type', 'attributes', 'realmRoles', 'clientRoles'])); + } else if (parts[2] === 'role-mappings' && parts[3] === 'clients') + value = parts[5] === 'composite' ? effectiveAccountRoles(user) : user.roles?.clientMappings?.account?.mappings ?? []; + else value = parts[2] === 'credentials' ? user.credentials : parts[2] === 'role-mappings' ? user.roles : projectUser(user); + } else if (parts[0] === 'roles-by-id') value = state.roleChildren[parts[1]] ?? []; + else if (parts.at(-1) === 'scope-mappings') value = state.scopeMappings[parts.slice(0, -1).join('/')] ?? {}; + else if (parts[0] === 'roles') value = state.roles.find(role => role.name === parts[1]); + else if (path === 'client-scopes') value = state.scopes; + else if (parts[0] === 'client-scopes') { + const scope = state.scopes.find(scope => scope.id === parts[1]); + value = parts[2] ? scope.protocolMappers ?? [] : scope; + } + else if (parts[0] === 'clients' && parts.length === 1) + value = state.clients.filter(client => client.clientId === url.searchParams.get('clientId')).map(client => ({ id: client.id, clientId: client.clientId })); + else if (parts[0] === 'clients') { + const client = state.clients.find(entry => entry.id === parts[1]); + if (!parts[2]) value = { ...omit(client, ['defaultClientScopes', 'optionalClientScopes', 'protocolMappers', 'secret', 'authorizationServicesEnabled']), + ...(client.authorizationServicesEnabled === true ? { authorizationServicesEnabled: true } : {}) }; + else if (parts[2] === 'roles') value = state.accountRoles.find(role => role.name === parts[3]); + else if (parts[2] === 'client-secret') value = { type: 'secret', value: client.secret }; + else if (parts[2] === 'protocol-mappers') value = client.protocolMappers; + else { + const names = parts[2] === 'default-client-scopes' ? client.defaultClientScopes : client.optionalClientScopes; + value = names.map(name => { + const scope = state.scopes.find(scope => scope.name === name); + return { id: scope.id, name: scope.name }; + }); + } + } + if (value === undefined) throw new Error(`unhandled fixture GET ${path}`); + return clone(value); + }; + const put = async (path, body) => { + writes.push({ method: 'PUT', path, body: clone(body) }); + const parts = path.split('/').map(decodeURIComponent); + if (path === '') Object.assign(state.realm, clone(body)); + else if (path === 'users/profile') state.profile = clone(body); + else if (parts[0] === 'users') { + const user = state.users.find(entry => entry.id === parts[1]); + const readonly = Object.fromEntries(Object.entries(user.attributes ?? {}).filter(([name]) => { + const attribute = state.profile.attributes.find(entry => entry.name === name); + return attribute ? !canEdit(attribute) : !['ENABLED', 'ADMIN_EDIT'].includes(state.profile.unmanagedAttributePolicy); + })); + Object.assign(user, clone(body), { attributes: { ...clone(body.attributes), ...readonly } }); + } + else if (parts[0] === 'clients') { + const client = state.clients.find(entry => entry.id === parts[1]); + if (!parts[2]) { + Object.assign(client, omit(clone(body), ['attributes'])); + // Pinned RepresentationToModel derives origins from redirects when an + // omitted list falls back to an existing empty list. + if (!Object.hasOwn(body, 'webOrigins') && client.webOrigins.length === 0) + client.webOrigins = [...new Set(client.redirectUris.filter(uri => uri.startsWith('http')) + .map(uri => new URL(uri).origin))]; + for (const [name, value] of Object.entries(body.attributes ?? {})) { + if (value === null || value.trim() === '') delete client.attributes[name]; + else client.attributes[name] = value; + } + // Pinned ClientResource reattaches this scope when an unchanged true + // flag is resent. Omitting the flag avoids that unrelated mutation. + if (body.serviceAccountsEnabled === true && !client.defaultClientScopes.includes('service_account')) + client.defaultClientScopes.push('service_account'); + // Omission disables the resource server, rather than preserving it. + client.authorizationServicesEnabled = body.authorizationServicesEnabled === true; + for (const name of ['client.secret.expiration.time', 'client.secret.remaining.expiration.time', + 'client.secret.rotated', 'client.secret.rotated.creation.time', 'client.secret.rotated.expiration.time']) + delete client.attributes[name]; + } + else { + const scope = state.scopes.find(entry => entry.id === parts[3]); + client[parts[2] === 'default-client-scopes' ? 'defaultClientScopes' : 'optionalClientScopes'].push(scope.name); + } + } else if (parts[0] === 'client-scopes') { + const scope = state.scopes.find(entry => entry.id === parts[1]); + if (parts[2]) Object.assign(scope.protocolMappers.find(mapper => mapper.id === parts[4]), clone(body)); + else { + // Independent pinned ClientScopeResource.update validation: omitted + // name is not an attribute-only PATCH and never reaches model update. + assert.equal(body.name, scope.name, 'pinned scope update requires unchanged name'); + assert.equal(body.protocol, scope.protocol, 'pinned scope update retains protocol'); + Object.assign(scope, clone(body)); + } + } else throw new Error('unhandled fixture PUT'); + return path === 'users/profile' ? 200 : 204; + }; + const post = async (path, body) => { + writes.push({ method: 'POST', path, body: clone(body) }); + const parts = path.split('/').map(decodeURIComponent); + if (path === 'client-scopes') state.scopes.push({ ...clone(body), id: id(), + protocolMappers: (body.protocolMappers ?? []).map(mapper => ({ ...storedMapper(mapper), id: id() })) }); + else if (parts[0] === 'client-scopes') + state.scopes.find(scope => scope.id === parts[1]).protocolMappers.push({ ...storedMapper(body), id: id() }); + else if (parts[0] === 'clients' && parts[2] === 'protocol-mappers') + state.clients.find(client => client.id === parts[1]).protocolMappers.push({ ...storedMapper(body), id: id() }); + else if (path === 'partialImport') { + assert.equal(body.ifResourceExists, 'FAIL'); + assert.deepEqual(Object.keys(body).sort(), ['ifResourceExists', 'users']); + assert.equal(body.users.length, 1); + const user = body.users[0]; + state.users.push({ ...omit(clone(user), ['credentials', 'realmRoles', 'clientRoles']), id: id(), requiredActions: [], + credentials: [{ id: id(), type: 'password' }], + roles: { realmMappings: user.realmRoles.map(name => clone(state.roles.find(role => role.name === name))), + clientMappings: { account: clientMapping(user.clientRoles.account) } } }); + return { status: 200, body: { added: 1, overwritten: 0, skipped: 0, + results: [{ action: 'ADDED', resourceType: 'USER', resourceName: user.username, id: state.users.at(-1).id }] } }; + } + else if (parts[0] === 'users' && parts[2] === 'role-mappings') { + const user = state.users.find(user => user.id === parts[1]); + if (parts[3] === 'clients') { + user.roles.clientMappings ??= {}; + user.roles.clientMappings.account ??= clientMapping([]); + user.roles.clientMappings.account.mappings.push(...clone(body)); + } else user.roles.realmMappings.push(...clone(body)); + return { status: 204 }; + } else throw new Error('unhandled fixture POST'); + return { status: 201 }; + }; + return { state, writes, reads, admin: { get, put, post } }; +} + +/** Exercise the real core and planner against the same independent test state. */ +// Compose the complete planner model with independently captured stock DTOs. +// This synthetic provider exercises dispatch/readback; the real-provider proof +// independently checks the committed historical source and expectation. +export async function stockProvider() { + const { stockConsoleProvider } = await import('./development-realm-stock-console-test-fixture.mjs'); + const stock = stockConsoleProvider('stock'); + const fixture = provider(); + const { state, reads, writes } = fixture; + const originalGet = fixture.admin.get; + state.realm.id = stock.capture[''].id; + const stockNames = new Set(stock.capture['client-scopes'].map(scope => scope.name)); + state.scopes = [...state.scopes.filter(scope => !stockNames.has(scope.name)), ...clone(stock.capture['client-scopes'])]; + for (const name of ['account', 'account-console']) { + const index = state.clients.findIndex(client => client.clientId === name); + state.clients[index] = clone(stock.capture[`clients/${stock[name === 'account' ? 'account' : 'console'].id}`]); + } + state.accountRoles = ['manage-account', 'manage-account-links', 'view-groups'] + .map(name => clone(stock.capture[`clients/${stock.account.id}/roles/${name}`])); + state.roleChildren = Object.fromEntries(state.accountRoles.map(role => [role.id, + clone(stock.capture[`roles-by-id/${role.id}/composites`])])); + state.roles.push(clone(stock.capture['roles/offline_access'])); + state.scopeMappings = Object.fromEntries(Object.entries(stock.capture) + .filter(([path]) => path.endsWith('/scope-mappings')).map(([path, value]) => [path.slice(0, -'/scope-mappings'.length), clone(value)])); + for (const user of state.users) { + const mapping = user.roles.clientMappings.account; + mapping.id = stock.account.id; + mapping.mappings = mapping.mappings.map(role => omit(clone(state.accountRoles.find(value => value.name === role.name)), ['attributes'])); + } + fixture.admin.get = async path => { + const parts = path.split('/'); + if (parts[0] === 'clients' && parts.length === 2 && [stock.account.id, stock.console.id].includes(parts[1])) { + reads.push(path); + return clone(state.clients.find(client => client.id === parts[1])); + } + if (path.endsWith('/protocol-mappers/models')) { + const values = await originalGet(path); + const captured = stock.capture[path] ?? []; + return values.map(value => clone(captured.find(mapper => mapper.id === value.id) ?? value)); + } + if (parts[0] === 'roles-by-id' && parts.length === 2) { + reads.push(path); + const role = [...state.roles, ...state.accountRoles].find(role => role.id === parts[1]); + if (!role) throw new Error('unknown stock fixture role'); + return clone(role); + } + return originalGet(path); + }; + fixture.admin.delete = async path => { + const match = /^clients\/([^/]+)\/(default|optional)-client-scopes\/([^/]+)$/.exec(path); + assert.ok(match && match[1] === stock.console.id); + const scope = state.scopes.find(scope => scope.id === match[3]); + assert.ok(scope); + const client = state.clients.find(client => client.id === stock.console.id); + const key = `${match[2]}ClientScopes`; + assert.ok(client[key].includes(scope.name)); + client[key] = client[key].filter(name => name !== scope.name); + writes.push({ method: 'DELETE', path }); + return 204; + }; + return { ...fixture, stock }; +} + +export function providerTransport(fixture) { + const requests = []; + let serial = 0; + let bearer; + let refresh; + const response = (status, body) => ({ status, bytes: Buffer.from(body === undefined ? '' : JSON.stringify(body)) }); + const transport = async (url, options = {}) => { + requests.push({ path: url.pathname + url.search, ...options }); + if (url.pathname === '/realms/learnstack/.well-known/openid-configuration') + return response(200, { issuer: new URL('realms/learnstack', url.origin + '/').href }); + if (url.pathname === '/realms/master/protocol/openid-connect/token') { + assert.equal(options.method, 'POST'); + assert.equal(options.form.client_id, 'admin-cli'); + if (options.form.grant_type === 'refresh_token') assert.equal(options.form.refresh_token, refresh); + else assert.equal(options.form.grant_type, 'password'); + bearer = `test-only-access-${++serial}`; + refresh = `test-only-refresh-${serial}`; + return response(200, { access_token: bearer, refresh_token: refresh, expires_in: 60 }); + } + assert.equal(options.bearer, bearer, 'every Admin exchange uses the current authentication'); + if (url.pathname === '/admin/serverinfo') return response(200, { systemInfo: { version: '26.6.2' } }); + assert.ok(url.pathname.startsWith('/admin/realms/learnstack/')); + const path = url.pathname.slice('/admin/realms/learnstack/'.length) + url.search; + if (options.method === 'DELETE') return response(await fixture.admin.delete(path)); + if (options.method === 'PUT') { + const status = await fixture.admin.put(path, options.json); + return response(status, path === 'users/profile' && status === 200 ? fixture.state.profile : undefined); + } + if (options.method === 'POST') { + const value = await fixture.admin.post(path, options.json); + return response(value.status, value.body); + } + return response(200, await fixture.admin.get(path)); + }; + return { requests, transport }; +} diff --git a/scripts/extract-development-realm-stock-console.mjs b/scripts/extract-development-realm-stock-console.mjs new file mode 100644 index 00000000..27b1e664 --- /dev/null +++ b/scripts/extract-development-realm-stock-console.mjs @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { RealmError, realmFailure, refuse } from './development-realm-errors.mjs'; +import { extractStockConsoleCanonicalGraph, readStockConsoleGraph } from './development-realm-stock-console.mjs'; + +const baselinePath = fileURLToPath(new URL('./development-realm-stock-console-baseline.json', import.meta.url)); +const copy = value => structuredClone(value); +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const encode = encodeURIComponent; + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!object(value)) return value; + return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])); +} + +/** The capture is untrusted data; absent independent endpoint reads fail closed. */ +export async function extractCapturedStockConsole(capture) { + if (!object(capture) || !Array.isArray(capture.clients) || capture.clients.length > 200) refuse('plan', 'response'); + if (Buffer.byteLength(JSON.stringify(capture.clients), 'utf8') > 256 * 1024) refuse('plan', 'body_limit'); + if (capture.clients.some(value => !object(value) || typeof value.id !== 'string' || !value.id + || typeof value.clientId !== 'string' || !value.clientId) + || new Set(capture.clients.map(value => value.id)).size !== capture.clients.length + || new Set(capture.clients.map(value => value.clientId)).size !== capture.clients.length) refuse('plan', 'unsupported'); + const admin = { get: async path => { + if (!Object.hasOwn(capture, path)) refuse('plan', 'response'); + if (Buffer.byteLength(JSON.stringify(capture[path]), 'utf8') > 256 * 1024) refuse('plan', 'body_limit'); + return copy(capture[path]); + } }; + const client = async name => { + const matches = capture.clients.filter(value => object(value) && value.clientId === name); + if (matches.length !== 1 || typeof matches[0].id !== 'string' || !matches[0].id) refuse('plan', 'unsupported'); + const full = await admin.get(`clients/${encode(matches[0].id)}`); + if (!object(full) || full.id !== matches[0].id || full.clientId !== name) refuse('plan', 'unsupported'); + return full; + }; + const account = await client('account'); + const console = await client('account-console'); + const base = `clients/${encode(console.id)}`; + const existingAccountGraph = { account, console, + defaults: await admin.get(`${base}/default-client-scopes`), + optional: await admin.get(`${base}/optional-client-scopes`), + directMappers: await admin.get(`${base}/protocol-mappers/models`), + allowances: new Map([[base, await admin.get(`${base}/scope-mappings`)]]), + }; + const graph = await readStockConsoleGraph({ admin, existingAccountGraph }); + const canonical = extractStockConsoleCanonicalGraph({ graph }); + const bytes = JSON.stringify(stable(canonical)) + '\n'; + return { canonical, canonicalSha256: createHash('sha256').update(bytes).digest('hex'), bytes }; +} + +async function main(arguments_) { + if (arguments_.length < 2 || arguments_.length > 3 || !['--check', '--candidate'].includes(arguments_[0]) + || (arguments_[0] === '--check' && arguments_.length !== 2) + || (arguments_[0] === '--candidate' && arguments_.length !== 3)) refuse('preflight', 'configuration'); + const captureBytes = await readFile(arguments_[1]); + if (captureBytes.length > 32 * 1024 * 1024) refuse('plan', 'body_limit'); + let capture; + try { capture = JSON.parse(captureBytes.toString('utf8')); } + catch { refuse('plan', 'response'); } + const result = await extractCapturedStockConsole(capture); + if (arguments_[0] === '--candidate') { + if (resolve(arguments_[2]) === baselinePath) refuse('preflight', 'configuration'); + await writeFile(arguments_[2], result.bytes, { flag: 'wx', mode: 0o600 }); + } + process.stdout.write(`stock-console: fixed expectation matched; sha256=${result.canonicalSha256}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { await main(process.argv.slice(2)); } + catch (error) { + process.stderr.write(realmFailure(error instanceof RealmError ? error : new RealmError('preflight', 'configuration')) + '\n'); + process.exitCode = 1; + } +} diff --git a/scripts/public-local.mjs b/scripts/public-local.mjs index d004c6fe..e65f282d 100644 --- a/scripts/public-local.mjs +++ b/scripts/public-local.mjs @@ -1,6 +1,14 @@ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { chmodSync, readFileSync, writeFileSync } from 'node:fs'; +import { + closeSync, + constants, + fstatSync, + ftruncateSync, + openSync, + readSync, + writeSync, +} from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { publicApiEnvironment } from './public-runtime.mjs'; @@ -17,29 +25,65 @@ try { const operation = process.argv[2]; if (operation === 'prepare') { const path = resolve(root, '.env'); - let source = readFileSync(path, 'utf8'); - const values = parseLocalEnvironment(source); - const defaults = { - LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', - LEARNSTACK_PUBLIC_TLS_CERT: '.data/tls/public.pem', - LEARNSTACK_PUBLIC_TLS_KEY: '.data/tls/public-key.pem', - }; - for (const [key, value] of Object.entries(defaults)) { - if (values[key] === undefined) source += '\n' + key + '=' + value; + const maximumBytes = 256 * 1024; + const descriptor = openSync( + path, + constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const state = fstatSync(descriptor); + if ( + !state.isFile() || + state.uid !== process.getuid() || + state.nlink !== 1 || + (state.mode & 0o077) !== 0 || + state.size > maximumBytes + ) + throw new Error(); + // Qualify the held file before its first read; growth cannot bypass the cap. + const bytes = Buffer.alloc(maximumBytes + 1); + let size = 0; + while (size < bytes.length) { + const count = readSync(descriptor, bytes, size, bytes.length - size, null); + if (count === 0) break; + size += count; + } + if (size > maximumBytes) throw new Error(); + let source = bytes.subarray(0, size).toString('utf8'); + if (Buffer.byteLength(source) > maximumBytes) throw new Error(); + const values = parseLocalEnvironment(source); + const defaults = { + LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', + LEARNSTACK_PUBLIC_TLS_CERT: '.data/tls/public.pem', + LEARNSTACK_PUBLIC_TLS_KEY: '.data/tls/public-key.pem', + }; + for (const [key, value] of Object.entries(defaults)) { + if (values[key] === undefined) source += '\n' + key + '=' + value; + } + const secret = values.LEARNSTACK_PUBLIC_HOP_SECRET; + if (!secret) { + const generated = randomBytes(32).toString('base64url'); + if (secret === undefined) source += '\nLEARNSTACK_PUBLIC_HOP_SECRET=' + generated; + else + source = source.replace( + /^LEARNSTACK_PUBLIC_HOP_SECRET=.*$/m, + 'LEARNSTACK_PUBLIC_HOP_SECRET=' + generated, + ); + } else if (!validSecret(secret)) throw new Error(); + publicServerConfiguration(parseLocalEnvironment(source), root); + const prepared = Buffer.from(source.replace(/\n*$/, '\n')); + if (prepared.length > maximumBytes) throw new Error(); + // Reads advance the descriptor; explicit write positions replace from byte zero. + let written = 0; + while (written < prepared.length) { + const count = writeSync(descriptor, prepared, written, prepared.length - written, written); + if (count === 0) throw new Error(); + written += count; + } + ftruncateSync(descriptor, prepared.length); + } finally { + closeSync(descriptor); } - const secret = values.LEARNSTACK_PUBLIC_HOP_SECRET; - if (!secret) { - const generated = randomBytes(32).toString('base64url'); - if (secret === undefined) source += '\nLEARNSTACK_PUBLIC_HOP_SECRET=' + generated; - else - source = source.replace( - /^LEARNSTACK_PUBLIC_HOP_SECRET=.*$/m, - 'LEARNSTACK_PUBLIC_HOP_SECRET=' + generated, - ); - } else if (!validSecret(secret)) throw new Error(); - publicServerConfiguration(parseLocalEnvironment(source), root); - writeFileSync(path, source.replace(/\n*$/, '\n'), { mode: 0o600 }); - chmodSync(path, 0o600); console.warn('Private renderer configuration prepared; TLS and hosts remain manual.'); } else if (operation === 'api') { const values = loadLocalEnvironment(root, process.env, { apiAuthentication: true }); diff --git a/scripts/reconcile-development-realm.mjs b/scripts/reconcile-development-realm.mjs new file mode 100644 index 00000000..99705042 --- /dev/null +++ b/scripts/reconcile-development-realm.mjs @@ -0,0 +1,77 @@ +import { readFile, realpath } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { composeSelection, repositoryRoot } from './compose.mjs'; +import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; +import { publicSeederEnvironment } from './demo-database.mjs'; +import { reconcileRealm, realmPassMilliseconds } from './development-realm-core.mjs'; +import { acquireRealmLock, isLocalDockerEndpoint, readRealmEnvironmentFile, realmEnvironment, validateRealmTarget } from './development-realm-owner.mjs'; +import { RealmError, realmFailureNotice, refuse } from './development-realm-errors.mjs'; + +/** Shared seed/demo operation. Neither target nor secrets are command-line parameters. */ +export async function reconcileDevelopmentRealm({ selected, signal }) { + const deadline = AbortSignal.timeout(realmPassMilliseconds); + const active = signal ? AbortSignal.any([signal, deadline]) : deadline; + const processes = new DemoProcesses(active); + let lock; + let result; + let failure; + let preparationDispatched = false; + const run = (args) => processes.run('docker', args, { + cwd: selected.cwd, env: selected.env, label: 'Realm target inspection', capture: true, timeout: 5000, + }); + try { + if (!selected || !['dev', 'e2e'].includes(selected.mode) || selected.cwd !== repositoryRoot + || (selected.env.DOCKER_HOST && !isLocalDockerEndpoint(selected.env.DOCKER_HOST))) + refuse('preflight', 'configuration'); + const values = realmEnvironment(await readRealmEnvironmentFile(selected.file)); + const context = JSON.parse(await run(['context', 'inspect'])); + const model = JSON.parse(await run([...selected.args, 'config', '--format', 'json'])); + const ids = (await run([...selected.args, 'ps', '--quiet', '--no-trunc', 'keycloak'])).trim().split(/\s+/).filter(Boolean); + if (ids.length !== 1 || !/^[a-f0-9]{64}$/.test(ids[0])) refuse('preflight', 'configuration'); + const inspections = JSON.parse(await run(['inspect', ids[0]])); + if (!Array.isArray(inspections) || inspections.length !== 1) refuse('preflight', 'configuration'); + const target = validateRealmTarget(selected, model, ids, inspections[0], context, values); + lock = await acquireRealmLock(target.lockIdentity); + const manifest = JSON.parse(await processes.run('dotnet', [join(repositoryRoot, + 'backend/src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll'), '--identity-manifest'], { + cwd: join(repositoryRoot, 'backend'), env: publicSeederEnvironment(undefined), + label: 'Realm identity projection', capture: true, timeout: 5000, + })); + const desired = JSON.parse(await readFile(join(repositoryRoot, 'infra/keycloak/realms/learnstack.json'), 'utf8')); + result = await reconcileRealm({ ...target, adminUsername: values.KEYCLOAK_ADMIN, + adminPassword: values.KEYCLOAK_ADMIN_PASSWORD, webSecret: values.KEYCLOAK_WEB_CLIENT_SECRET, + manifest, desired, signal: active }); + preparationDispatched = result.preparationDispatched === true; + } catch (error) { + preparationDispatched = error instanceof RealmError && error.preparationDispatched === true; + failure = active.aborted ? new RealmError(error instanceof RealmError ? error.stage : 'preflight', + signal?.aborted ? 'cancelled' : 'deadline', preparationDispatched) + : error instanceof RealmError ? error : new RealmError('preflight', + error instanceof DemoCommandError && ['deadline', 'cancelled', 'cleanup'].includes(error.reason) + ? error.reason : 'configuration'); + } + // If child cleanup is not established, keep the exclusive file as an explicit refusal. + try { await processes.dispose(); } + catch { throw new RealmError('cleanup', 'cleanup', preparationDispatched); } + if (lock) { + try { await lock.release(); } + catch { throw new RealmError('cleanup', 'cleanup', preparationDispatched); } + } + if (failure) throw failure; + return result; +} + +if (process.argv[1] && await realpath(resolve(process.argv[1])).catch(() => '') === fileURLToPath(import.meta.url)) { + const stop = new AbortController(); + const remove = installDemoCancellation(() => stop.abort()); + try { + if (process.argv.length !== 2) refuse('preflight', 'configuration'); + const selected = composeSelection(process.env.LEARNSTACK_COMPOSE_MODE ?? 'dev'); + const result = await reconcileDevelopmentRealm({ selected, signal: stop.signal }); + console.log(`realm: completed (${result.mutations} writes)`); + } catch (error) { + console.error(realmFailureNotice(error)); + process.exitCode = 1; + } finally { remove(); } +} diff --git a/scripts/reconcile-development-realm.test.mjs b/scripts/reconcile-development-realm.test.mjs new file mode 100644 index 00000000..ed89b405 --- /dev/null +++ b/scripts/reconcile-development-realm.test.mjs @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { access, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { realmFailure } from './development-realm-errors.mjs'; +import { reconcileDevelopmentRealm } from './reconcile-development-realm.mjs'; +import { repositoryRoot } from './compose.mjs'; + +const canary = 'synthetic-private-cli-canary-73f2'; +const entry = fileURLToPath(new URL('./reconcile-development-realm.mjs', import.meta.url)); + +async function cli(args, environment = {}, script = entry) { + const child = spawn(process.execPath, [script, ...args], { + // No executable is available to perform Docker inspection or Seeder work. + env: { PATH: '', KEYCLOAK_ADMIN_PASSWORD: canary, KEYCLOAK_WEB_CLIENT_SECRET: canary, ...environment }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const out = []; + const err = []; + child.stdout.on('data', value => out.push(value)); + child.stderr.on('data', value => err.push(value)); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + try { + const status = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + return { ...status, stdout: Buffer.concat(out).toString(), stderr: Buffer.concat(err).toString() }; + } finally { clearTimeout(timer); } +} + +for (const args of [ + ['--help'], ['--url', `https://${canary}.invalid/`], ['--realm', canary], + ['--import', canary], ['--reset'], ['--response-limit', '1048576'], +]) { + test(`the closed CLI rejects ${args[0]} before selecting or authenticating a target`, async t => { + const dir = await mkdtemp(join(tmpdir(), 'learnstack-realm-argument-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, '.env'); + await writeFile(file, `KEYCLOAK_ADMIN=synthetic-admin\nKEYCLOAK_ADMIN_PASSWORD=${canary}\n`, { mode: 0o600 }); + const environment = { LEARNSTACK_COMPOSE_ENV_FILE: file }; + // A removed argv guard reaches a DIFFERENT closed downstream refusal. + assert.match((await cli([], environment)).stderr, /preflight\/missing_web_secret/); + const result = await cli(args, environment); + assert.equal(result.code, 1); + assert.equal(result.signal, null); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'realm: preflight/configuration\n'); + assert.ok(!result.stderr.includes(canary)); + }); +} + +test('an older private file is refused with a closed missing-secret reason before any command', async t => { + const dir = await mkdtemp(join(tmpdir(), 'learnstack-realm-entry-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, '.env'); + const source = `KEYCLOAK_ADMIN=synthetic-admin\nKEYCLOAK_ADMIN_PASSWORD=${canary}\n`; + await writeFile(file, source, { mode: 0o600 }); + const result = await cli([], { LEARNSTACK_COMPOSE_ENV_FILE: file }); + assert.equal(result.code, 1); + assert.equal(result.signal, null); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'realm: preflight/missing_web_secret\n' + + 'realm: add KEYCLOAK_WEB_CLIENT_SECRET explicitly to the selected private file; see infra/keycloak/README.md\n'); + assert.ok(!result.stderr.includes(canary)); + assert.equal(await readFile(file, 'utf8'), source); +}); + +for (const [name, script, args] of [ + ['reconcile', entry, []], + ['seed validation', fileURLToPath(new URL('./seed.mjs', import.meta.url)), ['--validate-config']], +]) { + for (const kind of ['FIFO', 'symlink', 'oversized file']) { + test(`the actual ${name} CLI refuses a selected ${kind} before reading or starting commands`, async t => { + const root = await mkdtemp(join(tmpdir(), 'learnstack-realm-private-entry-')); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, 'selected.env'); + if (kind === 'FIFO') execFileSync('mkfifo', [file], { stdio: 'ignore' }); + else if (kind === 'symlink') { + const target = join(root, 'private.env'); + await writeFile(target, `KEYCLOAK_ADMIN_PASSWORD=${canary}\n`, { mode: 0o600 }); + await symlink(target, file); + } else await writeFile(file, '#'.repeat(256 * 1024 + 1), { mode: 0o600 }); + const result = await cli(args, { LEARNSTACK_COMPOSE_ENV_FILE: file }, script); + // A blocked synchronous read needs the helper's watchdog and produces + // SIGKILL. Successful refusal exits naturally with the closed diagnostic. + assert.equal(result.signal, null); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + if (kind === 'oversized file') assert.equal(result.stderr, 'realm: preflight/configuration\n'); + else { + assert.ok(result.stderr.startsWith('realm: preflight/private_file\n')); + for (const text of ['regular', 'single-link', 'current user', 'owner-only', 'chmod 600']) + assert.ok(result.stderr.includes(text)); + } + assert.ok(!result.stderr.includes(canary)); + assert.ok(!result.stderr.includes(file)); + }); + } +} + +async function ownedEntryFixture(t, scenario) { + const root = await mkdtemp(join(tmpdir(), 'learnstack-real-realm-entry-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const directory of ['scripts', 'bin', 'tmp', 'backend', 'infra/keycloak/realms', 'frontend/apps/web/scripts']) + await mkdir(join(root, directory), { recursive: true }); + for (const name of ['compose', 'demo-process', 'demo-database', 'reconcile-development-realm', + 'development-realm-owner', 'development-realm-errors', 'development-realm-core', 'development-realm-plan', + 'development-realm-account', 'development-realm-mappers', 'development-realm-groups', 'development-realm-test-fixture', + 'development-realm-stock-console', 'development-realm-stock-console-test-fixture']) + await copyFile(new URL(`./${name}.mjs`, import.meta.url), join(root, `scripts/${name}.mjs`)); + await copyFile(new URL('./development-realm-stock-console-baseline.json', import.meta.url), + join(root, 'scripts/development-realm-stock-console-baseline.json')); + await copyFile(new URL('./development-realm-http.mjs', import.meta.url), join(root, 'scripts/development-realm-http-real.mjs')); + await copyFile(new URL('../frontend/apps/web/scripts/stop-test-child.mjs', import.meta.url), + join(root, 'frontend/apps/web/scripts/stop-test-child.mjs')); + await copyFile(new URL('../infra/keycloak/realms/learnstack.json', import.meta.url), join(root, 'infra/keycloak/realms/learnstack.json')); + await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); + const { secret } = await import('./development-realm-test-fixture.mjs'); + const values = { KEYCLOAK_ADMIN: 'synthetic-admin', KEYCLOAK_ADMIN_PASSWORD: canary, KEYCLOAK_WEB_CLIENT_SECRET: secret }; + const id = randomBytes(32).toString('hex'); + const lock = join(root, 'tmp', `learnstack-realm-${process.getuid()}`, + createHash('sha256').update(id + '/realms/learnstack').digest('hex') + '.lock'); + const events = join(root, 'events'); + const ready = join(root, 'ready'); + await writeFile(join(root, '.env'), Object.entries(values).map(([key, value]) => `${key}=${value}`).join('\n'), { mode: 0o600 }); + // Only the external provider boundary is substituted in this private copy. + // CLI, target validation, lock, manifest, full planner/readback and cleanup are real. + await writeFile(join(root, 'scripts/development-realm-http.mjs'), ` +import { appendFileSync, existsSync, writeFileSync } from 'node:fs'; +import { provider } from './development-realm-test-fixture.mjs'; +import { refuse } from './development-realm-errors.mjs'; +export { realmJson } from './development-realm-http-real.mjs'; +const fixture = provider(); +if (${JSON.stringify(scenario)} === 'failure') fixture.state.realm.accessTokenLifespan = 1800; +if (${JSON.stringify(scenario)}.startsWith('prepare-')) { + fixture.state.profile.attributes = fixture.state.profile.attributes.filter(attribute => !['user_id', 'tenant_id', 'organization_id'].includes(attribute.name)); + delete fixture.state.profile.unmanagedAttributePolicy; +} +export async function realmRequest(url, options) { + if (!existsSync(${JSON.stringify(lock)})) throw new Error('No lock before provider use'); + appendFileSync(${JSON.stringify(events)}, JSON.stringify({ provider: true, method: options.method ?? 'GET', stage: options.stage }) + '\\n'); + if (${JSON.stringify(scenario)} === 'cancel') { + writeFileSync(${JSON.stringify(ready)}, 'ready'); + if (!options.signal.aborted) await new Promise(resolve => options.signal.addEventListener('abort', resolve, { once: true })); + refuse(options.stage, 'cancelled'); + } + if (${JSON.stringify(scenario)} === 'failure' && options.method === 'PUT') refuse(options.stage, 'transport'); + let body; + if (url.pathname.includes('/.well-known/')) body = { issuer: 'http://localhost:8080/realms/learnstack' }; + else if (url.pathname.endsWith('/token')) body = { access_token: 'synthetic-admin-token', expires_in: 300, refresh_token: 'synthetic-refresh' }; + else if (url.pathname === '/admin/serverinfo') body = { systemInfo: { version: '26.6.2' } }; + else { + const path = url.pathname.slice('/admin/realms/learnstack/'.length) + url.search; + if (options.method === 'PUT') { + const status = await fixture.admin.put(path, options.json); + if (options.stage === 'prepare') { + if (${JSON.stringify(scenario)}.endsWith('-release')) writeFileSync(${JSON.stringify(lock)}, 'changed owned fixture lock nonce'); + if (${JSON.stringify(scenario)}.includes('-failure')) refuse(options.stage, 'transport'); + if (${JSON.stringify(scenario)} === 'prepare-cancel') { + writeFileSync(${JSON.stringify(ready)}, 'ready'); + if (!options.signal.aborted) await new Promise(resolve => options.signal.addEventListener('abort', resolve, { once: true })); + refuse(options.stage, 'cancelled'); + } + } + return { status, bytes: path === 'users/profile' ? Buffer.from(JSON.stringify(fixture.state.profile)) : Buffer.alloc(0) }; + } + if (options.method === 'POST') { const result = await fixture.admin.post(path, options.json); return { status: result.status, bytes: Buffer.from(JSON.stringify(result.body ?? {})) }; } + body = await fixture.admin.get(path); + } + return { status: 200, bytes: Buffer.from(JSON.stringify(body)) }; +} +`); + for (const command of ['docker', 'dotnet']) { + await writeFile(join(root, 'bin', command), `#!${process.execPath} +import { appendFileSync } from 'node:fs'; +import { manifest } from '../scripts/development-realm-test-fixture.mjs'; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(events)}, JSON.stringify({ command: ${JSON.stringify(command)}, args, + privateEnvironment: Object.keys(process.env).some(key => /keycloak|connectionstrings|password|_pw$/i.test(key)) }) + '\\n'); +const values = ${JSON.stringify(values)}; +let result; +if (${JSON.stringify(command)} === 'dotnet') { + if (args.length !== 2 || args[1] !== '--identity-manifest') process.exit(19); + result = manifest; +} else if (args[0] === 'context') result = [{ Endpoints: { docker: { Host: 'unix:///var/run/docker.sock' } } }]; +else if (args.includes('config')) result = { name: 'learnstack-dev', services: { keycloak: { image: 'quay.io/keycloak/keycloak:26.6.2', environment: values } } }; +else if (args.includes('ps')) { process.stdout.write(${JSON.stringify(id)}); process.exit(0); } +else if (args[0] === 'inspect') result = [{ Id: ${JSON.stringify(id)}, State: { Running: true }, + Config: { Image: 'quay.io/keycloak/keycloak:26.6.2', Labels: { 'com.docker.compose.project': 'learnstack-dev', 'com.docker.compose.service': 'keycloak' }, Env: Object.entries(values).map(([key, value]) => key + '=' + value) }, + NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '127.0.0.1', HostPort: '8080' }] } } }]; +else process.exit(19); +process.stdout.write(JSON.stringify(result)); +`, { mode: 0o700 }); + } + return { root, lock, events, ready, values }; +} + +for (const scenario of ['success', 'failure', 'cancel']) { + test(`the actual CLI ${scenario} path owns target, manifest, provider and lock cleanup`, async t => { + const f = await ownedEntryFixture(t, scenario); + const child = spawn(process.execPath, [join(f.root, 'scripts/reconcile-development-realm.mjs')], { + cwd: f.root, env: { PATH: join(f.root, 'bin'), HOME: f.root, TMPDIR: join(f.root, 'tmp'), + ...f.values, ConnectionStrings__Default: canary, POSTGRES_PASSWORD: canary }, stdio: ['ignore', 'pipe', 'pipe'], + }); + const output = [], errors = []; + child.stdout.on('data', value => output.push(value)); + child.stderr.on('data', value => errors.push(value)); + const status = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const timer = setTimeout(() => child.kill('SIGKILL'), 10000); + try { + if (scenario === 'cancel') { + const deadline = Date.now() + 5000; + while (!await access(f.ready).then(() => true, () => false)) { + if (Date.now() >= deadline) throw new Error('Owned provider did not start'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + child.kill('SIGTERM'); + } + assert.deepEqual(await status, { code: scenario === 'success' ? 0 : 1, signal: null }, + Buffer.concat(errors).toString() + await readFile(f.events, 'utf8').catch(() => 'no commands')); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await status; } + } + const stdout = Buffer.concat(output).toString(), stderr = Buffer.concat(errors).toString(); + assert.equal(stdout, scenario === 'success' ? 'realm: completed (0 writes)\n' : ''); + assert.equal(stderr, scenario === 'success' ? '' : `realm: ${scenario === 'failure' ? 'apply/transport' : 'discovery/cancelled'}\n`); + for (const value of [canary, 'private-test-source', 'synthetic-admin-token', 'synthetic-refresh']) + assert.ok(!(stdout + stderr).includes(value)); + assert.equal(await access(f.lock).then(() => true, () => false), false); + const events = (await readFile(f.events, 'utf8')).trim().split('\n').map(value => JSON.parse(value)); + assert.equal(events.filter(value => value.command === 'dotnet').length, 1); + assert.ok(events.some(value => value.provider)); + assert.ok(events.filter(value => value.command).every(value => !value.privateEnvironment)); + for (const privateValue of [canary, 'private-test-source', 'synthetic-admin-token', 'synthetic-refresh']) + assert.ok(events.filter(value => value.command).every(value => !JSON.stringify(value.args).includes(privateValue))); + }); +} + +test('a symlink CLI invocation executes the same closed argument guard', async t => { + const root = await mkdtemp(join(tmpdir(), 'learnstack-realm-symlink-')); + t.after(() => rm(root, { recursive: true, force: true })); + const alias = join(root, 'realm.mjs'); + await symlink(entry, alias); + const child = spawn(process.execPath, [alias, '--reset'], { env: { PATH: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stdout.on('data', value => { output += value; }); + child.stderr.on('data', value => { errors += value; }); + const code = await new Promise(resolve => child.once('close', resolve)); + assert.equal(code, 1); + assert.equal(output, ''); + assert.equal(errors, 'realm: preflight/configuration\n'); +}); + +test('pre-aborted setup does not start target inspection or expose cancellation details', async () => { + await assert.rejects(reconcileDevelopmentRealm({ signal: AbortSignal.abort(new Error(canary)), + selected: { mode: 'foreign', cwd: repositoryRoot, env: { PATH: '' } }, + }), error => { + assert.equal(realmFailure(error), 'realm: preflight/cancelled'); + assert.ok(!error.stack.includes(canary)); + return true; + }); +}); + +const preparationNotice = 'realm: profile metadata may already be durable; inspect current realm state before an explicit rerun; see infra/keycloak/README.md'; + +for (const scenario of ['prepare-failure', 'prepare-cancel', + 'prepare-success-dispose', 'prepare-failure-dispose', 'prepare-success-release', 'prepare-failure-release']) { + test(`the outer owner preserves dispatched preparation through ${scenario} and its cleanup`, async t => { + const f = await ownedEntryFixture(t, scenario); + let script = join(f.root, 'scripts/reconcile-development-realm.mjs'); + if (scenario.endsWith('-dispose')) { + script = join(f.root, 'probe.mjs'); + await writeFile(script, ` +import { composeSelection } from './scripts/compose.mjs'; +import { DemoProcesses } from './scripts/demo-process.mjs'; +import { reconcileDevelopmentRealm } from './scripts/reconcile-development-realm.mjs'; +import { realmFailureNotice } from './scripts/development-realm-errors.mjs'; +const dispose = DemoProcesses.prototype.dispose; +DemoProcesses.prototype.dispose = async function () { await dispose.call(this); throw new Error(${JSON.stringify(canary)}); }; +try { await reconcileDevelopmentRealm({ selected: composeSelection('dev') }); process.exitCode = 19; } +catch (error) { console.error(realmFailureNotice(error)); process.exitCode = 1; } +`); + } + const child = spawn(process.execPath, [script], { cwd: f.root, + env: { PATH: join(f.root, 'bin'), HOME: f.root, TMPDIR: join(f.root, 'tmp') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', stderr = ''; + child.stdout.on('data', value => { stdout += value; }); + child.stderr.on('data', value => { stderr += value; }); + const status = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const timer = setTimeout(() => child.kill('SIGKILL'), 10000); + try { + if (scenario === 'prepare-cancel') { + const deadline = Date.now() + 5000; + while (!await access(f.ready).then(() => true, () => false)) { + if (Date.now() >= deadline) throw new Error('Owned preparation did not start'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + child.kill('SIGTERM'); + } + assert.deepEqual(await status, { code: 1, signal: null }, stderr); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await status; } + } + const cleanupFailure = scenario.endsWith('-dispose') || scenario.endsWith('-release'); + assert.equal(stdout, ''); + assert.equal(stderr, `realm: ${cleanupFailure ? 'cleanup/cleanup' : scenario === 'prepare-cancel' ? 'prepare/cancelled' : 'prepare/transport'}\n${preparationNotice}\n`); + assert.equal(stderr.includes(canary), false); + assert.equal(await access(f.lock).then(() => true, () => false), cleanupFailure, + 'unestablished process cleanup or changed lock ownership keeps the exclusive lock'); + const events = (await readFile(f.events, 'utf8')).trim().split('\n').map(value => JSON.parse(value)); + assert.deepEqual(events.filter(event => event.provider && event.method !== 'GET' + && event.stage !== 'authentication').map(event => [event.stage, event.method]), [['prepare', 'PUT']]); + }); +} diff --git a/scripts/require-node-test-cases.mjs b/scripts/require-node-test-cases.mjs new file mode 100644 index 00000000..2d40d87d --- /dev/null +++ b/scripts/require-node-test-cases.mjs @@ -0,0 +1,23 @@ +// JUnit can call an empty describe() a passing testcase. Require Node's real +// same-invocation test counters as well; the workflow separately checks JUnit. +export default async function* requireTestCases(source) { + const expected = Number(process.env.LEARNSTACK_NODE_EXPECTED_TESTS); + if (!Number.isSafeInteger(expected) || expected <= 0) { + throw new Error('Node controls require an explicit positive expected test count.'); + } + let summaries = 0; + for await (const event of source) { + if (event.type !== 'test:summary' || event.data.file !== undefined) continue; + summaries++; + const { counts, success } = event.data; + const fields = ['tests', 'passed', 'failed', 'cancelled', 'skipped', 'todo']; + if (summaries !== 1 || success !== true + || fields.some(field => !Number.isSafeInteger(counts?.[field]) || counts[field] < 0) + || counts.tests !== expected || counts.passed !== counts.tests + || ['failed', 'cancelled', 'skipped', 'todo'].some(field => counts[field] !== 0)) { + throw new Error('Node controls require executed passing tests and zero cancellations/skips/todos.'); + } + yield `${counts.tests} executed tests passed; zero cancellations/skips/todos.\n`; + } + if (summaries !== 1) throw new Error('Node controls require one complete test summary.'); +} diff --git a/scripts/run-realm-node-tests.py b/scripts/run-realm-node-tests.py new file mode 100644 index 00000000..f9f9773b --- /dev/null +++ b/scripts/run-realm-node-tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run the complete source-censused realm controls with exact executed counts.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +# Update only with reviewed additions/removals, alongside their executing proof. +# Per-file expectations also fix the total; an empty or deleted test file cannot be +# replaced by a passing named suite or by extra tests in another file. +EXPECTED = { + 'development-realm-core.test.mjs': 62, + 'development-realm-http.test.mjs': 42, + 'development-realm-owner.test.mjs': 56, + 'development-realm-plan.test.mjs': 106, + 'development-realm-preparation.test.mjs': 28, + 'development-realm-stock-console.test.mjs': 25, + 'reconcile-development-realm.test.mjs': 24, +} + + +def run(): + scripts = ROOT / 'scripts' + actual = {path.name for path in scripts.glob('development-realm-*.test.mjs')} + actual.update(path.name for path in scripts.glob('reconcile-development-realm*.test.mjs')) + if actual != set(EXPECTED): + raise ValueError('Development realm test-file census does not match its reviewed plan.') + total = 0 + with tempfile.TemporaryDirectory(prefix='learnstack-realm-node-', dir=os.environ.get('RUNNER_TEMP')) as temporary: + for index, (filename, expected) in enumerate(EXPECTED.items()): + if type(expected) is not int or expected <= 0: + raise ValueError('Development realm expected count must be positive.') + report = Path(temporary) / f'{index}.xml' + environment = dict(os.environ, LEARNSTACK_NODE_EXPECTED_TESTS=str(expected)) + subprocess.run(['node', '--test', '--test-reporter=./scripts/require-node-test-cases.mjs', + '--test-reporter-destination=stdout', '--test-reporter=junit', + '--test-reporter-destination=' + str(report), 'scripts/' + filename], + cwd=ROOT, env=environment, check=True) + root = ET.parse(report).getroot() + cases = list(root.iter('testcase')) + empty_file_names = {'scripts/' + filename, str((scripts / filename).resolve())} + if (root.tag != 'testsuites' or len(cases) != expected + or any(case.get('name') in empty_file_names for case in cases) + or any(list(root.iter(tag)) for tag in ('failure', 'error', 'skipped'))): + raise ValueError('Development realm JUnit requires the exact named passing cases.') + total += expected + print(f'{filename}: {expected} passed; zero cancellations/skips/todos.', flush=True) + print(f'Complete development realm census: {len(EXPECTED)} files, {total} executed passing tests.', flush=True) + + +if __name__ == '__main__': + if len(sys.argv) != 1: + raise SystemExit('Development realm controls accept no arguments.') + run() diff --git a/scripts/seed.mjs b/scripts/seed.mjs new file mode 100644 index 00000000..d51e1c63 --- /dev/null +++ b/scripts/seed.mjs @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseEnv } from 'node:util'; +import { composeProcessEnvironment, composeSelection, composeSelectionFailure, repositoryRoot } from './compose.mjs'; +import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; +import { publicSeederEnvironment } from './demo-database.mjs'; +import { RealmError, realmFailureNotice, refuse } from './development-realm-errors.mjs'; +import { readRealmEnvironmentFile, realmEnvironment, validateLocalDockerContext } from './development-realm-owner.mjs'; +import { reconcileDevelopmentRealm } from './reconcile-development-realm.mjs'; +import { waitCompose } from './wait-compose.mjs'; + +const backendRoot = join(repositoryRoot, 'backend'); +const seederDll = join(backendRoot, + 'src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll'); + +async function selectedConfiguration(selected) { + const source = await readRealmEnvironmentFile(selected.file); + const values = parseEnv(source); + if (!values.ConnectionStrings__Default) refuse('preflight', 'configuration'); + // Reuse migration's measured Npgsql alias/quoted-value parser. The credential + // reaches only stdin, never argv, logs or the parser's environment. + let role; + try { + role = execFileSync('awk', ['-f', join(repositoryRoot, 'scripts/connection-string.awk'), '-v', 'field=user'], { + input: values.ConnectionStrings__Default ?? '', encoding: 'utf8', timeout: 5000, + maxBuffer: 2048, env: publicSeederEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { refuse('preflight', 'configuration'); } + if (role !== 'learnstack_app') + throw new Error('seed: ConnectionStrings__Default must name learnstack_app; credentials were not used.'); + realmEnvironment(source); + for (const key of ['ConnectionStrings__Default', 'ConnectionStrings__Migration']) + if (!values[key] || (process.env[key] !== undefined && process.env[key] !== values[key])) + refuse('preflight', 'configuration'); + return values; +} + +/** One selected stack and cancellation owner for readiness, application seed and identity. */ +export async function seedDevelopment({ selected, signal }) { + const values = await selectedConfiguration(selected); + const connection = values.ConnectionStrings__Default; + const prerequisites = { ...publicSeederEnvironment(), ...composeProcessEnvironment(selected.env) }; + const selectedPrerequisites = { ...prerequisites, + LEARNSTACK_COMPOSE_PROJECT: selected.project, LEARNSTACK_COMPOSE_ENV_FILE: selected.file, + LEARNSTACK_COMPOSE_MODE: selected.mode }; + const processes = new DemoProcesses(signal); + let preparationDispatched = false; + const run = (command, args, options) => { + console.log(`seed: ${options.label}`); + return processes.run(command, args, options); + }; + try { + await run('docker', ['compose', 'version'], { + cwd: repositoryRoot, env: prerequisites, label: 'Docker Compose prerequisite', timeout: 5000, + }); + validateLocalDockerContext(selected.env, JSON.parse(await run('docker', ['context', 'inspect'], { + cwd: repositoryRoot, env: selected.env, label: 'Local Docker context', capture: true, timeout: 5000, + }))); + await run('make', ['dev'], { + cwd: repositoryRoot, + env: selectedPrerequisites, label: 'Selected stack startup', timeout: 600000, + }); + // Keep readiness Docker handles in this owner. A nested wait-postgres CLI + // would create detached grandchildren with a competing teardown deadline. + await waitCompose(selected, processes, signal, true); + await run('make', ['migrate'], { + cwd: repositoryRoot, + env: { ...selectedPrerequisites, ConnectionStrings__Migration: values.ConnectionStrings__Migration }, + label: 'Selected migrations', timeout: 300000, + }); + await waitCompose(selected, processes, signal); + await run('dotnet', ['build', 'src/LearnStack.Tools.Seeder', '--configuration', 'Release', '--nologo'], { + cwd: backendRoot, env: publicSeederEnvironment(), label: 'Release Seeder build', timeout: 180000, + }); + await run('dotnet', [seederDll], { + cwd: backendRoot, env: publicSeederEnvironment(connection), label: 'Application seed', timeout: 180000, + }); + const result = await reconcileDevelopmentRealm({ selected, signal }); + preparationDispatched = result.preparationDispatched === true; + return result; + } catch (error) { + preparationDispatched = error instanceof RealmError && error.preparationDispatched === true; + throw error; + } finally { + try { await processes.dispose(); } + catch (error) { + if (preparationDispatched) throw new RealmError('cleanup', 'cleanup', true); + throw error; + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const controller = new AbortController(); + const removeCancellation = installDemoCancellation(() => controller.abort()); + try { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--validate-config')) + refuse('preflight', 'configuration'); + const selected = composeSelection(process.env.LEARNSTACK_COMPOSE_MODE ?? 'dev'); + if (args[0] === '--validate-config') await selectedConfiguration(selected); + else { + const result = await seedDevelopment({ selected, signal: controller.signal }); + console.log(`seed: application data and development realm converged (${result.mutations} realm writes).`); + console.log('seed: existing credentials are retained; account setup is documented in infra/keycloak/README.md.'); + } + } catch (error) { + const selectionNotice = composeSelectionFailure(error); + if (error instanceof RealmError) { + console.error(realmFailureNotice(error)); + } else if (selectionNotice !== undefined) { + console.error(`seed: ${selectionNotice}`); + } else { + console.error(error instanceof DemoCommandError + ? `${error.message.replace(/^demo:/, 'seed:')} (${error.reason})` + : error?.message === 'seed: ConnectionStrings__Default must name learnstack_app; credentials were not used.' + ? error.message : 'seed: setup or convergence failed; verify configuration and migrations.'); + } + process.exitCode = controller.signal.aborted ? 130 : 1; + } finally { + removeCancellation(); + } +} diff --git a/scripts/seed.sh b/scripts/seed.sh index d2ca7150..f942d7a9 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -1,38 +1,11 @@ #!/usr/bin/env bash -# LearnStack — local dev seed. -# -# Invoked by `make seed`. Idempotent: runs end-to-end every time, no -# destructive operations. -# -# Phase 01 scope (this file): verify the compose stack is healthy, confirm -# the two Keycloak realms are imported (`learnstack` + `learnstack-hub`), -# print a session summary with the demo credentials. -# -# Phase 02a Packet 7 scope (wired): provision the two demo tenants through -# `LearnStack.Tools.Seeder`, which sends the same commands a request sends — -# ProvisionTenantCommand, then CreateOrganizationCommand and -# MapHostToTenantCommand under each tenant's own announcement. Idempotent: a -# second run verifies contextual postconditions without overwriting tenant data. -# -# Phase 02a Packet 8 scope (wired): a third act per tenant installs the two -# built-in customizations — a `card` content type drawn by `default-card`, and -# a `plain` level taxonomy with three bands — through the same four commands a -# tenant admin uses, so a tenant that has authored nothing still has something -# for the runtime to resolve. Idempotent by the same mechanism. -# -# There is no platform-admin user to seed. This packet creates no `users` -# table — Phase 03's Identity migration owns it — and `UserId.SystemActor` is -# a CLR constant with no row behind it, deliberately, because the audit -# subsystem depends on an erased actor becoming an orphan surrogate. - +# LearnStack local seed: application-role data convergence followed by the shared +# development-realm reconciler. Existing provider credentials and unrelated data +# survive reruns; direct .NET Seeder remains an application-data tool. set -eu -o pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" - -# The combined lifecycle must never start one database and migrate/seed another -# through stale exported connection strings. Standalone `make migrate` retains -# its explicit connection override; `make seed` validates before any stack act. if [[ $# -gt 1 || ($# -eq 1 && "$1" != "--validate-config") ]]; then echo "seed: unsupported arguments." >&2 exit 2 @@ -42,113 +15,24 @@ if [[ ! -f "$seed_env" ]]; then echo "seed: selected environment file is missing." >&2 exit 1 fi -for _suffix in Migration Default; do - _name="ConnectionStrings__${_suffix}" - _selected=$(sed -n "s/^${_name}=//p" "$seed_env" \ - | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") - _ambient="${!_name:-}" - if [[ -z "$_selected" || (-n "$_ambient" && "$_ambient" != "$_selected") ]]; then - echo "seed: connection configuration disagrees with the selected environment." >&2 - exit 1 - fi -done +if ! command -v node >/dev/null 2>&1; then + echo "seed: node is required and was not found on PATH." >&2 + exit 1 +fi +# The selected private source must be complete before readiness, builds, migration +# or public-local preparation. Never copy a missing secret into an older file. +node scripts/seed.mjs --validate-config if [[ "${1:-}" == "--validate-config" ]]; then exit 0; fi - -# Readiness uses Node for bounded Compose and exact OIDC issuer checks. -for _dep in docker node dotnet; do +for _dep in docker dotnet; do if ! command -v "$_dep" >/dev/null 2>&1; then - echo "seed: '$_dep' is required and was not found on PATH." >&2 + echo "seed: required tooling was not found on PATH." >&2 exit 1 fi done +# The Node owner confines stack/migration prerequisites, supplies only the app +# credential to the fresh Release DLL, and joins its commands on failure. +node scripts/seed.mjs -# And `docker` on PATH is not `docker compose`: the V1 `docker-compose` binary satisfies the -# loop above and then every call below fails with "'compose' is not a docker command" — a -# message that names the subcommand rather than the missing plugin. Checked once, here. -if ! docker compose version >/dev/null 2>&1; then - echo "seed: the Docker Compose V2 plugin is required ('docker compose'), and was not found." >&2 - echo " The standalone 'docker-compose' binary is V1 and is not a substitute." >&2 - exit 1 -fi - -# One explicit selection is shared with make/demo; readiness has real command -# and HTTP deadlines, plus exact service and both-realm issuer checks. -node scripts/wait-compose.mjs - -cyan() { printf "\033[36m%s\033[0m\n" "$*"; } -green() { printf "\033[32m%s\033[0m\n" "$*"; } -red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } - -# ─── Step 3: application-level tenant seeding ──────────────────────────── -cyan "▶ Step 3/3: seeding the two demo tenants" - -# The application role, not the migration role. The seeder writes through the -# same policies a request does, so a seed that succeeds is evidence the request -# path works — and a seed run as the owner would pass with every policy inert, -# which is the failure mode ADR-0003 Amendment 3 names by role. -# -# Read from the environment, then from `.env`. The Makefile does not export -# `.env` into the recipe's environment — `--env-file` feeds docker compose's own -# variable substitution and nothing else — so on the documented first-run path -# (`make install` → `make dev` → `make migrate` → `make seed`) the variable is -# unset and only this fallback finds it. `make migrate` learned the same lesson -# and this mirrors its reader, CR-strip and quote-strip included: .env.example -# single-quotes the values. -seed_cs="${ConnectionStrings__Default:-}" -if [[ -z "$seed_cs" && -f "$seed_env" ]]; then - seed_cs=$(sed -n "s/^ConnectionStrings__Default=//p" "$seed_env" \ - | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") -fi - -if [[ -z "$seed_cs" ]]; then - red "seed: ConnectionStrings__Default is not set and .env does not carry it." - red " It arrives with the four-role model in Phase 02a Packet 6: copy the" - red " 'four connection strings' block out of .env.example into your .env" - red " and re-run. A .env written before that packet has neither." - exit 1 -fi - -seed_role=$(printf '%s' "$seed_cs" | awk -f scripts/connection-string.awk -v field=user) -seed_redacted=$(printf '%s' "$seed_cs" | awk -f scripts/connection-string.awk -v field=redacted) - -if [[ "$seed_role" != "learnstack_app" ]]; then - red "seed: ConnectionStrings__Default names Username='$seed_role', not learnstack_app:" - red " $seed_redacted" - red "Seeding requires learnstack_app so runtime grants and RLS are exercised." - red "A privileged bypass role makes isolation proof invalid; the migration owner" - red "is still subject to FORCE RLS but is not the supported runtime credential." - exit 1 -fi - -# Passed in the environment, not on argv: the value carries the database -# password, and an argument is visible to any local user through `ps`. The -# seeder reads this variable and takes no flag for it. -# Run from `backend/`, where the SDK pin lives (`backend/global.json`): from the -# repository root no global.json applies and whichever SDK is newest answers, which is -# not the one CI and `make migrate` use. -# The API's dispatcher credential and its aliases never enter the Seeder process. -# Node is already required above and shares the demo's exact child allowlist. -if ! ConnectionStrings__Default="$seed_cs" node --input-type=module <<'NODE' -import { spawnSync } from 'node:child_process'; -import { publicSeederEnvironment } from './scripts/demo-database.mjs'; -const result = spawnSync('dotnet', ['run', '--project', 'src/LearnStack.Tools.Seeder'], { - cwd: 'backend', - env: publicSeederEnvironment(process.env.ConnectionStrings__Default), - stdio: 'inherit', -}); -process.exit(result.status ?? 1); -NODE -then - red "seed: tenant seeding failed." - red " Has the schema been applied? → make migrate" - exit 1 -fi - -green " ✓ demo-english and demo-yoga present." - -# Accepted P02d-5/G32 keeps these SeedData hosts and manual local HTTPS setup. -# P02d-6 owns product pages; P02d-7/G45 owns make demo and the browser harness. -# README Quickstart owns the complete host/CA/leaf preparation instructions. cat <<'HOSTS' Both tenants resolve by host. Manually add these entries to your hosts file: @@ -170,18 +54,4 @@ cat <<'HOSTS' HOSTS -cyan "▶ Demo identities ready" -cat <<'IDENTITIES' - - Keycloak admin console: http://localhost:8080 (admin / admin-dev-secret) - - Realm: learnstack - demo-admin@tenant-a.test / demo-dev-secret (tenant-admin) - demo-learner@tenant-a.test / demo-dev-secret (tenant-learner) - - Realm: learnstack-hub - demo-operator@learnstack.test / demo-dev-secret (hub-operator; CONFIGURE_TOTP required-action) - -IDENTITIES - -green "✓ Seed complete." +printf "\033[32m%s\033[0m\n" "✓ Seed complete." diff --git a/scripts/test-ci-native-tests.py b/scripts/test-ci-native-tests.py index d28a9cc9..c32caa24 100644 --- a/scripts/test-ci-native-tests.py +++ b/scripts/test-ci-native-tests.py @@ -399,25 +399,49 @@ def test_failed_protocol_retains_only_closed_source_codes_and_bounded_line_numbe '/private/path/BackgroundJobRuntimeFacadeTests.ControlFailures.cs:line 74\n' '/private/path/BackgroundJobRuntimeFacadeTests.ControlFailures.cs:line 74\n' r'C:\private\BackgroundJobRuntimeFacadeTests.CapacityNoStart.cs:line 109' + '\n' + '/private/path/BackgroundJobPositiveSdkTests.CausalRestart.cs:line 123\n' + '/private/path/BackgroundJobPositiveSdkTests.NativeSettlement.cs:line 186\n' + '/private/path/BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs:line 57\n' + '/private/path/BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs:line 89\n' + '/private/path/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs:line 71\n' + '/private/path/BackgroundJobRecurrenceStoreTests.Runtime.cs:line 179\n' + '/private/path/TenantRealmApiTests.WarmQualification.cs:line 321\n' + '/private/path/UnexpectedBackgroundJobPositiveSdkTests.NativeSettlement.cs:line 555\n' '/private/path/BackgroundJobRuntimeFacadeTests.ControlFailures.cs:line 10001\n') second = self.execution_result(cases[1]); second['Outcome'] = 2 second['ErrorStackTrace'] = {'unexpected': 'PRIVATE-CANARY'} with contextlib.redirect_stdout(io.StringIO()): progress.results({'NewTestResults': [first, second]}) self.assertEqual([{'assembly': cases[0]['assembly'], 'id': cases[0]['id'], 'locations': [ - {'source': 'facade_capacity_no_start', 'line': 109}, {'source': 'facade_control_failure', 'line': 74}]}, + {'source': 'facade_capacity_no_start', 'line': 109}, {'source': 'facade_control_failure', 'line': 74}, + {'source': 'positive_sdk_causal_restart', 'line': 123}, + {'source': 'positive_sdk_native_settlement', 'line': 186}, + {'source': 'positive_sdk_settlement_prepare', 'line': 89}, + {'source': 'positive_sdk_settlement_scheduling', 'line': 57}, + {'source': 'realm_warm_qualification', 'line': 321}, + {'source': 'recurrence_native_ownership', 'line': 71}]}, {'assembly': cases[1]['assembly'], 'id': cases[1]['id'], 'locations': []}], progress.failure_locations) + self.assertEqual(8, len(progress.failure_locations[0]['locations']), + 'The additional helper roles do not widen the eight-location bound') self.assertNotIn('PRIVATE-CANARY', json.dumps(progress.failure_locations)) self.assertNotIn('private', json.dumps(progress.failure_locations)) rows = [json.loads(line) for line in stream.getvalue().splitlines()] self.assertTrue(all(set(row) == {'assembly', 'id', 'name', 'outcome', 'duration'} and row['outcome'] == 'Failed' for row in rows)) expected = {path.name for path in (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database').glob( - 'BackgroundJobRuntimeFacadeTests*.cs')} | {'PublicAdmissionRenderingTests.cs'} + 'BackgroundJobRuntimeFacadeTests*.cs')} | { + 'BackgroundJobPositiveSdkTests.CausalRestart.cs', + 'BackgroundJobPositiveSdkTests.NativeSettlement.cs', 'PublicAdmissionRenderingTests.cs', + 'BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs', + 'BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs', + 'BackgroundJobRecurrenceStoreTests.NativeOwnership.cs', + 'BackgroundJobRecurrenceStoreTests.Runtime.cs', + 'TenantRealmApiTests.WarmQualification.cs'} self.assertEqual(expected, set(self.owner.FAILURE_SOURCES)) self.assertEqual(len(expected), len(set(self.owner.FAILURE_SOURCES.values()))) for filename, code in self.owner.FAILURE_SOURCES.items(): - self.assertTrue((ROOT / 'backend/tests/LearnStack.Tests.Integration/Database' / filename).is_file()) + directory = 'TenancyIdentity' if filename == 'TenantRealmApiTests.WarmQualification.cs' else 'Database' + self.assertTrue((ROOT / 'backend/tests/LearnStack.Tests.Integration' / directory / filename).is_file()) progress.failure_locations.clear() item = self.execution_result(cases[0]); item['Outcome'] = 2 item['ErrorStackTrace'] = '/private/path/' + filename + ':line 123' @@ -425,6 +449,49 @@ def test_failed_protocol_retains_only_closed_source_codes_and_bounded_line_numbe with contextlib.redirect_stdout(io.StringIO()): progress.results({'NewTestResults': [item]}) self.assertEqual([{'source': code, 'line': 123}], progress.failure_locations[0]['locations']) + labels = { + 'System.TimeoutException': 'timeout', + 'System.OperationCanceledException': 'cancellation', + 'System.Threading.Tasks.TaskCanceledException': 'cancellation', + 'System.InvalidOperationException': 'invalid_state', + } + self.assertEqual(labels, self.owner.FAILURE_EXCEPTION_LABELS) + for index, (label, kind) in enumerate(labels.items()): + case = self.case(index + 10) + stream, console = io.StringIO(), io.StringIO() + progress = self.progress(self.root / ('exception-' + str(index) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + item = self.execution_result(case, outcome=2) + item['ErrorMessage'] = label + ' : PRIVATE-CANARY ---> System.TimeoutException : inner detail' + item['ErrorStackTrace'] = '/private/PRIVATE-CANARY/Unknown.cs:line 123' + with contextlib.redirect_stdout(console): + progress.results({'NewTestResults': [item]}) + diagnostic = {'assembly': case['assembly'], 'id': case['id'], 'locations': [], 'exceptionKind': kind} + self.assertEqual([diagnostic], progress.failure_locations) + self.assertEqual(console.getvalue(), stream.getvalue()) + measurements = self.root / ('exception-' + str(index) + '.json') + self.owner.write_json(measurements, {'failureLocations': progress.failure_locations}) + self.assertEqual([diagnostic], json.loads(measurements.read_text())['failureLocations']) + for text in (console.getvalue(), measurements.read_text()): + for private in ('PRIVATE-CANARY', '/private/', label, 'inner detail'): + self.assertNotIn(private, text) + self.assertNotIn('exceptionKind', stream.getvalue()) + self.assertFalse(progress.completed, 'Exception labels never supply a completion witness') + parse = self.owner.closed_exception_kind + for message in (None, {}, 'System.TimeoutException', 'TimeoutException : PRIVATE-CANARY', + 'Expected value to be true, but found false. PRIVATE-CANARY', + 'Xunit.Sdk.XunitException : PRIVATE-CANARY', + ' System.TimeoutException : PRIVATE-CANARY', '\nSystem.TimeoutException : PRIVATE-CANARY', + 'System.TimeoutExceptionOther : PRIVATE-CANARY', 'System.TimeoutException\x00 : PRIVATE-CANARY', + 'System.TimeoutException: PRIVATE-CANARY', + 'PRIVATE-CANARY System.TimeoutException : detail', + 'System.Exception : PRIVATE-CANARY ---> System.TimeoutException : detail', + 'Unknown.PrivateException : PRIVATE-CANARY', + 'System.TimeoutException : ' + 'x' * 65536): + self.assertIsNone(parse({'ErrorMessage': message, 'ErrorStackTrace': 'System.TimeoutException : detail'})) + prefix = 'System.TimeoutException : ' + self.assertEqual('timeout', parse({'ErrorMessage': prefix + 'x' * (65536 - len(prefix))})) + self.assertIsNone(parse({'ErrorMessage': prefix + 'x' * (65537 - len(prefix))})) def test_public_failure_details_export_only_registered_test_owned_stages(self): case = self.case(1) @@ -452,6 +519,558 @@ def test_public_failure_details_export_only_registered_test_owned_stages(self): self.assertEqual(bool(progress.failure_locations), outcome == 2) self.assertNotIn('production build', stream.getvalue()) + def test_keycloak_startup_vocabulary_matches_the_existing_fixture_source(self): + directory = ROOT / 'backend/tests/LearnStack.Tests.Integration/TenancyIdentity' + source = (directory / 'KeycloakFixture.cs').read_text() + initialize = source.split('public async Task InitializeAsync()', 1)[1].split( + 'internal async Task', 1)[0] + self.assertEqual(set(re.findall(r'\bstage = "([a-z_]+)";', initialize)), + self.owner.KEYCLOAK_FAILURE_STAGES) + self.assertIn('var reason = KeycloakStartupFailure.Classify(exception);', initialize) + classifier = (directory / 'KeycloakStartupFailure.cs').read_text() + reasons = set(re.findall(r'(?:=>|\breturn) "([a-z_]+)"', classifier)) + self.assertEqual(reasons, self.owner.KEYCLOAK_FAILURE_REASONS) + self.assertEqual(set(re.findall(r'\bcleanup = "([a-z_]+)";', initialize)), + self.owner.KEYCLOAK_FAILURE_CLEANUP) + self.assertIn('"Keycloak fixture startup failed at " + stage + "/" + reason', initialize) + self.assertIn('"; owned cleanup " + cleanup + "."', initialize) + self.assertEqual({'TenantRealmApiTests', 'TenantRealmIssuanceTests', 'TenantRealmProfileProtectionTests', + 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests'}, + self.owner.KEYCLOAK_FAILURE_CLASSES) + for name in self.owner.KEYCLOAK_FAILURE_CLASSES: + test = (directory / (name + '.cs')).read_text() + self.assertIn('namespace LearnStack.Tests.Integration.TenancyIdentity;', test) + self.assertIn('class ' + name, test) + self.assertIn('KeycloakFixture', test) + + def test_keycloak_startup_details_require_named_cases_and_one_complete_bounded_sentence(self): + parse = self.owner.closed_failure_details + prefix = 'LearnStack.Tests.Integration.TenancyIdentity.' + sentence = ('Keycloak fixture startup failed at provider_start/unsupported_operation; ' + 'owned cleanup completed.') + case = self.case(1, prefix + 'TenantRealmIssuanceTests.Proof(row: True)') + for name in self.owner.KEYCLOAK_FAILURE_CLASSES: + named = {**case, 'name': prefix + name + '.Proof(row: True)'} + for stage in self.owner.KEYCLOAK_FAILURE_STAGES: + for reason in self.owner.KEYCLOAK_FAILURE_REASONS: + for cleanup in self.owner.KEYCLOAK_FAILURE_CLEANUP: + message = ('System.InvalidOperationException : Keycloak fixture startup failed at ' + + stage + '/' + reason + '; owned cleanup ' + cleanup + '.\nPRIVATE-CANARY') + self.assertEqual({'stage': stage, 'reason': reason, 'cleanup': cleanup}, + parse(named, {'ErrorMessage': message})) + boundary = sentence + 'x' * (65536 - len(sentence)) + self.assertEqual({'stage': 'provider_start', 'reason': 'unsupported_operation', 'cleanup': 'completed'}, + parse(case, {'ErrorMessage': boundary})) + for message in (None, {}, sentence.replace('provider_start/', 'PRIVATE-CANARY/'), + sentence.replace('/unsupported_operation', '/PRIVATE-CANARY'), + sentence.replace('cleanup completed', 'cleanup PRIVATE-CANARY'), + sentence.replace('provider_start/', 'provider_start\n/'), + sentence.replace('; owned cleanup ', '; owned cleanup\x00 '), + sentence[:-1], sentence + '\n' + sentence, + sentence + '\n' + sentence.replace('provider_start/', 'unknown/'), + 'The following constructor parameters did not have matching fixture data: PRIVATE-CANARY', + boundary + 'x', 'x' * 65536 + sentence): + with self.subTest(message_type=type(message).__name__): + self.assertIsNone(parse(case, {'ErrorMessage': message})) + for changed in ({**case, 'assembly': 'learnstack.tests.unit'}, + {**case, 'name': prefix + 'OtherTests.Proof'}, + {**case, 'name': prefix + 'TenantRealmIssuanceTestsOther.Proof'}, + {**case, 'name': 'LearnStack.Tests.Integration.Database.TenantRealmIssuanceTests.Proof'}): + self.assertIsNone(parse(changed, {'ErrorMessage': sentence})) + self.assertIsNone(parse(case, {'ErrorMessage': 'unrelated failure', 'ErrorStackTrace': sentence})) + + def test_keycloak_fixture_results_keep_only_closed_measurements_and_truthful_sanitized_failures(self): + sentence = ('Keycloak fixture startup failed at provider_start/unsupported_operation; ' + 'owned cleanup completed.') + messages = [sentence, sentence, sentence.replace('/unsupported_operation', '/PRIVATE-CANARY'), + sentence + '\n' + sentence, sentence + 'x' * 65536, + 'The following constructor parameters did not have matching fixture data: PRIVATE-CANARY'] + cases = [self.case(index + 1, 'LearnStack.Tests.Integration.TenancyIdentity.' + 'TenantRealmIssuanceTests.Proof(row: ' + str(index) + ')') + for index in range(len(messages))] + stream, console = io.StringIO(), io.StringIO() + progress = self.progress(self.root / 'keycloak.diag', 'LearnStack.Tests.Integration', cases, stream) + results = [] + for index, (case, message) in enumerate(zip(cases, messages)): + result = self.execution_result(case, outcome=1 if index == 1 else 2) + result['ErrorMessage'] = 'System.InvalidOperationException : ' + message + '\nPRIVATE-CANARY' + result['ErrorStackTrace'] = '/private/PRIVATE-CANARY/KeycloakFixture.cs:line 141' + results.append(result) + with contextlib.redirect_stdout(console): + progress.results({'NewTestResults': results}) + expected = {'assembly': cases[0]['assembly'], 'id': cases[0]['id'], 'locations': [], + 'exceptionKind': 'invalid_state', + 'details': {'stage': 'provider_start', 'reason': 'unsupported_operation', 'cleanup': 'completed'}} + self.assertEqual([expected, + {'assembly': cases[2]['assembly'], 'id': cases[2]['id'], 'locations': [], 'exceptionKind': 'invalid_state'}, + {'assembly': cases[3]['assembly'], 'id': cases[3]['id'], 'locations': [], 'exceptionKind': 'invalid_state'}, + {'assembly': cases[4]['assembly'], 'id': cases[4]['id'], 'locations': []}, + {'assembly': cases[5]['assembly'], 'id': cases[5]['id'], 'locations': [], 'exceptionKind': 'invalid_state'}], + progress.failure_locations) + measurements = self.root / 'measurements.json' + self.owner.write_json(measurements, {'projects': [{'failureLocations': progress.failure_locations}]}) + self.assertEqual(expected, json.loads(measurements.read_text())['projects'][0]['failureLocations'][0]) + rows = [json.loads(line) for line in stream.getvalue().splitlines()] + self.assertEqual(console.getvalue(), stream.getvalue()) + self.assertEqual([{**case, 'outcome': 'Passed' if index == 1 else 'Failed', + 'duration': '00:00:00.0010000'} for index, case in enumerate(cases)], rows) + self.assertFalse(progress.completed, 'Failure details never supply a completion witness') + for text in (stream.getvalue(), console.getvalue(), measurements.read_text()): + for private in ('PRIVATE-CANARY', '/private/', 'System.InvalidOperationException', sentence): + self.assertNotIn(private, text) + self.assertNotIn('provider_start', stream.getvalue()) + output = self.root / 'output' + path = self.trx(output / 'results', cases[:1], outcome='Failed', raw_canary=True) + tree = ET.parse(path) + raw_output = tree.find('t:Results/t:UnitTestResult/t:Output', self.owner.NS) + tag = lambda name: '{' + self.owner.NS['t'] + '}' + name + ET.SubElement(ET.SubElement(raw_output, tag('ErrorInfo')), tag('Message')).text = sentence + tree.write(path, encoding='utf-8') + retained = self.owner.trx_cases(path, sanitize=True) + self.assertEqual('Failed', retained[0]['outcome']) + self.assertEqual([], ET.parse(path).findall('.//t:Output', self.owner.NS)) + self.assertNotIn('PRIVATE-CANARY', path.read_text()) + self.assertNotIn('provider_start', path.read_text()) + self.refuses('results_incomplete', self.owner.validate_results, output, cases[:1]) + + @staticmethod + def recurrence_native_sentence(kind='failure', **changes): + values = {'boundary': 'sibling_join', 'callbacks': '1', 'entered': 'succeeded', 'returned': 'pending', + 'executed': 'succeeded', 'thread': 'alive', 'nativeOutstanding': 'true', 'nativeRetained': 'true', + 'sqlHealthy': 'true', 'applications': '0', 'retainedApplications': '0', 'pendingHistory': '0', + 'executions': '0', 'pendingOutcomes': '0', 'bootstrap': 'true', + 'pools': 'succeeded,pending,succeeded', 'loops': 'succeeded,succeeded,succeeded', + 'controlRelay': 'succeeded', 'recurrenceRelay': 'succeeded', 'shared': 'pending', + 'bridgeLoop': 'succeeded', 'bridgeStop': 'succeeded', 'controlCancellation': 'succeeded', + 'recurrenceLoop': 'succeeded', 'control': 'pending', 'recurrence': 'pending', 'runtime': 'pending', + 'sourceDisposed': 'false', 'applicationDisposed': 'false', + 'sourceUnconfirmed': 'false', 'applicationUnconfirmed': 'false'} + values.update(changes) + if kind == 'cleanup': + values.pop('boundary') + return 'Recurrence native ' + kind + ';' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_recurrence_native_details_bind_one_case_and_keep_failure_separate_from_cleanup(self): + case = self.case(1, self.owner.RECURRENCE_NATIVE_FAILURE_CASE) + parse = self.owner.closed_failure_details + stdout = lambda text: {'Category': 'StdOutMsgs', 'Text': text} + failure = self.recurrence_native_sentence() + cleanup = self.recurrence_native_sentence('cleanup', returned='succeeded', thread='returned', + pools='succeeded,succeeded,succeeded', shared='succeeded', control='succeeded', recurrence='succeeded', + runtime='succeeded', sourceDisposed='true', applicationDisposed='true') + raw = {'ErrorMessage': 'PRIVATE-CANARY original failure', + 'Messages': [stdout('PRIVATE-CANARY\n' + failure + '\n' + cleanup + '\n')]} + value = parse(case, raw) + self.assertEqual('recurrence_native', value['stage']) + self.assertEqual('sibling_join', value['boundary']) + self.assertEqual(['succeeded', 'pending', 'succeeded'], value['pools']) + self.assertEqual('pending', value['returned']) + self.assertTrue(value['nativeRetained']) + self.assertFalse(value['sourceDisposed']) + self.assertEqual('succeeded', value['cleanup']['returned']) + self.assertTrue(value['cleanup']['sourceDisposed']) + self.assertNotIn('boundary', value['cleanup']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(value)) + self.assertIsNotNone(parse(case, {'Messages': [stdout(failure)]})) + for boundary in self.owner.RECURRENCE_NATIVE_BOUNDARIES: + observed = parse(case, {'Messages': [stdout(self.recurrence_native_sentence(boundary=boundary))]}) + self.assertEqual(boundary, observed['boundary']) + for changed in ({**case, 'assembly': 'learnstack.tests.unit'}, {**case, 'name': case['name'] + 'Extra'}, + {**case, 'name': case['name'] + '(row: True)'}, + {**case, 'name': case['name'].replace('BackgroundJobRecurrenceStoreTests', 'OtherTests')}): + self.assertIsNone(parse(changed, raw)) + + def test_recurrence_native_details_allow_only_bounded_owner_states_and_counts(self): + case = self.case(1, self.owner.RECURRENCE_NATIVE_FAILURE_CASE) + def parse(**changes): + sentence = self.recurrence_native_sentence(**changes) + return self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence}]}) + count_fields = ('callbacks', 'applications', 'retainedApplications', 'pendingHistory', 'executions', 'pendingOutcomes') + bool_fields = ('nativeOutstanding', 'nativeRetained', 'sqlHealthy', 'bootstrap', 'sourceDisposed', + 'applicationDisposed', 'sourceUnconfirmed', 'applicationUnconfirmed') + task_fields = ('entered', 'returned', 'executed', 'controlRelay', 'recurrenceRelay', 'shared', 'bridgeLoop', + 'bridgeStop', 'controlCancellation', 'recurrenceLoop', 'control', 'recurrence', 'runtime') + for field in count_fields: + for raw, expected in (('0', 0), ('64', 64), ('unavailable', None), ('out_of_range', 'out_of_range')): + self.assertEqual(expected, parse(**{field: raw})[field]) + for invalid in ('-1', '65', '00', '+1', '1.0', '1e0', 'unknown', 'PRIVATE-CANARY'): + self.assertIsNone(parse(**{field: invalid})) + for field in bool_fields: + for raw, expected in (('true', True), ('false', False), ('unavailable', None)): + self.assertIs(expected, parse(**{field: raw})[field]) + for invalid in ('True', 'False', '0', '1', 'unknown', 'PRIVATE-CANARY'): + self.assertIsNone(parse(**{field: invalid})) + for field in task_fields: + for state in ('not_issued', 'unpublished', 'pending', 'succeeded', 'faulted', 'canceled', 'unavailable'): + self.assertEqual(state, parse(**{field: state})[field]) + self.assertIsNone(parse(**{field: 'PRIVATE-CANARY'})) + for state in ('not_entered', 'alive', 'returned', 'unavailable'): + self.assertEqual(state, parse(thread=state)['thread']) + for field, maximum in (('pools', 8), ('loops', 3)): + self.assertEqual(['pending'] * maximum, parse(**{field: ','.join(['pending'] * maximum)})[field]) + for invalid in (','.join(['pending'] * (maximum + 1)), ',pending', 'pending,', 'pending,,succeeded', + 'succeeded,PRIVATE-CANARY', 'succeeded;' + 'x' * 128): + self.assertIsNone(parse(**{field: invalid})) + for sentinel in ('not_issued', 'unavailable'): + self.assertEqual(sentinel, parse(**{field: sentinel})[field]) + self.assertEqual('unpublished', parse(loops='unpublished')['loops']) + self.assertEqual(['unpublished'], parse(pools='unpublished')['pools']) + + def test_recurrence_native_details_refuse_ambiguous_malformed_or_wrong_channel_data(self): + case = self.case(1, self.owner.RECURRENCE_NATIVE_FAILURE_CASE) + parse = self.owner.closed_failure_details + failure, cleanup = self.recurrence_native_sentence(), self.recurrence_native_sentence('cleanup') + stdout = lambda text: {'Category': 'StdOutMsgs', 'Text': text} + exact_limit = failure + '\n' + 'x' * (65536 - len(failure) - 1) + self.assertIsNotNone(parse(case, {'Messages': [stdout(exact_limit)]})) + for messages in (None, {}, [], ['PRIVATE-CANARY'], [stdout(failure)] * 33, + [{'Category': 'StdOutMsgs'}], [{'Category': 'StdOutMsgs', 'Text': None}], + [{'Category': '', 'Text': failure}], [{'Category': 'x' * 33, 'Text': failure}], + [{'Category': 'StdOutMsgs', 'Text': failure, 'extra': 'PRIVATE-CANARY'}], + [{'Category': 'StdErrMsgs', 'Text': failure}], [{'Category': 'StdOut', 'Text': failure}], + [stdout(cleanup)], [stdout(failure), stdout(failure)], [stdout(failure + '\n' + failure)], + [stdout(failure), {'Category': 'StdErrMsgs', 'Text': failure}], [stdout(exact_limit + 'x')], + [stdout(failure[:-1])], [stdout('prefix ' + failure)], [stdout(failure + 'PRIVATE-CANARY')], + [stdout(failure.replace(';callbacks=', ';unknown='))], + [stdout(failure + '\n' + cleanup + '\n' + cleanup)], + [stdout(failure), {'Category': 'StdErrMsgs', 'Text': cleanup}], + [stdout(failure + '\n' + cleanup[:-1])], [stdout(failure + '\n' + 'prefix ' + cleanup)], + [stdout(self.recurrence_native_sentence(boundary='unlisted'))], + [stdout(self.recurrence_native_sentence(thread='PRIVATE-CANARY'))]): + self.assertIsNone(parse(case, {'Messages': messages})) + self.assertIsNone(parse(case, {'ErrorMessage': failure, 'ErrorStackTrace': failure})) + + def test_recurrence_native_actual_protocol_keeps_failure_and_sanitizes_private_output(self): + cases = [self.case(1, self.owner.RECURRENCE_NATIVE_FAILURE_CASE), self.case(2)] + stream, console = io.StringIO(), io.StringIO() + progress = self.progress(self.root / 'recurrence.diag', 'LearnStack.Tests.Integration', cases, stream) + results = [] + for case, outcome in zip(cases, (2, 1)): + item = self.execution_result(case, outcome=outcome) + item['ErrorMessage'] = 'PRIVATE-CANARY original failure' + item['ErrorStackTrace'] = '/private/PRIVATE-CANARY/BackgroundJobRecurrenceStoreTests.NativeOwnership.cs:line 71' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + self.recurrence_native_sentence() + + '\n' + self.recurrence_native_sentence('cleanup') + '\n'}] + results.append(item) + with contextlib.redirect_stdout(console): + progress.line(self.execution_message('TestExecution.StatsChange', {'NewTestResults': results}).encode().rstrip(b'\n')) + self.assertEqual(1, len(progress.failure_locations)) + observed = progress.failure_locations[0] + self.assertEqual([{'source': 'recurrence_native_ownership', 'line': 71}], observed['locations']) + self.assertEqual('sibling_join', observed['details']['boundary']) + self.assertFalse(progress.completed, 'Diagnostics never provide an owned-completion witness') + rows = [json.loads(line) for line in stream.getvalue().splitlines()] + self.assertEqual(['Failed', 'Passed'], [row['outcome'] for row in rows]) + measurement = self.root / 'recurrence-measurements.json' + self.owner.write_json(measurement, {'failureLocations': progress.failure_locations}) + for text in (measurement.read_text(), stream.getvalue(), console.getvalue()): + for private in ('PRIVATE-CANARY', '/private/', 'Recurrence native failure;', 'Recurrence native cleanup;'): + self.assertNotIn(private, text) + output = self.root / 'recurrence-output' + trx = self.trx(output / 'results', cases[:1], outcome='Failed', raw_canary=True) + tree = ET.parse(trx) + raw_output = tree.find('t:Results/t:UnitTestResult/t:Output', self.owner.NS) + ET.SubElement(raw_output, '{' + self.owner.NS['t'] + '}StdOut').text = self.recurrence_native_sentence() + tree.write(trx, encoding='utf-8') + self.assertEqual('Failed', self.owner.trx_cases(trx, sanitize=True)[0]['outcome']) + self.assertEqual([], ET.parse(trx).findall('.//t:Output', self.owner.NS)) + self.refuses('results_incomplete', self.owner.validate_results, output, cases[:1]) + + @staticmethod + def settlement_sentence(**changes): + values = {'boundary': 'count_assert', 'observedAttempts': '3', 'snapshot': 'available', + 'total': '3', 'normal': '1', 'finalize': '2', 'normalCounter': '1', 'finalizeCounter': '2', + 'phase': 'terminal_pending', 'terminal': 'external_retry_forbidden', 'generationDelta': '2', + 'executions': '0', 'outcome': 'succeeded', 'finalized': '1', + 'originalCause': 'none', 'finalizeCause': 'renewal_refused', 'renewalLosses': '1', 'elapsedLosses': '0'} + values.update(changes) + return 'Native settlement failure;' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_settlement_details_bind_exact_cases_output_channel_and_finite_observations(self): + parse = self.owner.closed_failure_details + sentence = self.settlement_sentence() + expected = {'stage': 'native_settlement', 'boundary': 'count_assert', 'observedAttempts': 3, + 'snapshot': 'available', 'total': 3, 'normal': 1, 'finalize': 2, 'normalCounter': 1, + 'finalizeCounter': 2, 'phase': 'terminal_pending', 'terminal': 'external_retry_forbidden', + 'generationDelta': 2, 'executions': 0, 'outcome': 'succeeded', 'finalized': 1, + 'originalCause': 'none', 'finalizeCause': 'renewal_refused', 'renewalLosses': 1, 'elapsedLosses': 0} + self.assertEqual(3, len(self.owner.SETTLEMENT_FAILURE_CASES)) + for name in self.owner.SETTLEMENT_FAILURE_CASES: + case = self.case(1, name) + raw = {'ErrorMessage': 'PRIVATE-CANARY original assertion', 'Messages': [ + {'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + sentence + '\n'}, + {'Category': 'StdErrMsgs', 'Text': 'PRIVATE-CANARY provider text'}]} + self.assertEqual(expected, parse(case, raw)) + for boundary in ('start', 'held_outcome', 'early_hint', 'held_count_read', 'held_count_assert', + 'renewal_first', 'renewal_progress', 'renewal_second', 'timer_cleanup', + 'completion_wait', 'count_read', 'normal_attempts', 'generation', 'outcome', 'provider_counts', + 'cleanup', 'post_cleanup', 'later_original', 'later_admission', 'later_commit_held', + 'later_refusal', 'later_ack', 'later_facts', 'later_cleanup', 'later_post_cleanup'): + text = self.settlement_sentence(boundary=boundary) + self.assertEqual({**expected, 'boundary': boundary}, + parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})) + for changed in ({**case, 'assembly': 'learnstack.tests.unit'}, + {**case, 'name': name + 'Extra'}, + {**case, 'name': name.replace('BackgroundJobPositiveSdkTests.', 'OtherTests.')}, + {**case, 'name': name.replace('knownFailure: True', 'knownFailure: true')} + if 'knownFailure: True' in name else {**case, 'name': name + '(row: True)'}): + self.assertIsNone(parse(changed, raw)) + case = self.case(1, sorted(self.owner.SETTLEMENT_FAILURE_CASES)[0]) + text = self.settlement_sentence(boundary='count_read', observedAttempts='unknown', snapshot='unavailable', + total='unknown', normal='unknown', finalize='unknown', normalCounter='unknown', finalizeCounter='unknown', + phase='unknown', terminal='unknown', generationDelta='unknown', executions='unknown', outcome='pending', finalized='unknown') + observed = parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]}) + self.assertEqual('count_read', observed['boundary']) + self.assertIsNone(observed['observedAttempts']) + self.assertIsNone(observed['total']) + self.assertEqual('unavailable', observed['snapshot']) + for field, low, high in (('observedAttempts', 0, 12), ('total', 0, 12), ('normal', 0, 6), + ('finalize', 0, 6), ('normalCounter', 0, 6), ('finalizeCounter', 0, 6), + ('generationDelta', -12, 12), ('executions', 0, 64), ('finalized', 0, 1), + ('renewalLosses', 0, 64), ('elapsedLosses', 0, 64)): + for value in (low, high, 'out_of_range'): + text = self.settlement_sentence(**{field: str(value)}) + self.assertEqual(value, parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})[field]) + for value in (low - 1, high + 1, '00', '-0', '01', '1.0', '+1', '1e0', '999999999999999999999'): + text = self.settlement_sentence(**{field: str(value)}) + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})) + + @staticmethod + def settlement_cleanup_sentence(**changes): + values = {'originalAdmission': 'joined', 'finalizeAdmission': 'joined', 'external': 'joined', + 'business': 'joined', 'disposition': 'not_issued', 'callback': 'joined', 'original': 'joined', + 'finalize': 'joined', 'control': 'joined', 'generation': 'joined', + 'executions': '0', 'running': '0', 'retained': '0', 'privateWork': 'joined'} + values.update(changes) + return 'Native settlement cleanup;' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_settlement_cleanup_witness_is_case_bound_and_independent_of_original_failure(self): + parse = self.owner.closed_failure_details + case = self.case(1, next(name for name in self.owner.SETTLEMENT_FAILURE_CASES if name.endswith('(knownFailure: True)'))) + failure = self.settlement_sentence(boundary='later_refusal', executions='1', observedAttempts='unknown') + cleanup = self.settlement_cleanup_sentence() + raw = {'ErrorMessage': 'PRIVATE-CANARY original assertion', 'Messages': [ + {'Category': 'StdOutMsgs', 'Text': failure + '\n' + cleanup + '\nPRIVATE-CANARY'}]} + details = parse(case, raw) + self.assertEqual('later_refusal', details['boundary']) + self.assertEqual(1, details['executions']) + self.assertEqual(0, details['cleanup']['executions']) + self.assertEqual('joined', details['cleanup']['business']) + self.assertEqual('joined', details['cleanup']['callback']) + self.assertEqual('joined', details['cleanup']['generation']) + for name in self.owner.SETTLEMENT_FAILURE_CASES - {case['name']}: + self.assertIsNone(parse({**case, 'name': name}, raw)) + self.assertIsNone(parse({**case, 'assembly': 'learnstack.tests.unit'}, raw)) + for field in ('originalAdmission', 'finalizeAdmission', 'external', 'business', 'disposition', + 'callback', 'original', 'finalize'): + for state in ('joined', 'incomplete', 'not_issued'): + text = failure + '\n' + self.settlement_cleanup_sentence(**{field: state}, generation='incomplete', privateWork='incomplete') + self.assertEqual(state, parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})['cleanup'][field]) + for field in ('executions', 'running', 'retained'): + for value in ('0', '64', 'out_of_range'): + text = failure + '\n' + self.settlement_cleanup_sentence(**{field: value}, generation='incomplete', privateWork='incomplete') + actual = parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})['cleanup'][field] + self.assertEqual(value if value == 'out_of_range' else int(value), actual) + + def test_settlement_cleanup_witness_refuses_malformed_ambiguous_or_private_facts(self): + parse = self.owner.closed_failure_details + case = self.case(1, next(name for name in self.owner.SETTLEMENT_FAILURE_CASES if name.endswith('(knownFailure: True)'))) + failure = self.settlement_sentence(boundary='later_refusal') + cleanup = self.settlement_cleanup_sentence() + stdout = lambda text: {'Category': 'StdOutMsgs', 'Text': text} + for messages in ([stdout(cleanup)], [stdout(failure + '\n' + cleanup + '\n' + cleanup)], + [stdout(failure), {'Category': 'StdErrMsgs', 'Text': cleanup}], + [stdout(self.settlement_sentence() + '\n' + cleanup)], + [stdout(failure + '\n' + cleanup + 'PRIVATE-CANARY')], + [stdout(failure + '\n' + 'prefix ' + cleanup)], + [stdout(failure + '\n' + cleanup[:-1])]): + self.assertIsNone(parse(case, {'Messages': messages})) + fields = ('originalAdmission', 'finalizeAdmission', 'external', 'business', 'disposition', + 'callback', 'original', 'finalize', 'control', 'generation', 'privateWork') + for field in fields: + for value in ('', 'true', 'false', 'PRIVATE-CANARY', 'joined\x00', 'unknown'): + text = failure + '\n' + self.settlement_cleanup_sentence(**{field: value}) + self.assertIsNone(parse(case, {'Messages': [stdout(text)]})) + for field in ('executions', 'running', 'retained'): + for value in ('-1', '65', '00', '1.0', '1e0', '+1', 'unknown', 'PRIVATE-CANARY'): + text = failure + '\n' + self.settlement_cleanup_sentence(**{field: value}) + self.assertIsNone(parse(case, {'Messages': [stdout(text)]})) + for changes in ({'generation': 'not_issued'}, {'control': 'not_issued'}, {'privateWork': 'not_issued'}, + {'generation': 'incomplete'}, {'running': '1'}, {'retained': '1'}): + text = failure + '\n' + self.settlement_cleanup_sentence(**changes) + self.assertIsNone(parse(case, {'Messages': [stdout(text)]})) + + def test_settlement_prepare_boundaries_and_cleanup_are_exact_true_case_only(self): + parse = self.owner.closed_failure_details + true_name = next(name for name in self.owner.SETTLEMENT_FAILURE_CASES if name.endswith('(knownFailure: True)')) + for boundary in ('prepare_admission', 'prepare_commit_held', 'prepare_renewal_held', 'prepare_refusal', + 'prepare_ack', 'prepare_facts', 'prepare_cleanup', 'prepare_post_cleanup'): + for cleanup in ('', '\n' + self.settlement_cleanup_sentence(finalizeAdmission='not_issued', + finalize='not_issued')): + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': self.settlement_sentence(boundary=boundary) + cleanup}]} + for name in self.owner.SETTLEMENT_FAILURE_CASES: + details = parse(self.case(1, name), raw) + if name == true_name: + self.assertEqual(boundary, details['boundary']) + self.assertEqual(bool(cleanup), 'cleanup' in details) + else: + self.assertIsNone(details) + + @staticmethod + def settlement_pacing_sentence(**changes): + values = {'lease': 'released', 'deadline': 'not_issued', 'first': 'joined', 'second': 'joined', + 'fault': 'none', 'cleanup': 'joined'} + values.update(changes) + return 'Native settlement pacing;' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_settlement_pacing_retains_only_finite_case_bound_timer_observations(self): + parse = self.owner.closed_failure_details + failure = self.settlement_sentence(boundary='renewal_second') + pacing = self.settlement_pacing_sentence() + stdout = lambda text: {'Category': 'StdOutMsgs', 'Text': text} + fields = {'lease': ('unseen', 'waiting', 'released', 'aborted'), + 'first': ('not_issued', 'creating', 'armed', 'entered', 'returned', 'joined', 'cancelled', 'incomplete'), + 'second': ('not_issued', 'creating', 'armed', 'entered', 'returned', 'joined', 'cancelled', 'incomplete'), + 'fault': ('none', 'ambiguous', 'timeout', 'callback_failed', 'timer_failed'), + 'cleanup': ('not_started', 'joined', 'incomplete')} + for name in self.owner.SETTLEMENT_FAILURE_CASES: + case = self.case(1, name) + raw = {'Messages': [stdout(failure + '\n' + pacing + '\nPRIVATE-CANARY')]} + details = parse(case, raw) + self.assertEqual('joined', details['pacing']['first']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(details)) + self.assertIsNone(parse({**case, 'assembly': 'learnstack.tests.unit'}, raw)) + self.assertIsNone(parse({**case, 'name': name + 'Extra'}, raw)) + for field, states in fields.items(): + for state in states: + text = failure + '\n' + self.settlement_pacing_sentence(**{field: state}) + self.assertEqual(state, parse(case, {'Messages': [stdout(text)]})['pacing'][field]) + for value in ('PRIVATE-CANARY', '', 'true', '0', 'joined\x00'): + text = failure + '\n' + self.settlement_pacing_sentence(**{field: value}) + self.assertIsNone(parse(case, {'Messages': [stdout(text)]})) + for messages in ([stdout(pacing)], [stdout(failure + '\n' + pacing + '\n' + pacing)], + [stdout(failure), {'Category': 'StdErrMsgs', 'Text': pacing}], + [stdout(failure + '\n' + 'prefix ' + pacing)], + [stdout(failure + '\n' + pacing + 'PRIVATE-CANARY')], + [stdout(failure + '\n' + pacing[:-1])], + [stdout(self.settlement_sentence(boundary='later_refusal') + '\n' + pacing)], + [stdout(self.settlement_sentence(boundary='prepare_refusal') + '\n' + pacing)]): + self.assertIsNone(parse(case, {'Messages': messages})) + + def test_settlement_deadline_observation_is_normal_publication_only(self): + parse = self.owner.closed_failure_details + for name in self.owner.SETTLEMENT_FAILURE_CASES: + case = self.case(1, name) + normal = 'knownFailure:' in name + for state in ('not_issued', 'creating', 'armed', 'entered', 'returned', 'joined', 'cancelled', 'incomplete'): + for boundary in ('start', 'deadline_armed', 'renewal_second'): + text = self.settlement_sentence(boundary=boundary) + '\n' + self.settlement_pacing_sentence(deadline=state) + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]} + details = parse(case, raw) + if normal or state == 'not_issued' and boundary != 'deadline_armed': + self.assertEqual(state, details['pacing']['deadline']) + else: + self.assertIsNone(details) + for state in ('PRIVATE-CANARY', '', '0', 'true', 'joined\x00'): + text = self.settlement_sentence() + '\n' + self.settlement_pacing_sentence(deadline=state) + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': text}]})) + + def test_settlement_attribution_is_sticky_and_independent_of_aggregate_totals(self): + case = self.case(1, sorted(self.owner.SETTLEMENT_FAILURE_CASES)[0]) + for field in ('originalCause', 'finalizeCause'): + for cause in ('unknown', 'other', 'none', 'renewal_refused', 'elapsed'): + sentence = self.settlement_sentence(boundary='later_post_cleanup', **{field: cause}) + details = self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence}]}) + self.assertEqual(cause, details[field]) + self.assertEqual(1, details['renewalLosses']) + for invalid in ('None', 'RenewalRefused', 'Elapsed', '0', '1', '2', 'private_cause'): + sentence = self.settlement_sentence(**{field: invalid}) + self.assertIsNone(self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence}]})) + for field in ('renewalLosses', 'elapsedLosses'): + sentence = self.settlement_sentence(**{field: 'unknown'}) + details = self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence}]}) + self.assertIsNone(details[field]) + + def test_settlement_details_refuse_ambiguous_malformed_oversized_or_private_output(self): + parse = self.owner.closed_failure_details + case = self.case(1, sorted(self.owner.SETTLEMENT_FAILURE_CASES)[0]) + sentence = self.settlement_sentence() + stdout = lambda text: {'Category': 'StdOutMsgs', 'Text': text} + boundary = sentence + '\n' + 'x' * (65536 - len(sentence) - 1) + self.assertIsNotNone(parse(case, {'Messages': [stdout(boundary)]})) + for messages in (None, {}, [], ['PRIVATE-CANARY'], [stdout(sentence)] * 33, + [{'Category': 'StdOutMsgs'}], [{'Category': 'StdOutMsgs', 'Text': None}], + [{'Category': '', 'Text': sentence}], [{'Category': 'x' * 33, 'Text': sentence}], + [{'Category': 'StdOutMsgs', 'Text': sentence, 'extra': 'PRIVATE-CANARY'}], + [{'Category': 'StdErrMsgs', 'Text': sentence}], [{'Category': 'StdOut', 'Text': sentence}], + [stdout(sentence), stdout(sentence)], [stdout(sentence + '\n' + sentence)], + [stdout(sentence), {'Category': 'StdErrMsgs', 'Text': sentence}], + [stdout(boundary + 'x')], [stdout('x' * 65536), stdout(sentence)], + [stdout(sentence[:-1])], [stdout('prefix ' + sentence)], [stdout(sentence + 'PRIVATE-CANARY')], + [stdout(sentence.replace(';total=', ';unknown='))], [stdout(sentence + ';total=3.')], + [stdout(self.settlement_sentence(boundary='count_assert', observedAttempts='unknown'))], + [stdout(self.settlement_sentence(snapshot='unavailable'))], + [stdout(self.settlement_sentence(total='unknown'))]): + with self.subTest(shape=type(messages).__name__): + self.assertIsNone(parse(case, {'Messages': messages})) + for field in ('boundary', 'snapshot', 'phase', 'terminal', 'outcome', 'originalCause', 'finalizeCause'): + for value in ('', 'PRIVATE-CANARY', 'unknown_private', 'pending\n', 'pending\x00'): + text = self.settlement_sentence(**{field: value}) + self.assertIsNone(parse(case, {'Messages': [stdout(text)]})) + self.assertIsNone(parse(case, {'ErrorMessage': sentence, 'ErrorStackTrace': sentence})) + + def test_settlement_failure_output_reaches_measurements_without_changing_results_or_exporting_text(self): + names = sorted(self.owner.SETTLEMENT_FAILURE_CASES) + cases = [self.case(index + 1, name) for index, name in enumerate(names)] + stream, console = io.StringIO(), io.StringIO() + progress = self.progress(self.root / 'settlement.diag', 'LearnStack.Tests.Integration', cases, stream) + results = [] + for index, case in enumerate(cases): + result = self.execution_result(case, outcome=1 if index == 1 else 2) + result['ErrorMessage'] = 'PRIVATE-CANARY original or cleanup failure' + result['ErrorStackTrace'] = '/private/PRIVATE-CANARY/BackgroundJobPositiveSdkTests.NativeSettlement.cs:line 164' + result['Messages'] = [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + self.settlement_sentence() + + '\n' + self.settlement_pacing_sentence() + '\n'}] + if index == 2: + result['Messages'][0]['Text'] += self.settlement_sentence() + results.append(result) + # Exercise the same protocol decoder used for real pinned-adapter events. + with contextlib.redirect_stdout(console): + progress.line(self.execution_message('TestExecution.StatsChange', {'NewTestResults': results}).encode().rstrip(b'\n')) + self.assertEqual(2, len(progress.failure_locations)) + details = progress.failure_locations[0]['details'] + self.assertEqual('count_assert', details['boundary']) + self.assertEqual(3, details['observedAttempts']) + self.assertEqual(2, details['finalize']) + self.assertEqual('joined', details['pacing']['cleanup']) + self.assertEqual('none', details['pacing']['fault']) + self.assertNotIn('details', progress.failure_locations[1]) + self.assertNotIn('exceptionKind', progress.failure_locations[0]) + self.assertFalse(progress.completed, 'A diagnostic never supplies a completion witness') + measurements = self.root / 'settlement-measurements.json' + self.owner.write_json(measurements, {'projects': [{'failureLocations': progress.failure_locations}]}) + self.assertEqual(details, json.loads(measurements.read_text())['projects'][0]['failureLocations'][0]['details']) + rows = [json.loads(line) for line in stream.getvalue().splitlines()] + self.assertEqual([{**case, 'outcome': 'Passed' if index == 1 else 'Failed', 'duration': '00:00:00.0010000'} + for index, case in enumerate(cases)], rows) + for text in (measurements.read_text(), stream.getvalue(), console.getvalue()): + for private in ('PRIVATE-CANARY', '/private/', 'Native settlement failure;', + 'Native settlement pacing;', 'original or cleanup failure'): + self.assertNotIn(private, text) + output = self.root / 'settlement-output' + trx = self.trx(output / 'results', cases[:1], outcome='Failed', raw_canary=True) + tree = ET.parse(trx) + raw_output = tree.find('t:Results/t:UnitTestResult/t:Output', self.owner.NS) + tag = lambda name: '{' + self.owner.NS['t'] + '}' + name + ET.SubElement(raw_output, tag('StdOut')).text = self.settlement_sentence() + '\n' + self.settlement_pacing_sentence() + tree.write(trx, encoding='utf-8') + retained = self.owner.trx_cases(trx, sanitize=True) + self.assertEqual('Failed', retained[0]['outcome']) + self.assertEqual([], ET.parse(trx).findall('.//t:Output', self.owner.NS)) + self.assertNotIn('Native settlement failure;', trx.read_text()) + self.assertNotIn('Native settlement pacing;', trx.read_text()) + self.refuses('results_incomplete', self.owner.validate_results, output, cases[:1]) + def test_outage_failure_details_keep_only_consistent_bounded_timing_and_closed_pool_facts(self): case = self.case(1) case['name'] = ('LearnStack.Tests.Integration.Database.BackgroundJobRuntimeFacadeTests.' diff --git a/scripts/test-ci-test-shard-results.py b/scripts/test-ci-test-shard-results.py index 2705fe9e..123472e6 100644 --- a/scripts/test-ci-test-shard-results.py +++ b/scripts/test-ci-test-shard-results.py @@ -160,6 +160,48 @@ def test_real_current_workflow_topology_and_both_full_synthetic_unions(self): self.assertEqual('A' if attempt == '1' else 'B', result['variant']) self.assertTrue(RESULTS.verify_jobs(self.jobs(scope, self.context(attempt)), self.context(attempt), self.plan, scope)) + def test_fixture_image_preparation_uses_fixed_images_and_preserves_cleanup_reserve(self): + environment = {'GITHUB_ACTIONS': 'true', 'RUNNER_ENVIRONMENT': 'github-hosted', + 'GITHUB_JOB': 'backend-integration-tests', 'LEARNSTACK_CI_STARTED_AT': '1000'} + output = io.StringIO() + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(RESULTS.time, 'time', return_value=1700), \ + mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ + mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ + mock.patch.object(RESULTS.OWNER, 'command', return_value=b'') as command, \ + mock.patch('sys.stdout', output): + self.assertEqual(0, RESULTS.prepare_fixture_images()) + self.assertEqual([mock.call(['/usr/bin/docker', 'pull', image], 610, + 'fixture_image_pull_failed', cap=55) for image in RESULTS.FIXTURE_IMAGES], + command.call_args_list) + self.assertEqual(list(RESULTS.FIXTURE_IMAGES), [row['image'] for row in json.loads(output.getvalue())['images']]) + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(RESULTS.time, 'time', return_value=2000), \ + mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ + mock.patch.object(RESULTS.OWNER, 'command') as command: + self.refuses('fixture_setup_deadline_exhausted', RESULTS.prepare_fixture_images) + command.assert_not_called() + + def test_auxiliary_artifacts_keep_attempt_identity_and_compose_failure_removes_env(self): + jobs = self.documents['.github/workflows/ci.yml']['jobs'] + for job_id, prefix in (('openapi-diff', 'openapi-comparison'), + ('public-demo', 'public-demo-lighthouse'), ('secret-scan', 'leakwatch-results')): + uploads = [step for step in jobs[job_id]['steps'] if step.get('uses') == RESULTS.UPLOAD_ACTION] + self.assertEqual(1, len(uploads)) + self.assertEqual(prefix + '-${{ github.run_id }}-${{ github.run_attempt }}', uploads[0]['with']['name']) + step = next(step for step in jobs['meta']['steps'] + if step.get('name') == 'Compose stack validates, with and without the gated profile') + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / '.env.example').write_text('DEVELOPMENT_PLACEHOLDER=value\n') + docker = root / 'docker' + docker.write_text('#!/bin/sh\nexit 17\n') + docker.chmod(0o700) + failed = subprocess.run(['/bin/bash', '-e', '-c', step['run']], cwd=root, + env={'PATH': str(root) + ':/usr/bin:/bin'}, capture_output=True, text=True, timeout=5) + self.assertEqual(17, failed.returncode) + self.assertFalse((root / '.env').exists()) + def test_yaml_parser_refuses_duplicates_aliases_tags_multiple_documents_and_complex_keys(self): with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / 'workflow.yml' @@ -183,6 +225,8 @@ def test_topology_rejects_wrong_counts_authority_gates_permissions_and_canonical if step.get('name') == RESULTS.UPLOAD_STEP).update(uses='actions/upload-artifact@v4')), ('artifact_upload_invalid', lambda jobs: next(step for step in jobs['backend-base']['steps'] if step.get('name') == RESULTS.UPLOAD_STEP).update(uses='actions/upload-artifact@' + 'f' * 40)), + ('fixture_image_gate_invalid', lambda jobs: next(step for step in jobs['backend-integration-tests']['steps'] + if step.get('run') == 'python3 scripts/ci-test-shards.py prepare-fixtures').update({'if': 'success()'})), ('aggregate_topology_invalid', lambda jobs: jobs['backend'].update({'if': 'success()'})), ('aggregate_topology_invalid', lambda jobs: jobs['backend-integration'].update(needs=['backend-native'])), ('aggregate_topology_invalid', lambda jobs: jobs['backend'].update({'timeout-minutes': 6})), @@ -309,6 +353,24 @@ def test_artifact_api_requires_unique_current_attempt_id_digest_and_job_window(s self.refuses(reason, RESULTS.verify_artifact, dirty, context, 'base', job) dirty = copy.deepcopy(clean); dirty['artifacts'].append(dirty['artifacts'][0]); dirty['total_count'] = 2 self.refuses('artifact_api_not_unique', RESULTS.verify_artifact, dirty, context, 'base', job) + self.refuses('artifact_api_missing', RESULTS.verify_artifact, + {'total_count': 0, 'artifacts': []}, context, 'base', job) + for metadata in ({'total_count': 1, 'artifacts': []}, {'total_count': True, 'artifacts': []}, + {'total_count': -1, 'artifacts': []}): + self.refuses('artifact_api_invalid', RESULTS.verify_artifact, metadata, context, 'base', job) + + def test_partial_rerun_job_projection_cannot_reuse_an_earlier_artifact(self): + # GitHub may project successful siblings as current-attempt jobs after + # a failed-jobs rerun, without producing new sibling artifacts. + context = self.context('3') + jobs = RESULTS.verify_jobs(self.jobs('integration', context), context, self.plan, 'integration') + self.assertIn('base', jobs) + self.refuses('artifact_api_missing', RESULTS.verify_artifact, + {'total_count': 0, 'artifacts': []}, context, 'base', jobs['base']) + self.refuses('artifact_api_invalid', RESULTS.verify_artifact, + self.metadata('base', self.context('2')), context, 'base', jobs['base']) + self.assertEqual(12345, RESULTS.verify_artifact( + self.metadata('base', context), context, 'base', jobs['base'])['id']) def test_archive_id_digest_and_allowlist_precede_parsing_without_extraction_fallback(self): import zipfile diff --git a/scripts/test-ci-test-shards.py b/scripts/test-ci-test-shards.py index 65003f74..a183917c 100644 --- a/scripts/test-ci-test-shards.py +++ b/scripts/test-ci-test-shards.py @@ -67,6 +67,111 @@ def test_current_plan_and_both_complete_assignments_have_exact_owners(self): self.assertEqual(set(map(OWNER.case_key, manifest['allTests'])), set(all_cases)) self.assertEqual('A' if attempt == '1' else 'B', assignment['variant']) + def test_shared_fixture_witness_matches_actual_source_classes(self): + self.assertEqual(OWNER.fixture_class_census(), self.plan['sharedFixtureClasses']) + self.assertIn('LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests', + self.plan['sharedFixtureClasses']) + self.assertIn('LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests', + self.plan['sharedFixtureClasses']) + for name in ('LearnStack.Tests.Integration.ApiHandlerCompositionTests', + 'LearnStack.Tests.Integration.Database.PublicSiteHttpTests', + 'LearnStack.Tests.Integration.Database.HangfireStorageTests'): + self.assertIn(name, self.plan['sharedFixtureClasses']) + + def test_fixture_census_binds_qualified_collections_and_other_explicit_class_fixtures(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'backend/tests/LearnStack.Tests.Integration' + source.mkdir(parents=True) + (source / 'Definitions.cs').write_text('''namespace Example.Database; +[CollectionDefinition(Name)] +public sealed class SharedSchema : ICollectionFixture { } +[CollectionDefinition(Name)] +public sealed class PublicReadTestGroup : ICollectionFixture { } +[CollectionDefinition(Name)] +public sealed class WithoutFixture { } +''') + (source / 'Tests.cs').write_text('''using Example.Database; +namespace Example; +[Collection(Database.SharedSchema.Name)] +public sealed class Qualified { } +[global::Xunit.CollectionAttribute(global::Example.Database.PublicReadTestGroup.Name)] +public sealed class OtherCollection { } +public sealed class OtherFixture(SchemaFixture fixture) : Xunit.IClassFixture { } +[Collection(WithoutFixture.Name)] +public sealed class NoFixtureCollection { } +public sealed class UnrelatedAfterCollection { } +// [Collection(Database.SharedSchema.Name)] public class Comment : IClassFixture { } +public sealed class LiteralHolder +{ + const string Fake = """ +[Collection(Database.SharedSchema.Name)] public class Literal : IClassFixture { } +"""; +} +''') + self.assertEqual(['Example.OtherCollection', 'Example.OtherFixture', 'Example.Qualified'], + OWNER.fixture_class_census(root)) + + def test_fixture_census_refuses_unknown_unsupported_and_ambiguous_forms(self): + for declaration, reason in ( + ('[Collection(Unknown.Name)] public sealed class Tests { }', 'fixture_collection_unresolved'), + ('[Collection(typeof(Known))] public sealed class Tests { }', 'fixture_attribute_unsupported'), + ('[Other.Collection(Known.Name)] public sealed class Tests { }', 'fixture_attribute_unsupported'), + ('public sealed class Tests : IClassFixture> { }', 'fixture_declaration_unsupported'), + ('public sealed class Tests(IClassFixture parameter) { }', 'fixture_declaration_unsupported')): + with self.subTest(declaration=declaration), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'backend/tests/LearnStack.Tests.Integration' + source.mkdir(parents=True) + (source / 'Tests.cs').write_text('namespace Example;\n' + declaration) + self.refuses(reason, OWNER.fixture_class_census, root) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'backend/tests/LearnStack.Tests.Integration' + source.mkdir(parents=True) + for filename in ('First.cs', 'Second.cs'): + (source / filename).write_text('namespace Example;\npublic partial class Tests : IClassFixture { }') + self.refuses('fixture_declaration_ambiguous', OWNER.fixture_class_census, root) + + def test_fixture_census_refuses_local_and_separate_global_attribute_aliases(self): + for prefix in ('using ', 'global using '): + for attribute in ('CollectionAttribute', 'CollectionDefinitionAttribute', + 'Xunit.CollectionAttribute', 'global::Xunit.CollectionDefinitionAttribute'): + with self.subTest(prefix=prefix, attribute=attribute), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'backend/tests/LearnStack.Tests.Integration' + source.mkdir(parents=True) + alias = prefix + 'Group = ' + attribute + ';\n' + if prefix.startswith('global'): + (source / 'Aliases.cs').write_text(alias) + (source / 'Tests.cs').write_text(('' if prefix.startswith('global') else alias) + '''namespace Example; +[CollectionDefinition(Name)] +public sealed class SharedSchema : ICollectionFixture { } +public sealed class Existing : IClassFixture { } +[Group(SharedSchema.Name)] +public sealed class AliasedCollectionTests { } +''') + self.refuses('fixture_attribute_alias_unsupported', OWNER.fixture_class_census, root) + + def test_plan_revision_diff_uses_one_manifest_and_reports_actual_case_moves(self): + manifest = self.manifest() + clean = OWNER.assignment_diff(manifest, self.plan, self.plan) + self.assertTrue(all(item['movedCases'] == 0 for item in clean['layouts'].values())) + changed = copy.deepcopy(self.plan) + changed['weights'] = [] + for index, case in enumerate(manifest['ordinary'][::2]): + changed['weights'].append({'assembly': case['assembly'], 'method': case['method'], + 'milliseconds': index + 1, 'source': next(iter(changed['sources']))}) + diff = OWNER.assignment_diff(manifest, self.plan, changed) + self.assertEqual(clean['manifestDigest'], diff['manifestDigest']) + self.assertNotEqual(diff['beforePlanDigest'], diff['afterPlanDigest']) + for attempt, layout in (('1', 'A'), ('2', 'B')): + actual = diff['layouts'][layout] + self.assertGreater(actual['movedCases'], 0) + self.assertEqual(actual['movedCases'], len(actual['changes'])) + self.assertEqual(OWNER.digest(actual['changes']), actual['changesDigest']) + self.assertEqual(actual['movedMethods'] * 2, actual['movedCases']) + def test_theory_rows_and_equal_selector_in_different_assemblies_are_not_split(self): manifest = self.manifest() manifest['nonDocker'][0]['method'] = manifest['ordinary'][0]['method'] diff --git a/scripts/test-realm-node-gate.py b/scripts/test-realm-node-gate.py new file mode 100644 index 00000000..f383acb5 --- /dev/null +++ b/scripts/test-realm-node-gate.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Execute the actual workflow and source-census/count/JUnit owners against counterexamples.""" +import copy +import importlib.util +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] + + +class RealmNodeGateControls(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location('realm_gate_topology', ROOT / 'scripts/ci-test-shard-results.py') + cls.topology = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.topology) + cls.plan = cls.topology.PLAN.load_plan() + cls.documents = {'.github/workflows/ci.yml': cls.topology.yaml_document(ROOT / '.github/workflows/ci.yml')} + + def execute(self, sources, *, expected=None, without_counter_guard=False): + with tempfile.TemporaryDirectory(prefix='learnstack-node-gate-') as temporary: + directory = Path(temporary) + scripts = directory / 'scripts' + scripts.mkdir() + for name, source in sources.items(): + (scripts / name).write_text(source) + reporter = (ROOT / 'scripts/require-node-test-cases.mjs').read_text() + (scripts / 'require-node-test-cases.mjs').write_text(reporter) + owner = (ROOT / 'scripts/run-realm-node-tests.py').read_text() + if without_counter_guard: + needle = "'--test-reporter=./scripts/require-node-test-cases.mjs',\n '--test-reporter-destination=stdout', " + self.assertEqual(1, owner.count(needle)) + owner = owner.replace(needle, '', 1) + (scripts / 'run-realm-node-tests.py').write_text(owner) + plan = expected if expected is not None else {name: 1 for name in sources} + wrapper = ("import importlib.util\n" + "spec=importlib.util.spec_from_file_location('owner','scripts/run-realm-node-tests.py')\n" + "owner=importlib.util.module_from_spec(spec);spec.loader.exec_module(owner)\n" + "owner.EXPECTED=" + repr(plan) + "\nowner.run()\n") + environment = {key: os.environ[key] for key in ('PATH', 'HOME') if key in os.environ} + environment['RUNNER_TEMP'] = str(directory) + return subprocess.run(['python3', '-c', wrapper], cwd=directory, env=environment, + capture_output=True, text=True, timeout=10) + + def test_workflow_executes_the_single_actual_census_owner(self): + self.assertTrue(self.topology.workflow_topology(self.documents, self.plan)) + mutations = [lambda job: job.update({'if': False}), + lambda job: job.update({'if': 'false'}), + lambda job: job.update({'continue-on-error': True}), + lambda job: job.update({'continue-on-error': '${{ true }}'}), + lambda job: job.update({'needs': ['frontend']})] + for name in ('Development realm transport, ownership and convergence controls', + 'Realm Node executed-case and JUnit gate controls'): + def step(job, name=name): + return next(item for item in job['steps'] if item.get('name') == name) + mutations.extend([ + lambda job, step=step: step(job).update({'if': False}), + lambda job, step=step: step(job).update({'if': 'false'}), + lambda job, step=step: step(job).update({'continue-on-error': True}), + lambda job, step=step: step(job).update({'continue-on-error': '${{ true }}'}), + lambda job, step=step: step(job).update(run='true'), + lambda job, step=step: job['steps'].remove(step(job)), + lambda job, step=step: job['steps'].append(copy.deepcopy(step(job))), + lambda job, step=step: job['steps'].append({**step(job), 'name': 'Unrecognized second realm gate'}), + ]) + for index, mutate in enumerate(mutations): + with self.subTest(mutation=index): + documents = copy.deepcopy(self.documents) + mutate(documents['.github/workflows/ci.yml']['jobs']['meta']) + # Removing only the production check admits the dirty topology; + # restoring it must reject that same input for the owned reason. + with mock.patch.object(self.topology, 'realm_node_gates'): + self.assertTrue(self.topology.workflow_topology(documents, self.plan)) + with self.assertRaises(self.topology.Refusal) as caught: + self.topology.workflow_topology(documents, self.plan) + self.assertEqual('realm_node_gate_invalid', caught.exception.reason) + + def test_real_cases_and_nested_real_cases_pass_the_actual_owner(self): + for source in ("import {test} from 'node:test'; test('real',()=>{});", + "import {test,describe} from 'node:test'; describe('suite',()=>{" + "describe('nested',()=>{test('real',()=>{});});});"): + with self.subTest(source=source): + result = self.execute({'development-realm-fixture.test.mjs': source}) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('1 executed tests passed;', result.stdout) + + def test_empty_named_suites_need_the_real_counter_guard(self): + for source in ("import {describe} from 'node:test'; describe('empty named suite',()=>{});", + "import {describe} from 'node:test'; describe('outer',()=>{describe('inner',()=>{});});"): + with self.subTest(source=source): + sources = {'development-realm-fixture.test.mjs': source} + dirty = self.execute(sources, without_counter_guard=True) + self.assertEqual(0, dirty.returncode, 'JUnit-only gate must expose this defect') + self.assertNotEqual(0, self.execute(sources).returncode) + + def test_empty_file_fail_skip_todo_cancel_and_nested_skip_are_refused(self): + for source in ('', + "import {test} from 'node:test'; test('failed',()=>{throw Error('planted');});", + "import {test} from 'node:test'; test.skip('skipped',()=>{});", + "import {test} from 'node:test'; test.todo('todo');", + "import {test} from 'node:test'; test('cancelled',()=>new Promise(()=>{}));", + "import {test,describe} from 'node:test'; describe('suite',()=>{test.skip('skip',()=>{});});"): + with self.subTest(source=source): + self.assertNotEqual(0, self.execute({'development-realm-fixture.test.mjs': source}).returncode) + + def test_deleted_cases_cannot_be_hidden_by_a_named_passing_case_or_another_file(self): + one = "import {test} from 'node:test';test('one',()=>{});" + sources = {'development-realm-first.test.mjs': one, 'development-realm-second.test.mjs': one} + expected = {'development-realm-first.test.mjs': 2, 'development-realm-second.test.mjs': 1} + self.assertNotEqual(0, self.execute(sources, expected=expected).returncode) + sources['development-realm-second.test.mjs'] += "test('extra',()=>{});" + self.assertNotEqual(0, self.execute(sources, expected=expected).returncode) + sources['development-realm-first.test.mjs'] += "test('two',()=>{});" + sources['development-realm-second.test.mjs'] = one + self.assertEqual(0, self.execute(sources, expected=expected).returncode) + + def test_added_or_missing_test_files_refuse_before_node_launch(self): + one = "throw Error('node must not launch');" + first, second = 'development-realm-first.test.mjs', 'development-realm-second.test.mjs' + for sources, expected in (({first: one}, {first: 1, second: 1}), + ({first: one, second: one}, {first: 1})): + with self.subTest(sources=list(sources)): + result = self.execute(sources, expected=expected) + self.assertNotEqual(0, result.returncode) + self.assertIn('test-file census', result.stderr) + self.assertNotIn('node must not launch', result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/wait-compose.mjs b/scripts/wait-compose.mjs index e13fc64f..07b14c36 100644 --- a/scripts/wait-compose.mjs +++ b/scripts/wait-compose.mjs @@ -2,7 +2,7 @@ import { setTimeout as pause } from 'node:timers/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { composeSelection } from './compose.mjs'; -import { DemoProcesses } from './demo-process.mjs'; +import { DemoCommandError, DemoProcesses } from './demo-process.mjs'; import { boundedBody } from './demo-http.mjs'; export function defaultServices(model) { @@ -57,7 +57,7 @@ export async function waitCompose(selected, processes, signal, postgresOnly = fa .map((line) => JSON.parse(line)); if (healthyServices(model, rows, postgresOnly)) break; if (Date.now() >= deadline) - throw new Error('Compose readiness deadline; inspect the selected project'); + throw new DemoCommandError('Compose readiness', undefined, undefined, { reason: 'deadline' }); await pause(1000, undefined, { signal }); } if (postgresOnly) return; @@ -75,7 +75,8 @@ export async function waitCompose(selected, processes, signal, postgresOnly = fa } catch { /* Bounded first-boot polling; never expose server responses. */ } - if (Date.now() >= deadline) throw new Error('Both Keycloak realm discoveries must be ready'); + if (Date.now() >= deadline) + throw new DemoCommandError('Keycloak realm readiness', undefined, undefined, { reason: 'deadline' }); await pause(1000, undefined, { signal }); } }