using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Repositories; using Omika.App.Local.Services; using Omika.App.Local.Sqlite; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Services.Mobile; namespace Omika.Tests.Mobile.Local; /// /// Tests de bout en bout de l'inscription sans réseau réel : QR → licence → API → /// stockage → redémarrage → bootstrap, ainsi que le scénario multi-client et le /// reset de changement de client (aucune contamination d'URL). /// public class EnrollmentEndToEndTests : 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 FakeDeviceIdProvider : IDeviceIdProvider { public Task GetDeviceIdAsync(CancellationToken cancellationToken = default) => Task.FromResult("device-1"); } private sealed class BootstrapCapturingHandler : HttpMessageHandler { public string? LastUri { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastUri = request.RequestUri?.ToString(); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }); } } private static (string qr, IReadOnlyDictionary registry) CreateQr(string code, string licenceUrl) { using var rsa = RSA.Create(2048); var registry = new Dictionary { [Kid] = rsa.ToXmlString(false) }; string qr = EnrollmentQrCodec.Sign( EnrollmentQrCodec.BuildPayloadJson(Kid, code, "LIC-001", licenceUrl), rsa.ToXmlString(true)); return (qr, registry); } private MobileEnrollmentService CreateService( MobileLicenseSessionResponse licenceResponse, MobileEnrollmentStore store, MobileApiClient apiClient, IReadOnlyDictionary registry) => new( new FakeLicenseEnrollmentClient { ActivateResponse = licenceResponse }, store, new FakeDeviceIdProvider(), new StubSessionService(), apiClient, registry); private static MobileLicenseSessionResponse ValidLicence(string apiUrl) => new() { License = new LicenseResponse { IsValid = true, ApiUrl = apiUrl }, AccessToken = "token-e2e" }; [Fact] public async Task E2E_UrlBootstrap_Egale_UrlRetourneeParLicence() { var store = new MobileEnrollmentStore(Database); var handler = new BootstrapCapturingHandler(); var apiClient = new MobileApiClient(new HttpClient(handler)); var (qr, registry) = CreateQr("BATIK.01", "https://licence-a.example.com"); var service = CreateService(ValidLicence("https://api-a.example.com"), store, apiClient, registry); EnrollmentResult result = await service.EnrollAsync(qr); Assert.True(result.Success); // Redémarrage simulé : nouvelle instance de service, même base locale. var restartApiClient = new MobileApiClient(new HttpClient(handler)); var restartService = CreateService(ValidLicence("https://api-a.example.com"), store, restartApiClient, registry); MobileEnrollmentConfiguration? loaded = await restartService.LoadAsync(); Assert.NotNull(loaded); restartApiClient.BaseUrl = loaded!.OmikaApiUrl; restartApiClient.AccessToken = "token-e2e"; await restartApiClient.GetBootstrapAsync(); Assert.Equal("https://api-a.example.com/api/mobile/bootstrap", handler.LastUri); } [Fact] public async Task MultiClient_ClientA_ClientB_UrlsDistinctesSansContamination() { // Deux bases locales distinctes (deux appareils) : même code, même binaire. using var dbA = new LocalDatabase(new LocalDatabaseOptions()); using var dbB = new LocalDatabase(new LocalDatabaseOptions()); dbA.InitializeSchemaAsync().GetAwaiter().GetResult(); dbB.InitializeSchemaAsync().GetAwaiter().GetResult(); var storeA = new MobileEnrollmentStore(dbA); var storeB = new MobileEnrollmentStore(dbB); var apiA = new MobileApiClient(new HttpClient()); var apiB = new MobileApiClient(new HttpClient()); var (qrA, regA) = CreateQr("CLIENT.A", "https://licence-a.example.com"); var (qrB, regB) = CreateQr("CLIENT.B", "https://licence-b.example.com"); EnrollmentResult resA = await CreateService(ValidLicence("https://api-a.example.com"), storeA, apiA, regA).EnrollAsync(qrA); EnrollmentResult resB = await CreateService(ValidLicence("https://api-b.example.com"), storeB, apiB, regB).EnrollAsync(qrB); Assert.True(resA.Success); Assert.True(resB.Success); Assert.Equal("https://api-a.example.com", apiA.BaseUrl); Assert.Equal("https://api-b.example.com", apiB.BaseUrl); Assert.Equal("https://api-a.example.com", (await storeA.LoadAsync())!.OmikaApiUrl); Assert.Equal("https://api-b.example.com", (await storeB.LoadAsync())!.OmikaApiUrl); } [Fact] public async Task Reset_ClientA_Vers_ClientB_AucuneReutilisationApiA() { var store = new MobileEnrollmentStore(Database); var apiClient = new MobileApiClient(new HttpClient()); var (qrA, regA) = CreateQr("CLIENT.A", "https://licence-a.example.com"); // Inscription Client A. await CreateService(ValidLicence("https://api-a.example.com"), store, apiClient, regA).EnrollAsync(qrA); Assert.Equal("https://api-a.example.com", apiClient.BaseUrl); // Reset complet (désinscription) : table + token + client API réinitialisés. var tokenStore = new FakeTokenStore(); var maintenance = new LocalDataMaintenanceService(Database, "unused", tokenStore); await maintenance.ResetAsync(); apiClient.BaseUrl = null; apiClient.AccessToken = null; Assert.Null(await store.LoadAsync()); Assert.Null(apiClient.BaseUrl); Assert.True(tokenStore.Cleared); // Inscription Client B. var (qrB, regB) = CreateQr("CLIENT.B", "https://licence-b.example.com"); EnrollmentResult resB = await CreateService(ValidLicence("https://api-b.example.com"), store, apiClient, regB).EnrollAsync(qrB); Assert.True(resB.Success); Assert.Equal("https://api-b.example.com", apiClient.BaseUrl); Assert.Equal("https://api-b.example.com", (await store.LoadAsync())!.OmikaApiUrl); Assert.NotEqual("https://api-a.example.com", apiClient.BaseUrl); } private sealed class FakeTokenStore : ISecureTokenStore { public bool Cleared { get; private set; } public Task SaveAsync(string token, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ClearAsync(CancellationToken cancellationToken = default) { Cleared = true; return Task.CompletedTask; } } }