using Microsoft.Extensions.Logging.Abstractions; using Omika.App.Local.Abstractions; using Omika.App.Local.Services; using Omika.Core.Configuration; using Omika.Core.Contracts.Mobile.License; namespace Omika.Tests.Mobile.Local; /// /// Tests du mécanisme de licence de développement locale (MICRO-LOT). /// /// Règles vérifiées : /// 1. Development + licence dev activée → licence acceptée localement. /// 2. Development + licence dev désactivée → comportement normal de licence. /// 3. Production/Release → la licence de développement ne peut jamais être /// utilisée (le bypass est compilé hors sous #if DEBUG). /// 4. Aucune requête au serveur de licence lorsque le mode dev est actif. /// public class DevelopmentLicenseTests : LocalTestBase { private static DevelopmentLicenseOptions EnabledOptions() => new() { Enabled = true, ClientCode = "LOCAL-DEV", UserDisplayName = "Utilisateur test", ExpirationDays = 365 }; private static DevelopmentLicenseOptions DisabledOptions() => new() { Enabled = false, ClientCode = "LOCAL-DEV", UserDisplayName = "Utilisateur test", ExpirationDays = 365 }; private static DevelopmentLicenseProvider CreateProvider(DevelopmentLicenseOptions options) => new(options, NullLogger.Instance); // ------------------------------------------------------------------ // 1. Development + licence dev activée → licence acceptée localement. // ------------------------------------------------------------------ [Fact] public async Task Provider_DevActive_RetourneLicenceLocaleValide() { // Arrange var provider = CreateProvider(EnabledOptions()); // Act LicenseResponse? license = await provider.TryGetDevelopmentLicenseAsync(); // Assert Assert.NotNull(license); Assert.True(license.IsValid); Assert.Equal("LOCAL-DEV", license.CodeClient); Assert.Equal("Utilisateur test", license.UserNom); Assert.Equal(LicenseErrorCodes.Ok, license.ErrorCode); Assert.NotNull(license.DateExpiration); Assert.True(license.DateExpiration > DateTime.Now); } [Fact] public async Task Provider_DevActive_ExpirationSuffisammentLongue() { // Arrange var provider = CreateProvider(EnabledOptions()); // Act LicenseResponse? license = await provider.TryGetDevelopmentLicenseAsync(); // Assert : expiration longue pour le test local (365 jours). Assert.NotNull(license); Assert.NotNull(license.DateExpiration); Assert.True(license.DateExpiration > DateTime.Now.AddDays(300)); } [Fact] public async Task SessionService_DevActive_ActivationLocale_SansAppelServeur() { // Arrange : provider dev actif + API qui échouerait si appelée. var provider = CreateProvider(EnabledOptions()); var api = new ThrowingApiClient(); var service = CreateSessionService(api, provider); // Act : activation locale. LicenseResponse result = await service.ActivateAsync("LOCAL-DEV", "DEV-LOCAL-LICENSE"); // Assert : licence locale valide, AUCUN appel serveur (l'API lèverait sinon). Assert.True(result.IsValid); Assert.Equal("LOCAL-DEV", result.CodeClient); Assert.Equal(0, api.ActivateCalls); } [Fact] public async Task SessionService_DevActive_HasValidSession_Vrai() { // Arrange var provider = CreateProvider(EnabledOptions()); var api = new ThrowingApiClient(); var service = CreateSessionService(api, provider); // Act bool hasValid = await service.HasValidSessionAsync(); // Assert : session considérée valide localement (accès direct au Shell). Assert.True(hasValid); } // ------------------------------------------------------------------ // 2. Development + licence dev désactivée → comportement normal. // ------------------------------------------------------------------ [Fact] public async Task Provider_DevDesactive_RetourneNull() { // Arrange var provider = CreateProvider(DisabledOptions()); // Act LicenseResponse? license = await provider.TryGetDevelopmentLicenseAsync(); // Assert : aucune licence locale. Assert.Null(license); } [Fact] public async Task SessionService_DevDesactive_ActivationAppelleServeur() { // Arrange : provider dev désactivé + API qui enregistre les appels. var provider = CreateProvider(DisabledOptions()); var api = new RecordingApiClient(); var service = CreateSessionService(api, provider); // Act : activation normale. LicenseResponse result = await service.ActivateAsync("CLIENT01", "LIC-001"); // Assert : le serveur est appelé (comportement normal). Assert.Equal(1, api.ActivateCalls); Assert.NotNull(api.LastActivateRequest); Assert.Equal("CLIENT01", api.LastActivateRequest.CodeClient); Assert.Equal("LIC-001", api.LastActivateRequest.NumeroLicence); } [Fact] public async Task SessionService_DevDesactive_HasValidSession_Faux() { // Arrange : aucune session locale. var provider = CreateProvider(DisabledOptions()); var api = new RecordingApiClient(); var service = CreateSessionService(api, provider); // Act bool hasValid = await service.HasValidSessionAsync(); // Assert : pas de session valide → écran d'activation. Assert.False(hasValid); } // ------------------------------------------------------------------ // 3. Production/Release → la licence de développement ne peut jamais // être utilisée. // ------------------------------------------------------------------ [Fact] public async Task Provider_DevDesactive_ComportementRelease() { // En Release, le bloc #if DEBUG est compilé hors : le provider retourne // toujours null, exactement comme ici avec Enabled=false. Ce test // documente le contrat de sécurité : aucune licence locale en production. var provider = CreateProvider(DisabledOptions()); LicenseResponse? license = await provider.TryGetDevelopmentLicenseAsync(); Assert.Null(license); } [Fact] public async Task SessionService_SansProvider_ComportementNormal() { // En Release, AppServices ne fournit PAS de provider (compilé hors). // Le service doit donc se comporter normalement : appel serveur. var api = new RecordingApiClient(); var service = CreateSessionService(api, developmentLicenseProvider: null); LicenseResponse result = await service.ActivateAsync("CLIENT01", "LIC-001"); Assert.Equal(1, api.ActivateCalls); Assert.NotNull(api.LastActivateRequest); } // ------------------------------------------------------------------ // 4. Aucune requête au serveur de licence lorsque le mode dev est actif. // ------------------------------------------------------------------ [Fact] public async Task SessionService_DevActive_AucunAppelServeur() { // Arrange : provider dev actif + API qui lève si appelée. var provider = CreateProvider(EnabledOptions()); var api = new ThrowingApiClient(); var service = CreateSessionService(api, provider); // Act : activation locale. LicenseResponse result = await service.ActivateAsync("LOCAL-DEV", "DEV-LOCAL-LICENSE"); // Assert : aucun appel serveur (ThrowingApiClient lèverait sinon). Assert.True(result.IsValid); Assert.Equal(0, api.ActivateCalls); } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ private MobileSessionService CreateSessionService( IMobileApiClient api, IDevelopmentLicenseProvider? developmentLicenseProvider) => new( api, new FakeTokenStore(), new FakeDeviceIdProvider(), BootstrapRepository, NullLogger.Instance, developmentLicenseProvider); private sealed class FakeTokenStore : ISecureTokenStore { public string? Token { get; private set; } public Task SaveAsync(string token, CancellationToken ct = default) { Token = token; return Task.CompletedTask; } public Task GetAsync(CancellationToken ct = default) => Task.FromResult(Token); public Task ClearAsync(CancellationToken ct = default) { Token = null; return Task.CompletedTask; } } private sealed class FakeDeviceIdProvider : IDeviceIdProvider { public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult("device-dev-1"); } /// /// Client API qui lève une exception si appelé : prouve qu'aucune requête /// serveur n'est émise en mode dev actif. /// private sealed class ThrowingApiClient : IMobileApiClient { public string? AccessToken { get; set; } public int ActivateCalls { get; private set; } public Task ActivateAsync( LicenseActivateRequest request, CancellationToken ct = default) { ActivateCalls++; throw new InvalidOperationException("Le serveur de licence ne doit PAS être appelé en mode dev."); } public Task ValidateAsync( LicenseValidateRequest request, CancellationToken ct = default) => throw new InvalidOperationException("Le serveur de licence ne doit PAS être appelé en mode dev."); public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default) => throw new InvalidOperationException("Le serveur ne doit PAS être appelé en mode dev."); public Task GetBootstrapAsync(CancellationToken ct = default) => throw new InvalidOperationException("Le serveur ne doit PAS être appelé en mode dev."); public Task SendOperationAsync( Omika.Core.Contracts.Mobile.Sync.SyncOperation operation, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetStatusAsync( Guid operationId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetSnapshotAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task DownloadDocumentAsync( Guid documentGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task UploadDocumentAsync( Omika.Core.Contracts.Mobile.Documents.DocumentUploadRequest request, CancellationToken ct = default) => throw new NotImplementedException(); } /// /// Client API qui enregistre les appels pour vérifier le comportement normal. /// private sealed class RecordingApiClient : IMobileApiClient { public string? AccessToken { get; set; } public int ActivateCalls { get; private set; } public LicenseActivateRequest? LastActivateRequest { get; private set; } public Task ActivateAsync( LicenseActivateRequest request, CancellationToken ct = default) { ActivateCalls++; LastActivateRequest = request; return Task.FromResult(new Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = false, ErrorCode = "E1001", ErrorMessage = "Licence inconnue" } }); } public Task ValidateAsync( LicenseValidateRequest request, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetBootstrapAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task SendOperationAsync( Omika.Core.Contracts.Mobile.Sync.SyncOperation operation, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetStatusAsync( Guid operationId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetSnapshotAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task DownloadDocumentAsync( Guid documentGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task UploadDocumentAsync( Omika.Core.Contracts.Mobile.Documents.DocumentUploadRequest request, CancellationToken ct = default) => throw new NotImplementedException(); } }