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; /// /// 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. /// public class EnrollmentPersistenceTests : LocalTestBase { private const string Kid = "test-kid-1"; private sealed class FakeLicenseEnrollmentClient : ILicenseEnrollmentClient { public MobileLicenseSessionResponse ActivateResponse { get; set; } = new(); public Task ActivateAsync( string licenseServiceUrl, LicenseActivateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(ActivateResponse); public Task 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 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 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 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 registry) CreateQr() { using var rsa = RSA.Create(2048); var registry = new Dictionary { [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 registry) { var session = new MobileSessionService( apiClient, tokenStore, new FakeDeviceIdProvider(), new LocalBootstrapRepository(Database), NullLogger.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); } }