You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

Testing Authenticated Endpoints

Create test authentication handlers and test secured endpoints with mock JWT tokens

Testing Authenticated Endpoints in ASP.NET

Master testing authenticated endpoints in ASP.NET with free flashcards and hands-on practice. This lesson covers authentication setup in tests, mocking JWT tokens, bypassing authentication for integration tests, and validating authorization policiesβ€”essential skills for building secure, reliable web APIs with .NET 10.

Welcome πŸŽ‰

Welcome to one of the most critical skills in modern ASP.NET development! Testing authenticated endpoints is where security meets quality assurance. While building authentication is important, verifying that it actually works correctly is equally crucial. In this lesson, you'll learn how to write comprehensive tests for endpoints that require authentication and authorization, ensuring your API security is robust and reliable.

πŸ’‘ Why This Matters: Untested authentication logic is a security vulnerability waiting to happen. By the end of this lesson, you'll know how to test everything from JWT validation to role-based authorization, giving you confidence that your secured endpoints work exactly as intended.

Core Concepts πŸ”

Understanding the Testing Challenge

When you protect an endpoint with [Authorize] or role-based attributes, testing becomes more complex. Your tests need to simulate authenticated users without actually running a full authentication flow. There are several approaches, each with specific use cases:

Approach Use Case Complexity
Mocking Authentication Unit tests, fast feedback Low
WebApplicationFactory with Test Auth Integration tests, realistic scenarios Medium
Generating Real JWT Tokens End-to-end tests, full validation High

The Authentication Pipeline in ASP.NET

Before diving into testing, let's understand what happens when a request hits an authenticated endpoint:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚        AUTHENTICATION REQUEST FLOW              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  πŸ“¨ HTTP Request with Bearer Token
           β”‚
           ↓
  πŸ” Authentication Middleware
           β”‚
           β”œβ”€β”€β†’ ❌ Invalid Token? β†’ 401 Unauthorized
           β”‚
           ↓
  βœ… Create ClaimsPrincipal
           β”‚
           ↓
  πŸ›‘οΈ Authorization Middleware
           β”‚
           β”œβ”€β”€β†’ ❌ Insufficient Rights? β†’ 403 Forbidden
           β”‚
           ↓
  🎯 Controller Action Executes
           β”‚
           ↓
  πŸ“€ Response Returned

Your tests need to intercept or simulate different parts of this pipeline depending on what you're testing.

WebApplicationFactory: Your Testing Foundation

The WebApplicationFactory class from Microsoft.AspNetCore.Mvc.Testing is the cornerstone of integration testing in ASP.NET. It creates an in-memory test server that hosts your actual application:

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Override services for testing
        });
    }
}

πŸ’‘ Key Insight: This factory creates a real HTTP server in memory, so your tests use actual HTTP requests and responsesβ€”but without network overhead.

Mocking Authentication with Test Schemes

The most flexible approach for integration tests is creating a test authentication scheme. This bypasses real token validation while still exercising the authorization pipeline:

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder) : base(options, logger, encoder)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.Name, "TestUser"),
            new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
            new Claim(ClaimTypes.Role, "Admin")
        };
        
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

What's happening here?

  • The handler always succeeds authentication
  • It creates a ClaimsPrincipal with predefined claims
  • No token validation occursβ€”perfect for controlled testing

Registering the Test Authentication Scheme

You configure the test scheme in your WebApplicationFactory:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        services.AddAuthentication("TestScheme")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                "TestScheme", options => { });
    });
}

⚠️ Important: Use ConfigureTestServices instead of ConfigureServices to ensure test services are registered after production services, allowing proper override.

Creating Authenticated HTTP Clients

Once your test authentication is set up, create HTTP clients that include the authentication scheme:

[Fact]
public async Task GetProtectedResource_WithAuthentication_ReturnsSuccess()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("TestScheme");

    var response = await client.GetAsync("/api/protected");
    
    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

Testing with Real JWT Tokens

For more realistic testing, you can generate actual JWT tokens:

