Files
Omika/tests/Omika.Tests/Mobile/Local/EnrollmentPersistenceTests.cs
2026-10-23 18:46:50 +02:00

220 lines
8.8 KiB
C#

using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Repositories;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Services.Mobile;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests de non-régression de la persistance d'inscription : après une
/// inscription réussie, l'état « appareil inscrit » (configuration + token +
/// bootstrap) doit survivre à un redémarrage, et une erreur réseau temporaire ne
/// doit JAMAIS effacer cette inscription.
/// </summary>
public class EnrollmentPersistenceTests : LocalTestBase
{
private const string Kid = "test-kid-1";
private sealed class FakeLicenseEnrollmentClient : ILicenseEnrollmentClient
{
public MobileLicenseSessionResponse ActivateResponse { get; set; } = new();
public Task<MobileLicenseSessionResponse> ActivateAsync(
string licenseServiceUrl, LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(ActivateResponse);
public Task<MobileLicenseSessionResponse> ValidateAsync(
string licenseServiceUrl, LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new MobileLicenseSessionResponse());
}
private sealed class FakeTokenStore : ISecureTokenStore
{
public string? Token { get; private set; }
public int ClearCalls { get; private set; }
public Task SaveAsync(string token, CancellationToken cancellationToken = default)
{
Token = token;
return Task.CompletedTask;
}
public Task<string?> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Token);
public Task ClearAsync(CancellationToken cancellationToken = default)
{
Token = null;
ClearCalls++;
return Task.CompletedTask;
}
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public Task<string> GetDeviceIdAsync(CancellationToken cancellationToken = default)
=> Task.FromResult("device-1");
}
private sealed class BootstrapHandler : HttpMessageHandler
{
private readonly string _bootstrapJson;
public bool FailWithNetworkError { get; set; }
public BootstrapHandler(string bootstrapJson)
{
_bootstrapJson = bootstrapJson;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (FailWithNetworkError)
{
throw new HttpRequestException("network down");
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_bootstrapJson, Encoding.UTF8, "application/json")
});
}
}
private static (string qr, IReadOnlyDictionary<string, string> registry) CreateQr()
{
using var rsa = RSA.Create(2048);
var registry = new Dictionary<string, string> { [Kid] = rsa.ToXmlString(false) };
string qr = EnrollmentQrCodec.Sign(
EnrollmentQrCodec.BuildPayloadJson(Kid, "BATIK.01", "LIC-001", "https://licence.example.com"),
rsa.ToXmlString(true));
return (qr, registry);
}
private static MobileLicenseSessionResponse ValidLicence(string apiUrl) => new()
{
License = new LicenseResponse
{
IsValid = true,
ApiUrl = apiUrl,
CodeClient = "BATIK.01",
NumeroLicence = "LIC-001",
OfflineGraceDays = 7
},
AccessToken = "token-persisted",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
private static string BootstrapJson() => """
{
"utilisateur": { "id": "U1", "login": "dupont", "nomAffiche": "Dupont" },
"licence": { "isValid": true, "codeClient": "BATIK.01", "numeroLicence": "LIC-001", "offlineGraceDays": 7 }
}
""";
private static string ExpiredToken()
{
var payload = new { ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() };
string b64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(payload))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
return b64 + ".signature-ignored";
}
private (MobileSessionService session, MobileEnrollmentService enrollment) CreateServices(
MobileApiClient apiClient,
MobileEnrollmentStore store,
ISecureTokenStore tokenStore,
IReadOnlyDictionary<string, string> registry)
{
var session = new MobileSessionService(
apiClient, tokenStore, new FakeDeviceIdProvider(),
new LocalBootstrapRepository(Database),
NullLogger<MobileSessionService>.Instance);
var enrollment = new MobileEnrollmentService(
new FakeLicenseEnrollmentClient { ActivateResponse = ValidLicence("https://api.example.com") },
store, new FakeDeviceIdProvider(), session, apiClient, registry);
return (session, enrollment);
}
[Fact]
public async Task Inscription_Redemarrage_IsEnrolledResteVrai()
{
// Test 5 : inscription réussie → état sauvegardé → recréation des services
// (simulation redémarrage) → état relu → IsEnrolled = true.
var (qr, registry) = CreateQr();
var tokenStore = new FakeTokenStore();
var handler = new BootstrapHandler(BootstrapJson());
var store = new MobileEnrollmentStore(Database);
// Premier lancement : inscription.
var api1 = new MobileApiClient(new HttpClient(handler));
var (session1, enrollment1) = CreateServices(api1, store, tokenStore, registry);
EnrollmentResult result = await enrollment1.EnrollAsync(qr);
Assert.True(result.Success);
// État immédiatement après inscription : config + token + bootstrap présents.
Assert.NotNull(await store.LoadAsync());
Assert.Equal("token-persisted", tokenStore.Token);
Assert.True(await session1.HasValidSessionAsync());
// Redémarrage : nouvelles instances de service, MÊME base SQLite + MÊME
// token store (aucun état conservé en mémoire entre les deux).
var api2 = new MobileApiClient(new HttpClient(handler));
var (session2, enrollment2) = CreateServices(api2, store, tokenStore, registry);
// État relu après redémarrage.
MobileEnrollmentConfiguration? reloaded = await enrollment2.LoadAsync();
Assert.NotNull(reloaded);
Assert.Equal("https://api.example.com", reloaded!.OmikaApiUrl);
Assert.Equal("BATIK.01", reloaded.CodeClient);
Assert.True(await session2.HasValidSessionAsync());
}
[Fact]
public async Task Inscription_ErreurReseauAuRenouvellement_NeSupprimePasLInscription()
{
// Test 6 : appareil déjà inscrit, API inaccessible → l'inscription locale
// n'est pas supprimée et l'appareil ne retombe pas en « jamais inscrit ».
var (qr, registry) = CreateQr();
var tokenStore = new FakeTokenStore();
var handler = new BootstrapHandler(BootstrapJson());
var store = new MobileEnrollmentStore(Database);
var bootstrapRepository = new LocalBootstrapRepository(Database);
var api = new MobileApiClient(new HttpClient(handler));
var (session, enrollment) = CreateServices(api, store, tokenStore, registry);
// Inscription réussie.
Assert.True((await enrollment.EnrollAsync(qr)).Success);
Assert.NotNull(await store.LoadAsync());
// Token expiré + API inaccessible (certificat/ réseau/DNS).
await tokenStore.SaveAsync(ExpiredToken());
handler.FailWithNetworkError = true;
// Le renouvellement échoue de façon TRANSITOIRE (jamais un rejet terminal).
SessionRevalidationResult renewal = await session.EnsureValidSessionAsync();
Assert.Equal(SessionRevalidationOutcome.TransientError, renewal.Outcome);
// L'inscription locale n'est PAS supprimée : config toujours présente,
// token (expiré) conservé, aucun Clear, bootstrap toujours présent.
Assert.NotNull(await store.LoadAsync());
Assert.Equal("https://api.example.com", (await store.LoadAsync())!.OmikaApiUrl);
Assert.NotNull(tokenStore.Token);
Assert.Equal(0, tokenStore.ClearCalls);
Assert.NotNull(await bootstrapRepository.GetAsync());
Assert.NotNull((await bootstrapRepository.GetAsync())!.Licence);
}
}