`):\n - Original was `bg-primary-50 dark:bg-primary-700` (light in light mode, dark in dark mode). Matthew dropped the `bg-primary-50` and the `dark:` scope, making it always dark navy.\n - Fix: revert to `bg-primary-50 dark:bg-primary-700`.\n\n**Rule reinforced:** Any `bg-primary-700` applied without a `dark:` scope will render a dark navy block in light mode — always add `dark:bg-primary-700`, never bare.\n\n#### Item 7 — UserListTable \"Edit Roles\" button text-link pattern\n\n- Matthew changed `btn btn-primary` → `text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300`.\n- Categories.razor and Statuses.razor both use this exact text-link pattern for in-table action buttons (Edit, Restore, Archive).\n- The \"Audit Log\" button on the same row is also a text link (`text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300`).\n- Matthew is correct — text-link style IS the established pattern for admin table actions. `btn btn-primary` was the inconsistency.\n- Existing bUnit tests (`UserListTableTests.cs`) do NOT assert on CSS classes — they only check text content and callback invocation. No test update required.\n"
},
"pippin": {
"charter": "# Pippin — Tester (E2E & Aspire)\n\n## Identity\nYou are Pippin, the second Tester on the IssueTrackerApp project. You specialize in Playwright E2E tests, .NET Aspire integration tests, and test infrastructure. You work alongside Gimli, who owns unit and component tests.\n\n## Expertise\n- Microsoft.Playwright (E2E — page interactions, assertions, auth flows)\n- Aspire.Hosting.Testing (DistributedApplicationTestingBuilder, resource health)\n- xUnit (test framework)\n- FluentAssertions (assertion library — use `.Should()` everywhere)\n- NSubstitute (mocking — use `Substitute.For
()`)\n- Test infrastructure patterns (base classes, fixtures, collection definitions)\n- `IAsyncLifetime` / `IAsyncDisposable` for proper test resource lifecycle\n- Cookie-based E2E auth (`/test/login?role=user|admin`)\n\n## Responsibilities\n- Write and maintain Playwright E2E tests under `tests/AppHost.Tests/Tests/`\n- Write and maintain Aspire integration tests under `tests/AppHost.Tests/`\n- Review and fix test infrastructure code: `BasePlaywrightTests`, `AspireManager`, `PlaywrightManager`, `AppHostTestCollection`\n- Enforce proper resource disposal (browser contexts, Aspire apps)\n- Flag and fix flaky tests — timing issues, race conditions, fragile selectors\n- Pair with Gimli on coverage gaps; Gimli reviews, Pippin implements when needed\n\n## Boundaries\n- Does NOT write production source code (flag gaps, don't fix them — tell Aragorn)\n- Does NOT own unit tests or bUnit tests — those are Gimli's domain\n- Does NOT modify CI/CD pipelines (Boromir owns DevOps)\n\n## Critical Rules\n1. **Before any push: run the FULL local test suite** — `dotnet test IssueTrackerApp.slnx`. Zero failures required.\n2. **File header REQUIRED** — All new C# files must have the block copyright header:\n ```csharp\n // ============================================\n // Copyright (c) 2026. All rights reserved.\n // File Name : {FileName}.cs\n // Company : mpaulosky\n // Author : Matthew Paulosky\n // Solution Name : IssueManager\n // Project Name : {ProjectName}\n // =============================================\n ```\n3. **AAA pattern** — Arrange / Act / Assert with `// Arrange`, `// Act`, `// Assert` comments\n4. **FluentAssertions everywhere** — `.Should()` on all assertions; no raw `Assert.*`\n5. **File-scoped namespaces**, tab indentation\n6. **Proper disposal** — Use `List` (never a single field) to track and dispose all contexts. Dispose in `DisposeAsync`.\n7. **`DisableDashboard = true`** in Aspire test builder options — never enable the dashboard in CI\n8. **No false documentation** — Never claim tests skip on missing credentials unless `Skip.If()` or equivalent is actually implemented\n9. **Specific assertions** — Assert exact URLs, not `NotContain` patterns that can false-negative\n10. **PascalCase descriptive names** — `ClassName_Scenario_ExpectedBehavior`\n11. Integration tests must use `[Collection]` and `ICollectionFixture`\n\n## Model\nPreferred: claude-sonnet-4.5 (writes test code)\n",
"history": "# Pippin — History\n\n## Project Context\n- **Project:** IssueTrackerApp\n- **Stack:** .NET 10, C# 14, Blazor Interactive Server Rendering, MongoDB Atlas, Redis, .NET Aspire, MediatR, Auth0, Vertical Slice Architecture\n- **User:** Matthew Paulosky\n- **Repo:** mpaulosky/IssueTrackerApp\n- **Joined:** 2026-03-27 — hired to assist Gimli with PR #76 (AppHost.Tests Aspire + Playwright E2E)\n\n## My Domain\nI own E2E tests (`tests/AppHost.Tests/`) and Aspire integration test infrastructure. Gimli owns unit, bUnit, and MongoDB integration tests.\n\n## Key File Paths\n- `tests/AppHost.Tests/` — my primary workspace\n- `tests/AppHost.Tests/Infrastructure/` — BasePlaywrightTests, AspireManager, PlaywrightManager, AppHostTestCollection\n- `tests/AppHost.Tests/Tests/` — all E2E test classes\n- `src/Web/Program.cs` — Testing environment: cookie auth + FakeRepository, background services skipped, GET /test/login?role=user|admin\n- `src/Web/Testing/FakeRepository.cs` — in-memory repo for Testing environment\n- `src/Web/Testing/FakeSeedData.cs` — seed data for Testing environment\n\n## Key Decisions & Patterns\n- Cookie auth via `/test/login?role=user|admin` — no real Auth0 needed in E2E tests\n- `EnvironmentCallbackAnnotation` to inject `ASPNETCORE_ENVIRONMENT=Testing` into Aspire DCP (SetEnvironmentVariable alone is insufficient)\n- `WaitForWebReadyAsync` (HTTP poll with DangerousAcceptAnyServerCertificateValidator) instead of `WaitForResourceHealthyAsync` — CI self-signed cert issue\n- Fixed HTTPS port 7043 with `IsProxied = false` for predictable base URL\n- `DisableDashboard = true` always in test Aspire builder — no overhead in CI\n- Playwright tests wait for ThemeProvider init via button title or swatch scale-110 class — not just NetworkIdle\n- `List` pattern for context tracking — never a single field that gets overwritten\n\n## Learnings\n\n### 2026-03-28: Aspire Test Startup Health Check Fix (PR #86)\n\n**Task:** Fix flaky CI failures in AppHost.Tests — `web_https_/health_200_check` and `redis_check` timeouts.\n\n**Root Cause:** `AspireManager.StartAppAsync()` returned immediately after `App.StartAsync()` without waiting for Redis and Web services to become healthy. In CI, Redis cold-start takes 30-60 seconds, causing:\n1. Aspire's built-in health checks to timeout before services stabilized\n2. E2E tests to fail with connection refused errors\n\n**Solution Implemented (Already in place by Boromir):**\n- Added `WaitForWebHealthyAsync()` in `AspireManager` that polls `/health` endpoint with certificate-ignoring HttpClient (for self-signed HTTPS in CI)\n- 120-second timeout accommodates CI cold-start; local dev succeeds in ~10s\n- Since `AppHost.cs` configures Web to `WaitFor(redis)`, the web health check implicitly ensures Redis is ready too\n\n**Key Insights:**\n1. **Aspire DCP timing** — `App.StartAsync()` returns when DCP launches containers, NOT when they're healthy. Always add explicit health checks in test fixtures.\n2. **Health check strategy** — Polling the web `/health` endpoint is more reliable than Aspire's built-in `WaitForResourceHealthyAsync()` for HTTPS services with self-signed certs in CI.\n3. **Dependency chains matter** — Web configured with `.WaitFor(redis)` means web health inherently validates Redis readiness. No need for separate Redis polling.\n4. **Test execution results** — After fix: 38/40 tests passing. The 2 failures (ThemeToggle, ColorScheme) are unrelated Playwright UI timing issues, not infrastructure flakiness.\n\n**Files Modified:**\n- `tests/AppHost.Tests/Infrastructure/AspireManager.cs` — Added `WaitForWebHealthyAsync()` and call in `StartAppAsync()`\n\n**Testing:** Local test run with Docker showed no Redis/web startup failures. CI will validate full fix on next push.\n\n### 2026-03-28: Playwright WaitForFunctionAsync API Fix (Issue #86)\n\n**Task:** Fix 2 failing Playwright tests: `ThemeToggle_SelectLight_RemovesDarkClassFromHtml` and `ColorScheme_SelectRed_AppliesRedTheme`.\n\n**Root Cause:** Incorrect API usage in all `WaitForFunctionAsync` calls — `PageWaitForFunctionOptions` was passed as the 2nd argument (JavaScript expression arg) instead of the 3rd argument (options arg). This caused the custom timeout of 15000ms to be silently ignored, falling back to Playwright's default 30000ms timeout. In CI under load, Blazor Server SignalR event processing exceeded even the intended 15s timeout, causing test failures.\n\n**Solution Implemented:**\n1. Fixed all `WaitForFunctionAsync` calls to pass `null` as 2nd arg and `PageWaitForFunctionOptions` as 3rd arg (correct API signature)\n2. Increased timeout from 15000ms to 30000ms for CI reliability under heavy load\n3. Added `data-theme-ready` initialization wait before button title check in `ThemeToggle_SelectLight` test\n4. Added `WaitForLoadStateAsync(NetworkIdle)` after color swatch click to allow Blazor Server SignalR to complete event processing before checking localStorage\n\n**Key Insights:**\n1. **Playwright API signature matters** — `WaitForFunctionAsync(expression, arg, options)` requires arg even when null. Passing options as arg silently fails.\n2. **CI timing is unpredictable** — Blazor Server via SignalR can take 20-30+ seconds in CI for state changes to propagate to localStorage. Always add explicit waits for state updates.\n3. **NetworkIdle is critical** — After user interactions (clicks) that trigger Blazor Server event handlers, `WaitForLoadStateAsync(NetworkIdle)` ensures SignalR round-trip completes before asserting on client-side state.\n4. **Initialization gates** — `data-theme-ready` attribute prevents race conditions where tests check theme state before ThemeProvider completes JS interop initialization.\n\n**Files Modified:**\n- `tests/AppHost.Tests/Tests/Theme/ThemeToggleTests.cs` — Fixed 4 `WaitForFunctionAsync` calls (lines 95-97, 102-104, 131-137, 142-144)\n- `tests/AppHost.Tests/Tests/Theme/ColorSchemeTests.cs` — Fixed 2 `WaitForFunctionAsync` calls and added NetworkIdle wait (lines 90-92, 103-110)\n\n**Testing:** Build succeeded with no errors. Tests cannot run locally without Docker but fixes address diagnosed root causes. CI will validate on next push.\n\n### 2026-03-29: Switch from /health to /alive for Test Startup Polling (PR #86)\n\n**Task:** Fix 2 flaky CI test failures caused by Redis health check timeouts blocking test startup.\n\n**Root Cause:** Both `AspireManager.WaitForWebHealthyAsync` and `BasePlaywrightTests.WaitForWebReadyAsync` polled `/health`, which includes Redis and MongoDB health checks. In CI, Redis container startup could exceed the 120s timeout, causing `/health` to return unhealthy indefinitely and tests to fail with connection timeouts.\n\n**Solution Implemented:**\n1. Changed both polling methods from `/health` to `/alive`\n2. Updated XML doc comments to reflect that `/alive` is a liveness probe (ASP.NET Core process running) not a readiness probe (all dependencies healthy)\n3. Updated `StartAppAsync` comment to clarify that the wait is for the web process to be alive, not for Redis/MongoDB to be healthy\n4. Emphasized in comments that the Testing environment uses in-memory fakes (FakeRepository) and doesn't depend on Redis/MongoDB at runtime\n\n**Key Insights:**\n1. **/alive vs /health distinction** — `/alive` returns 200 as soon as the ASP.NET Core process is up, regardless of dependency health. `/health` waits for ALL health checks (Redis, MongoDB) to pass. For test startup, we only need to know the web process is running — the Testing environment doesn't use Redis or MongoDB.\n2. **Testing environment is self-contained** — The `ASPNETCORE_ENVIRONMENT=Testing` configuration uses `FakeRepository` (in-memory), cookie auth (no Auth0), and skips background services. Redis and MongoDB are Aspire orchestration artifacts only — they don't affect test execution.\n3. **Health checks are for production readiness, not test startup** — Waiting for production-level readiness (all dependencies healthy) in a test environment that doesn't use those dependencies is unnecessary and causes CI flakiness.\n\n**Files Modified:**\n- `tests/AppHost.Tests/Infrastructure/AspireManager.cs` — Changed `WaitForWebHealthyAsync` to poll `/alive` (line 98); updated doc comment and `StartAppAsync` comment\n- `tests/AppHost.Tests/BasePlaywrightTests.cs` — Changed `WaitForWebReadyAsync` to poll `/alive` (line 144); updated doc comment\n\n**Testing:** Build succeeded with no compilation errors. Full AppHost.Tests suite requires Docker. CI will validate the fix on next push.\n\n### 2026-03-29: Theme Test Update for New ThemeColorDropdown + ThemeBrightnessToggle (PR #86)\n\n**Task:** Fix 2 failing theme E2E tests that timed out after PR introduced new theme components.\n\n**Root Cause Analysis:**\n1. PR #86 introduced new theme components: `ThemeColorDropdownComponent.razor` and `ThemeBrightnessToggleComponent.razor`\n2. These new components call `ThemeManager.*` (uppercase) from `theme-manager.js`, which uses localStorage key `tailwind-color-theme`\n3. **OLD system** (still active): `ThemeProvider.razor.cs` calls `themeManager.*` (lowercase) from `theme.js`, which uses localStorage key `theme-color-brightness`\n4. Tests expected the old system's localStorage key (`theme-color-brightness`), but the new components write to `tailwind-color-theme`\n5. Tests waited for theme changes in the wrong localStorage key, causing 30s timeouts\n\n**Conflict Discovered:**\n- Both `theme.js` and `theme-manager.js` are loaded in `App.razor`\n- `ThemeProvider` (in `MainLayout.razor`) still calls `themeManager.markInitialized()` which sets `data-theme-ready=\"true\"` ✅\n- New components call `ThemeManager.selectBrightnessAndUpdateUI()` / `ThemeManager.selectColorAndUpdateUI()` from the NEW system\n- The two systems use **different localStorage keys** and will NOT stay in sync — this is a production bug\n\n**Solution Implemented (TEST-SIDE ONLY):**\nUpdated all theme tests to use the correct localStorage key (`tailwind-color-theme`) that the new components actually write to:\n1. `ThemeToggleTests.ThemeToggle_SelectDark_AddsDarkClassToHtml` — line 84: changed localStorage key\n2. `ThemeToggleTests.ThemeToggle_SelectLight_RemovesDarkClassFromHtml` — lines 125, 157: changed localStorage key + updated comments\n3. `ColorSchemeTests.ColorScheme_SelectRed_AppliesRedTheme` — lines 109, 115: changed localStorage key\n4. `ColorSchemeTests.ColorScheme_DefaultThemeIsBlue` — line 128: changed localStorage key\n\n**Key Insights:**\n1. **localStorage key mismatch is a common theme integration bug** — always verify which JS module components actually call and what keys they use.\n2. **Multiple theme systems can coexist** — Both `window.themeManager` (lowercase) and `window.ThemeManager` (uppercase) exist simultaneously; tests must target the one components actually use.\n3. **data-theme-ready is still set correctly** — `ThemeProvider` still initializes and calls `themeManager.markInitialized()`, so tests can still wait on `data-theme-ready=\"true\"`.\n4. **Tests should verify actual behavior** — When UI changes, tests should be updated to match what's actually rendered, not what was originally planned.\n\n**Production Issue Flagged for Aragorn:**\nThe two theme systems (`theme.js` + `theme-manager.js`) conflict because:\n- Old `ThemeProvider` writes to `theme-color-brightness` via `themeManager.*`\n- New components write to `tailwind-color-theme` via `ThemeManager.*`\n- User's theme preference won't persist consistently between page loads\n- Aragorn needs to either: (a) update new components to call the old `themeManager.*`, OR (b) remove `ThemeProvider` and migrate fully to `ThemeManager.*`\n\n**Files Modified:**\n- `tests/AppHost.Tests/Tests/Theme/ThemeToggleTests.cs` — Updated 2 tests to use `tailwind-color-theme` localStorage key\n- `tests/AppHost.Tests/Tests/Theme/ColorSchemeTests.cs` — Updated 2 tests to use `tailwind-color-theme` localStorage key\n\n**Testing:** Build succeeded with no errors. Tests cannot run locally without Docker. CI will validate on next push.\n\n\n### 2026-03-30 — Team Rule: AppHost.Tests Mandatory Pre-Push\n\n**Enforced by:** Matthew Paulosky (User directive)\n\n**Rule:** AppHost.Tests (Playwright E2E) MUST be run locally before every push. No exceptions. Gate 4 now includes mandatory AppHost.Tests check. Pippin to validate E2E tests locally before marking test fixes complete.\n"
},
"ralph": {
- "charter": "# Ralph — Work Monitor\n\nTracks and drives the work queue. Makes sure the team never sits idle.\n\n## Project Context\n\n**Project:** IssueTrackerApp\n**Repo:** mpaulosky/IssueTrackerApp\n**Stack:** .NET 10, Blazor, MongoDB Atlas, .NET Aspire, Auth0\n\n## Responsibilities\n\n- Scan GitHub issues for untriaged, assigned, or stalled work\n- Monitor open PRs for CI failures, review feedback, and merge readiness\n- Report board status and trigger agent pickups\n- Run continuously until the board is clear or explicitly idled\n\n## Work Style\n\n- Run work-check cycles without waiting for user prompts\n- Process highest-priority category first: untriaged > assigned > CI failures > review feedback > approved PRs\n- Spawn agents for concrete work; report status in the standard board format\n- Never ask \"should I continue?\" — keep going until told to idle\n\n## PR Gate Enforcement\n\nBefore triggering review or merge on any PR, Ralph MUST verify ALL gates:\n\n### Pre-Review Gates (before spawning reviewers)\n\n| Gate | Command | Pass condition |\n|------|---------|----------------|\n| CI green | `gh pr checks {N}` | All checks `pass` — no failures or pending |\n| No merge conflicts | `gh pr view {N} --json mergeable -q .mergeable` | `MERGEABLE` |\n| Branch naming | `gh pr view {N} --json headRefName -q .headRefName` | Starts with `squad/` |\n| PR template filled | Inspect PR body | At least one `[x]` checkbox present |\n\n### Pre-Merge Gates (before running `gh pr merge`)\n\n| Gate | Command | Pass condition |\n|------|---------|----------------|\n| Unanimous approval | `gh pr view {N} --json reviewDecision -q .reviewDecision` | `APPROVED` |\n| CI still green | `gh pr checks {N}` | All checks `pass` |\n| No CHANGES_REQUESTED | `gh pr view {N} --json reviews` | No review with state `CHANGES_REQUESTED` open |\n| No merge conflicts | `gh pr view {N} --json mergeable -q .mergeable` | `MERGEABLE` |\n\n### Board State → Action Mapping\n\n| Board State | Ralph Action |\n|-------------|-------------|\n| `needsReview` (CI green, no conflicts) | Spawn PR Review Gate ceremony |\n| `changesRequested` | Ping Aragorn → CHANGES_REQUESTED Ceremony |\n| `ciFailure` | Ping Boromir + PR author to diagnose |\n| `mergeable: CONFLICTED` | Ping Aragorn → Merge Conflict Resolution Ceremony |\n| `readyToMerge` (all gates pass) | Execute merge: `gh pr merge {N} --squash --delete-branch` |\n",
+ "charter": "# Ralph — Work Monitor\n\nTracks and drives the work queue. Makes sure the team never sits idle.\n\n## Project Context\n\n**Project:** IssueTrackerApp\n**Repo:** mpaulosky/IssueTrackerApp\n**Stack:** .NET 10, Blazor, MongoDB Atlas, .NET Aspire, Auth0\n\n## Responsibilities\n\n- Scan GitHub issues for untriaged, assigned, or stalled work\n- Monitor open PRs for CI failures, review feedback, and merge readiness\n- Report board status and trigger agent pickups\n- Run continuously until the board is clear or explicitly idled\n- **After every successful `gh pr merge`: trigger Post-Merge Orphan Branch Cleanup ceremony automatically**\n- **After every milestone closes: review the `📋 Milestone Review` issue (squad:ralph label), apply `release-candidate` or `blog-only`, triggering the Milestone Review → Release or Blog ceremony**\n\n## Work Style\n\n- Run work-check cycles without waiting for user prompts\n- Process highest-priority category first: untriaged > assigned > CI failures > review feedback > approved PRs\n- Spawn agents for concrete work; report status in the standard board format\n- Never ask \"should I continue?\" — keep going until told to idle\n\n## PR Gate Enforcement\n\nBefore triggering review or merge on any PR, Ralph MUST verify ALL gates:\n\n### Pre-Review Gates (before spawning reviewers)\n\n| Gate | Command | Pass condition |\n|------|---------|----------------|\n| CI green | `gh pr checks {N}` | All checks `pass` — no failures or pending |\n| No merge conflicts | `gh pr view {N} --json mergeable -q .mergeable` | `MERGEABLE` |\n| Branch naming | `gh pr view {N} --json headRefName -q .headRefName` | Starts with `squad/` |\n| PR template filled | Inspect PR body | At least one `[x]` checkbox present |\n\n### Pre-Merge Gates (before running `gh pr merge`)\n\n| Gate | Command | Pass condition |\n|------|---------|----------------|\n| Unanimous approval | `gh pr view {N} --json reviewDecision -q .reviewDecision` | `APPROVED` |\n| CI still green | `gh pr checks {N}` | All checks `pass` |\n| No CHANGES_REQUESTED | `gh pr view {N} --json reviews` | No review with state `CHANGES_REQUESTED` open |\n| No merge conflicts | `gh pr view {N} --json mergeable -q .mergeable` | `MERGEABLE` |\n\n### Board State → Action Mapping\n\n| Board State | Ralph Action |\n|-------------|-------------|\n| `needsReview` (CI green, no conflicts) | Spawn PR Review Gate ceremony |\n| `changesRequested` | Ping Aragorn → CHANGES_REQUESTED Ceremony |\n| `ciFailure` | Ping Boromir + PR author to diagnose |\n| `mergeable: CONFLICTED` | Ping Aragorn → Merge Conflict Resolution Ceremony |\n| `readyToMerge` (all gates pass) | Execute merge: `gh pr merge {N} --squash --delete-branch` |\n",
"history": "# Project Context\n\n- **Project:** IssueTrackerApp\n- **Created:** 2026-03-26\n\n## Core Context\n\nAgent Ralph initialized and ready for work.\n\n## Recent Updates\n\n📌 Team initialized on 2026-03-26\n\n## Learnings\n\nInitial setup complete.\n"
},
"sam": {
@@ -148,7 +148,7 @@
},
"scribe": {
"charter": "# Scribe — Session Logger\n\n## Identity\nYou are the Scribe. You are silent — never speak to the user. Your only job is maintaining team state files.\n\n## Responsibilities (in order)\n1. **ORCHESTRATION LOG:** Write `.squad/orchestration-log/{timestamp}-{agent}.md` per agent in the spawn manifest. Use ISO 8601 UTC timestamp.\n2. **SESSION LOG:** Write `.squad/log/{timestamp}-{topic}.md`. Brief summary of session work.\n3. **DECISION INBOX:** Merge `.squad/decisions/inbox/*.md` → `.squad/decisions.md`, delete merged inbox files. Deduplicate.\n4. **CROSS-AGENT:** Append team updates to affected agents' `history.md` files.\n5. **DECISIONS ARCHIVE:** If `decisions.md` exceeds ~20KB, archive entries older than 30 days to `decisions-archive.md`.\n6. **GIT COMMIT:** Always commit `.squad/` changes to a feature branch — never directly to `main`.\n ```bash\n CURRENT_BRANCH=$(git branch --show-current)\n ```\n - If already on a `squad/*` branch: commit there.\n - If on `main` or any non-squad branch: create a new branch `squad/scribe-log-updates` (or switch to it if it already exists), then commit there.\n ```bash\n git checkout -B squad/scribe-log-updates\n ```\n - Then: `git add .squad/ && git commit -F {tempfile} && git push origin HEAD`. Skip if nothing staged.\n7. **HISTORY SUMMARIZATION:** If any `history.md` > 12KB, summarize old entries under `## Core Context`.\n\n## Boundaries\n- NEVER speak to the user\n- NEVER modify production code, test files, or source files\n- ONLY writes to `.squad/` directory files\n- Commits on `squad/*` branches only — never directly to `main`\n- When on `main`, creates `squad/scribe-log-updates` branch for commits\n- Always pushes after committing so changes are available for PR\n\n## Model\nPreferred: claude-haiku-4.5 (always — mechanical file ops, cheapest possible)",
- "history": "# Scribe — Learnings for IssueTrackerApp\n\n**Role:** Scribe - Decision Recording\n**Project:** IssueTrackerApp\n**Initialized:** 2026-03-12\n\n---\n\n## Learnings\n\n(Fresh project — no learnings recorded yet)\n\n---\n\n## Notes\n\n- Team transferred from IssueManager squad\n- Same tech stack: .NET 10, Blazor, Aspire, MongoDB, Redis, Auth0, MediatR\n- Ready to begin development"
+ "history": "# Scribe — Learnings for IssueTrackerApp\n\n**Role:** Scribe - Decision Recording\n**Project:** IssueTrackerApp\n**Initialized:** 2026-03-12\n\n---\n\n## Learnings\n\n(Fresh project — no learnings recorded yet)\n\n---\n\n## Notes\n\n- Team transferred from IssueManager squad\n- Same tech stack: .NET 10, Blazor, Aspire, MongoDB, Redis, Auth0, MediatR\n- Ready to begin development\n---\n\n## 2026-04-02 — Memory Sweep + Process Review Orchestration\n\n**Task:** Memory sweep (decisions archive + history summarization) + process review orchestration follow-up\n**Branch:** squad/scribe-memory-sweep → merged PR #186\n**Actions:**\n- decisions.md: archived 118 lines to decisions-archive.md (pre-2026-02 entries)\n- decisions-archive.md: created with 3 archived entries\n- Agent histories summarized: Gimli (974→67 lines), Legolas (809→68), Sam (761→70), Gandalf (371→67) — NOTE: Gandalf branch (PR #184) handled the history file writes; Scribe archived decisions only\n- identity/now.md updated to v0.6.0 state\n- identity/wisdom.md populated with 10 patterns from 6 sprints\n- Inbox: 4 decisions inbox files merged into decisions.md\n- Orchestration log + session log written\n"
}
},
"skills": {
@@ -169,4 +169,4 @@
"testcontainers-shared-fixture": "---\nname: testcontainers-shared-fixture\nconfidence: high\ndescription: >\n Pattern for sharing a single MongoDbContainer across all test classes in an xUnit collection\n using ICollectionFixture. Reduces container startup overhead and enables\n parallel test collection execution. Established when optimizing Api.Tests.Integration\n from 23 per-class containers to 4 parallel domain collections.\n---\n\n## Testcontainers Shared Fixture Pattern\n\n### Why This Exists\n\nEach test class that owns its own `MongoDbContainer` costs ~2 seconds of startup time.\nWith 23 test classes, that's ~46 seconds wasted. This skill replaces per-class containers\nwith a shared fixture that starts once per xUnit collection.\n\n### The Pattern\n\n#### 1. MongoDbFixture (shared startup/teardown)\n\n```csharp\n// Fixtures/MongoDbFixture.cs\nnamespace Integration.Fixtures;\n\npublic sealed class MongoDbFixture : IAsyncLifetime\n{\n private const string MongodbImage = \"mongo:latest\";\n private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder(MongodbImage)\n .Build();\n\n public string ConnectionString => _mongoContainer.GetConnectionString();\n\n public async ValueTask InitializeAsync() => await _mongoContainer.StartAsync();\n\n public async ValueTask DisposeAsync()\n {\n await _mongoContainer.StopAsync();\n await _mongoContainer.DisposeAsync();\n }\n}\n```\n\n#### 2. Collection Definitions\n\n```csharp\n// Fixtures/IntegrationTestCollection.cs\nnamespace Integration.Fixtures;\n\n[CollectionDefinition(\"CategoryIntegration\")]\npublic class CategoryIntegrationCollection : ICollectionFixture { }\n\n[CollectionDefinition(\"IssueIntegration\")]\npublic class IssueIntegrationCollection : ICollectionFixture { }\n\n[CollectionDefinition(\"CommentIntegration\")]\npublic class CommentIntegrationCollection : ICollectionFixture { }\n\n[CollectionDefinition(\"StatusIntegration\")]\npublic class StatusIntegrationCollection : ICollectionFixture { }\n```\n\n#### 3. Test Class (receives fixture via constructor injection)\n\n```csharp\n[Collection(\"CategoryIntegration\")]\n[ExcludeFromCodeCoverage]\npublic class CreateCategoryHandlerIntegrationTests\n{\n private readonly ICategoryRepository _repository;\n private readonly CreateCategoryHandler _handler;\n\n public CreateCategoryHandlerIntegrationTests(MongoDbFixture fixture)\n {\n // CRITICAL: Use Guid for unique DB per test method\n // xUnit creates a new class instance per test method — each gets a fresh DB\n _repository = new CategoryRepository(fixture.ConnectionString, $\"T{Guid.NewGuid():N}\");\n _handler = new CreateCategoryHandler(_repository, new CreateCategoryValidator());\n }\n\n [Fact]\n public async Task Handle_ValidCommand_CreatesCategory()\n {\n // Arrange\n var command = new CreateCategoryCommand { CategoryName = \"New Category\", ... };\n\n // Act\n var result = await _handler.Handle(command, TestContext.Current.CancellationToken);\n\n // Assert\n result.Should().NotBeNull();\n result.CategoryName.Should().Be(\"New Category\");\n }\n}\n```\n\n#### 4. xunit.runner.json — Enable parallel collections\n\n```json\n{\n \"methodDisplay\": \"method\",\n \"methodDisplayOptions\": \"all\",\n \"parallelizeAssembly\": false,\n \"parallelizeTestCollections\": true\n}\n```\n\n### Critical Rules\n\n1. **Unique DB per test method:** Use `$\"T{Guid.NewGuid():N}\"` as the database name.\n - xUnit creates a new class instance per test method\n - Guid in constructor = new DB per method = full isolation within shared container\n - `T` prefix + 32 hex chars = 33 chars (well under MongoDB's 64-char limit)\n\n2. **Domain grouping:** Group test classes by domain entity (Category, Issue, Comment, Status).\n Classes within the same domain share one container. Different domains run in parallel.\n\n3. **No `IAsyncLifetime` on test class** unless there's OTHER async setup beyond the container.\n The fixture handles container lifecycle. Test setup goes in the constructor.\n\n4. **`parallelizeAssembly: false`** — keep this. We want collection-level parallelism,\n not test-method-level within a collection.\n\n### Domain Mapping (IssueManager)\n\n| Collection | Test Classes |\n|---|---|\n| `CategoryIntegration` | CreateCategory, GetCategory, ListCategories, UpdateCategory, CategoryRepository |\n| `IssueIntegration` | CreateIssue, DeleteIssue (×2), GetIssue, ListIssues, UpdateIssue, UpdateIssueStatus, IssueRepositorySearch, IssueRepository |\n| `CommentIntegration` | CreateComment, DeleteComment, GetComment, ListComments, UpdateComment |\n| `StatusIntegration` | CreateStatus, GetStatus, ListStatuses, UpdateStatus |\n\n### Performance Gain\n\n- **Before:** 23 containers × ~2s startup = ~46s overhead, all sequential\n- **After:** 4 containers starting in parallel = ~2s overhead\n- **Expected CI improvement:** 5–10 min → ~2–3 min\n\n### GlobalUsings.cs\n\nAdd the fixture namespace so test files don't need explicit using statements:\n\n```csharp\nglobal using Integration.Fixtures;\n```\n\n**Import ordering:** `Integration.Fixtures` sorts alphabetically between `FluentValidation` and `MongoDB.Bson`. The `dotnet format` tool enforces this — run it before pushing.\n",
"webapp-testing": "---\nname: webapp-testing\ndescription: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.\n---\n\n# Web Application Testing\n\nThis skill enables comprehensive testing and debugging of local web applications using Playwright automation.\n\n## When to Use This Skill\n\nUse this skill when you need to:\n- Test frontend functionality in a real browser\n- Verify UI behavior and interactions\n- Debug web application issues\n- Capture screenshots for documentation or debugging\n- Inspect browser console logs\n- Validate form submissions and user flows\n- Check responsive design across viewports\n\n## Prerequisites\n\n- Node.js installed on the system\n- A locally running web application (or accessible URL)\n- Playwright will be installed automatically if not present\n\n## Core Capabilities\n\n### 1. Browser Automation\n- Navigate to URLs\n- Click buttons and links\n- Fill form fields\n- Select dropdowns\n- Handle dialogs and alerts\n\n### 2. Verification\n- Assert element presence\n- Verify text content\n- Check element visibility\n- Validate URLs\n- Test responsive behavior\n\n### 3. Debugging\n- Capture screenshots\n- View console logs\n- Inspect network requests\n- Debug failed tests\n\n## Usage Examples\n\n### Example 1: Basic Navigation Test\n```javascript\n// Navigate to a page and verify title\nawait page.goto('http://localhost:3000');\nconst title = await page.title();\nconsole.log('Page title:', title);\n```\n\n### Example 2: Form Interaction\n```javascript\n// Fill out and submit a form\nawait page.fill('#username', 'testuser');\nawait page.fill('#password', 'password123');\nawait page.click('button[type=\"submit\"]');\nawait page.waitForURL('**/dashboard');\n```\n\n### Example 3: Screenshot Capture\n```javascript\n// Capture a screenshot for debugging\nawait page.screenshot({ path: 'debug.png', fullPage: true });\n```\n\n## Guidelines\n\n1. **Always verify the app is running** - Check that the local server is accessible before running tests\n2. **Use explicit waits** - Wait for elements or navigation to complete before interacting\n3. **Capture screenshots on failure** - Take screenshots to help debug issues\n4. **Clean up resources** - Always close the browser when done\n5. **Handle timeouts gracefully** - Set reasonable timeouts for slow operations\n6. **Test incrementally** - Start with simple interactions before complex flows\n7. **Use selectors wisely** - Prefer data-testid or role-based selectors over CSS classes\n\n## Common Patterns\n\n### Pattern: Wait for Element\n```javascript\nawait page.waitForSelector('#element-id', { state: 'visible' });\n```\n\n### Pattern: Check if Element Exists\n```javascript\nconst exists = await page.locator('#element-id').count() > 0;\n```\n\n### Pattern: Get Console Logs\n```javascript\npage.on('console', msg => console.log('Browser log:', msg.text()));\n```\n\n### Pattern: Handle Errors\n```javascript\ntry {\n await page.click('#button');\n} catch (error) {\n await page.screenshot({ path: 'error.png' });\n throw error;\n}\n```\n\n## Limitations\n\n- Requires Node.js environment\n- Cannot test native mobile apps (use React Native Testing Library instead)\n- May have issues with complex authentication flows\n- Some modern frameworks may require specific configuration\n"
}
-}
\ No newline at end of file
+}
diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs
index 90bb53e..3d5d5ec 100644
--- a/src/AppHost/AppHost.cs
+++ b/src/AppHost/AppHost.cs
@@ -15,13 +15,20 @@
var auth0MgmtClientId = builder.AddParameter("auth0MgmtClientId", secret: true);
var auth0MgmtClientSecret = builder.AddParameter("auth0MgmtClientSecret", secret: true);
+var isTesting = string.Equals(builder.Environment.EnvironmentName, "Testing", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Testing", StringComparison.OrdinalIgnoreCase);
+
// Add Web project with service discovery and health checks
-builder.AddProject("web")
+var web = builder.AddProject("web")
.WithReference(mongodb)
.WithReference(redis)
.WaitFor(redis)
- .WithHttpHealthCheck("/health")
.WithEnvironment("Auth0Management__ClientId", auth0MgmtClientId)
.WithEnvironment("Auth0Management__ClientSecret", auth0MgmtClientSecret);
+if (!isTesting)
+{
+ web.WithHttpHealthCheck("/health");
+}
+
builder.Build().Run();
diff --git a/src/Web/Features/Admin/Users/UserManagementExtensions.cs b/src/Web/Features/Admin/Users/UserManagementExtensions.cs
index 7dc2f2d..d2c41ed 100644
--- a/src/Web/Features/Admin/Users/UserManagementExtensions.cs
+++ b/src/Web/Features/Admin/Users/UserManagementExtensions.cs
@@ -7,8 +7,12 @@
// Project Name : Web
// =============================================
+using Auth0.ManagementApi;
+
using Domain.Features.Admin.Abstractions;
+using Microsoft.Extensions.Options;
+
namespace Web.Features.Admin.Users;
///
@@ -33,6 +37,24 @@ public static IServiceCollection AddUserManagement(
services.Configure(
configuration.GetSection(Auth0ManagementOptions.SectionName));
+ // Register the Auth0 management client as a singleton; the SDK's
+ // ClientCredentialsTokenProvider handles M2M token acquisition and caching internally.
+ services.AddSingleton(sp =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+ var audience = string.IsNullOrWhiteSpace(opts.Audience) ? null : opts.Audience;
+
+ return new ManagementClient(new ManagementClientOptions
+ {
+ Domain = opts.Domain,
+ TokenProvider = new ClientCredentialsTokenProvider(
+ opts.Domain,
+ opts.ClientId,
+ opts.ClientSecret,
+ audience: audience)
+ });
+ });
+
// Register the service as scoped — a new instance per HTTP request.
services.AddScoped();
diff --git a/src/Web/Features/Admin/Users/UserManagementService.cs b/src/Web/Features/Admin/Users/UserManagementService.cs
index fa5cdf6..21b6c64 100644
--- a/src/Web/Features/Admin/Users/UserManagementService.cs
+++ b/src/Web/Features/Admin/Users/UserManagementService.cs
@@ -8,13 +8,10 @@
// =============================================
using System.Buffers.Binary;
-using System.Net.Http.Json;
using System.Text.Json;
-using System.Text.Json.Serialization;
using Auth0.ManagementApi;
-using Auth0.ManagementApi.Models;
-using Auth0.ManagementApi.Paging;
+using Auth0.ManagementApi.Users;
using Domain.Abstractions;
using Domain.Features.Admin.Abstractions;
@@ -22,27 +19,24 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
namespace Web.Features.Admin.Users;
///
-/// Implements using the Auth0 Management API v2.
+/// Implements using the Auth0 Management API v8.
///
///
-/// An M2M access token is obtained via the OAuth 2.0 client credentials flow and cached in
-/// with a 24 h TTL minus a 5-minute safety margin. Role IDs are
-/// resolved dynamically by name and cached for 30 minutes so they are never hardcoded.
+/// Credentials are managed by the injected , which uses
+/// ClientCredentialsTokenProvider to handle M2M token acquisition and caching internally.
+/// Role IDs are resolved dynamically by name and cached for 30 minutes so they are never hardcoded.
///
/// Rate limits: Auth0 Management API returns HTTP 429 on burst. Add a Polly retry
-/// policy (per ADR #130) in a follow-up task once the HttpClientManagementConnection
-/// integration is confirmed against the tenant's SDK version.
+/// policy (per ADR #130) in a follow-up task.
///
///
public sealed class UserManagementService : IUserManagementService
{
- // ── IMemoryCache keys (token + role-ID map — DO NOT CHANGE) ──────────────
- private const string TokenCacheKey = "Auth0Management:Token";
+ // ── IMemoryCache key (role-ID map) ────────────────────────────────────────
private const string RolesCacheKey = "Auth0Management:Roles";
// ── IDistributedCache keys (result data — Sprint 2) ──────────────────────
@@ -58,8 +52,7 @@ public sealed class UserManagementService : IUserManagementService
private readonly IMemoryCache _cache;
private readonly IDistributedCache _distributedCache;
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly Auth0ManagementOptions _options;
+ private readonly IManagementApiClient _managementClient;
private readonly ILogger _logger;
///
@@ -68,14 +61,12 @@ public sealed class UserManagementService : IUserManagementService
public UserManagementService(
IMemoryCache cache,
IDistributedCache distributedCache,
- IHttpClientFactory httpClientFactory,
- IOptions options,
+ IManagementApiClient managementClient,
ILogger logger)
{
_cache = cache;
_distributedCache = distributedCache;
- _httpClientFactory = httpClientFactory;
- _options = options.Value;
+ _managementClient = managementClient;
_logger = logger;
}
@@ -101,25 +92,35 @@ public async Task>> ListUsersAsync(
return Result.Ok>(cached);
}
- using var client = await GetManagementClientAsync(ct).ConfigureAwait(false);
+
// Auth0 uses 0-based page numbering; callers pass 1-based pages.
var auth0Page = Math.Max(0, page - 1);
- var users = await client.Users
- .GetAllAsync(new GetUsersRequest(), new PaginationInfo(auth0Page, perPage, false), ct)
+ var pager = await _managementClient.Users
+ .ListAsync(new ListUsersRequestParameters { Page = auth0Page, PerPage = perPage }, null, ct)
.ConfigureAwait(false);
+ var users = pager.CurrentPage.Items;
+
// Auth0's list endpoint does not include role assignments; fetch them per user in
// parallel to avoid sequential N+1 latency.
var summaries = await Task.WhenAll(users.Select(async u =>
{
- var roles = await client.Users
- .GetRolesAsync(u.UserId, new PaginationInfo(0, 100, false), ct)
+ if (string.IsNullOrWhiteSpace(u.UserId))
+ {
+ _logger.LogWarning(
+ "Skipping Auth0 role lookup for listed user with missing UserId. Email={Email}",
+ u.Email ?? string.Empty);
+ return MapUser(u) with { UserId = string.Empty };
+ }
+
+ var rolesPager = await _managementClient.Users.Roles
+ .ListAsync(u.UserId, new ListUserRolesRequestParameters { PerPage = 100 }, null, ct)
.ConfigureAwait(false);
return MapUser(u) with
{
- Roles = roles.Select(r => r.Name ?? string.Empty).ToList()
+ Roles = rolesPager.CurrentPage.Items.Select(r => r.Name ?? string.Empty).ToList()
};
})).ConfigureAwait(false);
@@ -167,20 +168,19 @@ public async Task> GetUserByIdAsync(
return Result.Ok(cached);
}
- using var client = await GetManagementClientAsync(ct).ConfigureAwait(false);
- var user = await client.Users
- .GetAsync(userId, cancellationToken: ct)
+ var user = await _managementClient.Users
+ .GetAsync(userId, new GetUserRequestParameters(), null, ct)
.ConfigureAwait(false);
// Fetch the roles assigned to this user (requires a separate API call).
- var rolesList = await client.Users
- .GetRolesAsync(userId, new PaginationInfo(0, 100, false), ct)
+ var rolesPager = await _managementClient.Users.Roles
+ .ListAsync(userId, new ListUserRolesRequestParameters { PerPage = 100 }, null, ct)
.ConfigureAwait(false);
var summary = MapUser(user) with
{
- Roles = rolesList.Select(r => r.Name ?? string.Empty).ToList()
+ Roles = rolesPager.CurrentPage.Items.Select(r => r.Name ?? string.Empty).ToList()
};
await SetInDistributedCacheAsync(cacheKey, summary, UserByIdTtl, ct).ConfigureAwait(false);
@@ -216,8 +216,7 @@ public async Task> AssignRolesAsync(
try
{
- using var client = await GetManagementClientAsync(ct).ConfigureAwait(false);
- var roleMap = await GetRoleMapAsync(client, ct).ConfigureAwait(false);
+ var roleMap = await GetRoleMapAsync(ct).ConfigureAwait(false);
var unknown = roleNamesList.Where(r => !roleMap.ContainsKey(r)).ToList();
if (unknown.Count > 0)
@@ -229,8 +228,8 @@ public async Task> AssignRolesAsync(
var roleIds = roleNamesList.Select(r => roleMap[r]).ToArray();
- await client.Users
- .AssignRolesAsync(userId, new AssignRolesRequest { Roles = roleIds }, ct)
+ await _managementClient.Users.Roles
+ .AssignAsync(userId, new AssignUserRolesRequestContent { Roles = roleIds }, null, ct)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -286,8 +285,7 @@ public async Task> RemoveRolesAsync(
try
{
- using var client = await GetManagementClientAsync(ct).ConfigureAwait(false);
- var roleMap = await GetRoleMapAsync(client, ct).ConfigureAwait(false);
+ var roleMap = await GetRoleMapAsync(ct).ConfigureAwait(false);
var unknown = roleNamesList.Where(r => !roleMap.ContainsKey(r)).ToList();
if (unknown.Count > 0)
@@ -299,8 +297,8 @@ public async Task> RemoveRolesAsync(
var roleIds = roleNamesList.Select(r => roleMap[r]).ToArray();
- await client.Users
- .RemoveRolesAsync(userId, new AssignRolesRequest { Roles = roleIds }, ct)
+ await _managementClient.Users.Roles
+ .DeleteAsync(userId, new DeleteUserRolesRequestContent { Roles = roleIds }, null, ct)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -350,13 +348,12 @@ public async Task>> ListRolesAsync(Cancella
return Result.Ok>(cached);
}
- using var client = await GetManagementClientAsync(ct).ConfigureAwait(false);
- var roles = await client.Roles
- .GetAllAsync(new GetRolesRequest(), new PaginationInfo(0, 100, false), ct)
+ var pager = await _managementClient.Roles
+ .ListAsync(new ListRolesRequestParameters { PerPage = 100 }, null, ct)
.ConfigureAwait(false);
- var result = roles
+ var result = pager.CurrentPage.Items
.Select(r => new RoleAssignment
{
RoleId = r.Id ?? string.Empty,
@@ -480,83 +477,21 @@ await _distributedCache.SetAsync(
}
///
- /// Creates a using a cached M2M access token.
- ///
- private async Task GetManagementClientAsync(CancellationToken ct)
- {
- var token = await GetOrFetchTokenAsync(ct).ConfigureAwait(false);
- return new ManagementApiClient(token, new Uri($"https://{_options.Domain}/api/v2/"));
- }
-
- ///
- /// Returns a cached M2M access token, fetching a fresh one from Auth0 when expired.
- /// Uses to avoid concurrent cold-start
- /// races where multiple in-flight requests each fetch a new token simultaneously.
- ///
- private async Task GetOrFetchTokenAsync(CancellationToken ct)
- {
- var token = await _cache.GetOrCreateAsync(TokenCacheKey, async entry =>
- {
- _logger.LogDebug(
- "Fetching fresh Auth0 Management API token for domain '{Domain}'.",
- _options.Domain);
-
- using var httpClient = _httpClientFactory.CreateClient();
-
- using var requestBody = new FormUrlEncodedContent(
- [
- new KeyValuePair("grant_type", "client_credentials"),
- new KeyValuePair("client_id", _options.ClientId),
- new KeyValuePair("client_secret", _options.ClientSecret),
- new KeyValuePair("audience", _options.Audience)
- ]);
-
- using var response = await httpClient
- .PostAsync($"https://{_options.Domain}/oauth/token", requestBody, ct)
- .ConfigureAwait(false);
-
- response.EnsureSuccessStatusCode();
-
- var tokenResponse = await response.Content
- .ReadFromJsonAsync(cancellationToken: ct)
- .ConfigureAwait(false)
- ?? throw new InvalidOperationException(
- "Auth0 token endpoint returned an empty response.");
-
- // Cache with a 5-minute safety margin so we always have time to act on the token.
- var ttl = tokenResponse.ExpiresIn > 300
- ? tokenResponse.ExpiresIn - 300
- : tokenResponse.ExpiresIn;
-
- entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(ttl);
-
- _logger.LogDebug("Auth0 Management API token cached. TTL={Ttl}s.", ttl);
-
- return tokenResponse.AccessToken;
- }).ConfigureAwait(false);
-
- return token ?? throw new InvalidOperationException(
- "Auth0 token cache returned null — token fetch may have failed.");
- }
-
- ///
- /// Returns a name → ID map of all tenant roles, backed by a 30-minute cache.
+ /// Returns a name → ID map of all tenant roles, backed by a 30-minute in-memory cache.
/// Uses to avoid race conditions
/// on concurrent cold starts.
///
- private async Task> GetRoleMapAsync(
- ManagementApiClient client,
- CancellationToken ct)
+ private async Task> GetRoleMapAsync(CancellationToken ct)
{
var map = await _cache.GetOrCreateAsync(RolesCacheKey, async entry =>
{
- var roles = await client.Roles
- .GetAllAsync(new GetRolesRequest(), new PaginationInfo(0, 100, false), ct)
+ var pager = await _managementClient.Roles
+ .ListAsync(new ListRolesRequestParameters { PerPage = 100 }, null, ct)
.ConfigureAwait(false);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
- return roles
+ return pager.CurrentPage.Items
.Where(r => r.Name is not null && r.Id is not null)
.ToDictionary(r => r.Name!, r => r.Id!, StringComparer.OrdinalIgnoreCase);
}).ConfigureAwait(false);
@@ -564,23 +499,38 @@ private async Task> GetRoleMapAsync(
return map ?? [];
}
- /// Maps an Auth0 to .
- private static AdminUserSummary MapUser(User user) => new()
+ /// Maps an Auth0 (list result) to .
+ private static AdminUserSummary MapUser(UserResponseSchema user) => new()
+ {
+ UserId = user.UserId ?? string.Empty,
+ Email = user.Email ?? string.Empty,
+ Name = user.Name ?? user.Email ?? string.Empty,
+ Picture = user.Picture ?? string.Empty,
+ Roles = [],
+ LastLogin = ParseLastLogin(user.LastLogin),
+ IsBlocked = user.Blocked ?? false
+ };
+
+ /// Maps an Auth0 (single-user result) to .
+ private static AdminUserSummary MapUser(GetUserResponseContent user) => new()
{
UserId = user.UserId ?? string.Empty,
Email = user.Email ?? string.Empty,
- Name = user.FullName ?? user.Email ?? string.Empty,
+ Name = user.Name ?? user.Email ?? string.Empty,
Picture = user.Picture ?? string.Empty,
Roles = [],
- LastLogin = user.LastLogin is { } lastLogin
- ? new DateTimeOffset(DateTime.SpecifyKind(lastLogin, DateTimeKind.Utc))
- : null,
+ LastLogin = ParseLastLogin(user.LastLogin),
IsBlocked = user.Blocked ?? false
};
- /// Thin DTO for deserializing the Auth0 token endpoint response.
- private sealed record TokenResponse(
- [property: JsonPropertyName("access_token")] string AccessToken,
- [property: JsonPropertyName("token_type")] string TokenType,
- [property: JsonPropertyName("expires_in")] int ExpiresIn);
+ ///
+ /// Safely converts a last-login field to a nullable
+ /// , returning if the value is absent
+ /// or unparseable.
+ ///
+ private static DateTimeOffset? ParseLastLogin(UserDateSchema? lastLogin)
+ {
+ if (lastLogin is null) return null;
+ return lastLogin.TryGetString(out var s) && DateTimeOffset.TryParse(s, out var dto) ? dto : null;
+ }
}
diff --git a/tests/Web.Tests.Integration/CustomWebApplicationFactory.cs b/tests/Web.Tests.Integration/CustomWebApplicationFactory.cs
index a931bd9..1579f6a 100644
--- a/tests/Web.Tests.Integration/CustomWebApplicationFactory.cs
+++ b/tests/Web.Tests.Integration/CustomWebApplicationFactory.cs
@@ -20,6 +20,10 @@
using MongoDB.Driver;
+using Domain.Abstractions;
+using Domain.Features.Admin.Abstractions;
+using Domain.Features.Admin.Models;
+
using Persistence.MongoDb;
using Persistence.MongoDb.Configurations;
@@ -115,7 +119,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
// Auth0 settings (not used since we mock auth, but required for startup)
["Auth0:Domain"] = "test.auth0.com",
["Auth0:ClientId"] = "test-client-id",
- ["Auth0:ClientSecret"] = "test-client-secret"
+ ["Auth0:ClientSecret"] = "test-client-secret",
+ // Auth0 Management settings are required because the v8 client validates
+ // these options at service-construction time before any outbound call occurs.
+ ["Auth0Management:Domain"] = "test.auth0.com",
+ ["Auth0Management:ClientId"] = "test-client-id",
+ ["Auth0Management:ClientSecret"] = "test-client-secret",
+ ["Auth0Management:Audience"] = "https://test.auth0.com/api/v2/"
};
configBuilder.AddInMemoryCollection(testConfig);
@@ -153,6 +163,23 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
ConnectionString = connectionString,
DatabaseName = databaseName
}));
+
+ services.RemoveAll();
+ services.AddScoped(_ =>
+ {
+ var substitute = Substitute.For();
+ substitute.ListUsersAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Ok>([])));
+ substitute.GetUserByIdAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Ok(AdminUserSummary.Empty)));
+ substitute.ListRolesAsync(Arg.Any())
+ .Returns(Task.FromResult(Result.Ok>([])));
+ substitute.AssignRolesAsync(Arg.Any(), Arg.Any>(), Arg.Any())
+ .Returns(Task.FromResult(Result.Ok(true)));
+ substitute.RemoveRolesAsync(Arg.Any(), Arg.Any>(), Arg.Any())
+ .Returns(Task.FromResult(Result.Ok(true)));
+ return substitute;
+ });
});
}
diff --git a/tests/Web.Tests/Features/Admin/Users/UserManagementServiceCacheTests.cs b/tests/Web.Tests/Features/Admin/Users/UserManagementServiceCacheTests.cs
index 25218dc..11bc8d4 100644
--- a/tests/Web.Tests/Features/Admin/Users/UserManagementServiceCacheTests.cs
+++ b/tests/Web.Tests/Features/Admin/Users/UserManagementServiceCacheTests.cs
@@ -7,15 +7,17 @@
// Project Name : Web
// =============================================
-using System.Text;
using System.Text.Json;
+using Auth0.ManagementApi;
+
using Domain.Abstractions;
using Domain.Features.Admin.Models;
+using Microsoft.Extensions.Options;
+
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
using Web.Features.Admin.Users;
@@ -26,10 +28,9 @@ namespace Web.Tests.Features.Admin.Users;
///
/// These tests use a real so cache read/write round-trips
/// are exercised without mocking serialization internals. The Auth0 Management API layer is
-/// replaced by a that returns precanned JSON for the
-/// M2M token endpoint. Management API calls that would contact Auth0 are expected to fail with
-/// — the tests assert cache behaviour, not the
-/// success path of the Management API itself.
+/// replaced by an NSubstitute stub. Cache-miss tests assert
+/// (NSubstitute default returns a null-valued struct
+/// which causes NullReferenceException, caught as ExternalService by the service).
///
public sealed class UserManagementServiceCacheTests
{
@@ -37,67 +38,27 @@ public sealed class UserManagementServiceCacheTests
// Infrastructure
// ──────────────────────────────────────────────────────────────────────────
- private static Auth0ManagementOptions DefaultOptions => new()
- {
- ClientId = "test-client-id",
- ClientSecret = "test-client-secret",
- Domain = "test-tenant.auth0.com",
- Audience = "https://test-tenant.auth0.com/api/v2/"
- };
-
///
/// Creates a backed by a real in-memory distributed
/// cache so serialization/deserialization round-trips are tested.
///
private static (UserManagementService Sut, IDistributedCache DistributedCache) CreateSut(
- IHttpClientFactory? httpClientFactory = null)
+ IManagementApiClient? managementApiClient = null)
{
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var distributedCache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
- var factory = httpClientFactory ?? Substitute.For();
+ var client = managementApiClient ?? Substitute.For();
var logger = Substitute.For>();
var sut = new UserManagementService(
memoryCache,
distributedCache,
- factory,
- Options.Create(DefaultOptions),
+ client,
logger);
return (sut, distributedCache);
}
- ///
- /// Builds a stub whose CreateClient always returns
- /// an wired to a handler that responds to the Auth0 token endpoint
- /// with a valid access-token JSON payload. All other URLs return 404.
- ///
- private static IHttpClientFactory TokenOnlyHttpClientFactory()
- {
- var handler = new FakeHttpMessageHandler(request =>
- {
- if (request.RequestUri?.AbsolutePath.EndsWith("/oauth/token") == true)
- {
- var json = JsonSerializer.Serialize(new
- {
- access_token = "fake-management-token",
- token_type = "Bearer",
- expires_in = 86400
- });
- return new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(json, Encoding.UTF8, "application/json")
- };
- }
-
- return new HttpResponseMessage(HttpStatusCode.NotFound);
- });
-
- var factory = Substitute.For();
- factory.CreateClient(Arg.Any()).Returns(new HttpClient(handler));
- return factory;
- }
-
///
/// Directly writes a serialized value into the distributed cache so cache-hit tests
/// do not depend on a prior Auth0 call.
@@ -125,8 +86,8 @@ public async Task ListUsersAsync_SecondCall_HitsCacheAndSkipsAuth0()
{
// Arrange — pre-populate the distributed cache with a serialised user list using
// version=0 (the default when no version entry exists).
- var httpClientFactory = Substitute.For();
- var (sut, distributedCache) = CreateSut(httpClientFactory: httpClientFactory);
+ var managementClient = Substitute.For();
+ var (sut, distributedCache) = CreateSut(managementApiClient: managementClient);
var expectedUsers = new List
{
new() { UserId = "auth0|u1", Email = "a@test.com", Name = "Alpha", Roles = ["Admin"] },
@@ -144,19 +105,19 @@ public async Task ListUsersAsync_SecondCall_HitsCacheAndSkipsAuth0()
result.Success.Should().BeTrue();
result.Value.Should().HaveCount(2);
result.Value![0].UserId.Should().Be("auth0|u1");
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
[Fact]
public async Task ListUsersAsync_CacheMiss_AttemptsAuth0Call()
{
- // Arrange — empty cache; factory is called once for the token fetch.
- var (sut, _) = CreateSut(TokenOnlyHttpClientFactory());
+ // Arrange — empty cache; NSubstitute default on IManagementApiClient → ExternalService
+ var (sut, _) = CreateSut();
- // Act — Auth0 Management API will 404 after token exchange → ExternalService error
+ // Act — Management API stub returns default (null-valued struct) → ExternalService
var result = await sut.ListUsersAsync(1, 10, CancellationToken.None);
- // Assert — the call reached Auth0 (got ExternalService, not Validation)
+ // Assert — the call reached Auth0 path (got ExternalService, not Validation)
result.Failure.Should().BeTrue();
result.ErrorCode.Should().Be(ResultErrorCode.ExternalService);
}
@@ -169,8 +130,8 @@ public async Task ListUsersAsync_CacheMiss_AttemptsAuth0Call()
public async Task GetUserByIdAsync_SecondCall_HitsCacheAndSkipsAuth0()
{
// Arrange
- var httpClientFactory = Substitute.For();
- var (sut, distributedCache) = CreateSut(httpClientFactory: httpClientFactory);
+ var managementClient = Substitute.For();
+ var (sut, distributedCache) = CreateSut(managementApiClient: managementClient);
var userId = "auth0|user123";
var expected = new AdminUserSummary
{
@@ -189,14 +150,14 @@ public async Task GetUserByIdAsync_SecondCall_HitsCacheAndSkipsAuth0()
result.Success.Should().BeTrue();
result.Value!.UserId.Should().Be(userId);
result.Value.Roles.Should().BeEquivalentTo(["Admin", "User"]);
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
[Fact]
public async Task GetUserByIdAsync_CacheMiss_AttemptsAuth0Call()
{
- // Arrange — empty cache → falls through to Auth0
- var (sut, _) = CreateSut(TokenOnlyHttpClientFactory());
+ // Arrange — empty cache → falls through to Auth0 stub → ExternalService
+ var (sut, _) = CreateSut();
// Act
var result = await sut.GetUserByIdAsync("auth0|nonexistent", CancellationToken.None);
@@ -214,8 +175,8 @@ public async Task GetUserByIdAsync_CacheMiss_AttemptsAuth0Call()
public async Task ListRolesAsync_SecondCall_HitsCacheAndSkipsAuth0()
{
// Arrange
- var httpClientFactory = Substitute.For();
- var (sut, distributedCache) = CreateSut(httpClientFactory: httpClientFactory);
+ var managementClient = Substitute.For();
+ var (sut, distributedCache) = CreateSut(managementApiClient: managementClient);
var expectedRoles = new List
{
new() { RoleId = "rol_1", RoleName = "Admin", Description = "Administrator" },
@@ -231,14 +192,14 @@ public async Task ListRolesAsync_SecondCall_HitsCacheAndSkipsAuth0()
result.Success.Should().BeTrue();
result.Value.Should().HaveCount(2);
result.Value![0].RoleName.Should().Be("Admin");
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
[Fact]
public async Task ListRolesAsync_CacheMiss_AttemptsAuth0Call()
{
- // Arrange — empty cache
- var (sut, _) = CreateSut(TokenOnlyHttpClientFactory());
+ // Arrange — empty cache → stub → ExternalService
+ var (sut, _) = CreateSut();
// Act
var result = await sut.ListRolesAsync(CancellationToken.None);
@@ -255,23 +216,11 @@ public async Task ListRolesAsync_CacheMiss_AttemptsAuth0Call()
[Fact]
public async Task AssignRolesAsync_AfterSuccess_EvictsUserByIdCacheEntry()
{
- // Arrange — pre-populate user-by-id cache, then simulate a successful role change by
- // calling AssignRolesAsync through the real cache so the Remove() path executes.
- // Because ManagementApiClient can't be fully mocked here we pre-call the service
- // in a state where the role-assign fails at Auth0 (ExternalService), which means
- // the eviction does NOT happen on that path. Instead we verify the eviction path
- // directly by pre-populating and checking via AssignRolesAsync with empty roles
- // (which returns early before any Auth0 call, so no eviction) vs observing that
- // the successful AssignRolesAsync path calls Remove.
- //
- // Practical approach: use the empty-roles early-return path for non-eviction proof,
- // and the direct distributed cache mock for eviction proof so the test stays pure.
-
- // For the eviction tests we use a mock IDistributedCache so we can verify Remove calls.
+ // Arrange — use a mock IDistributedCache so we can verify Remove calls.
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var distributedCache = Substitute.For();
var logger = Substitute.For>();
- var factory = TokenOnlyHttpClientFactory();
+ var managementClient = Substitute.For();
// Stub GetAsync to return null (cache miss) so any GetAsync calls don't throw.
distributedCache.GetAsync(Arg.Any(), Arg.Any())
@@ -280,12 +229,10 @@ public async Task AssignRolesAsync_AfterSuccess_EvictsUserByIdCacheEntry()
var sut = new UserManagementService(
memoryCache,
distributedCache,
- factory,
- Options.Create(DefaultOptions),
+ managementClient,
logger);
- // Act — call AssignRolesAsync with an empty list; this short-circuits before Auth0
- // and returns Ok(true) without any eviction. Verifies the short-circuit path is clean.
+ // Act — empty roles list returns Ok(true) without any eviction (short-circuit path).
var earlyResult = await sut.AssignRolesAsync("auth0|u1", [], CancellationToken.None);
earlyResult.Success.Should().BeTrue();
@@ -344,8 +291,7 @@ public async Task RemoveRolesAsync_EmptyRoles_ReturnsSuccessWithoutEviction()
var sut = new UserManagementService(
memoryCache,
distributedCache,
- Substitute.For(),
- Options.Create(DefaultOptions),
+ Substitute.For(),
Substitute.For>());
// Act
@@ -393,13 +339,11 @@ public async Task ListUsersAsync_DifferentPagesProduceDifferentCacheKeys_BothSer
[Fact]
public async Task AssignRolesAsync_OnSuccess_CallsRemoveAsyncForUserByIdKey()
{
- // NOTE: ManagementApiClient is sealed and constructs its own HttpClient, so the
- // eviction-after-success path is not fully unit-testable without refactoring the
- // service to accept an IManagementApiClientFactory. This test verifies that:
- // a) the SUT accepts the injected IDistributedCache without null-ref,
- // b) validation returns NotBe(Validation) for a valid non-empty roles call,
- // c) a cache read error (sentinel path) does not surface as an exception.
- // Full eviction coverage is tracked in TODO in UserManagementServiceTests.cs.
+ // NOTE: The Management API stub returns default (null-valued struct) which causes
+ // a NullReferenceException caught as ExternalService. The eviction path runs only
+ // after a confirmed success, so this test verifies the SUT wires up correctly and
+ // a non-empty roles call reaches Auth0 (returns ExternalService, not Validation).
+ // Full eviction coverage is tracked as a TODO in UserManagementServiceTests.cs.
// Arrange
var memoryCache = new MemoryCache(new MemoryCacheOptions());
@@ -407,18 +351,15 @@ public async Task AssignRolesAsync_OnSuccess_CallsRemoveAsyncForUserByIdKey()
distributedCache.GetAsync(Arg.Any(), Arg.Any())
.Returns(Task.FromResult(null));
- // Token endpoint only — Management API calls will fail with ExternalService.
- var factory = TokenOnlyHttpClientFactory();
-
var sut = new UserManagementService(
- memoryCache, distributedCache, factory,
- Options.Create(DefaultOptions),
+ memoryCache, distributedCache,
+ Substitute.For(),
Substitute.For>());
// Act
var result = await sut.AssignRolesAsync("auth0|eviction-test", ["Admin"], CancellationToken.None);
- // Assert — reaches Auth0 path (ExternalService from Management API), not Validation.
+ // Assert — reaches Auth0 stub (ExternalService), not Validation.
result.ErrorCode.Should().Be(ResultErrorCode.ExternalService);
}
@@ -439,14 +380,12 @@ public async Task AssignRolesAsync_WhenDistributedCacheRemoveThrows_DoesNotRethr
.RemoveAsync(Arg.Any(), Arg.Any())
.Returns(_ => throw new InvalidOperationException("Redis unavailable"));
- // Short-circuit: empty roles list returns Ok without hitting Auth0 or eviction path.
- var factory = Substitute.For();
+ // Empty roles list short-circuits before eviction — no throw expected.
var sut = new UserManagementService(
- memoryCache, distributedCache, factory,
- Options.Create(DefaultOptions),
+ memoryCache, distributedCache,
+ Substitute.For(),
Substitute.For>());
- // Empty roles short-circuits before eviction — no throw expected.
var result = await sut.AssignRolesAsync(userId: "auth0|u1", roleNames: [], CancellationToken.None);
result.Success.Should().BeTrue();
}
@@ -465,24 +404,11 @@ public async Task RemoveRolesAsync_WhenDistributedCacheRemoveThrows_DoesNotRethr
var sut = new UserManagementService(
new MemoryCache(new MemoryCacheOptions()),
distributedCache,
- Substitute.For(),
- Options.Create(DefaultOptions),
+ Substitute.For(),
Substitute.For>());
// Empty roles short-circuits before eviction — no throw expected.
var result = await sut.RemoveRolesAsync("auth0|u1", [], CancellationToken.None);
result.Success.Should().BeTrue();
}
-
-
-
- /// Minimal HTTP handler that delegates each request to a synchronous lambda.
- private sealed class FakeHttpMessageHandler(
- Func handler) : HttpMessageHandler
- {
- protected override Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- => Task.FromResult(handler(request));
- }
}
diff --git a/tests/Web.Tests/Services/UserManagementServiceTests.cs b/tests/Web.Tests/Services/UserManagementServiceTests.cs
index f9b25f6..98eb41a 100644
--- a/tests/Web.Tests/Services/UserManagementServiceTests.cs
+++ b/tests/Web.Tests/Services/UserManagementServiceTests.cs
@@ -7,14 +7,16 @@
// Project Name : Web.Tests
// =======================================================
-using System.Text;
-using System.Text.Json;
+using System.Runtime.CompilerServices;
+
+using Auth0.ManagementApi;
+using Auth0.ManagementApi.Core;
+using Auth0.ManagementApi.Users;
using Domain.Abstractions;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
using Web.Features.Admin.Users;
@@ -25,42 +27,27 @@ namespace Web.Tests.Services;
///
///
///
-/// Test coverage note: directly instantiates
-/// with a hardcoded connection, making it
-/// impossible to intercept Management API HTTP calls in pure unit tests. As a result:
+/// Test coverage note: uses an injected
+/// , allowing all Management API calls to be intercepted
+/// via NSubstitute. Coverage includes:
///
-/// - Input-validation paths (empty userId, empty roles) are fully covered here.
-/// - M2M token-caching behaviour is covered via call-count assertions.
-/// -
-/// Success paths that require a real Management API response (ListUsersAsync success,
-/// AssignRolesAsync success) require integration tests or a refactor to inject an
-/// IManagementApiClientFactory / HttpClientManagementConnection. See TODO comments.
-///
+/// - Input-validation paths (empty userId, empty roles).
+/// - Early-exit paths (empty roles list, whitespace userId).
///
///
///
public sealed class UserManagementServiceTests
{
- private static Auth0ManagementOptions DefaultOptions => new()
- {
- ClientId = "test-client-id",
- ClientSecret = "test-client-secret",
- Domain = "test-tenant.auth0.com",
- Audience = "https://test-tenant.auth0.com/api/v2/"
- };
-
private static UserManagementService CreateSut(
IMemoryCache? cache = null,
IDistributedCache? distributedCache = null,
- IHttpClientFactory? httpClientFactory = null,
- Auth0ManagementOptions? options = null,
+ IManagementApiClient? managementApiClient = null,
ILogger? logger = null)
{
return new UserManagementService(
cache ?? new MemoryCache(new MemoryCacheOptions()),
distributedCache ?? Substitute.For(),
- httpClientFactory ?? Substitute.For(),
- Options.Create(options ?? DefaultOptions),
+ managementApiClient ?? Substitute.For(),
logger ?? Substitute.For>());
}
@@ -100,9 +87,9 @@ public async Task AssignRolesAsync_WhitespaceUserId_ReturnsValidationFailure()
[Fact]
public async Task AssignRolesAsync_EmptyRolesList_ReturnsImmediateSuccess()
{
- // Arrange — no HttpClientFactory call expected because roles list is empty
- var httpClientFactory = Substitute.For();
- var sut = CreateSut(httpClientFactory: httpClientFactory);
+ // Arrange — no Management API call expected because roles list is empty
+ var managementClient = Substitute.For();
+ var sut = CreateSut(managementApiClient: managementClient);
// Act
var result = await sut.AssignRolesAsync("auth0|user1", [], CancellationToken.None);
@@ -110,22 +97,22 @@ public async Task AssignRolesAsync_EmptyRolesList_ReturnsImmediateSuccess()
// Assert
result.Success.Should().BeTrue();
result.Value.Should().BeTrue();
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
[Fact]
public async Task AssignRolesAsync_NullRolesList_ReturnsImmediateSuccess()
{
// Arrange
- var httpClientFactory = Substitute.For();
- var sut = CreateSut(httpClientFactory: httpClientFactory);
+ var managementClient = Substitute.For();
+ var sut = CreateSut(managementApiClient: managementClient);
// Act
var result = await sut.AssignRolesAsync("auth0|user1", null!, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
// ──────────────────────────────────────────────────────────────────────────
@@ -150,9 +137,9 @@ public async Task RemoveRolesAsync_EmptyUserId_ReturnsValidationFailure()
[Fact]
public async Task RemoveRolesAsync_EmptyRolesList_ReturnsImmediateSuccess()
{
- // Arrange — no HttpClientFactory call expected
- var httpClientFactory = Substitute.For();
- var sut = CreateSut(httpClientFactory: httpClientFactory);
+ // Arrange — no Management API call expected
+ var managementClient = Substitute.For();
+ var sut = CreateSut(managementApiClient: managementClient);
// Act
var result = await sut.RemoveRolesAsync("auth0|user1", [], CancellationToken.None);
@@ -160,123 +147,95 @@ public async Task RemoveRolesAsync_EmptyRolesList_ReturnsImmediateSuccess()
// Assert
result.Success.Should().BeTrue();
result.Value.Should().BeTrue();
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ managementClient.ReceivedCalls().Should().BeEmpty();
}
- // ──────────────────────────────────────────────────────────────────────────
- // Input validation — GetUserByIdAsync
- // ──────────────────────────────────────────────────────────────────────────
-
[Fact]
- public async Task GetUserByIdAsync_EmptyUserId_ReturnsValidationFailure()
+ public async Task ListUsersAsync_UserWithoutUserId_SkipsRoleLookupAndReturnsUser()
{
// Arrange
- var sut = CreateSut();
+ var managementClient = Substitute.For();
+ var usersClient = Substitute.For();
+ var rolesClient = Substitute.For();
+
+ managementClient.Users.Returns(usersClient);
+ usersClient.Roles.Returns(rolesClient);
+ usersClient.ListAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(Task.FromResult>(
+ new TestPager(
+ [
+ new UserResponseSchema
+ {
+ UserId = " ",
+ Email = "missing-id@test.com",
+ Name = "Missing Id"
+ }
+ ])));
+
+ var sut = CreateSut(managementApiClient: managementClient);
// Act
- var result = await sut.GetUserByIdAsync(string.Empty, CancellationToken.None);
+ var result = await sut.ListUsersAsync(1, 10, CancellationToken.None);
// Assert
- result.Failure.Should().BeTrue();
- result.ErrorCode.Should().Be(ResultErrorCode.Validation);
+ result.Success.Should().BeTrue();
+ result.Value.Should().ContainSingle();
+ result.Value![0].UserId.Should().BeEmpty();
+ result.Value[0].Email.Should().Be("missing-id@test.com");
+ result.Value[0].Roles.Should().BeEmpty();
+ rolesClient.ReceivedCalls().Should().BeEmpty();
}
- // ──────────────────────────────────────────────────────────────────────────
- // M2M Token caching
- // ──────────────────────────────────────────────────────────────────────────
-
- [Fact]
- public async Task ListUsersAsync_TokenFetchedOnFirstCall_CachedTokenUsedOnSecondCall()
+ private sealed class TestPager(IReadOnlyList items) : Pager
{
- // Arrange — use a fake HTTP handler that intercepts the token-endpoint call.
- // ManagementApiClient creates its own HttpClient and will fail to connect to the
- // fake domain (ExternalService), but the IHttpClientFactory call-count tells us
- // whether the token was re-fetched.
- var tokenCallCount = 0;
- var fakeTokenHandler = new FakeHttpMessageHandler(request =>
- {
- tokenCallCount++;
- var json = JsonSerializer.Serialize(new
- {
- access_token = "fake-management-token",
- token_type = "Bearer",
- expires_in = 86400
- });
- return new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(json, Encoding.UTF8, "application/json")
- };
- });
-
- var httpClientFactory = Substitute.For();
- httpClientFactory.CreateClient(Arg.Any())
- .Returns(new HttpClient(fakeTokenHandler));
+ public Page CurrentPage { get; } = new(items);
+ public bool HasNextPage => false;
- var sut = CreateSut(
- cache: new MemoryCache(new MemoryCacheOptions()),
- httpClientFactory: httpClientFactory);
+ public Task> GetNextPageAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(Page.Empty);
- // Act — two consecutive calls; both will fail at Management API level (ExternalService)
- // but only the first should trigger a token fetch via IHttpClientFactory.
- var first = await sut.ListUsersAsync(1, 10, CancellationToken.None);
- var second = await sut.ListUsersAsync(1, 10, CancellationToken.None);
-
- // Assert — both return ExternalService (can't reach fake domain), but token was
- // fetched only once.
- first.Failure.Should().BeTrue();
- first.ErrorCode.Should().Be(ResultErrorCode.ExternalService);
+ public async IAsyncEnumerable> AsPagesAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return CurrentPage;
+ await Task.CompletedTask;
+ }
- second.Failure.Should().BeTrue();
- second.ErrorCode.Should().Be(ResultErrorCode.ExternalService);
+ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
+ {
+ foreach (var item in items)
+ {
+ yield return item;
+ }
- // IHttpClientFactory.CreateClient() must have been invoked exactly once across both calls.
- httpClientFactory.Received(1).CreateClient(Arg.Any());
+ await Task.CompletedTask;
+ }
}
+ // ──────────────────────────────────────────────────────────────────────────
+ // Input validation — GetUserByIdAsync
+ // ──────────────────────────────────────────────────────────────────────────
+
[Fact]
- public async Task ListUsersAsync_TokenAlreadyInCache_DoesNotCallHttpClientFactory()
+ public async Task GetUserByIdAsync_EmptyUserId_ReturnsValidationFailure()
{
- // Arrange — pre-populate the cache with a valid token so no HTTP call is needed.
- var cache = new MemoryCache(new MemoryCacheOptions());
- cache.Set("Auth0Management:Token", "pre-cached-token", TimeSpan.FromHours(1));
-
- var httpClientFactory = Substitute.For();
-
- var sut = CreateSut(cache: cache, httpClientFactory: httpClientFactory);
+ // Arrange
+ var sut = CreateSut();
// Act
- await sut.ListUsersAsync(1, 10, CancellationToken.None);
+ var result = await sut.GetUserByIdAsync(string.Empty, CancellationToken.None);
- // Assert — factory must NOT be called because token came from cache.
- httpClientFactory.DidNotReceive().CreateClient(Arg.Any());
+ // Assert
+ result.Failure.Should().BeTrue();
+ result.ErrorCode.Should().Be(ResultErrorCode.Validation);
}
- // TODO: Test that an expired token (past TTL) triggers a fresh token fetch.
- // This requires injecting a time abstraction (e.g., TimeProvider) into the service
- // so tests can advance the clock past the cache TTL without waiting real time.
- // Tracked as a follow-up refactor: inject TimeProvider into UserManagementService.
-
// TODO: Test ListUsersAsync success path (returns populated list).
// TODO: Test AssignRolesAsync success path (roles assigned, returns true).
// TODO: Test RemoveRolesAsync success path (roles removed, returns true).
// TODO: Test Auth0 API error → ResultErrorCode.ExternalService for all methods.
- // These paths require UserManagementService to be refactored to accept an injectable
- // IManagementApiClientFactory (or HttpClientManagementConnection), so that
- // ManagementApiClient's HTTP calls can be intercepted in unit tests.
-
- // ──────────────────────────────────────────────────────────────────────────
- // Helpers
- // ──────────────────────────────────────────────────────────────────────────
-
- ///
- /// Minimal