Integration Testing for ASP.NET APIs (3/3) - Auth
Overview
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.
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!