public class JwtTokenGenerator
{
    public string GenerateToken(string userId, string[] roles)
    {
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes("your-super-secret-key-min-32-chars"));
        var credentials = new SigningCredentials(
            securityKey, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };
        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var token = new JwtSecurityToken(
            issuer: "TestIssuer",
            audience: "TestAudience",
            claims: claims,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

🧠 Mnemonic - JWT Testing: "SICK" tokens need validation:

  • Signature
  • Issuer
  • Claims
  • Key (secret)

Testing Authorization Policies

ASP.NET supports policy-based authorization, which you also need to test:

// In Startup/Program.cs
services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAdminRole", policy =>
        policy.RequireRole("Admin"));
    options.AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Controller
[Authorize(Policy = "RequireAdminRole")]
public IActionResult AdminOnly() => Ok("Admin access");

Testing this requires configuring claims that satisfy the policy:

public class TestAuthHandlerWithAge : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly int _age;

    public TestAuthHandlerWithAge(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        int age) : base(options, logger, encoder)
    {
        _age = age;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.Name, "TestUser"),
            new Claim("age", _age.ToString())
        };
        
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Testing Unauthorized Access

Don't forget to test the negative casesβ€”what happens when authentication fails:

[Fact]
public async Task GetProtectedResource_WithoutAuthentication_Returns401()
{
    var client = _factory.CreateClient();
    // No authentication header set

    var response = await client.GetAsync("/api/protected");
    
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task AdminEndpoint_AsRegularUser_Returns403()
{
    // Create client with non-admin user claims
    var client = CreateClientWithRole("User");

    var response = await client.GetAsync("/api/admin/dashboard");
    
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Practical Examples πŸ”§

Example 1: Complete Integration Test Setup

Let's build a complete test project structure:

// CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    public string TestUserId { get; set; } = "test-user-123";
    public string[] TestUserRoles { get; set; } = new[] { "User" };

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove existing authentication
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(IAuthenticationService));
            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            // Add test authentication
            services.AddAuthentication("TestScheme")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    "TestScheme", options => { });

            // Provide test user info to the handler
            services.AddSingleton<TestUserProvider>(sp => 
                new TestUserProvider
                {
                    UserId = TestUserId,
                    Roles = TestUserRoles
                });
        });
    }
}

// TestUserProvider.cs
public class TestUserProvider
{
    public string UserId { get; set; }
    public string[] Roles { get; set; }
}

// TestAuthHandler.cs
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly TestUserProvider _userProvider;

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        TestUserProvider userProvider) : base(options, logger, encoder)
    {
        _userProvider = userProvider;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, _userProvider.UserId),
            new Claim(ClaimTypes.Name, $"Test User {_userProvider.UserId}")
        };

        claims.AddRange(_userProvider.Roles.Select(role => 
            new Claim(ClaimTypes.Role, role)));

        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// UserProfileTests.cs
public class UserProfileTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly HttpClient _client;

    public UserProfileTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetCurrentUser_WithAuthentication_ReturnsUserProfile()
    {
        // Arrange
        _factory.TestUserId = "user-456";
        _factory.TestUserRoles = new[] { "Premium" };
        _client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("TestScheme");

        // Act
        var response = await _client.GetAsync("/api/users/me");
        var content = await response.Content.ReadAsStringAsync();
        var user = JsonSerializer.Deserialize<UserDto>(content);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        user.Id.Should().Be("user-456");
    }
}

What makes this great?

  • βœ… Reusable factory for all test classes
  • βœ… Configurable test user properties
  • βœ… Clean separation of concerns
  • βœ… Easy to extend for different scenarios

Example 2: Testing Role-Based Authorization

