Table of Contents

Bsft.Testing and Bsft.Auth.Testing

Test doubles and harnesses for the framework's ambient concerns — the tenant, the correlation id, the clock and, with Bsft.Auth.Testing, the current user — plus in-process harnesses for mediator handlers and saga flows, and a WebApplicationFactory with the doubles already installed.

Both packages are referenced by test projects only, and both are development dependencies. They are split by tier (package-conventions.md):

Package Tier Carries
Bsft.Testing Foundation tenant and correlation doubles, the clock, the mediator and saga harnesses, BsftWebApplicationFactory<TEntryPoint>, the fake OIDC authority and the fake push service
Bsft.Auth.Testing framework the user and permission doubles, the BsftTest authentication scheme and BsftAuthWebApplicationFactory<TEntryPoint>

Bsft.Testing depends on no Bsft.Auth package, so a Foundation test project can use it; an application on Bsft.Auth adds Bsft.Auth.Testing, which brings Bsft.Testing along. Until 2.0.0 everything was in Bsft.Testing; the migration notes describe the move.

The types of Bsft.Testing live in namespaces by role: the context doubles in Bsft.Testing.Contexts, the clock in Bsft.Testing.Time, the mediator harness in Bsft.Testing.Mediation, the saga harness in Bsft.Testing.Sagas, and the web feature in Bsft.Testing.Web with BsftWebApplicationFactory<TEntryPoint> and the test-host environment policy at its root, the fake authority and its records in Bsft.Testing.Web.Oidc and the fake push service in Bsft.Testing.Web.Push. The registration methods are in Bsft.Testing.DependencyInjection. Bsft.Auth.Testing keeps its types at its root and its registration methods in Bsft.Auth.Testing.DependencyInjection.

Context doubles

Four fakes replace the ambient services that are otherwise resolved from an HTTP request:

Double Package Replaces Default
TestTenantContext Bsft.Testing ITenantContext tenant "test-tenant"
TestCorrelationContext Bsft.Testing CorrelationContext / ICorrelationContext "test-correlation-id"
TestUserContext Bsft.Auth.Testing ICurrentUser user …0001, authenticated
TestPermissionSource Bsft.Auth.Testing the granted permissions none
services.AddBsftTestContexts();                          // tenant + correlation, defaulted
services.AddBsftTestTenant(new TestTenantContext("acme"));
services.AddBsftTestUser(new TestUserContext(userId));   // Bsft.Auth.Testing
services.AddBsftTestPermissions(new TestPermissionSource("orders.read"));

Every one of these replaces the existing registration rather than adding to it, so they are safe to apply to a fully configured container.

The doubles are mutable mid-test — SignIn/SignOut on the user, SetTenant/ Clear on the tenant — which is how you assert that an endpoint rejects an anonymous caller without building a second host.

SetTenant also changes what the tenant query filter returns, so one host can assert that a row written under one tenant is a 404 for the next. It works with UseTenantFilter and UseStaticTenantFilter alike, because the double is a single instance the test mutates and the filter reads its value per query. The defect that UseTenantFilter(this) fixes is the instance a filter captures, and no mutated singleton can reproduce it — that needs a tenant context resolved per scope, which is what a real host has. See data.md.

Permission grants follow the production shape: tenant:{tenantId}:{permission} is tenant-scoped, a bare permission is global. See Bsft.Auth.

The clock

public interface ITestClock
{
    DateTimeOffset UtcNow { get; }
    void Advance(TimeSpan by);
    void SetUtcNow(DateTimeOffset utcNow);
}

TestClock derives from TimeProvider, so registering it as the TimeProvider makes every framework component that respects the clock — saga timeouts, the scheduler, the outbox — virtual. It starts at DateTimeOffset.UnixEpoch. Advance refuses a negative span.

Only GetUtcNow() is overridden; timers created from the base class still run on the real clock. Advance the clock and drive the pass explicitly rather than waiting for a timer to fire.

Mediator harness

using var harness = new MediatorHarnessBuilder()
    .WithHandler<AddNumbersHandler>()
    .WithService<IRateProvider>(fakeRates)
    .Build();

