Skip to content
← Todas las publicaciones
· Delta1 Labs LicenciamientoGuía

Cómo probar la integración de licenciamiento de tu software

Los errores de licenciamiento llegan a producción porque el único camino que alguien ejerce es «clave válida, servidor arriba, reloj correcto». Aquí tienes cómo poner la caducidad, la revocación, la gracia sin conexión y los límites de puestos bajo pruebas automatizadas: inyecta el reloj, falsea el cliente de licencias y dirige cada caso límite desde xUnit para que CI detecte los bloqueos antes que tus clientes.

La mayor parte del código de licenciamiento se prueba exactamente una vez, a mano, el día en que se escribe: pega una clave válida, mira cómo la app se desbloquea, envíala. Cada camino que importa —la clave que caducó durante la noche, el servidor inalcanzable en la red restringida de un cliente, el puesto que debería haberse rechazado, la suscripción que se renovó un segundo tarde— se descubre en producción, por el cliente, como un bloqueo. Ese es el peor lugar posible para encontrar un error de licenciamiento, porque la persona que lo sufre ya te pagó.

La razón por la que estos caminos quedan sin probar no es pereza. Es que el comportamiento interesante depende de tres cosas sobre las que una prueba ordinaria no tiene control: el reloj (caducidad, gracia, retroceso del reloj), la red (modo sin conexión) y el estado en el servidor (revocación, conteos de puestos). Si tu puerta lee DateTime.UtcNow directamente y construye un cliente HTTP concreto en línea, no hay costura para mover ninguno de ellos, así que el único caso que puedes ejecutar es «válido, en línea, ahora». Haz esos tres inyectables y cada caso límite se convierte en una prueba unitaria sencilla.

Construye primero la costura

Toda la técnica se apoya en dos dependencias inyectadas: un reloj y un cliente de licencias. .NET 8 incluye una abstracción de reloj justo para esto —TimeProvider— y un FakeTimeProvider en Microsoft.Extensions.TimeProvider.Testing que permite a una prueba fijar y avanzar la hora actual. El cliente de licencias se convierte en una interfaz para que un falso pueda sustituir al real respaldado por la red.

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

La puerta toma ambos como parámetros del constructor y nunca recurre a 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();           // reloj inyectable, no 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 producción pasas TimeProvider.System y el KeyrightClient real. En pruebas pasas un FakeTimeProvider y un cliente falso. Nada más en la puerta cambia.

Dirige los caminos que dependen del reloj

Con el reloj inyectado, la caducidad y la gracia son deterministas. Fija la hora, construye una lease, verifica el resultado:

[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 días pasada la caducidad
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

    Assert.True(result.IsUsable);                                  // gracia, no bloqueado
    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));         // pasada la gracia de 14 días
    var gate   = new LicenseGate(new FakeLicenseClient(), clock, grace: TimeSpan.FromDays(14));

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

El mismo FakeTimeProvider atrapa el retroceso del reloj, el truco más viejo para esquivar la caducidad. Si tu puerta persiste la marca de tiempo más alta que ha visto y rechaza un reloj que va hacia atrás, demuéstralo:

[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")));   // registra el «visto por última vez»

    clock.SetUtcNow(DateTimeOffset.Parse("2026-05-01Z"));            // el usuario atrasa el reloj
    Assert.False(gate.Evaluate(Lease.Paid(DateTimeOffset.Parse("2026-07-01Z"))).ClockTrusted);
}

Falsea el cliente para forzar el estado de red y de servidor

El cliente real habla con un servidor de licencias; el falso devuelve lo que la prueba necesite, incluidos los fallos. Un error de conectividad es el que más sufren los clientes, y no debe bloquear a un usuario cuya lease cacheada aún está dentro de su gracia:

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();   // por defecto lanza un error de conectividad
    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);           // sin conexión, pero la caché es válida → aún desbloqueado
    Assert.True(result.ServedFromCache);
}

La revocación es la imagen especular: la red está arriba y el servidor devuelve una lease marcada como revocada. Una clave que era válida la última vez que se cacheó debe fallar de forma cerrada en el siguiente check-in exitoso:

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

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

Los límites de puestos funcionan igual: apunta OnValidate a una respuesta que informe de que el grupo de activación está lleno y verifica que la activación se rechaza, no que la app ejecute en silencio una copia extra sin licencia.

Mantén una suite honesta de extremo a extremo

Los falsos demuestran tu lógica. No demuestran que tus firmas de lease de verdad verifiquen, ni que parsees correctamente el formato de cable real: un falso puede estar felizmente de acuerdo con un error en ambos lados. Protégete de eso con una suite de integración pequeña y separada que active una clave real en modo prueba contra un sandbox. Las claves en modo prueba se emiten exactamente como las de producción pero marcadas como no productivas, así que CI puede crearlas y revocarlas libremente sin tocar jamás a un cliente que paga:

[Fact]
[Trait("Category", "Integration")]   // excluida de la ejecución unitaria rápida
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);                 // firma real, clave pública real
    Assert.Contains("pro", lease.Entitlements.Tier);
}

Ejecuta los falsos rápidos en cada push (dotnet test --filter Category!=Integration) y la suite de integración de forma programada o antes de cada versión. La separación mantiene el bucle interno en milisegundos mientras sigue atrapando una firma rota o un campo cambiado antes de que llegue a un cliente.

La lista de verificación

Si cada uno de estos tiene una prueba, el licenciamiento deja de ser la función que te despierta a las 2 de la madrugada:

  • Clave válida, en línea → desbloqueada con los derechos correctos.
  • Clave caducada, dentro de la gracia → aún usable; pasada la gracia → bloqueada.
  • Reloj atrasado → rechazado, no premiado.
  • Servidor inalcanzable, caché válida → el modo sin conexión desbloquea; caché pasada la gracia → bloqueada.
  • Clave revocada en el siguiente check-in en línea → bloqueada.
  • Grupo de puestos lleno → activación rechazada.
  • Una clave real en modo prueba → activa y verifica su firma de extremo a extremo.

Ninguno de estos vive en el camino feliz, que es exactamente por lo que hay que escribirlos como pruebas. El reloj y el cliente son las dos únicas costuras que necesitas; en cuanto son inyectables, cada bloqueo que tus clientes habrían encontrado se convierte en una prueba en rojo en tu máquina.

¿Lo estás construyendo en una app .NET? Keyright incluye claves en modo prueba y un sandbox para que la suite de arriba se ejecute en CI sin una clave de producción: consulta la guía de licenciamiento para cablear primero la puerta.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.