Skip to content
← Alle Beiträge
· Delta1 Labs LizenzierungLeitfaden

So testen Sie die Lizenzierungs-Integration Ihrer Software

Lizenzierungsfehler gehen in Produktion, weil der einzige Pfad, den jemand durchläuft, „gültiger Schlüssel, Server erreichbar, Uhr korrekt" lautet. So bringen Sie Ablauf, Widerruf, Offline-Kulanz und Platzlimits unter automatisierte Tests: injizieren Sie die Uhr, faken Sie den Lizenz-Client und steuern Sie jeden Grenzfall aus xUnit, damit die CI die Aussperrungen vor Ihren Kunden findet.

Der meiste Lizenzierungscode wird genau einmal getestet, von Hand, am Tag seiner Entstehung: einen gültigen Schlüssel einfügen, zusehen, wie die App freischaltet, ausliefern. Jeder Pfad, der zählt — der über Nacht abgelaufene Schlüssel, der im abgeschotteten Netzwerk eines Kunden nicht erreichbare Server, der Platz, der hätte abgelehnt werden müssen, das Abonnement, das eine Sekunde zu spät verlängert wurde — wird in Produktion entdeckt, vom Kunden, als Aussperrung. Das ist der schlechtestmögliche Ort, um einen Lizenzierungsfehler zu finden, denn die Person, die ihn trifft, hat Sie bereits bezahlt.

Der Grund, warum diese Pfade ungetestet bleiben, ist nicht Faulheit. Es ist, dass das interessante Verhalten von drei Dingen abhängt, über die ein gewöhnlicher Test keine Kontrolle hat: die Uhr (Ablauf, Kulanz, Uhr-Rückdrehung), das Netzwerk (Offline-Rückfall) und der serverseitige Zustand (Widerruf, Platzzählung). Wenn Ihr Gate DateTime.UtcNow direkt liest und einen konkreten HTTP-Client inline baut, gibt es keine Naht, um eines davon zu verschieben, also ist der einzige ausführbare Fall „gültig, online, jetzt”. Machen Sie diese drei injizierbar, und jeder Grenzfall wird zu einem schlichten Unit-Test.

Bauen Sie zuerst die Naht

Die ganze Technik ruht auf zwei injizierten Abhängigkeiten: einer Uhr und einem Lizenz-Client. .NET 8 liefert eine Uhr-Abstraktion genau dafür — TimeProvider — und einen FakeTimeProvider in Microsoft.Extensions.TimeProvider.Testing, mit dem ein Test die aktuelle Zeit setzen und vorstellen kann. Der Lizenz-Client wird zu einer Schnittstelle, damit ein Fake den echten, netzgestützten ersetzen kann.

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

Das Gate nimmt beide als Konstruktorparameter und greift nie auf ein Global zurück:

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();           // injizierbare Uhr, nicht 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 Produktion übergeben Sie TimeProvider.System und den echten KeyrightClient. In Tests übergeben Sie einen FakeTimeProvider und einen Fake-Client. Sonst ändert sich am Gate nichts.

Steuern Sie die uhrabhängigen Pfade

Mit injizierter Uhr sind Ablauf und Kulanz deterministisch. Setzen Sie die Zeit, bauen Sie ein Lease, prüfen Sie das Ergebnis:

[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 Tage nach Ablauf
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

    Assert.True(result.IsUsable);                                  // Kulanz, nicht blockiert
    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));         // über die 14-Tage-Kulanz hinaus
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

Derselbe FakeTimeProvider fängt die Uhr-Rückdrehung, den ältesten Trick zum Umgehen des Ablaufs. Wenn Ihr Gate den höchsten je gesehenen Zeitstempel speichert und eine rückwärts laufende Uhr ablehnt, beweisen Sie es:

[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")));   // merkt sich „zuletzt gesehen"

    clock.SetUtcNow(DateTimeOffset.Parse("2026-05-01Z"));            // der Nutzer dreht die Uhr zurück
    Assert.False(gate.Evaluate(Lease.Paid(DateTimeOffset.Parse("2026-07-01Z"))).ClockTrusted);
}