public class AdminEndpointTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;

    public AdminEndpointTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
    }

    private HttpClient CreateAuthenticatedClient(string[] roles)
    {
        _factory.TestUserRoles = roles;
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("TestScheme");
        return client;
    }

    [Theory]
    [InlineData(new[] { "Admin" }, HttpStatusCode.OK)]
    [InlineData(new[] { "User" }, HttpStatusCode.Forbidden)]
    [InlineData(new[] { "Moderator" }, HttpStatusCode.Forbidden)]
    public async Task DeleteUser_WithDifferentRoles_ReturnsExpectedStatus(
        string[] roles, HttpStatusCode expectedStatus)
    {
        // Arrange
        var client = CreateAuthenticatedClient(roles);

        // Act
        var response = await client.DeleteAsync("/api/admin/users/123");

        // Assert
        response.StatusCode.Should().Be(expectedStatus);
    }

    [Fact]
    public async Task GetAdminDashboard_AsAdmin_ReturnsStatistics()
    {
        // Arrange
        var client = CreateAuthenticatedClient(new[] { "Admin" });

        // Act
        var response = await client.GetAsync("/api/admin/dashboard");
        var content = await response.Content.ReadAsStringAsync();
        var stats = JsonSerializer.Deserialize<DashboardStats>(content);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        stats.Should().NotBeNull();
        stats.TotalUsers.Should().BeGreaterThan(0);
    }
}

πŸ”” Did you know? ASP.NET evaluates authorization in a specific order: authentication schemes first, then policies, then individual role/claim requirements. Understanding this order helps you debug authorization failures!

Example 3: Testing Custom Authorization Requirements

// Custom Requirement
public class MinimumAccountAgeRequirement : IAuthorizationRequirement
{
    public int MinimumDays { get; }
    
    public MinimumAccountAgeRequirement(int minimumDays)
    {
        MinimumDays = minimumDays;
    }
}

// Handler
public class MinimumAccountAgeHandler : 
    AuthorizationHandler<MinimumAccountAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAccountAgeRequirement requirement)
    {
        var accountCreatedClaim = context.User.FindFirst("AccountCreated");
        
        if (accountCreatedClaim == null)
        {
            return Task.CompletedTask;
        }

        var accountCreated = DateTime.Parse(accountCreatedClaim.Value);
        var accountAge = (DateTime.UtcNow - accountCreated).TotalDays;

        if (accountAge >= requirement.MinimumDays)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

// Test
public class AccountAgeAuthorizationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;

    public AccountAgeAuthorizationTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData(-10, HttpStatusCode.Forbidden)]  // 10 days in the future (invalid)
    [InlineData(5, HttpStatusCode.Forbidden)]     // Only 5 days old
    [InlineData(30, HttpStatusCode.OK)]           // 30 days old - allowed
    [InlineData(365, HttpStatusCode.OK)]          // 1 year old - allowed
    public async Task AccessPremiumFeature_WithDifferentAccountAge_ReturnsExpectedStatus(
        int daysOld, HttpStatusCode expectedStatus)
    {
        // Arrange
        var accountCreated = DateTime.UtcNow.AddDays(-daysOld);
        
        var handler = new TestAuthHandlerWithAccountAge(
            accountCreated,
            Mock.Of<IOptionsMonitor<AuthenticationSchemeOptions>>(),
            Mock.Of<ILoggerFactory>(),
            Mock.Of<UrlEncoder>());

        // Configure factory to use this handler
        _factory.TestAccountCreated = accountCreated;
        
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("TestScheme");

        // Act
        var response = await client.GetAsync("/api/premium/feature");

        // Assert
        response.StatusCode.Should().Be(expectedStatus);
    }
}

// Modified test handler to support account age
public class TestAuthHandlerWithAccountAge : 
    AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly DateTime _accountCreated;

    public TestAuthHandlerWithAccountAge(
        DateTime accountCreated,
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder) : base(options, logger, encoder)
    {
        _accountCreated = accountCreated;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user"),
            new Claim("AccountCreated", _accountCreated.ToString("O"))
        };

        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

πŸ’‘ Pro Tip: When testing custom authorization requirements, create separate test auth handlers for each scenario. This gives you fine-grained control over the claims being tested.

Example 4: Testing JWT Token Validation

