Authentication Integration Tests — JWT, Roles and Forbidden Access

Authentication integration tests verify the complete login flow — from credential validation through JWT generation to the cookie-based refresh token mechanism. They also verify that route-level authorization works: protected endpoints return 401 with no token, 403 with an insufficient role, and 200 with the correct role. The JWT test helper generates tokens using the test-specific signing key configured in the factory, allowing tests to bypass the login flow when testing endpoints that require auth.

JWT Test Helper and Auth Tests

// ── JwtTestHelper — generate test tokens without real login ────────────────
public static class JwtTestHelper
{
    public static string GenerateToken(
        string userId,
        string[] roles,
        string key      = "test-signing-key-long-enough-for-hmac-256",
        string issuer   = "test-issuer",
        string audience = "test-audience",
        int    minutesValid = 60)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, userId),
            new(ClaimTypes.Email,          $"{userId}@test.com"),
            new("displayName",             $"Test User {userId}"),
        };
        claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

        var key_    = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        var creds   = new SigningCredentials(key_, SecurityAlgorithms.HmacSha256);
        var token   = new JwtSecurityToken(
            issuer:             issuer,
            audience:           audience,
            claims:             claims,
            notBefore:          DateTime.UtcNow,
            expires:            DateTime.UtcNow.AddMinutes(minutesValid),
            signingCredentials: creds);

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

// ── Auth endpoint integration tests ───────────────────────────────────────
public class AuthEndpointTests : IClassFixture<BlogAppFactory>, IAsyncLifetime
{
    private readonly BlogAppFactory _factory;
    private readonly HttpClient     _client;

    public AuthEndpointTests(BlogAppFactory factory)
    {
        _factory = factory;
        _client  = factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
            HandleCookies     = true,   // needed for refresh token cookie
        });
    }

    public async Task InitializeAsync() => await _factory.ResetDatabaseAsync();
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task Login_WithValidCredentials_ReturnsTokenAndSetsCookie()
    {
        var response = await _client.PostAsJsonAsync("/api/auth/login",
            new { email = "admin@test.com", password = "Admin!123Test" });

        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var auth = await response.Content.ReadFromJsonAsync<AuthResponse>();
        auth.Should().NotBeNull();
        auth!.AccessToken.Should().NotBeNullOrEmpty();
        auth.ExpiresIn.Should().BeGreaterThan(0);

        // Verify httpOnly refresh token cookie is set
        var setCookieHeader = response.Headers
            .Where(h => h.Key == "Set-Cookie")
            .SelectMany(h => h.Value)
            .FirstOrDefault(v => v.Contains("refreshToken"));

        setCookieHeader.Should().NotBeNull();
        setCookieHeader.Should().Contain("HttpOnly");
        setCookieHeader.Should().Contain("Secure");
        setCookieHeader.Should().Contain("Path=/api/auth");
    }

    [Fact]
    public async Task Login_WithWrongPassword_Returns401()
    {
        var response = await _client.PostAsJsonAsync("/api/auth/login",
            new { email = "admin@test.com", password = "WrongPassword123!" });

        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        problem.Should().NotBeNull();
        problem!.Status.Should().Be(401);
    }

    [Fact]
    public async Task ProtectedEndpoint_WithAdminRole_ReturnsOk()
    {
        var client = _factory.CreateAuthenticatedClient(
            userId: "admin-user",
            roles:  ["Admin"]);

        var response = await client.GetAsync("/api/admin/posts");

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

    [Fact]
    public async Task ProtectedEndpoint_WithAuthorRole_Returns403()
    {
        var client = _factory.CreateAuthenticatedClient(
            userId: "regular-user",
            roles:  ["Author"]);  // Not Admin

        var response = await client.GetAsync("/api/admin/posts");

        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }

    [Fact]
    public async Task ProtectedEndpoint_WithNoToken_Returns401()
    {
        var response = await _client.GetAsync("/api/admin/posts");
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }
}
Note: The WebApplicationFactoryClientOptions with HandleCookies = true enables the test HTTP client to store and resend cookies — essential for testing the refresh token flow. Without this, the client discards the Set-Cookie header from the login response, and subsequent calls to /api/auth/refresh fail because the cookie is not sent. The client’s cookie container mimics a browser’s cookie jar for the test session.
Tip: Always test the Set-Cookie response header attributes directly in the login integration test — verify that HttpOnly, Secure, and Path=/api/auth are present. These security attributes cannot be tested at the unit level (they are set in the controller’s Response.Cookies.Append call) and are easy to accidentally remove during refactoring. The integration test is the only level that can verify these cookie attributes.
Warning: Never use the production JWT signing key in integration tests. Configure a separate test-specific key in the factory’s ConfigureTestServices override. This ensures test tokens cannot be used against the production API (they would fail validation since the signing key differs). It also prevents accidentally hardcoding the production key in test code that gets committed to source control. Always use a clearly named test key: "test-signing-key-long-enough-for-hmac-256".

Common Mistakes

❌ Wrong — default HttpClient without cookie handling; refresh test cannot send the cookie; 401 Unauthorized from refresh endpoint.

✅ Correct — HandleCookies = true in WebApplicationFactoryClientOptions for any test that uses cookie-based auth.

Mistake 2 — Using production JWT key in test factory (security risk)

❌ Wrong — test factory uses the real Jwt:Key from appsettings; test tokens could theoretically work against production.

✅ Correct — override Jwt:Key with a test-specific key that is clearly different from production.

🧠 Test Yourself

An integration test creates an authenticated client with roles: ["Author"] and calls POST /api/posts (which has [Authorize] only, no role requirement). What response code?