Faken Sie den Client, um Netzwerk- und Serverzustand zu erzwingen

Der echte Client spricht mit einem Lizenzserver; der Fake gibt zurück, was der Test braucht — einschließlich Fehlern. Ein Konnektivitätsfehler ist der, den Kunden am häufigsten treffen, und er darf einen Nutzer, dessen zwischengespeichertes Lease noch in seiner Kulanz ist, nicht aussperren:

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();   // wirft standardmäßig einen Konnektivitätsfehler
    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, aber der Cache ist gültig → weiterhin freigeschaltet
    Assert.True(result.ServedFromCache);
}

Der Widerruf ist das Spiegelbild: das Netzwerk ist oben, und der Server gibt ein als widerrufen markiertes Lease zurück. Ein Schlüssel, der beim letzten Zwischenspeichern gültig war, muss beim nächsten erfolgreichen Check-in geschlossen fehlschlagen:

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

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

Platzlimits funktionieren genauso: richten Sie OnValidate auf eine Antwort, die meldet, dass der Aktivierungspool voll ist, und prüfen Sie, dass die Aktivierung abgelehnt wird — nicht, dass die App still eine zusätzliche unlizenzierte Kopie laufen lässt.

Behalten Sie eine ehrliche durchgängige Suite

Fakes beweisen Ihre Logik. Sie beweisen nicht, dass Ihre Lease-Signaturen wirklich verifizieren oder dass Sie das echte Drahtformat korrekt parsen — ein Fake kann sich fröhlich mit einem Fehler auf beiden Seiten einig sein. Schützen Sie sich davor mit einer kleinen, separaten Integrationssuite, die einen echten Testmodus-Schlüssel gegen eine Sandbox aktiviert. Testmodus-Schlüssel werden genau wie Produktionsschlüssel ausgestellt, aber als nicht-produktiv markiert, sodass die CI sie frei erstellen und widerrufen kann, ohne je einen zahlenden Kunden zu berühren:

[Fact]
[Trait("Category", "Integration")]   // vom schnellen Unit-Lauf ausgeschlossen
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);                 // echte Signatur, echter öffentlicher Schlüssel
    Assert.Contains("pro", lease.Entitlements.Tier);
}

Lassen Sie die schnellen Fakes bei jedem Push laufen (dotnet test --filter Category!=Integration) und die Integrationssuite planmäßig oder vor jeder Veröffentlichung. Die Trennung hält die innere Schleife im Millisekundenbereich und fängt dennoch eine kaputte Signatur oder ein geändertes Feld, bevor es einen Kunden erreicht.

Die Checkliste

Wenn jeder dieser Punkte einen Test hat, hört die Lizenzierung auf, das Feature zu sein, das Sie um 2 Uhr morgens aus dem Bett holt:

  • Gültiger Schlüssel, online → freigeschaltet mit den richtigen Berechtigungen.
  • Abgelaufener Schlüssel, in der Kulanz → noch nutzbar; nach der Kulanz → blockiert.
  • Zurückgedrehte Uhr → abgelehnt, nicht belohnt.
  • Server nicht erreichbar, Cache gültig → Offline-Modus schaltet frei; Cache nach der Kulanz → blockiert.
  • Widerrufener Schlüssel beim nächsten Online-Check-in → blockiert.
  • Platzpool voll → Aktivierung abgelehnt.
  • Ein echter Testmodus-Schlüssel → aktiviert und verifiziert seine Signatur durchgängig.

Keiner dieser Fälle lebt auf dem glücklichen Pfad, und genau deshalb müssen sie als Tests festgehalten werden. Die Uhr und der Client sind die einzigen zwei Nähte, die Sie brauchen; sobald sie injizierbar sind, wird jede Aussperrung, die Ihre Kunden gefunden hätten, zu einem roten Test auf Ihrer Maschine.

Bauen Sie das in eine .NET-App? Keyright liefert Testmodus-Schlüssel und eine Sandbox, damit die obige Suite ohne Produktionsschlüssel in der CI läuft — siehe den Lizenzierungs-Leitfaden, um das Gate zuerst zu verdrahten.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.