var result = await harness.SendAsync(new AddNumbers(2, 3));

The builder composes a minimal container: WithHandler<T>() (or the Type overload), WithBehavior(Type), WithService<T>(instance), WithServices(Action<IServiceCollection>) and WithTestContexts(). Build() adds the mediator last and builds the provider with validateScopes: true, so a captive dependency fails in the test rather than in production.

Behaviors run in the order added, first outermost — matching the production rule in mediator.md.

The harness exposes the same four operations as IMediator (SendAsync ×2, QueryAsync, PublishAsync) plus Mediator and Services for anything else.

Saga harness

using var harness = new SagaHarnessBuilder()
    .WithFlow<OrderFulfillmentFlow>()
    .StartingAt(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero))
    .Build();

await harness.PublishAsync(new OrderPlaced(orderId, 100m));

var fired = await harness.AdvanceTimeAsync(TimeSpan.FromDays(3));

var saga = await harness.GetSagaAsync<OrderState, OrderPlaced>(orderId.ToString());
saga!.State.IsFollowUpSent.Should().BeTrue();

Build() registers the test clock before the engine, which is what makes durable timers virtual, and calls UseBsftSagaFlows() so the flows are actually subscribed.

Member Purpose
PublishAsync Push an event into the flow.
AdvanceTimeAsync(by) Move the clock and drain everything that became due.
DrainTimeoutsAsync() Drain due timers without moving the clock.
FindSagaAsync<TState, TStartEvent>(key) Read the persisted instance.
ListPendingTimeoutsAsync() Inspect scheduled timers.
Events, Store, Timeouts, Clock, Services Direct access.

The timeout hosted service is deliberately not started; both drain methods pump the real dispatcher by hand, so no poll interval is involved and the test has no wall-clock waits. Both repeat until nothing is due, and return a count that is a diagnostic rather than an assertion target — assert on the saga's state, not on how many timers fired. See saga-timeouts.md.

Web application factory

public sealed class ApiFactory : BsftWebApplicationFactory<Program>
{
    protected override void ConfigureTestServices(IServiceCollection services)
    {
        services.AddBsftTestDatabase<AppDbContext>("orders-tests");
    }
}

BsftWebApplicationFactory<TEntryPoint> sets the environment to Testing and installs the tenant and correlation doubles. The database is not touched by default — call AddBsftTestDatabase<TContext> to swap in an in-memory provider. Override CreateTenantContext, CreateCorrelationContext or EnvironmentName to change the starting point.

An application on Bsft.Auth derives from BsftAuthWebApplicationFactory<TEntryPoint> (Bsft.Auth.Testing) instead. It adds the user double and makes the test authentication scheme the default, told the environment the factory boots in; override CreateUserContext to start as another or an anonymous user. A factory bringing doubles of its own overrides ConfigureTestDoubles and calls the base first, which is how the auth factory is built. The live doubles are reachable through the provider, which is how a test changes identity between requests:

factory.Services.GetRequiredService<TestUserContext>().SignIn(otherUserId);

The fake OpenID Connect authority

FakeOidcAuthority publishes a discovery document and a key set and exchanges authorization codes for signed identity tokens, all in-process over a real RSA signature. Point the application's OpenID Connect back channel at CreateBackchannelHandler() and a federated login runs end to end with no identity provider in the loop.

It also mints back-channel logout tokens, signed with the same key and the same kid as the identity tokens — which is the property a real deployment relies on, and therefore the one a test has to reproduce:

using var backChannel = host.CreateCookielessClient();   // a back-channel POST carries no cookies

var response = await host.Authority.SendBackChannelLogoutAsync(
    backChannel,
    "/auth/oidc/backchannel-logout",
    new FakeLogoutTokenRequest(host.ClientId, SessionId: "sess-1"));

FakeOidcUser gains a trailing SessionId, published as sid, so the login under test is one a logout token can name precisely.

For the negative cases, FakeLogoutTokenDefect asks the authority for a token with exactly one thing wrong with it — a foreign signing key, the wrong audience, a stale iat, a forbidden nonce, a missing or mis-keyed events claim, an unknown issuer, or neither sub nor sid. One defect at a time and never two, because a receiver test has to prove which check refused the token; a token that was both unsigned and stale would satisfy whichever assertion was written and say nothing about the other rule.

SendLogoutTokenAsync posts an already-minted token, which is how a replay test sends the very same string twice.

The fake push service

FakePushService plays both halves of a Web Push conversation: the push service that answers, and the browser whose key the message was encrypted to. It has to play both, because asserting on a push means asserting on something encrypted to a subscription's key, and only the holder of that key can read it.

using var push = new FakePushService();

// Post push.Subscription at the registration endpoint exactly as a real client posts what
// pushManager.subscribe() returned, then send a notification through the web-push channel.

var received = push.Requests.Should().ContainSingle().Subject;

received.Payload.Should().Contain("Invoice 4711");     // decrypted, not pattern-matched
received.Vapid!.IsSignatureValid.Should().BeTrue();

Decryption succeeding is the assertion that the sender followed RFC 8291: a body whose key derivation, salt placement or record framing is wrong does not produce a mismatched string, it fails to authenticate. Vapid is the token read the way a push service reads it, with the signature verified against the key the header itself declared — which is the one thing about a VAPID header a hand-written expectation cannot fake.

Status makes the paths that matter reachable: 410 for a browser that has gone, which is the case the whole subscription lifecycle turns on, plus 413 and 429; RetryAfter rides along with a 429. It applies to every push until it is changed, because a test needing a sequence of answers should change it between the calls it asserts on. CreateHandler() returns the HttpMessageHandler to give the application's push client, and the caller owns it.

Test authentication

AddBsftTestAuthentication() (Bsft.Auth.Testing) registers the BsftTest scheme, which builds a principal from the current doubles — register the user with AddBsftTestUser too, or use the auth factory, which does both. It is idempotent, and it is guarded: the handler's constructor throws unless the host is a recognised test environment (Testing, plus any extra names you pass). A stray reference from a production host fails loudly instead of authenticating everyone.

Development is not recognised. A deployed host running with ASPNETCORE_ENVIRONMENT=Development is exactly the misconfiguration the guard exists to catch, and accepting it would authenticate every request as the default test user. A project that genuinely boots its test host in Development opts in by name:

services.AddBsftTestAuthentication("Development");

One nuance for HTTP tests: TestCorrelationContext derives from the concrete CorrelationContext because the middleware takes that type, and the fixed id only survives a request that sends no X-Correlation-Id header — otherwise the middleware honours the header. See correlation.md.

Driving a background pass by hand

The saga harness is not the only place a test has to stop waiting on a poll interval. Three further components expose the pass itself, on the same terms: the seam is registered on the instance the host runs as a background service, so a pass exercises the real policy, and the count it returns is a diagnostic rather than an assertion target.

Seam Package One pass is
ISagaTimeoutDrainer.DrainDueAsync Bsft.EventAggregator.Saga The timers that are due now.
IOutboxProcessor.ProcessOnceAsync Bsft.Messaging, registered by AddBsftMessagingEFCore One leased batch of the transactional outbox.
ILogoutDeliveryDispatcher.DispatchOnceAsync Bsft.Auth.TokenServer.Sessions One claimed batch of back-channel logout deliveries.

Each drains at most one batch, so a test working through a backlog repeats the pass until it returns zero. Nothing here starts or stops the hosted services; a test host that leaves them registered simply races its own drain, so switch them off or accept that the pass may find the work already done.

The fourth thing a host test usually wants to hold still is the mail itself, and that is a substitution rather than a pass: register your own Bsft.Notifications.Email.ISmtpTransport, which is public since 1.3.6.1. Replacing the transport leaves the contact resolution, the header sanitizer and the message builder on the real path; replacing the whole INotificationChannel does not. See Bsft.Notifications.

See also

  • mediator.md, saga.md — what the harnesses exercise.
  • Bsft.Auth — the identity and permission model the doubles stand in for.