Skip to content

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.

1
2
3
4
5
6
7
8
// 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
1
2
3
4
5
6
7
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)
1
2
3
4
5
6
7
8
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:

  1. Seed an Account into the database
  2. Call SetCurrentUserAccountId(account.Id) to set the tenant context
  3. 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.

1
2
3
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:

1
2
3
4
5
_claimsPrincipalAccessor.User.Returns((ClaimsPrincipal?)null);

// All CurrentUserService properties will return null/false
_sut.UserId.ShouldBeNull();
_sut.IsAuthenticated.ShouldBeFalse();

Verifying fail-fast on missing tenant

1
2
3
4
5
6
7
8
9
// 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():

1
2
3
4
5
6
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.