public class JwtAuthenticationTests
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly string _secretKey = "super-secret-key-for-testing-minimum-32-characters";
    private readonly string _issuer = "TestIssuer";
    private readonly string _audience = "TestAudience";

    public JwtAuthenticationTests()
    {
        _factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureAppConfiguration((context, config) =>
                {
                    config.AddInMemoryCollection(new Dictionary<string, string>
                    {
                        ["Jwt:Key"] = _secretKey,
                        ["Jwt:Issuer"] = _issuer,
                        ["Jwt:Audience"] = _audience
                    });
                });
            });
    }

    private string GenerateJwtToken(string userId, string[] roles, 
        DateTime? expiration = null)
    {
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_secretKey));
        var credentials = new SigningCredentials(
            securityKey, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };
        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var token = new JwtSecurityToken(
            issuer: _issuer,
            audience: _audience,
            claims: claims,
            expires: expiration ?? DateTime.UtcNow.AddHours(1),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    [Fact]
    public async Task GetProtectedResource_WithValidJwt_ReturnsSuccess()
    {
        // Arrange
        var token = GenerateJwtToken("user-123", new[] { "User" });
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await client.GetAsync("/api/protected");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task GetProtectedResource_WithExpiredJwt_Returns401()
    {
        // Arrange
        var expiredTime = DateTime.UtcNow.AddHours(-1);
        var token = GenerateJwtToken("user-123", new[] { "User" }, expiredTime);
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await client.GetAsync("/api/protected");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task GetProtectedResource_WithInvalidSignature_Returns401()
    {
        // Arrange
        var wrongKey = "different-secret-key-that-wont-validate-properly";
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(wrongKey));
        var credentials = new SigningCredentials(
            securityKey, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _issuer,
            audience: _audience,
            claims: new[] { new Claim(ClaimTypes.NameIdentifier, "user-123") },
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: credentials);

        var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
        
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Bearer", tokenString);

        // Act
        var response = await client.GetAsync("/api/protected");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }
}

Common Mistakes ⚠️

Mistake 1: Not Using ConfigureTestServices

❌ Wrong:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureServices(services =>
    {
        services.AddAuthentication("TestScheme")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                "TestScheme", options => { });
    });
}

βœ… Correct:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        services.AddAuthentication("TestScheme")
            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                "TestScheme", options => { });
    });
}

Why it matters: ConfigureServices runs before the production configuration, meaning your test authentication might be overridden. ConfigureTestServices runs last, ensuring your test setup takes precedence.

Mistake 2: Forgetting to Set the Authorization Header

❌ Wrong:

[Fact]
public async Task GetProtectedResource_ReturnsSuccess()
{
    var client = _factory.CreateClient();
    var response = await client.GetAsync("/api/protected");
    // Returns 401 because no auth header!
}

βœ… Correct:

[Fact]
public async Task GetProtectedResource_ReturnsSuccess()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("TestScheme");
    var response = await client.GetAsync("/api/protected");
}

Mistake 3: Not Testing Both Success and Failure Cases

❌ Incomplete:

[Fact]
public async Task AdminEndpoint_AsAdmin_ReturnsSuccess()
{
    var client = CreateClientWithRole("Admin");
    var response = await client.GetAsync("/api/admin");
    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

βœ… Complete:

[Theory]
[InlineData("Admin", HttpStatusCode.OK)]
[InlineData("User", HttpStatusCode.Forbidden)]
[InlineData("Guest", HttpStatusCode.Forbidden)]
public async Task AdminEndpoint_WithDifferentRoles_ReturnsExpectedStatus(
    string role, HttpStatusCode expectedStatus)
{
    var client = CreateClientWithRole(role);
    var response = await client.GetAsync("/api/admin");
    response.StatusCode.Should().Be(expectedStatus);
}

[Fact]
public async Task AdminEndpoint_WithoutAuth_Returns401()
{
    var client = _factory.CreateClient();
    var response = await client.GetAsync("/api/admin");
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

Mistake 4: Hardcoding Claims in Every Test

❌ Repetitive:

[Fact]
public async Task Test1()
{
    var claims = new[] { new Claim(ClaimTypes.Role, "Admin") };
    // Setup handler with claims...
}

[Fact]
public async Task Test2()
{
    var claims = new[] { new Claim(ClaimTypes.Role, "Admin") };
    // Setup handler with claims...
}

βœ… Reusable:

public class AuthenticatedTestBase
{
    protected HttpClient CreateClientWithRole(string role)
    {
        _factory.TestUserRoles = new[] { role };
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("TestScheme");
        return client;
    }

    protected HttpClient CreateClientWithClaims(params Claim[] claims)
    {
        _factory.TestUserClaims = claims;
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("TestScheme");
        return client;
    }
}

Mistake 5: Not Isolating Database State Between Tests

❌ Problematic:

[Fact]
public async Task CreateUser_AsAdmin_CreatesUser()
{
    var client = CreateClientWithRole("Admin");
    var response = await client.PostAsync("/api/users", content);
    // User remains in database for next test!
}

[Fact]
public async Task GetUsers_ReturnsAllUsers()
{
    var client = CreateClientWithRole("Admin");
    var response = await client.GetAsync("/api/users");
    // Might include user from previous test
}

βœ… Isolated:

public class UserTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;

    public UserTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        // Reset database before each test
        _factory.ResetDatabase();
    }

    [Fact]
    public async Task CreateUser_AsAdmin_CreatesUser()
    {
        var client = CreateClientWithRole("Admin");
        var response = await client.PostAsync("/api/users", content);
        // Database is reset before next test
    }
}

