Assuming a basic ASP Net API is already existing and using JWT as method of authentication. ASP Net Rest API – Authenticate – JWT
To ensure authentication work as expected, let’s add unittests.
As application would normaly use a persistent database that would need whiping after each test, first thing is to drop used database and replaye it by a non persistent in memory database.
using System;
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using tryout_blazor_api.Server;
namespace Tests.IntegrationTests
{
public class TestingWebAppFactory<TEntryPoint> : WebApplicationFactory<Program> where TEntryPoint : Program
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<ApplicationDbContext>));
services.Remove(descriptor);
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
});
}
}
}
Wrapping some recurring actions in a base class.
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using tryout_blazor_api.Shared;
using tryout_blazor_api.Shared.Auth;
using Xunit;
namespace Tests.IntegrationTests
{
public class AuthTestBase : IAsyncLifetime
{
protected readonly TestingWebAppFactory<Program> _factory;
private static bool Initialized = false;
public AuthTestBase()
{
_factory = new TestingWebAppFactory<Program>();
}
public async Task InitializeAsync()
{
if(Initialized)
return;
using (var client = _factory.CreateClient())
{
await RegisterAsUser(client);
}
Initialized = true;
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
protected async Task<string> LoginAsUser(HttpClient client)
{
return await LoginAs(client, "user", "P4$$w0rd");
}
protected async Task<string> LoginAs(HttpClient client, string user, string pass)
{
LoginModel loginData = new () {
Username = user,
Password = pass
};
var responseLogin = await client.PostAsJsonAsync("Auth/login", loginData);
responseLogin.EnsureSuccessStatusCode();
var responseString = await responseLogin.Content.ReadAsStringAsync();
var resultLogin = JsonSerializer.Deserialize<LoginToken>(responseString, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", resultLogin!.Token);
return resultLogin!.Token;
}
protected async Task<Response?> RegisterAsUser(HttpClient client)
{
return await RegisterAs(client, "user", "P4$$w0rd", "user@example.net");
}
protected async Task<Response?> RegisterAs(HttpClient client, string user, string pass, string mail)
{
RegisterModel regData = new () {
Username = user,
Password = pass,
Email = mail
};
var response = await client.PostAsJsonAsync("Auth/register", regData);
response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
var resultRegister = JsonSerializer.Deserialize<Response>(responseString, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
});
return resultRegister;
}
}
}
As previously the /WeatherForcast
API was configured to require authentication, we now can add tests if we’re correctly denied if not logged in.
As well as creating a new account, logging in with it’s data and being grated access to /WeatherForcast
.
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
namespace Tests.IntegrationTests
{
public class AuthTests : AuthTestBase
{
[Fact]
public async Task Unauthorized_Access_is_prevented()
{
// Arrange
var client = _factory.CreateClient();
// Act
// Assert
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
var responseAuthorized = await client.GetAsync("WeatherForecast");
responseAuthorized.EnsureSuccessStatusCode();
});
}
[Fact]
public async Task Authorized_Access_is_permitted()
{
// Arrange
var client = _factory.CreateClient();
// Act
string bearerToken = await LoginAsUser(client);
var responseAuthorized = await client.GetAsync("WeatherForecast");
// Assert
responseAuthorized.EnsureSuccessStatusCode();
}
[Fact]
public async Task Register()
{
// Arrange
var client = _factory.CreateClient();
// Act
var resultReg = await RegisterAs(client, "User2", "P4$$w0rd", "User2@example.net");
var resultLogin = await LoginAs(client, "User2", "P4$$w0rd");
// Assert
Assert.NotNull(resultReg);
Assert.Contains("Success", resultReg!.Status);
Assert.NotEqual("", resultLogin);
}
[Fact]
public async Task RegisterShortPassword()
{
// Arrange
var client = _factory.CreateClient();
// Act
var exception = await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
var resultReg = await RegisterAs(client, "User3", "pass", "User3@example.net");
});
// Assert
Assert.NotEqual("", exception.Message);
}
[Fact]
public async Task Token_duration()
{
// Arrange
var client = _factory.CreateClient();
var expiryMin = DateTime.Now + TimeSpan.FromHours(1);
var expiryMax = DateTime.Now + TimeSpan.FromHours(6);
// Act
string bearerToken = await LoginAsUser(client);
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadToken(bearerToken) as JwtSecurityToken;
// Assert
Assert.NotEqual("", bearerToken);
Assert.NotNull(token);
Assert.Equal(1, token!.ValidTo.CompareTo(expiryMin));
Assert.Equal(-1, token!.ValidTo.CompareTo(expiryMax));
}
}
To comply with DRY principle the Register Test should definetly be broken down further and probably be split into helper methods. As logging in and registering will most probably be used several times but then not as the main test target.
Full code can be found here: https://github.com/sukapx/tryout_blazor_api/tree/3cc988daa81bdc59cf18055a45d4a6fc5e1b5391