Integration Testing for ASP.NET APIs (3/3) - Auth

Overview

Where's the code?

Fully working examples for this code are in the 04-api-with-postgres-and-auth folder of the GitHub repo with the examples for this series of posts.

In the previous post we got started with some basic integration tests. In this one we'll require authentication in the API and write tests to ensure it's working.

API Changes

In the API I added JWT bearer token (OAuth2) authentication and am requiring authenticated callers for both the WeatherForecast and Products routes.

Most of that code is right in Program.cs for the API:

 1JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
 2builder.Services.AddAuthentication("Bearer")
 3    .AddJwtBearer("Bearer", options =>
 4    {
 5        options.Authority = "https://demo.duendesoftware.com";
 6        options.Audience = "api";
 7        options.TokenValidationParameters = new TokenValidationParameters
 8        {
 9            NameClaimType = "email",
10            RoleClaimType = "role"
11        };
12        options.SaveToken = true;
13    });
14    
15    //...
16    app.UseAuthentication();
17    app.UseSerilogRequestLogging();
18    app.UseAuthorization();
19
20    app.MapControllers().RequireAuthorization();
21
22    app.MapGet("/weatherforecast", [Authorize]
23    //...

I'm accepting JWT bearer tokens from the demo Duende Identity Server (super helpful for demos and simple testing btw).

Then note the RequireAuthorization() method on the end of MapControllers() as well as the [Authorize] attribute on the MapGet("/weatherforecast") call. Those lines will require us to have an authenticated caller for all of the API methods.

To verify this, try the .http file when the API is running.

It has a POST call at the top to get an access (bearer) token from the identity server, and then you can paste the result into the value for the token variable.

Make the calls with and without this bearer token - if you don't have a valid one you should get 401 Unauthorized responses and the calls should work normally if you provide a valid token.

One other nuance is that I added the following attribute to the POST Products method:

1[Authorize(Roles = "admin")]

This goes further than simply requiring an authenticated caller and ALSO requires that they belong to the admin role in order to create a product.

If you wanted to test this in real code from the identity server you would need to implement some "claims transformation" logic.

Fortunately, we can use another technique to perform integration tests against our simple auth logic that completely bypasses the identity server (keeping these tests fast).

IStartupFilter with Custom Middleware

We can customize the WebApplicationFactory using the techniques we described in the previous posts of this series.

To add some custom "auto-authentication" logic, you can have code something like this:

 1public class CustomApiFactory<TProgram>(DatabaseFixture dbFixture) 
 2    : WebApplicationFactory<TProgram> where TProgram : class
 3{
 4    protected override void ConfigureWebHost(IWebHostBuilder builder)
 5    {
 6        //... other logic 
 7
 8        builder.ConfigureServices(services => 
 9        {
10            services.AddSingleton<IStartupFilter>(new AutoAuthorizeStartupFilter());
11        });
12    }
13}

Note the highlighted addition of the IStartupFilter.

The AutoAuthorizeStartupFilter is something that we need to provide code for - and this is where we can include some custom logic.

Credit Where Credit is Due

The AutoAuthorizeStartupFilter described here was something I discovered while exploring the new Aspire version of the eShop reference architecture - in the tests/Ordering.FunctionalTests project. Thanks, eShop team! :)

All of the code for the authorization logic is in the Utilities/AuthorizationHelper.cs file in the code repo.

 1internal class AutoAuthorizeStartupFilter : IStartupFilter
 2{
 3    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
 4    {
 5        return builder =>
 6        {
 7            builder.UseMiddleware<AutoAuthorizeMiddleware>();
 8            next(builder);
 9        };
10    }
11}
12internal class AutoAuthorizeMiddleware(RequestDelegate rd)
13{
14    public async Task Invoke(HttpContext httpContext)
15    {
16        var identity = new ClaimsIdentity("Bearer");
17
18        identity.AddClaim(new Claim("sub", "1234567"));
19        identity.AddClaim(new Claim(ClaimTypes.Name, "test-name"));
20
21        httpContext.User.AddIdentity(identity);
22        await rd.Invoke(httpContext);
23    }
24}

The AutoAuthorizeStartupFilter is what we referenced in the CustomApiFactory code -- that adds some middleware called AutoAuthorizeMiddleware which is where the real logic comes into play.

I think you'll appreciate that this code is pretty simple and can be what you want it to be. It creates a ClaimsIdentity, adds some claims to it, and then adds the created identity to the User of the HttpContext. Voila!

Any tests that uses the CustomApiFactory will have an authenticated user with the claims specified in the code above - you don't even need to adjust the tests!

Testing Anonymous Users

When you add authentication requirements to your app, you should make sure that if you don't have an authenticated user that the caller is denied access to the resource in question.

That means we need to have a different CustomApiFactory that doesn't include this auto-authorize middleware.

To achieve this, I created a new class called AnonymousApiFactory and also a static class called ApiFactoryExtensions.

Here's the AnonymousApiFactory code:

1public class AnonymousApiFactory<TProgram>(DatabaseFixture dbFixture) : WebApplicationFactory<TProgram>
2    where TProgram : class
3{
4    protected override void ConfigureWebHost(IWebHostBuilder builder)
5    {
6        builder.UseEnvironment("test");
7        builder.SwapDatabase(dbFixture.TestConnectionString);
8    }
9}

