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.

Conflicting requirements between integration tests

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.

All integration tests pass

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.

Gunnar Peipman

Gunnar Peipman is ASP.NET, Azure and SharePoint fan, Estonian Microsoft user group leader, blogger, conference speaker, teacher, and tech maniac. Since 2008 he is Microsoft MVP specialized on ASP.NET.

    2 thoughts on “Using ASP.NET Core Identity user accounts in integration tests

    • May 3, 2019 at 3:45 pm
      Permalink

      Thanks for this, Gunnar – I can see this technique being very useful.

    • September 5, 2019 at 12:41 pm
      Permalink

      Cool

    Leave a Reply

    Your email address will not be published. Required fields are marked *