Using ASP.NET Core Identity user accounts in integration tests
I have application that uses ASP.NET Core Identity with classic logins and there’s need to cover this application with integration tests. Some tests are for anonymous users and others for authenticated users. This blog post shows how to set selectively set authenticated ASP.NET Core Identity user for ASP.NET Core integration tests.
Getting started
We start with ASP.NET Core web application where basic authentication is done using ASP.NET Core Identity. There’s integration tests project that uses fake startup class and custom appsettings.json from my blog post Using custom startup class with ASP.NET Core integration tests. Take a look at this post as there are some additional classes defined.
Our starting point is simple integration test from my previous blog post.
public class HomeControllerTests : IClassFixture<MediaGalleryFactory<FakeStartup>>
{
private readonly WebApplicationFactory<FakeStartup> _factory;
public HomeControllerTests(MediaGalleryFactory<FakeStartup> factory)
{
var projectDir = Directory.GetCurrentDirectory();
var configPath = Path.Combine(projectDir, "appsettings.json");
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSolutionRelativeContentRoot("MediaGallery");
builder.ConfigureAppConfiguration(conf =>
{
conf.AddJsonFile(configPath);
});
builder.ConfigureTestServices(services =>
{
services.AddMvc().AddApplicationPart(typeof(Startup).Assembly);
});
});
}
[Theory]
[InlineData("/")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
}
To work with identity we need also controller action that has authorize attribute. Here’s the action of home controller we will use for this post.
[Authorize(Roles = "Admin")]
[Route("/foradmin")]
public IActionResult ForAdmin()
{
return Content(string.Format("User: {0}, is admin: {1}",
User.Identity.Name, User.IsInRole("Admin")));
}
For this action we add test from ASP.NET Core integration testing documentation.
[Fact]
public async Task Get_SecurePageRequiresAnAuthenticatedUser()
{
// Arrange
var client = _factory.CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/ForAdmin");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith("http://localhost/Identity/Account/Login",
response.Headers.Location.OriginalString);
}
This test turns off redirects for HTTP client. If response is 301 or 302 then HTTP client doesn’t go to URL given in location header. It stops so we can explore the response. For anonymous user this test passes and we can see that browser is redirected to login page.
Introducing fake user filter
We can use global action filters to “authenticate” users so ASP.NET Core thinks there’s real user authenticated. It’s a little bit tricky. We have to turn off default user detection as otherwise HTTP client is redirected to login page. We can use AllowAnonymousFilter for this. This filter will open the door for request to pass authentication but we need another filter right next to it that sets authenticated user.
To introduce authenticated user we need custom action filter shown here.
class FakeUserFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, "123"),
new Claim(ClaimTypes.Name, "Test user"),
new Claim(ClaimTypes.Email, test@example.com),
new Claim(ClaimTypes.Role, "Admin")
}));
await next();
}
}
It sets dummy claims for user and ASP.NET Core is okay with it.
Here is the new test that expects authenticated user.
[Fact]
public async Task Get_SecurePageIsAvailableForAuthenticatedUser()
{
// Arrange
var client = _factory.CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/ForAdmin");
var body = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
Now we have two integration tests that depend on authenticated user and their needs are conflicting. One test needs anonymous user and the other needs authenticated user.
We have to solve this conflict in integration tests file.
Setting authenticated user for integration tests
The easiest solution I found was to modify tests and make presence of authenticated user configurable. We need to move configuring of factory to separate method so we can set if authenticated user is needed or not. I give here full source of integration tests file.
public class HomeControllerTests : IClassFixture<MediaGalleryFactory<FakeStartup>>
{
private readonly WebApplicationFactory<FakeStartup> _factory;
public HomeControllerTests(MediaGalleryFactory<FakeStartup> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_SecurePageRequiresAnAuthenticatedUser()
{
// Arrange
var client = GetFactory().CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/ForAdmin");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith("http://localhost/Identity/Account/Login",
response.Headers.Location.OriginalString);
}
[Fact]
public async Task Get_SecurePageIsAvailableForAuthenticatedUser()
{
// Arrange
var client = GetFactory(hasUser: true).CreateClient();
// Act
var response = await client.GetAsync("/ForAdmin");
var body = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/plain; charset=utf-8",response.Content.Headers.ContentType.ToString());
}
[Theory]
[InlineData("/")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = GetFactory().CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
private WebApplicationFactory<FakeStartup> GetFactory(bool hasUser = false)
{
var projectDir = Directory.GetCurrentDirectory();
var configPath = Path.Combine(projectDir, "appsettings.json");
return _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration(conf =>
{
conf.AddJsonFile(configPath);
});
builder.UseSolutionRelativeContentRoot("MediaGallery");
builder.ConfigureTestServices(services =>
{
services.AddMvc(options =>
{
if (hasUser)
{
options.Filters.Add(new AllowAnonymousFilter());
options.Filters.Add(new FakeUserFilter());
}
})
.AddApplicationPart(typeof(Startup).Assembly);
});
});
}
}
Tests can now specify if authenticated user is needed or not through hasUser parameter of GetFactory() method.
Best of all – our tests pass now.
There’s one thing to take care of – controller actions that use current user identity to query data from database or other services.
Using real ASP.NET Core Identity accounts
We need now a way to have test user in ASP.NET Core Identity database. If we have users then usually we have data related to these users. To make sure that user identity is always the same through tests I created simple user settings class.
public static class UserSettings
{
public const string UserId = "47d90476-8de1-4a71-b0f0-9beaf4d89c98";
public const string Name = "Test User";
public const string UserEmail = "user@test.com";
}
Our fake user filter must use the same settings.
class FakeUserFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, UserSettings.UserId),
new Claim(ClaimTypes.Name, UserSettings.Name),
new Claim(ClaimTypes.Email, UserSettings.UserEmail),
new Claim(ClaimTypes.Role, "Admin")
}));
await next();
}
}
To get this user to database we can use fake startup class. My solution supports empty and previously filled databases.
public class FakeStartup : Startup
{
public FakeStartup(IConfiguration configuration) : base(configuration)
{
}
public override void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
base.Configure(app, env, loggerFactory);
var serviceScopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using (var serviceScope = serviceScopeFactory.CreateScope())
{
var dbContext = serviceScope.ServiceProvider.GetService<ApplicationDbContext>();
if (dbContext.Database.GetDbConnection().ConnectionString.ToLower().Contains("database.windows.net"))
{
throw new Exception("LIVE SETTINGS IN TESTS!");
}
if (!dbContext.Users.Any(u => u.Id == UserSettings.UserId))
{
var user = new IdentityUser();
user.ConcurrencyStamp = DateTime.Now.Ticks.ToString();
user.Email = UserSettings.UserEmail;
user.EmailConfirmed = true;
user.Id = UserSettings.UserId;
user.NormalizedEmail = user.Email;
user.NormalizedUserName = user.Email;
user.PasswordHash = Guid.NewGuid().ToString();
user.UserName = user.Email;
var role = new IdentityRole();
role.ConcurrencyStamp = DateTime.Now.Ticks.ToString();
role.Id = "Admin";
role.Name = "Admin";
var userRole = new IdentityUserRole<string>();
userRole.RoleId = "Admin";
userRole.UserId = user.Id;
dbContext.Users.Add(user);
dbContext.Roles.Add(role);
dbContext.UserRoles.Add(userRole);
dbContext.SaveChanges();
}
}
}
}
Now we can also test controller actions where data is queried based on current user.
Moving web application factory to base class
It’s time to clean up all the mess. When I look at integration tests file I don’t like the fact that there’s GetFactory() method and _factory attribute equally accessible to tests. Those who know use GetFactory() method and novice guys in project may accidentally use _factory attribute instead. Another this is that we don’t want to repeat GetFactory() method to all integration test classes we have.
To get rid of these issues I create base class for integration tests.
public class IntegrationTestBase : IClassFixture<MediaGalleryFactory<FakeStartup>>
{
private readonly WebApplicationFactory<FakeStartup> _factory;
public IntegrationTestBase(MediaGalleryFactory<FakeStartup> factory)
{
_factory = factory;
}
protected WebApplicationFactory<FakeStartup> GetFactory(bool hasUser = false)
{
var projectDir = Directory.GetCurrentDirectory();
var configPath = Path.Combine(projectDir, "appsettings.json");
return _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context,conf) =>
{
conf.AddJsonFile(configPath);
});
builder.UseSolutionRelativeContentRoot("MediaGallery");
builder.ConfigureTestServices(services =>
{
services.AddMvc(options =>
{
if (hasUser)
{
options.Filters.Add(new AllowAnonymousFilter());
options.Filters.Add(new FakeUserFilter());
}
})
.AddApplicationPart(typeof(Startup).Assembly);
});
});
}
}
Our test class gets also smaller and it doesn’t expose injected _factory attribute anymore.
public class HomeControllerTests : IntegrationTestBase
{
public HomeControllerTests(MediaGalleryFactory<FakeStartup> factory) : base(factory)
{
}
[Fact]
public async Task Get_SecurePageRequiresAnAuthenticatedUser()
{
// Arrange
var client = GetFactory().CreateClient(
new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/ForAdmin");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith("http://localhost/Identity/Account/Login",
response.Headers.Location.OriginalString);
}
[Fact]
public async Task Get_SecurePageIsAvailableForAuthenticatedUser()
{
// Arrange
var client = GetFactory(hasUser: true).CreateClient();
// Act
var response = await client.GetAsync("/ForAdmin");
var body = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/plain; charset=utf-8",response.Content.Headers.ContentType.ToString());
}
[Theory]
[InlineData("/")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = GetFactory().CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
}
Wrapping up
There are more than one way to get user context to ASP.NET Core Identity integration tests. The method introduced here is kind of hacky but effective. If needed we can easily extend it for more complex scenarios. To support scenarios where there’s user related data in database we have test user available also in database – be ie empty or previously filled. By moving factory creating logic to base class we ended up with nice and clean integration test classes.
Thanks for this, Gunnar – I can see this technique being very useful.
Cool