System Testing ASP.NET Core APIs using XUnit

This article shows how an ASP.NET Core API could be tested using system tests implemented using XUnit. The API is protected using JWT Bearer token authorization, and the API uses a secure token server to validate the API requests. When running the tests, the access token needs to be requested, and used to access the APIs. We can then validate, for example if invalid tokens are rejected.

Code: https://github.com/damienbod/AspNetCoreMvcProtobufFormatters

The demo sofware has two solutions. The target software has an APP which implements the API and a secure token service (STS) which provides and validates the tokens. The second solution is for the system tests. The tests will only work when both the API and the STS are running. We need to run and test the tests locally and well as when the solution is deployed. This means it must be possible to configure different URLs and secrets for the different deployments, so that the tests can be run in each of the different dev, test, production deployments, or whatever system you have.

The API is implemented and secured using the Authorize attribute. Only valid requests with the correct authorization can access this API.

[Authorize]
[Route("api/[controller]")]
public class ValuesController : Controller
{
	// GET api/values/5
	[HttpGet("{id}")]
	public ProtobufModelDto Get(int id)
	{
		return new ProtobufModelDto() { 
		  Id = 1, Name = "HelloWorld", 
		  StringValue = "My first MVC 6 Protobuf service" 
		};
	}

The Startup class ConfigureServices method implements the authentication and authorization settings for the API. The API only accepts access token from the correct STS and tokens which have the apiproto scope, otherwise the request will not be authorized.

public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	  .AddIdentityServerAuthentication(options =>
	  {
		  options.Authority = "https://localhost:44318";
		  options.ApiName = "apiproto";
		  options.ApiSecret = "apiprotoSecret";
	  });

	services.AddAuthorization(options =>
	   options.AddPolicy("RequiredScope", policy =>
	   {
		   policy.RequireClaim("scope", "apiproto");
	   })
	);

	...
}

Test Setup

The system tests are setup to read app settings for the different URLs and secrets, so that it can be run against the different deployments.

private readonly HttpClient _client;
private readonly ApiTokenInMemoryClient  _tokenService;
private readonly IConfigurationRoot _configurationRoot;

public ProtobufApiTests()
{
	_configurationRoot = GetIConfigurationRoot();
	//Arrange;
	_client = new HttpClient
	{
		BaseAddress = new System.Uri(_configurationRoot["ApiUrl"])
	};

	_tokenService = 
	  new ApiTokenInMemoryClient(
	    _configurationRoot["StsUrl"], 
		new HttpClient());
}

The access token is requested using the SetTokenAsync method.

private async Task SetTokenAsync(HttpClient client)
{
	var access_token = await _tokenService.GetApiToken(
			"ClientProtectedApi",
			"apiproto",
			_configurationRoot["ApiSecret"]
		);

	client.SetBearerToken(access_token);
}

The token service is used to get an access token for the correct scope, secret and API.

using IdentityModel.Client;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

namespace AspNetCoreProtobuf.Tests
{
    public class ApiTokenInMemoryClient
    {
        private readonly HttpClient _httpClient;
        private readonly string _stsServerUrl;

        private class AccessTokenItem
        {
            public string AccessToken { get; set; } = string.Empty;
            public DateTime ExpiresIn { get; set; }
        }

        private ConcurrentDictionary<string, AccessTokenItem> _accessTokens = new ConcurrentDictionary<string, AccessTokenItem>();

        public ApiTokenInMemoryClient(
            string stsServerUrl,
            HttpClient httpClient)
        {
            _httpClient = httpClient;
            _stsServerUrl = stsServerUrl;
        }

        public async Task<string> GetApiToken(string api_name, string api_scope, string secret)
        {
            if (_accessTokens.ContainsKey(api_name))
            {
                var accessToken = _accessTokens.GetValueOrDefault(api_name);
                if (accessToken.ExpiresIn > DateTime.UtcNow)
                {
                    return accessToken.AccessToken;
                }
                else
                {
                    // remove
                    _accessTokens.TryRemove(api_name, out AccessTokenItem accessTokenItem);
                }
            }

            var newAccessToken = await getApiToken( api_name,  api_scope,  secret);
            _accessTokens.TryAdd(api_name, newAccessToken);

            return newAccessToken.AccessToken;
        }

        private async Task<AccessTokenItem> getApiToken(string api_name, string api_scope, string secret)
        {
            try
            {
                var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
                    _httpClient,
                    _stsServerUrl);

                if (disco.IsError)
                {
                    throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
                }

                var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest
                {
                    Scope = api_scope,
                    ClientSecret = secret,
                    Address = disco.TokenEndpoint,
                    ClientId = api_name
                });

                if (tokenResponse.IsError)
                {
                    throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
                }

                return new AccessTokenItem
                {
                    ExpiresIn = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn),
                    AccessToken = tokenResponse.AccessToken
                };
                
            }
            catch (Exception e)
            {
                throw new ApplicationException($"Exception {e}");
            }
        }
    }
}

The Tests

Once the system tests are setup and have an access token, the tests can be run. The following test checks if the basic GET request works with the correct token.

[Fact]
public async Task GetProtobufDataAsString()
{
	await SetTokenAsync(_client);
	// Act
	_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-protobuf"));
	var response = await _client.GetAsync("/api/values/1");
	response.EnsureSuccessStatusCode();

	var responseString = System.Text.Encoding.UTF8.GetString(
		await response.Content.ReadAsByteArrayAsync()
	);

	// Assert
	Assert.Equal("application/x-protobuf", response.Content.Headers.ContentType.MediaType);
	Assert.Equal("\b\u0001\u0012\nHelloWorld\u001a\u001fMy first MVC 6 Protobuf service", responseString);
}

The API can also be tested that a 401 is returned, when no token is sent.

[Fact]
public async Task Get401ForNoToken()
{
	// Act
	_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-protobuf"));
	var response = await _client.GetAsync("/api/values/1");

	// Assert
	Assert.Equal("Unauthorized", response.StatusCode.ToString());
}

The following test uses a valid token, but one which was not created for this API and is rejected. The API must also return a 401.

[Fact]
public async Task Get401ForIncorrectToken()
{
	var access_token = await _tokenService.GetApiToken(
			"ClientProtectedApi",
			"dummy",
			"apiprotoSecret"
		);

	_client.SetBearerToken(access_token);

	// Act
	_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-protobuf"));
	var response = await _client.GetAsync("/api/values/1");

	// Assert
	Assert.Equal("Unauthorized", response.StatusCode.ToString());
}

With this setup, it is really easy to do system tests for you API. These tests are really easy to maintain, as the same technologies are used as the ones the developers use, and so that with each change, the developers have less effort to maintain this.

Links:

https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/testing?view=aspnetcore-2.2

https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-2.2

https://xunit.net/

One comment

  1. […] System Testing ASP.NET Core APIs using XUnit – Damien Bowden […]

Leave a Reply to The Morning Brew - Chris Alcock » The Morning Brew #2782 Cancel reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: