Using ASP.NET Core Identity Users in Integration Tests
This blog post shows how to set selectively set authenticated ASP.NET Core user identities for ASP.NET Core integration tests.
Join the DZone community and get the full member experience.
Join For FreeI have an application that uses ASP.NET Core Identity with classic logins and there's a 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 user identities for ASP.NET Core integration tests.
Getting Started
We start with an ASP.NET Core web application where basic authentication is done using ASP.NET Core Identity. There's an integration tests project that uses a 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);}[Authorize(Roles = "Admin")]
[Route("/foradmin")]
public IActionResult ForAdmin()
{
return Content(string.Format("User: {0}, is admin: {1}",
User.Identity.Name, User.IsInRole("Admin")));
}
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 users, 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 the authenticated user is needed or not through the hasUser
parameter of the 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 the 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 the current user.
Moving the Web Application Factory to the 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 a GetFactory()
method and the _factory
attribute equally accessible to tests. Those who know use the GetFactory()
method and novices in the project may accidentally use the _factory
attribute instead. Another this is that we don't want to repeat the GetFactory()
method to all the integration test classes we have.
To get rid of these issues I create a 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 the 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 contexts 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 the database we have to test the users that are available and that are also in the database — be it empty or previously filled. By moving factory creating logic to base class we ended up with nice and clean integration test classes.
Published at DZone with permission of Gunnar Peipman, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments