Skip to content
← All posts
· Delta1 Labs LicensingGuide

How to Test Your Software Licensing Integration

Licensing bugs ship because the only path anyone exercises is "valid key, server up, clock correct." Here is how to put expiry, revocation, offline grace and seat limits under automated test: inject the clock, fake the license client, and drive every edge from xUnit so CI catches the lockouts before your customers do.

Most licensing code is tested exactly once, by hand, on the day it is written: paste in a valid key, watch the app unlock, ship it. Every path that matters — the key that expired overnight, the server that is unreachable on a customer’s locked-down network, the seat that should have been refused, the subscription that renewed a second too late — is discovered in production, by the customer, as a lockout. That is the worst possible place to find a licensing bug, because the person hitting it has already paid you.

The reason these paths go untested is not laziness. It is that the interesting behaviour depends on three things an ordinary test has no control over: the clock (expiry, grace, clock-rollback), the network (offline fallback), and server-side state (revocation, seat counts). If your gate reads DateTime.UtcNow directly and constructs a concrete HTTP client inline, there is no seam to move any of them, so the only case you can run is “valid, online, now.” Make those three injectable and every edge becomes a plain unit test.

Build the seam first

The whole technique rests on two injected dependencies: a clock and a license client. .NET 8 ships a clock abstraction for exactly this — TimeProvider — and a FakeTimeProvider in Microsoft.Extensions.TimeProvider.Testing that lets a test set and advance the current time. The license client becomes an interface so a fake can stand in for the real network-backed one.

LicenseGate TimeProvider ILicenseClient System (prod) FakeTimeProvider Keyright (prod) FakeLicenseClient

The gate takes both as constructor parameters and never reaches for a global:

public sealed class LicenseGate
{
    private readonly ILicenseClient _client;
    private readonly TimeProvider _clock;
    private readonly TimeSpan _grace;

    public LicenseGate(ILicenseClient client, TimeProvider clock, TimeSpan grace)
        => (_client, _clock, _grace) = (client, clock, grace);

    public GateResult Evaluate(CachedLease lease)
    {
        var now = _clock.GetUtcNow();           // injectable clock, not DateTime.UtcNow
        if (now > lease.ExpiryUtc + _grace) return GateResult.Blocked("expired");
        if (lease.Revoked)                      return GateResult.Blocked("revoked");
        if (now > lease.ExpiryUtc)              return GateResult.Grace(lease.ExpiryUtc + _grace - now);
        return GateResult.Ok(lease.Entitlements);
    }
}

In production you pass TimeProvider.System and the real KeyrightClient. In tests you pass a FakeTimeProvider and a fake client. Nothing else about the gate changes.

Drive the clock-dependent paths

With the clock injected, expiry and grace are deterministic. Set the time, build a lease, assert the result:

[Fact]
public void Lapsed_license_still_validates_inside_the_grace_window()
{
    var expiry = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
    var clock  = new FakeTimeProvider(expiry.AddDays(3));          // 3 days past expiry
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

    var result = gate.Evaluate(Lease.Paid(expiry));

    Assert.True(result.IsUsable);                                  // grace, not blocked
    Assert.Equal(GateState.Grace, result.State);
}

[Fact]
public void Lapsed_license_fails_closed_after_the_grace_window()
{
    var expiry = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
    var clock  = new FakeTimeProvider(expiry.AddDays(20));         // past the 14-day grace
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

    Assert.False(gate.Evaluate(Lease.Paid(expiry)).IsUsable);
}

The same FakeTimeProvider catches clock rollback, the oldest trick for dodging expiry. If your gate persists the highest timestamp it has ever seen and refuses a clock that moves backward, prove it:

[Fact]
public void Clock_rolled_backward_is_rejected()
{
    var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-06-01Z"));
    var gate  = new LicenseGate(new FakeLicenseClient(), clock, TimeSpan.FromDays(14));
    gate.Evaluate(Lease.Paid(DateTimeOffset.Parse("2026-07-01Z")));   // records "last seen"

    clock.SetUtcNow(DateTimeOffset.Parse("2026-05-01Z"));            // user winds the clock back
    Assert.False(gate.Evaluate(Lease.Paid(DateTimeOffset.Parse("2026-07-01Z"))).ClockTrusted);
}

Fake the client to force network and server state

The real client talks to a license server; the fake one returns whatever the test needs — including failures. A connectivity error is the one customers hit most, and it must not lock out a user whose cached lease is still inside its grace:

internal sealed class FakeLicenseClient : ILicenseClient
{
    public Func<string, Task<Lease>> OnValidate { get; set; }
        = _ => throw new LicenseConnectivityException("server unreachable");

    public Task<Lease> ValidateAsync(string key) => OnValidate(key);
}

[Fact]
public async Task Offline_falls_back_to_the_cached_lease()
{
    var client = new FakeLicenseClient();   // defaults to throwing a connectivity error
    var clock  = new FakeTimeProvider(DateTimeOffset.Parse("2026-06-15Z"));
    var sut    = new LicenseService(client, clock, cache: CacheWith(Lease.Paid(
                     expiry: DateTimeOffset.Parse("2026-12-31Z"))));

    var result = await sut.RefreshAsync("KR-TEST-...");

    Assert.True(result.IsUsable);           // offline, but cache is valid → still unlocked
    Assert.True(result.ServedFromCache);
}

Revocation is the mirror image: the network is up, and the server returns a lease marked revoked. A key that was valid when last cached must fail closed on the next successful check-in:

[Fact]
public async Task Revoked_key_is_blocked_on_next_online_check()
{
    var client = new FakeLicenseClient
    {
        OnValidate = _ => Task.FromResult(Lease.Revoked())   // server says: revoked
    };
    var sut = new LicenseService(client, new FakeTimeProvider(), cache: CacheWith(Lease.Paid(...)));

    Assert.False((await sut.RefreshAsync("KR-TEST-...")).IsUsable);
}

Seat limits work the same way — point OnValidate at a response that reports the activation pool is full and assert the activation is refused, not that the app silently runs an unlicensed extra copy.

Keep one honest end-to-end suite

Fakes prove your logic. They do not prove that your lease signatures actually verify, or that you parse the real wire format correctly — a fake can happily agree with a bug on both sides. Guard against that with a small, separate integration suite that activates a real test-mode key against a sandbox. Test-mode keys are issued exactly like production keys but flagged non-production, so CI can create and revoke them freely without ever touching a paying customer:

[Fact]
[Trait("Category", "Integration")]   // excluded from the fast unit run
public async Task Real_test_key_activates_and_reports_its_entitlements()
{
    var client = new KeyrightClient(TestConfig.SandboxEndpoint, TestConfig.PublicKey);
    var lease  = await client.ValidateAsync(TestConfig.TestKey);

    Assert.True(lease.SignatureValid);                 // real signature, real public key
    Assert.Contains("pro", lease.Entitlements.Tier);
}

Run the fast fakes on every push (dotnet test --filter Category!=Integration) and the integration suite on a schedule or before release. The split keeps the inner loop in milliseconds while still catching a broken signature or a changed field before it reaches a customer.

The checklist

If every one of these has a test, licensing stops being the feature that pages you at 2 a.m.:

  • Valid key, online → unlocked with the right entitlements.
  • Expired key, inside grace → still usable; past grace → blocked.
  • Clock rolled backward → rejected, not rewarded.
  • Server unreachable, cache valid → offline fallback unlocks; cache past grace → blocked.
  • Revoked key on next online check → blocked.
  • Seat pool full → activation refused.
  • One real test-mode key → activates and verifies its signature end to end.

None of these live on the happy path, which is exactly why they have to be written down as tests. The clock and the client are the only two seams you need; once they are injectable, every lockout your customers would have found becomes a red test on your machine instead.

Building this into a .NET app? Keyright ships test-mode keys and a sandbox so the suite above runs in CI without a production key — see the licensing guide to wire the gate in first.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.