Key Takeaways 🎯

  1. WebApplicationFactory is your foundation - Use it to create in-memory test servers that exercise your real application code while maintaining control over authentication.

  2. Test authentication schemes are powerful - Creating a custom AuthenticationHandler lets you bypass token validation while still testing authorization logic.

  3. Test both positive and negative cases - Don't just test successful authentication; verify that unauthorized and forbidden responses work correctly too.

  4. Use Theory tests for role variations - Testing multiple roles and permissions is cleaner with [Theory] and [InlineData] attributes.

  5. Real JWTs for integration tests - When you need complete validation, generate actual JWT tokens with proper signing keys.

  6. Isolate your tests - Reset database state and use fresh HTTP clients to prevent tests from interfering with each other.

  7. ConfigureTestServices over ConfigureServices - Always use ConfigureTestServices to ensure your test setup overrides production configuration.

  8. Create reusable helper methods - Build utility methods for creating authenticated clients to reduce test code duplication.

πŸ“š Further Study

πŸ“‹ Quick Reference Card

WebApplicationFactoryCreates in-memory test server for integration tests
ConfigureTestServicesOverride services AFTER production config (use this!)
TestAuthHandlerCustom handler that bypasses real authentication
ClaimsPrincipalRepresents authenticated user with claims
Authorization HeaderSet via client.DefaultRequestHeaders.Authorization
Test JWT GenerationUse JwtSecurityTokenHandler + SymmetricSecurityKey
Status Codes401 = Unauthorized, 403 = Forbidden, 200 = OK
Theory TestsUse [Theory] + [InlineData] for multiple roles

Practice Questions

Test your understanding with these questions:

Q1: Complete the method to configure test authentication services: ```csharp protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.{{1}}(services => { services.AddAuthentication("TestScheme") .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>( "TestScheme", options => { }); }); } ```
A: ConfigureTestServices
Q2: What HTTP status code is returned when a user tries to access an endpoint without providing authentication credentials? A. 200 OK B. 400 Bad Request C. 401 Unauthorized D. 403 Forbidden E. 404 Not Found
A: C
Q3: Complete the authentication handler method: ```csharp protected override Task<AuthenticateResult> HandleAuthenticateAsync() { var claims = new[] { new Claim(ClaimTypes.Name, "TestUser") }; var identity = new ClaimsIdentity(claims, "Test"); var principal = new {{1}}(identity); var ticket = new AuthenticationTicket(principal, "TestScheme"); return Task.FromResult(AuthenticateResult.{{2}}(ticket)); } ```
A: ["ClaimsPrincipal","Success"]
Q4: Fill in the claim type for adding a user role: ```csharp new Claim(ClaimTypes.{{1}}, "Admin") ```
A: Role
Q5: What does this test verify? ```csharp [Fact] public async Task AdminEndpoint_AsRegularUser_Returns403() { var client = CreateClientWithRole("User"); var response = await client.GetAsync("/api/admin/dashboard"); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } ``` A. Authentication works correctly B. User cannot access without logging in C. Authorized user lacks permission for resource D. Endpoint returns correct data E. JWT token is valid
A: C