The SwapDatabase method is something I put in the ApiFactoryExtensions class and the code is something I described in the previous post to use a TestContainer for Postgres instead of the normal Postgres instance used by the API.

Of note is the absence of the addition of the IStartupFilter here. But the rest of this code is the same.

Then with this AnonymousApiFactory, we can use that in some tests to make sure that anonymous access to the API methods is not allowed:

 1[Collection("IntegrationTests")]
 2public class AnonymousTests(AnonymousApiFactory<Program> factory, ITestOutputHelper output)
 3    : BaseTest(factory, output), IClassFixture<AnonymousApiFactory<Program>>
 4{
 5    [Fact]
 6    public async Task AnonymousGetWeatherShouldReturnUnauthorized()
 7    {
 8        var response = await Client.GetAsync("/weatherForecast?postalCode=12345");
 9        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
10    }

Pretty straight-forward - note the primary constructor parameter for the AnonymousApiFactory<Program>.

Some Code Cleanup

Since we have an AnonymousApiFactory that has most of the code we want, we can update our CustomApiFactory to inherit from that, and then include the custom IStartupFilter:

 1public class CustomApiFactory<TProgram>(DatabaseFixture dbFixture) : AnonymousApiFactory<TProgram>(dbFixture)
 2    where TProgram : class
 3{
 4    protected override void ConfigureWebHost(IWebHostBuilder builder)
 5    {
 6        base.ConfigureWebHost(builder);
 7
 8        builder.ConfigureServices(services => 
 9        {
10            services.AddSingleton<IStartupFilter>(new AutoAuthorizeStartupFilter());
11        });
12    }
13}

The important line here is the one that calls base.ConfigureWebHost - that will include the logic from the AnonymousApiFactory here and then after that adds in the IStartupFilter.

Testing Authorization (Roles, etc)

The POST Products method has that [Authorize(Roles="admin")] attribute that we should test.

And we would want to test both positive and negative cases here.

To accommodate these tests, I updated the AutoAuthorizeMiddleware to look for some specific HTTP headers in the request (remember that this is for our tests, not in the real API code) and creating claims based on those http headers.

Here's the code (in AuthorizationHelper.cs):

 1private static IEnumerable<Claim> GetClaimsBasedOnHttpHeaders(HttpContext context)
 2{
 3    const string headerPrefix = "X-Test-";
 4
 5    var claims = new List<Claim>();
 6
 7    var claimHeaders = context.Request.Headers.Keys.Where(k => k.StartsWith(headerPrefix));
 8    foreach (var header in claimHeaders)
 9    {
10        var value = context.Request.Headers[header];
11        var claimType = header[headerPrefix.Length..];
12        if (!string.IsNullOrEmpty(value))
13        {
14            claims.Add(new Claim(claimType == "role" ? ClaimTypes.Role : claimType, value!));
15        }
16    }
17    return claims;
18}

The code looks for header that look like X-Test-** and then creates claims based on those headers with some special handling for X-Test-role which uses ClaimTypes.Role for the claim type.

In the BaseTest class, I created a new HttpClient called AdminCLient, and in the constructor:

1AdminClient = factory.CreateClient();
2AdminClient.DefaultRequestHeaders.Add("X-Test-role", "admin");

Now any time I want to make an API call with an admin role I can simply reference the AdminClient - see this example:

 1[Fact(DisplayName = "Create Product multiple validation errors")]
 2public async Task CreateProductMultipleValidations()
 3{
 4    var newProduct = dbFixture.ProductFaker.Generate();
 5    newProduct.Name = "";
 6    newProduct.Category = "";
 7
 8    var problem = await AdminClient.PostJsonForResultAsync<ProblemDetails>($"/v1/products", newProduct,
 9        HttpStatusCode.BadRequest);
10
11    Assert.Equal("Validation error(s) occurred.", problem.Title);
12    Assert.Equal("Name is required.", problem.Extensions["Name"]!.ToString());
13    Assert.Equal("Category is required.", problem.Extensions["Category"]!.ToString());
14}

And to test non-admins is pretty simple, too - the following code uses the regular Client (no admin header) with and without role headers:

 1[Theory(DisplayName = "Non-Admin role cannot create product")]
 2[InlineData("")]
 3[InlineData("non-admin")]
 4public async Task NonAdminCannotCreateProduct(string role)
 5{
 6    var newProduct = dbFixture.ProductFaker.Generate();
 7
 8    if (!string.IsNullOrEmpty(role))
 9    {
10        Client.DefaultRequestHeaders.Add("X-Test-role", role);
11    }
12    var response = await Client.PostAsJsonAsync($"/v1/products", newProduct);
13    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
14}

Write Some Tests!

This series of posts offered up some guidance and suggestions about how to create some effective and easy-to-read integration tests for ASP.NET Core APIs.

Try them out, and your feedback is welcome.

I'll be adding one more (bonus!!) post to this series that will show how to review code coverage for the tests we've got.

Happy coding and testing!