Testing Auth and Tenancy
This page covers how to test the authentication and multi-tenancy infrastructure. The codebase provides patterns for unit tests (mocking interfaces) and integration tests (using a stub service).
Unit tests
Unit tests mock the individual interfaces to test components in isolation. The project uses NSubstitute for mocking and Shouldly for assertions.
Mocking IClaimsPrincipalAccessor
Use this when testing CurrentUserService itself. You control exactly which claims are present.
| CurrentUserServiceTests.cs |
|---|
| using System.Security.Claims;
using NSubstitute;
using Petfolio.Service.Domain.Common;
using Petfolio.Service.Infrastructure.Services;
using Shouldly;
public class CurrentUserServiceTests
{
private readonly IClaimsPrincipalAccessor _claimsPrincipalAccessor;
private readonly CurrentUserService _sut;
public CurrentUserServiceTests()
{
_claimsPrincipalAccessor = Substitute.For<IClaimsPrincipalAccessor>();
_sut = new CurrentUserService(_claimsPrincipalAccessor);
}
[Fact]
public void UserId_ReturnsNameIdentifierClaim_WhenAuthenticated()
{
// Arrange
var expectedUserId = "auth0|abc123";
var principal = CreateAuthenticatedPrincipal(
(ClaimTypes.NameIdentifier, expectedUserId));
_claimsPrincipalAccessor.User.Returns(principal);
// Act
var result = _sut.UserId;
// Assert
result.ShouldBe(expectedUserId);
}
[Fact]
public void AllProperties_ReturnNullsAndFalse_WhenUnauthenticated()
{
// Arrange
var identity = new ClaimsIdentity();
var principal = new ClaimsPrincipal(identity);
_claimsPrincipalAccessor.User.Returns(principal);
// Act & Assert
_sut.UserId.ShouldBeNull();
_sut.AccountId.ShouldBeNull();
_sut.Email.ShouldBeNull();
_sut.IsAuthenticated.ShouldBeFalse();
}
private static ClaimsPrincipal CreateAuthenticatedPrincipal(
params (string Type, string Value)[] claims)
{
var claimsList = claims.Select(c => new Claim(c.Type, c.Value)).ToList();
var identity = new ClaimsIdentity(claimsList, "TestAuth");
return new ClaimsPrincipal(identity);
}
}
|
The helper method CreateAuthenticatedPrincipal creates a ClaimsPrincipal with an authenticated identity (the "TestAuth" string marks it as authenticated). To simulate an unauthenticated user, create a ClaimsIdentity without an authentication type.
Mocking ITenantProvider
Use this when testing PetfolioDbContext - query filters and account stamping. You control the AccountId returned.
| PetfolioDbContextTests.cs |
|---|
| using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Petfolio.Service.Domain.Common;
using Petfolio.Service.Infrastructure.Persistence;
using Shouldly;
public sealed class PetfolioDbContextTests : IDisposable
{
private readonly ITenantProvider _tenantProvider;
private readonly PetfolioDbContext _sut;
public PetfolioDbContextTests()
{
_tenantProvider = Substitute.For<ITenantProvider>();
var options = new DbContextOptionsBuilder<PetfolioDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_sut = new PetfolioDbContext(options, _tenantProvider);
}
[Fact]
public async Task SaveChangesAsync_SetsAccountId_WhenEntityIsNewAndAccountIdIsEmpty()
{
// Arrange
var expectedAccountId = Guid.NewGuid();
_tenantProvider.AccountId.Returns(expectedAccountId);
var animal = CreateAnimalWithEmptyAccountId();
_sut.Animals.Add(animal);
// Act
await _sut.SaveChangesAsync();
// Assert
animal.AccountId.ShouldBe(expectedAccountId);
}
[Fact]
public async Task SaveChangesAsync_ThrowsInvalidOperationException_WhenNoUserContext()
{
// Arrange
_tenantProvider.AccountId.Returns((Guid?)null);
var animal = CreateAnimalWithEmptyAccountId();
_sut.Animals.Add(animal);
// Act & Assert
var exception = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.SaveChangesAsync());
exception.Message.ShouldBe(Validation.MultiTenant.NoUserContextError);
}
[Fact]
public async Task QueryFilter_ReturnsOnlyMatchingTenantAnimals()
{
// Arrange - two animals in different accounts
var accountId1 = Guid.NewGuid();
var accountId2 = Guid.NewGuid();
var animal1 = CreateAnimalWithAccountId(accountId1);
var animal2 = CreateAnimalWithAccountId(accountId2);
_sut.Animals.AddRange(animal1, animal2);
await _sut.SaveChangesAsync();
// Set tenant to account 1
_tenantProvider.AccountId.Returns(accountId1);
// Act
var result = await _sut.Animals.ToListAsync();
// Assert - only account 1's animal
result.Count.ShouldBe(1);
result[0].AccountId.ShouldBe(accountId1);
}
public void Dispose()
{
_sut.Dispose();
GC.SuppressFinalize(this);
}
}
|
Mocking ICurrentUserService
Use this when testing command/query handlers that need user identity.
| // Arrange
var currentUserService = Substitute.For<ICurrentUserService>();
currentUserService.UserId.Returns("google|123456");
currentUserService.Email.Returns("alice@example.com");
currentUserService.IsAuthenticated.Returns(true);
// Inject into handler under test
var handler = new SomeCommandHandler(repository, currentUserService);
|
Integration tests
Integration tests use a stub instead of mocks. The TestCurrentUserService class implements both ICurrentUserService and ITenantProvider with settable properties, giving you direct control over the tenant context for each test.
TestCurrentUserService
| Tests/.../Infrastructure/TestCurrentUserService.cs |
|---|
| internal sealed class TestCurrentUserService : ICurrentUserService, ITenantProvider
{
public string? UserId { get; set; }
public Guid? AccountId { get; set; }
public string? Email { get; set; }
public bool IsAuthenticated => AccountId.HasValue;
}
|
Unlike the real CurrentUserService (which reads claims from a ClaimsPrincipal), this stub has public setters. You set the values directly before making HTTP calls.
How it is registered
The IntegrationTestWebApplicationFactory replaces the real services with the stub:
| IntegrationTestWebApplicationFactory.cs (excerpt) |
|---|
| builder.ConfigureTestServices(services =>
{
// Replace the real services with the test stub
services.RemoveAll<ICurrentUserService>();
services.RemoveAll<ITenantProvider>();
services.AddSingleton<ICurrentUserService>(TestCurrentUserService);
services.AddSingleton<ITenantProvider>(TestCurrentUserService);
});
|
The stub is registered as a singleton so all scopes within a test share the same tenant context. The factory exposes it via TestCurrentUserService so tests can set the account ID.
BaseIntegrationTest helper
BaseIntegrationTest provides a SetCurrentUserAccountId method and resets the account ID before each test:
| BaseIntegrationTest.cs (excerpt) |
|---|
| protected void SetCurrentUserAccountId(Guid accountId)
{
Factory.TestCurrentUserService.AccountId = accountId;
}
public async Task InitializeAsync()
{
await Factory.ResetDatabaseAsync();
Factory.TestCurrentUserService.AccountId = null; // Reset between tests
}
|
Usage in a test
| CreateAnimalTests.cs (excerpt) |
|---|
| [Collection(nameof(IntegrationTestCollection))]
public class CreateAnimalTests(IntegrationTestWebApplicationFactory factory)
: BaseIntegrationTest(factory)
{
private async Task<Account> SetupDataAsync()
{
var account = new AccountFaker().Generate();
await SeedEntityAsync(account);
SetCurrentUserAccountId(account.Id); // Set tenant context
return account;
}
[Fact]
public async Task Should_ReturnCreated_WhenModelIsValid()
{
// Arrange
var account = await SetupDataAsync();
var command = new CreateAnimalCommandFaker(account.Id).Generate();
// Act
var response = await HttpClient.HandleCommandAsync(_createAnimalRoute, command);
// Assert
response.ShouldHaveStatusCode(HttpStatusCode.Created);
}
}
|
The pattern is:
- Seed an
Account into the database
- Call
SetCurrentUserAccountId(account.Id) to set the tenant context
- Make HTTP calls - the
PetfolioDbContext will use the stub's AccountId for query filters and account stamping
Common patterns
Simulating different tenants
To verify data isolation, seed data under one account and query under another:
| // Seed animals under Account A
SetCurrentUserAccountId(accountA.Id);
await SeedEntityAsync(animalForAccountA);
// Switch to Account B
SetCurrentUserAccountId(accountB.Id);
// Query - should NOT see Account A's animals
var response = await HttpClient.GetAsync("/api/animals");
var animals = await response.ShouldHaveContentAsync<List<AnimalDto>>();
animals.ShouldBeEmpty();
|
Simulating an unauthenticated request
Set the AccountId to null. The stub's IsAuthenticated property returns false when AccountId is null.
| Factory.TestCurrentUserService.AccountId = null;
// Any operation requiring tenant context will now behave as unauthenticated
|
Simulating missing claims in unit tests
Return null from the accessor to simulate a request with no ClaimsPrincipal at all:
| _claimsPrincipalAccessor.User.Returns((ClaimsPrincipal?)null);
// All CurrentUserService properties will return null/false
_sut.UserId.ShouldBeNull();
_sut.IsAuthenticated.ShouldBeFalse();
|
Verifying fail-fast on missing tenant
| // Arrange - no tenant context
_tenantProvider.AccountId.Returns((Guid?)null);
var animal = CreateAnimalWithEmptyAccountId();
_sut.Animals.Add(animal);
// Act & Assert - SaveChangesAsync should throw
var exception = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.SaveChangesAsync());
exception.Message.ShouldBe(Validation.MultiTenant.NoUserContextError);
|
Bypassing query filters
In tests where you need to verify data regardless of tenant (e.g. checking that data was actually saved), use IgnoreQueryFilters():
| protected async Task<List<T>> GetAllEntitiesFromDbAsync<T>() where T : class
{
using var scope = Factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<PetfolioDbContext>();
return await dbContext.Set<T>().IgnoreQueryFilters().ToListAsync();
}
|
BaseIntegrationTest already provides this method.