← Duende SkillsCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to Duende Skills
Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"name": "identity-testing-patterns",
"description": "Testing patterns for IdentityServer-based systems including integration testing with WebApplicationFactory, mock token issuance, test authority configuration, protocol response validation, and end-to-end authentication flow testing.",
"included_files": [
{
"relative_path": "docs/aspire-testing.md",
"size_in_bytes": 1380
},
{
"relative_path": "docs/bff-testing.md",
"size_in_bytes": 2521
}
],
"skill_md_contents": "---\nname: identity-testing-patterns\ndescription: Testing patterns for IdentityServer-based systems including integration testing with WebApplicationFactory, mock token issuance, test authority configuration, protocol response validation, and end-to-end authentication flow testing.\ninvocable: false\n---\n\n# Identity Testing Patterns\n\n## When to Use This Skill\n\nUse this skill when:\n- Writing integration tests for applications that issue or validate tokens using Duende IdentityServer\n- Hosting IdentityServer in-memory with `WebApplicationFactory<T>` to test grant flows end-to-end\n- Creating mock JWT tokens for testing protected APIs without a live authority\n- Testing custom `IProfileService` implementations or claim transformation logic\n- Verifying `IAuthorizationHandler` and policy-based authorization against specific claim sets\n- Testing BFF endpoints that rely on cookie-based sessions and proxied API calls\n- Scaffolding an integration-test project from the `duende-is-inmem` template to test IdentityServer itself\n- Running a post-deployment login-flow smoke test without a headless browser\n\n## Core Principles\n\n1. **Integration over unit** — Test token issuance, claim mapping, and policy enforcement against a real (in-process) IdentityServer instance. Avoid mocking the token pipeline itself; mock only external I/O (databases, downstream services).\n2. **In-process authority** — Use `WebApplicationFactory<T>` to host IdentityServer inside the test process. This avoids network round-trips, eliminates certificate trust issues, and makes tests deterministic.\n3. **Predictable signing keys** — Override key management in tests with a static development signing key so token signatures are verifiable without key rotation logic.\n4. **Minimal test clients** — Register only the clients, scopes, and resources each test needs. Over-broad test configurations mask permission bugs.\n5. **Test auth handler for API tests** — When testing protected APIs in isolation (without a live token endpoint), replace JWT Bearer authentication with a `TestAuthHandler` that accepts a fake scheme. Never disable authorization wholesale.\n6. **Builder pattern for test data** — Use fluent builders for `Client`, `ApiScope`, `ApiResource`, and test users to keep test setup readable and reduce duplication.\n\n## Related Skills\n\n- `identityserver-configuration` — Production client and resource registration patterns\n- `aspnetcore-authentication` — OIDC and JWT Bearer handler configuration\n- `aspnetcore-authorization` — Policy definitions and requirement handlers\n- `claims-authorization` — `IProfileService` and claim pipeline internals\n- `duende-bff` — BFF session and proxy architecture being tested\n\nDocs: https://docs.duendesoftware.com/identityserver/fundamentals\n\n---\n\n## Sub-Documents\n\n| Document | Description | When to Load |\n|----------|-------------|--------------|\n| [docs/bff-testing.md](docs/bff-testing.md) | BFF endpoint testing with cookie simulation, antiforgery headers, and OIDC redirect bypass | BFF testing, CookieContainer, x-csrf header, BffFactory, session simulation |\n| [docs/aspire-testing.md](docs/aspire-testing.md) | Full-stack Aspire testing with identity server health checks and token endpoint wiring | Aspire testing, DistributedApplicationTestingBuilder, WaitForResourceHealthyAsync, end-to-end |\n\n---\n\n## Testing Strategy Overview\n\n| What to test | Recommended approach |\n|---|---|\n| Token issuance (client credentials, code flow) | In-process `WebApplicationFactory` hitting `/connect/token` |\n| Claim mapping / `IProfileService` | Unit test with `DefaultProfileService` + mock context, or integration test |\n| Authorization policy requirements | `IAuthorizationService` + `TestAuthHandler` in integration test |\n| `IAuthorizationHandler` logic | Direct unit test with `AuthorizationHandlerContext` |\n| Protected API access control | `WebApplicationFactory` with `TestAuthHandler` and constructed `ClaimsPrincipal` |\n| BFF endpoints (login/logout/user) | `WebApplicationFactory` with cookie simulation |\n| EF Core store implementations | In-memory EF provider or isolated SQL container |\n| Deployed login flow (smoke test) | Cookie-aware `HttpClient` + AngleSharp HTML parsing (Pattern 10) |\n\n---\n\n## Pattern 1: WebApplicationFactory for IdentityServer\n\nHost a complete IdentityServer in-memory. Override configuration to inject test clients, resources, and a static signing key.\n\n### Required NuGet Packages\n\n```xml\n<ItemGroup>\n <PackageReference Include=\"Microsoft.AspNetCore.Mvc.Testing\" Version=\"*\" />\n <PackageReference Include=\"xunit\" Version=\"*\" />\n <PackageReference Include=\"xunit.runner.visualstudio\" Version=\"*\" />\n <PackageReference Include=\"Microsoft.NET.Test.Sdk\" Version=\"*\" />\n <PackageReference Include=\"IdentityModel\" Version=\"*\" />\n</ItemGroup>\n```\n\n### IdentityServer WebApplicationFactory\n\n```csharp\n// ✅ Factory that runs a real IdentityServer in-process\npublic sealed class IdentityServerFactory : WebApplicationFactory<Program>\n{\n protected override void ConfigureWebHost(IWebHostBuilder builder)\n {\n builder.UseEnvironment(\"Testing\");\n\n builder.ConfigureTestServices(services =>\n {\n // Remove any existing IdentityServer registration to replace it cleanly\n var descriptor = services.SingleOrDefault(\n d => d.ServiceType == typeof(IConfigureOptions<IdentityServerOptions>));\n if (descriptor is not null)\n services.Remove(descriptor);\n\n services.AddIdentityServer(options =>\n {\n options.Events.RaiseErrorEvents = true;\n options.Events.RaiseFailureEvents = true;\n\n // Disable automatic key management — use a static key for predictability\n options.KeyManagement.Enabled = false;\n })\n .AddInMemoryClients(TestConfig.Clients)\n .AddInMemoryApiScopes(TestConfig.ApiScopes)\n .AddInMemoryApiResources(TestConfig.ApiResources)\n .AddInMemoryIdentityResources(TestConfig.IdentityResources)\n .AddTestUsers(TestConfig.Users)\n // Static development signing key — never use this in production\n .AddDeveloperSigningCredential(persistKey: false);\n });\n }\n}\n```\n\n### Requesting a Token in a Test\n\n```csharp\n[Collection(\"IdentityServer\")]\npublic class TokenEndpointTests : IClassFixture<IdentityServerFactory>\n{\n private readonly HttpClient _client;\n\n public TokenEndpointTests(IdentityServerFactory factory)\n {\n _client = factory.CreateClient();\n }\n\n [Fact]\n public async Task ClientCredentials_ShouldReturnAccessToken()\n {\n var response = await _client.RequestClientCredentialsTokenAsync(\n new ClientCredentialsTokenRequest\n {\n Address = \"https://localhost/connect/token\",\n ClientId = \"test.service\",\n ClientSecret = \"test-secret\",\n Scope = \"api1\"\n });\n\n Assert.False(response.IsError, response.Error);\n Assert.NotEmpty(response.AccessToken);\n Assert.Equal(\"Bearer\", response.TokenType);\n }\n\n [Fact]\n public async Task ClientCredentials_InvalidScope_ShouldReturnError()\n {\n var response = await _client.RequestClientCredentialsTokenAsync(\n new ClientCredentialsTokenRequest\n {\n Address = \"https://localhost/connect/token\",\n ClientId = \"test.service\",\n ClientSecret = \"test-secret\",\n Scope = \"not.allowed\" // ❌ scope not granted to this client\n });\n\n Assert.True(response.IsError);\n Assert.Equal(\"invalid_scope\", response.Error);\n }\n}\n```\n\n### Testing IdentityServer Itself from the `duende-is-inmem` Template\n\nThe fastest way to get a real, in-process IdentityServer under test is to scaffold from the in-memory template `duende-is-inmem` (from the **`Duende.Templates`** NuGet package) and add a test project that references the host. Unlike the `TestAuthHandler`/`TestTokenFactory` patterns below — which mock auth in a *downstream API* — this exercises IdentityServer's **real** endpoints and token issuance (no auth mocking).\n\n**Test project setup:**\n\n1. The test project must use the **Web SDK** so ASP.NET Core testing APIs resolve. Change the top of the `.csproj`:\n\n```xml\n<!-- ❌ Default test project SDK -->\n<Project Sdk=\"Microsoft.NET.Sdk\">\n\n<!-- ✅ Web SDK — required for WebApplicationFactory<T> against a web host -->\n<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n```\n\n2. Add packages to the test project:\n\n```xml\n<PackageReference Include=\"Microsoft.AspNetCore.Mvc.Testing\" Version=\"*\" /> <!-- WebApplicationFactory<T> -->\n<PackageReference Include=\"Duende.IdentityModel\" Version=\"*\" /> <!-- OIDC/OAuth client helpers -->\n```\n\n3. Reference the IdentityServer host project (`<ProjectReference Include=\"..\\IdentityServerHost\\IdentityServerHost.csproj\" />`).\n\n4. Make the template's static `Config` collections mutable so tests can add/clear clients and scopes:\n\n```csharp\n// Config.cs — expose List<> instead of IEnumerable<> so tests can mutate\npublic static List<Client> Clients = [ /* ... */ ];\npublic static List<ApiScope> ApiScopes = [ /* ... */ ];\npublic static List<IdentityResource> IdentityResources = [ /* ... */ ];\n```\n\n> **Caution:** With xUnit's parallel test execution, mutating shared static `Config` collections causes cross-test interference. Prefer per-test collections (or a fresh factory per test) over mutating shared statics.\n\n**Test class using the primary-constructor `IClassFixture`:**\n\n```csharp\npublic class IdentityServerTests(WebApplicationFactory<Program> factory)\n : IClassFixture<WebApplicationFactory<Program>>\n{\n // factory.CreateClient() serves the host over https on localhost\n private readonly HttpClient _client = factory.CreateClient();\n\n [Fact]\n public async Task Discovery_document_is_available()\n {\n var disco = await _client.GetDiscoveryDocumentAsync();\n Assert.False(disco.IsError);\n }\n\n [Fact]\n public async Task Can_request_client_credentials_token()\n {\n var token = await _client.RequestClientCredentialsTokenAsync(new()\n {\n Address = \"connect/token\",\n ClientId = \"m2m.client\",\n ClientSecret = \"secret\",\n Scope = \"api1\"\n });\n\n Assert.False(token.IsError);\n Assert.NotNull(token.AccessToken);\n }\n\n [Fact]\n public void Can_resolve_in_process_services()\n {\n // Reach into the running host's DI container\n using var scope = factory.Services.CreateScope();\n var profileService = scope.ServiceProvider.GetRequiredService<IProfileService>();\n Assert.NotNull(profileService);\n }\n}\n```\n\n> The discovery/token helpers (`GetDiscoveryDocumentAsync`, `RequestClientCredentialsTokenAsync`) come from **`Duende.IdentityModel`**. Accessing `factory.Services` lets you assert on real in-process services such as `IProfileService`, stores, or options.\n\n---\n\n## Pattern 2: Test Configuration Builders\n\nUse static builders — not scattered inline literals — so every test builds from a consistent baseline.\n\n```csharp\npublic static class TestConfig\n{\n public static IEnumerable<Client> Clients =>\n [\n ClientBuilder.ClientCredentials(\"test.service\", \"test-secret\")\n .WithScopes(\"api1\", \"api2.read\")\n .Build(),\n\n ClientBuilder.AuthorizationCode(\"test.webapp\", \"webapp-secret\")\n .WithRedirectUri(\"https://testapp/signin-oidc\")\n .WithScopes(\"openid\", \"profile\", \"api1\")\n .Build()\n ];\n\n public static IEnumerable<ApiScope> ApiScopes =>\n [\n new ApiScope(\"api1\", \"Primary API\"),\n new ApiScope(\"api2.read\", \"Read from API 2\")\n ];\n\n public static IEnumerable<ApiResource> ApiResources =>\n [\n new ApiResource(\"api1-resource\", \"API 1 Resource\")\n {\n Scopes = { \"api1\" }\n }\n ];\n\n public static IEnumerable<IdentityResource> IdentityResources =>\n [\n new IdentityResources.OpenId(),\n new IdentityResources.Profile()\n ];\n\n public static List<TestUser> Users =>\n [\n TestUserBuilder.Active(\"alice\", \"Password1!\")\n .WithClaim(\"email\", \"alice@example.com\")\n .WithClaim(\"role\", \"admin\")\n .Build(),\n\n TestUserBuilder.Active(\"bob\", \"Password1!\")\n .WithClaim(\"email\", \"bob@example.com\")\n .Build()\n ];\n}\n```\n\n### Client Builder\n\n```csharp\npublic sealed class ClientBuilder\n{\n private readonly Client _client = new();\n\n public static ClientBuilder ClientCredentials(string clientId, string secret)\n {\n var builder = new ClientBuilder();\n builder._client.ClientId = clientId;\n builder._client.AllowedGrantTypes = GrantTypes.ClientCredentials;\n builder._client.ClientSecrets = [new Secret(secret.Sha256())];\n return builder;\n }\n\n public static ClientBuilder AuthorizationCode(string clientId, string secret)\n {\n var builder = new ClientBuilder();\n builder._client.ClientId = clientId;\n builder._client.AllowedGrantTypes = GrantTypes.Code;\n builder._client.RequirePkce = true;\n builder._client.ClientSecrets = [new Secret(secret.Sha256())];\n builder._client.AllowOfflineAccess = true;\n return builder;\n }\n\n public ClientBuilder WithScopes(params string[] scopes)\n {\n foreach (var scope in scopes)\n _client.AllowedScopes.Add(scope);\n return this;\n }\n\n public ClientBuilder WithRedirectUri(string uri)\n {\n _client.RedirectUris.Add(uri);\n return this;\n }\n\n public Client Build() => _client;\n}\n```\n\n### TestUser Builder\n\n```csharp\npublic sealed class TestUserBuilder\n{\n private readonly TestUser _user = new();\n\n public static TestUserBuilder Active(string username, string password)\n {\n var builder = new TestUserBuilder();\n builder._user.SubjectId = Guid.NewGuid().ToString(\"N\");\n builder._user.Username = username;\n builder._user.Password = password;\n builder._user.IsActive = true;\n return builder;\n }\n\n public TestUserBuilder WithSubject(string subjectId)\n {\n _user.SubjectId = subjectId;\n return this;\n }\n\n public TestUserBuilder WithClaim(string type, string value)\n {\n _user.Claims.Add(new Claim(type, value));\n return this;\n }\n\n public TestUser Build() => _user;\n}\n```\n\n---\n\n## Pattern 3: Mock Token Issuance\n\nWhen testing a protected API in isolation (no live IdentityServer needed), issue a self-signed JWT in the test and configure the API to trust it. This avoids spinning up an IdentityServer host for every API test.\n\n### Generating a Self-Signed Test Token\n\n```csharp\npublic static class TestTokenFactory\n{\n // Static key shared between the token factory and the test auth configuration\n private static readonly RsaSecurityKey TestSigningKey = CreateRsaKey();\n\n public static SecurityKey SigningKey => TestSigningKey;\n\n private static RsaSecurityKey CreateRsaKey()\n {\n var rsa = RSA.Create(2048);\n return new RsaSecurityKey(rsa) { KeyId = \"test-key-1\" };\n }\n\n public static string CreateAccessToken(\n string subject,\n string audience,\n IEnumerable<Claim> claims,\n TimeSpan? lifetime = null)\n {\n var allClaims = new List<Claim>\n {\n new(JwtClaimTypes.Subject, subject),\n new(JwtClaimTypes.JwtId, Guid.NewGuid().ToString())\n };\n allClaims.AddRange(claims);\n\n var tokenDescriptor = new SecurityTokenDescriptor\n {\n Subject = new ClaimsIdentity(allClaims),\n Audience = audience,\n Issuer = \"https://test-authority\",\n Expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(5)),\n SigningCredentials = new SigningCredentials(\n TestSigningKey,\n SecurityAlgorithms.RsaSha256),\n // ✅ RFC 9068: access tokens must carry typ=at+jwt\n TokenType = \"at+jwt\"\n };\n\n var handler = new JsonWebTokenHandler();\n return handler.CreateToken(tokenDescriptor);\n }\n}\n```\n\n### Configuring the API to Trust the Test Token\n\n```csharp\n// ✅ In WebApplicationFactory for the API project\nprotected override void ConfigureWebHost(IWebHostBuilder builder)\n{\n builder.ConfigureTestServices(services =>\n {\n // Remove production JWT Bearer authentication\n var jwtDescriptor = services.FirstOrDefault(\n d => d.ServiceType == typeof(IConfigureOptions<JwtBearerOptions>));\n if (jwtDescriptor is not null)\n services.Remove(jwtDescriptor);\n\n // Replace with test-friendly JWT Bearer that trusts our static key\n services.AddAuthentication(\"Bearer\")\n .AddJwtBearer(\"Bearer\", options =>\n {\n options.MapInboundClaims = false;\n options.TokenValidationParameters = new TokenValidationParameters\n {\n ValidateIssuerSigningKey = true,\n IssuerSigningKey = TestTokenFactory.SigningKey,\n ValidateIssuer = true,\n ValidIssuer = \"https://test-authority\",\n ValidateAudience = true,\n ValidAudience = \"my-api\",\n ValidateLifetime = true,\n ClockSkew = TimeSpan.Zero\n };\n });\n });\n}\n```\n\n### Using the Test Token in a Test\n\n```csharp\n[Fact]\npublic async Task GetProducts_WithValidToken_ShouldReturn200()\n{\n var token = TestTokenFactory.CreateAccessToken(\n subject: \"user-123\",\n audience: \"my-api\",\n claims: [new Claim(\"scope\", \"api1\"), new Claim(\"role\", \"viewer\")]);\n\n _client.SetBearerToken(token);\n\n var response = await _client.GetAsync(\"/api/products\");\n Assert.Equal(HttpStatusCode.OK, response.StatusCode);\n}\n\n[Fact]\npublic async Task GetProducts_WithoutToken_ShouldReturn401()\n{\n var response = await _client.GetAsync(\"/api/products\");\n Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);\n}\n\n[Fact]\npublic async Task DeleteProduct_WithViewerRole_ShouldReturn403()\n{\n var token = TestTokenFactory.CreateAccessToken(\n subject: \"user-123\",\n audience: \"my-api\",\n claims: [new Claim(\"scope\", \"api1\"), new Claim(\"role\", \"viewer\")]); // ❌ missing \"admin\"\n\n _client.SetBearerToken(token);\n\n var response = await _client.DeleteAsync(\"/api/products/1\");\n Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);\n}\n```\n\n---\n\n## Pattern 4: TestAuthHandler for Protected API Tests\n\nFor APIs that use `[Authorize]`, replace the authentication handler entirely with a `TestAuthHandler` that accepts any pre-built `ClaimsPrincipal`. This gives full control over identity in each test without token serialization.\n\n```csharp\n// ✅ TestAuthHandler — injects a ClaimsPrincipal directly into the pipeline\npublic sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>\n{\n public const string SchemeName = \"Test\";\n\n private readonly ITestClaimsProvider _claimsProvider;\n\n public TestAuthHandler(\n IOptionsMonitor<AuthenticationSchemeOptions> options,\n ILoggerFactory logger,\n UrlEncoder encoder,\n ITestClaimsProvider claimsProvider)\n : base(options, logger, encoder)\n {\n _claimsProvider = claimsProvider;\n }\n\n protected override Task<AuthenticateResult> HandleAuthenticateAsync()\n {\n var claims = _claimsProvider.GetClaims();\n if (claims is null)\n return Task.FromResult(AuthenticateResult.NoResult());\n\n var identity = new ClaimsIdentity(claims, SchemeName);\n var principal = new ClaimsPrincipal(identity);\n var ticket = new AuthenticationTicket(principal, SchemeName);\n\n return Task.FromResult(AuthenticateResult.Success(ticket));\n }\n}\n\n// Swap this per-test to change the authenticated user\npublic interface ITestClaimsProvider\n{\n IEnumerable<Claim>? GetClaims();\n}\n\npublic sealed class TestClaimsProvider : ITestClaimsProvider\n{\n private IEnumerable<Claim>? _claims;\n\n public void SetClaims(IEnumerable<Claim> claims) => _claims = claims;\n public void ClearClaims() => _claims = null;\n public IEnumerable<Claim>? GetClaims() => _claims;\n}\n```\n\n### Factory Registration\n\n```csharp\npublic sealed class ApiFactory : WebApplicationFactory<Program>\n{\n // Expose so tests can configure the identity per-test\n public TestClaimsProvider ClaimsProvider { get; } = new();\n\n protected override void ConfigureWebHost(IWebHostBuilder builder)\n {\n builder.ConfigureTestServices(services =>\n {\n services.AddSingleton<ITestClaimsProvider>(ClaimsProvider);\n\n services.AddAuthentication(TestAuthHandler.SchemeName)\n .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(\n TestAuthHandler.SchemeName, _ => { });\n });\n }\n}\n```\n\n### Test Using the Handler\n\n```csharp\npublic class ProductsApiTests : IClassFixture<ApiFactory>\n{\n private readonly ApiFactory _factory;\n private readonly HttpClient _client;\n\n public ProductsApiTests(ApiFactory factory)\n {\n _factory = factory;\n _client = factory.CreateClient();\n }\n\n [Fact]\n public async Task GetProducts_AsAdmin_ShouldSucceed()\n {\n _factory.ClaimsProvider.SetClaims(\n [\n new Claim(JwtClaimTypes.Subject, \"user-001\"),\n new Claim(JwtClaimTypes.Name, \"Alice\"),\n new Claim(\"role\", \"admin\"),\n new Claim(\"scope\", \"api1\")\n ]);\n\n var response = await _client.GetAsync(\"/api/products\");\n Assert.Equal(HttpStatusCode.OK, response.StatusCode);\n }\n\n [Fact]\n public async Task GetProducts_Unauthenticated_ShouldReturn401()\n {\n _factory.ClaimsProvider.ClearClaims(); // No claims = not authenticated\n\n var response = await _client.GetAsync(\"/api/products\");\n Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);\n }\n}\n```\n\n---\n\n## Pattern 5: Testing IProfileService\n\nUnit test `IProfileService` implementations directly against the `ProfileDataRequestContext` contract. Use real `ProfileDataRequestContext` instances — do not mock the context.\n\n```csharp\npublic class CustomProfileServiceTests\n{\n private readonly CustomProfileService _sut;\n private readonly Mock<IUserRepository> _userRepo;\n\n public CustomProfileServiceTests()\n {\n _userRepo = new Mock<IUserRepository>();\n _sut = new CustomProfileService(_userRepo.Object);\n }\n\n [Fact]\n public async Task GetProfileData_ShouldIncludeRoleClaimsForAccessToken()\n {\n // Arrange: a subject with known sub claim\n var subject = new ClaimsPrincipal(new ClaimsIdentity(\n [\n new Claim(JwtClaimTypes.Subject, \"user-123\")\n ]));\n\n _userRepo\n .Setup(r => r.GetRolesAsync(\"user-123\", CancellationToken.None))\n .ReturnsAsync([\"admin\", \"billing\"]);\n\n var context = new ProfileDataRequestContext(\n subject: subject,\n client: new Client { ClientId = \"test.client\" },\n caller: \"test\",\n requestedClaimTypes: [JwtClaimTypes.Role]);\n\n // Act\n await _sut.GetProfileDataAsync(context);\n\n // Assert\n var roles = context.IssuedClaims\n .Where(c => c.Type == JwtClaimTypes.Role)\n .Select(c => c.Value)\n .ToList();\n\n Assert.Contains(\"admin\", roles);\n Assert.Contains(\"billing\", roles);\n }\n\n [Fact]\n public async Task IsActive_WithDeactivatedUser_ShouldSetIsActiveFalse()\n {\n var subject = new ClaimsPrincipal(new ClaimsIdentity(\n [\n new Claim(JwtClaimTypes.Subject, \"user-deactivated\")\n ]));\n\n _userRepo\n .Setup(r => r.IsActiveAsync(\"user-deactivated\", CancellationToken.None))\n .ReturnsAsync(false);\n\n var context = new IsActiveContext(\n subject: subject,\n client: new Client { ClientId = \"test.client\" },\n caller: \"test\");\n\n await _sut.IsActiveAsync(context);\n\n Assert.False(context.IsActive);\n }\n}\n```\n\n> **Note:** `ProfileDataRequestContext` and `IsActiveContext` constructors are internal to Duende IdentityServer in some versions. If the constructors are inaccessible, test through the in-process `WebApplicationFactory` by issuing a real token and inspecting its claims with `JsonWebTokenHandler`.\n\n---\n\n## Pattern 6: Testing Authorization Policies\n\n### Unit Testing an IAuthorizationHandler\n\nTest `IAuthorizationHandler` implementations in isolation by constructing `AuthorizationHandlerContext` with synthetic claims.\n\n```csharp\npublic class MinimumAgeHandlerTests\n{\n private readonly MinimumAgeHandler _sut = new();\n\n [Fact]\n public async Task HandleRequirement_WithSufficientAge_ShouldSucceed()\n {\n var user = new ClaimsPrincipal(new ClaimsIdentity(\n [\n new Claim(JwtClaimTypes.BirthDate, \"1990-01-01\")\n ], \"Bearer\"));\n\n var requirement = new MinimumAgeRequirement(18);\n var context = new AuthorizationHandlerContext(\n [requirement], user, resource: null);\n\n await _sut.HandleAsync(context);\n\n Assert.True(context.HasSucceeded);\n }\n\n [Fact]\n public async Task HandleRequirement_WithInsufficientAge_ShouldNotSucceed()\n {\n var user = new ClaimsPrincipal(new ClaimsIdentity(\n [\n new Claim(JwtClaimTypes.BirthDate,\n DateTime.UtcNow.AddYears(-10).ToString(\"yyyy-MM-dd\"))\n ], \"Bearer\"));\n\n var requirement = new MinimumAgeRequirement(18);\n var context = new AuthorizationHandlerContext(\n [requirement], user, resource: null);\n\n await _sut.HandleAsync(context);\n\n Assert.False(context.HasSucceeded);\n }\n}\n```\n\n### Integration Testing Policy Enforcement\n\nVerify that policies enforce correctly against real endpoints using `TestAuthHandler`:\n\n```csharp\n[Fact]\npublic async Task AdminEndpoint_WithoutAdminRole_ShouldReturn403()\n{\n _factory.ClaimsProvider.SetClaims(\n [\n new Claim(JwtClaimTypes.Subject, \"user-002\"),\n new Claim(\"role\", \"viewer\") // ❌ not an admin\n ]);\n\n var response = await _client.DeleteAsync(\"/api/admin/users/42\");\n\n Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);\n}\n\n[Fact]\npublic async Task AdminEndpoint_WithAdminRole_ShouldReturn204()\n{\n _factory.ClaimsProvider.SetClaims(\n [\n new Claim(JwtClaimTypes.Subject, \"user-001\"),\n new Claim(\"role\", \"admin\")\n ]);\n\n var response = await _client.DeleteAsync(\"/api/admin/users/42\");\n\n Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);\n}\n```\n\n---\n\n## Pattern 7: Testing BFF Endpoints\n\nBFF tests require cookie-based session simulation using `CookieContainer` + `HttpClientHandler`. Set `AllowAutoRedirect = false` so session redirects don't swallow status codes. Include `x-csrf: 1` header on all BFF local API calls — missing it returns 400. Override the OIDC `OnRedirectToIdentityProvider` event to bypass external redirects in tests.\n\n> See [docs/bff-testing.md](docs/bff-testing.md) for the complete `BffFactory`, `CookieContainer` setup, and antiforgery header test examples.\n\n---\n\n## Pattern 8: Testing with Aspire (Full-Stack)\n\nWire IdentityServer as a named Aspire resource, then use `WaitForResourceHealthyAsync(\"idp\", cts.Token)` before requesting tokens. Obtain `idp` endpoint via `_app.GetEndpoint(\"idp\", \"https\")` and pass it to `RequestClientCredentialsTokenAsync`.\n\n> See [docs/aspire-testing.md](docs/aspire-testing.md) for the complete AppHost wiring and test fixture setup.\n\n---\n\n## Pattern 9: Validating Issued Token Claims\n\nAfter issuing a token through the in-process IdentityServer, parse the JWT and assert on its claims without making a separate network call.\n\n```csharp\n[Fact]\npublic async Task IssuedToken_ShouldContainExpectedClaims()\n{\n var tokenResponse = await _client.RequestClientCredentialsTokenAsync(\n new ClientCredentialsTokenRequest\n {\n Address = \"https://localhost/connect/token\",\n ClientId = \"test.service\",\n ClientSecret = \"test-secret\",\n Scope = \"api1\"\n });\n\n Assert.False(tokenResponse.IsError);\n\n // ✅ Parse without validation (signature not verifiable externally)\n // or configure validation parameters matching the dev signing key\n var handler = new JsonWebTokenHandler();\n var jwt = handler.ReadJsonWebToken(tokenResponse.AccessToken);\n\n Assert.Equal(\"test.service\", jwt.GetClaim(JwtClaimTypes.ClientId).Value);\n Assert.Contains(\"api1\", jwt.GetClaim(JwtClaimTypes.Scope).Value.Split(' '));\n Assert.Equal(\"https://localhost\", jwt.Issuer);\n Assert.True(jwt.ValidTo > DateTime.UtcNow);\n}\n```\n\n---\n\n## Pattern 10: Post-Deployment Login Smoke Test (No Headless Browser)\n\nVerify a real login flow against a deployed environment **without** Playwright/Selenium by driving a cookie-aware `HttpClient` and parsing HTML with **AngleSharp**. This exercises the interactive authorize → login-form → post-back → redirect-back chain end to end.\n\n```\ndotnet add package AngleSharp\n```\n\n```csharp\nusing AngleSharp.Html.Parser;\n\n[Fact]\npublic async Task User_can_log_in_via_the_login_form()\n{\n // ✅ Cookie-aware client so the antiforgery + auth cookies flow across requests\n var cookies = new CookieContainer();\n using var handler = new HttpClientHandler { CookieContainer = cookies };\n using var client = new HttpClient(handler) { BaseAddress = new Uri(\"https://app.example.com\") };\n\n // 1) GET the protected URL — auto-redirects to the IdentityServer login page\n var loginPage = await client.GetAsync(\"/protected\");\n\n // 2) Parse the login HTML and read the antiforgery token from the form\n var html = await loginPage.Content.ReadAsStringAsync();\n var doc = await new HtmlParser().ParseDocumentAsync(html);\n var form = doc.QuerySelector(\"form\")!;\n var antiforgery = form.QuerySelector(\"input[name='__RequestVerificationToken']\")!\n .GetAttribute(\"value\");\n\n // 3) POST credentials to the form's resolved action URL\n var action = new Uri(loginPage.RequestMessage!.RequestUri!, form.GetAttribute(\"action\"));\n var result = await client.PostAsync(action, new FormUrlEncodedContent(new Dictionary<string, string>\n {\n [\"Username\"] = \"alice\",\n [\"Password\"] = \"alice\",\n [\"__RequestVerificationToken\"] = antiforgery!,\n [\"button\"] = \"login\"\n }));\n\n // 4) Success = we ended back on the original protected host (login redirected us home)\n Assert.Equal(new Uri(\"https://app.example.com\").Host,\n result.RequestMessage!.RequestUri!.Host);\n}\n```\n\n> Field names (`Username`, `Password`, `__RequestVerificationToken`, `button=\"login\"`) assume the **default template login form**. Adjust selectors if you customized the login UI. This is a smoke test — it confirms the deployed flow works, not per-claim correctness (use the in-process patterns above for that).\n\n---\n\n## Common Pitfalls\n\n### 1. Not Disabling Automatic Key Management in Tests\n\n```csharp\n// ❌ WRONG — Automatic key management tries to write key files to disk in CI\nservices.AddIdentityServer();\n\n// ✅ CORRECT — Use a static developer key in tests\nservices.AddIdentityServer(options =>\n{\n options.KeyManagement.Enabled = false;\n})\n.AddDeveloperSigningCredential(persistKey: false);\n```\n\n### 2. Disabling Authorization Entirely in Tests\n\n```csharp\n// ❌ WRONG — Removing authorization makes every endpoint open; you can't test 403 behavior\nservices.AddSingleton<IAuthorizationHandler, AllowAllHandler>();\n\n// ✅ CORRECT — Use TestAuthHandler to control the identity per-test\n// Authorization runs normally; only the authentication source changes\n```\n\n### 3. Hard-Coding Localhost Ports\n\n```csharp\n// ❌ WRONG — Port conflicts in CI\nnew ClientCredentialsTokenRequest\n{\n Address = \"http://localhost:5001/connect/token\",\n ...\n}\n\n// ✅ CORRECT — Use the client's BaseAddress via the factory\n_client = factory.CreateClient(); // BaseAddress is set to the test server\nnew ClientCredentialsTokenRequest\n{\n Address = new Uri(_client.BaseAddress!, \"connect/token\").ToString(),\n ...\n}\n```\n\n### 4. Forgetting to Add the openid Scope for Interactive Flows\n\n```csharp\n// ❌ WRONG — Without openid scope, no ID token is returned\nnew Client\n{\n AllowedGrantTypes = GrantTypes.Code,\n AllowedScopes = { \"profile\", \"api1\" } // Missing openid!\n}\n\n// ✅ CORRECT\nnew Client\n{\n AllowedGrantTypes = GrantTypes.Code,\n AllowedScopes =\n {\n IdentityServerConstants.StandardScopes.OpenId,\n IdentityServerConstants.StandardScopes.Profile,\n \"api1\"\n }\n}\n```\n\n### 5. Sharing a Single HttpClient Across Tests with TestAuthHandler\n\n```csharp\n// ❌ WRONG — Identity set in one test bleeds into the next\npublic class MyTests : IClassFixture<ApiFactory>\n{\n private static readonly HttpClient _sharedClient = factory.CreateClient();\n // ClaimsProvider state is shared and can be set by different tests in parallel\n\n// ✅ CORRECT — Create a fresh client per test, or reset ClaimsProvider in IAsyncLifetime\npublic async Task InitializeAsync()\n{\n _factory.ClaimsProvider.ClearClaims();\n await Task.CompletedTask;\n}\n```\n\n### 6. Not Awaiting Token Endpoint During Aspire Startup\n\n```csharp\n// ❌ WRONG — IdentityServer may not be ready when the first test runs\nawait _app.StartAsync(cts.Token);\n// Immediately request a token — connection refused\n\n// ✅ CORRECT — Wait for the identity service to be healthy first\nawait _app.ResourceNotifications.WaitForResourceHealthyAsync(\"idp\", cts.Token);\n```\n\n### 7. Incorrect Audience in Self-Signed Test Tokens\n\n```csharp\n// ❌ WRONG — Audience in token doesn't match API's expected audience\nvar token = TestTokenFactory.CreateAccessToken(\n subject: \"user-1\",\n audience: \"wrong-api\", // API expects \"my-api\"\n claims: []);\n\n// ✅ CORRECT — Audience must match ValidAudience in the token validation parameters\nvar token = TestTokenFactory.CreateAccessToken(\n subject: \"user-1\",\n audience: \"my-api\",\n claims: []);\n```\n\n---\n\n## Resources\n\n- [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/)\n- [Duende IdentityServer Samples — GitHub](https://github.com/DuendeSoftware/Samples/tree/main/IdentityServer)\n- [ASP.NET Core Integration Tests with WebApplicationFactory](https://learn.microsoft.com/aspnet/core/test/integration-tests)\n- [IProfileService Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/services/profile-service/)\n- [Protecting APIs with JWT — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/)\n- [ASP.NET Core Authorization Tests — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies)\n- [IdentityModel Client Library](https://docs.duendesoftware.com/identitymodel/)\n- [Duende BFF Samples](https://docs.duendesoftware.com/bff/samples/)\n"
}SHA-256: 66eb0a83e04dce95bf029a200f4b2b770ba74882526877e3f5ff3eea73040686