Skip to content
← Tous les articles
· Delta1 Labs LicencesGuide

Comment tester l'intégration de licence de votre logiciel

Les bugs de licence partent en production parce que le seul chemin que quelqu'un exerce est « clé valide, serveur disponible, horloge correcte ». Voici comment mettre l'expiration, la révocation, la grâce hors ligne et les limites de postes sous test automatisé : injectez l'horloge, simulez le client de licence et pilotez chaque cas limite depuis xUnit pour que la CI attrape les blocages avant vos clients.

La plupart du code de licence est testé exactement une fois, à la main, le jour où il est écrit : collez une clé valide, regardez l’app se déverrouiller, livrez. Chaque chemin qui compte — la clé qui a expiré dans la nuit, le serveur injoignable sur le réseau verrouillé d’un client, le poste qui aurait dû être refusé, l’abonnement renouvelé une seconde trop tard — est découvert en production, par le client, sous forme de blocage. C’est le pire endroit possible pour trouver un bug de licence, car la personne qui le subit vous a déjà payé.

La raison pour laquelle ces chemins restent non testés n’est pas la paresse. C’est que le comportement intéressant dépend de trois choses sur lesquelles un test ordinaire n’a aucun contrôle : l’horloge (expiration, grâce, recul d’horloge), le réseau (repli hors ligne) et l’état côté serveur (révocation, décompte de postes). Si votre porte lit DateTime.UtcNow directement et construit un client HTTP concret en ligne, il n’y a aucune couture pour déplacer l’un d’eux, donc le seul cas exécutable est « valide, en ligne, maintenant ». Rendez ces trois éléments injectables et chaque cas limite devient un simple test unitaire.

Construisez d’abord la couture

Toute la technique repose sur deux dépendances injectées : une horloge et un client de licence. .NET 8 fournit une abstraction d’horloge faite pour cela — TimeProvider — et un FakeTimeProvider dans Microsoft.Extensions.TimeProvider.Testing qui permet à un test de fixer et d’avancer l’heure courante. Le client de licence devient une interface pour qu’un faux puisse remplacer le vrai, adossé au réseau.

LicenseGate TimeProvider ILicenseClient Système (prod) FakeTimeProvider Keyright (prod) FakeLicenseClient

La porte prend les deux comme paramètres de constructeur et ne recourt jamais à un 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();           // horloge injectable, pas 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);
    }
}

En production, vous passez TimeProvider.System et le vrai KeyrightClient. En test, vous passez un FakeTimeProvider et un faux client. Rien d’autre dans la porte ne change.

Pilotez les chemins dépendants de l’horloge

Avec l’horloge injectée, l’expiration et la grâce sont déterministes. Fixez l’heure, construisez un lease, vérifiez le résultat :

[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 jours après l'expiration
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

    Assert.True(result.IsUsable);                                  // grâce, pas bloqué
    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));         // au-delà de la grâce de 14 jours
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

Le même FakeTimeProvider attrape le recul d’horloge, la plus vieille astuce pour esquiver l’expiration. Si votre porte conserve l’horodatage le plus élevé qu’elle ait vu et refuse une horloge qui recule, prouvez-le :

[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")));   // enregistre le « vu la dernière fois »

    clock.SetUtcNow(DateTimeOffset.Parse("2026-05-01Z"));            // l'utilisateur recule l'horloge
    Assert.False(gate.Evaluate(Lease.Paid(DateTimeOffset.Parse("2026-07-01Z"))).ClockTrusted);
}

Simulez le client pour forcer l’état du réseau et du serveur

Le vrai client parle à un serveur de licences ; le faux renvoie ce dont le test a besoin — y compris les échecs. Une erreur de connectivité est celle que les clients rencontrent le plus, et elle ne doit pas bloquer un utilisateur dont le lease en cache est encore dans sa grâce :

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();   // par défaut, lève une erreur de connectivité
    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);           // hors ligne, mais le cache est valide → encore déverrouillé
    Assert.True(result.ServedFromCache);
}

La révocation est l’image miroir : le réseau est disponible, et le serveur renvoie un lease marqué révoqué. Une clé qui était valide au dernier cache doit échouer en mode fermé au prochain check-in réussi :

[Fact]
public async Task Revoked_key_is_blocked_on_next_online_check()
{
    var client = new FakeLicenseClient
    {
        OnValidate = _ => Task.FromResult(Lease.Revoked())   // le serveur dit : révoqué
    };
    var sut = new LicenseService(client, new FakeTimeProvider(), cache: CacheWith(Lease.Paid(...)));

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

Les limites de postes fonctionnent pareil : pointez OnValidate vers une réponse indiquant que le pool d’activation est plein et vérifiez que l’activation est refusée, et non que l’app exécute en silence une copie supplémentaire sans licence.

Gardez une suite honnête de bout en bout

Les faux prouvent votre logique. Ils ne prouvent pas que vos signatures de lease se vérifient vraiment, ni que vous analysez correctement le vrai format filaire — un faux peut volontiers s’accorder avec un bug des deux côtés. Prémunissez-vous avec une petite suite d’intégration séparée qui active une vraie clé en mode test contre un bac à sable. Les clés en mode test sont émises exactement comme celles de production mais marquées non productives, donc la CI peut les créer et les révoquer librement sans jamais toucher à un client payant :

[Fact]
[Trait("Category", "Integration")]   // exclue de l'exécution unitaire rapide
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);                 // vraie signature, vraie clé publique
    Assert.Contains("pro", lease.Entitlements.Tier);
}

Exécutez les faux rapides à chaque push (dotnet test --filter Category!=Integration) et la suite d’intégration de façon planifiée ou avant chaque version. La séparation garde la boucle interne en millisecondes tout en attrapant une signature cassée ou un champ modifié avant qu’il n’atteigne un client.

La liste de contrôle

Si chacun de ces points a un test, la licence cesse d’être la fonctionnalité qui vous réveille à 2 h du matin :

  • Clé valide, en ligne → déverrouillée avec les bons droits.
  • Clé expirée, dans la grâce → encore utilisable ; passé la grâce → bloquée.
  • Horloge reculée → refusée, pas récompensée.
  • Serveur injoignable, cache valide → le mode hors ligne déverrouille ; cache passé la grâce → bloqué.
  • Clé révoquée au prochain check-in en ligne → bloquée.
  • Pool de postes plein → activation refusée.
  • Une vraie clé en mode test → active et vérifie sa signature de bout en bout.

Aucun de ces cas ne vit sur le chemin heureux, c’est exactement pourquoi il faut les écrire comme des tests. L’horloge et le client sont les deux seules coutures dont vous avez besoin ; dès qu’elles sont injectables, chaque blocage que vos clients auraient trouvé devient un test rouge sur votre machine.

Vous le construisez dans une app .NET ? Keyright fournit des clés en mode test et un bac à sable pour que la suite ci-dessus s’exécute en CI sans clé de production — voyez le guide de licence pour câbler d’abord la porte.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.