using System.Net.Http; 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.Services; using Omika.Core.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Mobile.Documents; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Contracts.Mobile.Snapshot; using Omika.Core.Contracts.Mobile.Sync; namespace Omika.Tests.Mobile.Local; /// /// Tests du MobileSessionService : activation, validation, persistance du token /// (secure storage), bootstrap local, période offline autorisée et suppression /// de session sans toucher aux données métier. /// public class MobileSessionServiceTests : LocalTestBase { private sealed class FakeTokenStore : ISecureTokenStore { public string? Token { get; private set; } public int SaveCalls { get; private set; } public int ClearCalls { get; private set; } public Task SaveAsync(string token, CancellationToken ct = default) { Token = token; SaveCalls++; return Task.CompletedTask; } public Task GetAsync(CancellationToken ct = default) => Task.FromResult(Token); public Task ClearAsync(CancellationToken ct = default) { Token = null; ClearCalls++; return Task.CompletedTask; } } private sealed class FakeDeviceIdProvider : IDeviceIdProvider { public string DeviceId { get; set; } = "device-123"; public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult(DeviceId); } private sealed class FakeApiClient : IMobileApiClient { public string? AccessToken { get; set; } public MobileLicenseSessionResponse ActivateToReturn { get; set; } = new(); public MobileLicenseSessionResponse ValidateToReturn { get; set; } = new(); public MobileBootstrapResponse BootstrapToReturn { get; set; } = new(); public LicenseActivateRequest? LastActivateRequest { get; private set; } public LicenseValidateRequest? LastValidateRequest { get; private set; } public int BootstrapCalls { get; private set; } public int ValidateCalls { get; private set; } public Exception? ValidateException { get; set; } public Task ActivateAsync( LicenseActivateRequest request, CancellationToken ct = default) { LastActivateRequest = request; return Task.FromResult(ActivateToReturn); } public Task ValidateAsync( LicenseValidateRequest request, CancellationToken ct = default) { LastValidateRequest = request; ValidateCalls++; if (ValidateException is not null) { throw ValidateException; } return Task.FromResult(ValidateToReturn); } public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetBootstrapAsync(CancellationToken ct = default) { BootstrapCalls++; return Task.FromResult(BootstrapToReturn); } public Task SendOperationAsync(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(DocumentUploadRequest request, CancellationToken ct = default) => throw new NotImplementedException(); } private readonly FakeTokenStore _tokenStore = new(); private readonly FakeDeviceIdProvider _deviceId = new(); private readonly FakeApiClient _api = new(); private readonly MobileSessionService _service; public MobileSessionServiceTests() { _service = new MobileSessionService( _api, _tokenStore, _deviceId, BootstrapRepository, NullLogger.Instance); } private static LicenseResponse ValidLicense() => new() { IsValid = true, CodeClient = "CLIENT01", NumeroLicence = "LIC-001", NomSociete = "Société Test", UserNom = "Dupont", UserPrenom = "Jean", OfflineGraceDays = 7 }; private static MobileLicenseSessionResponse ValidSession() => new() { License = ValidLicense(), AccessToken = "token-secret-123", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }; [Fact] public async Task SansSession_HasValidSession_Faux() { // Act bool result = await _service.HasValidSessionAsync(); // Assert Assert.False(result); } [Fact] public async Task SansSession_HasExpiredSession_Faux() { // Act bool result = await _service.HasExpiredSessionAsync(); // Assert Assert.False(result); } [Fact] public async Task Activation_Valide_PersisteTokenEtBootstrap() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Utilisateur = new Omika.Core.Contracts.Security.CurrentUserDto { NomAffiche = "Dupont" }, Licence = ValidLicense() }; // Act LicenseResponse result = await _service.ActivateAsync("CLIENT01", "LIC-001"); // Assert : licence valide, token persisté, bootstrap persisté. Assert.True(result.IsValid); Assert.Equal("token-secret-123", _tokenStore.Token); Assert.Equal(1, _tokenStore.SaveCalls); Assert.Equal(1, _api.BootstrapCalls); LocalBootstrapState? bootstrap = await _service.GetBootstrapAsync(); Assert.NotNull(bootstrap); Assert.Equal("CLIENT01", bootstrap.Licence!.CodeClient); Assert.Equal("Dupont", bootstrap.Utilisateur!.NomAffiche); } [Fact] public async Task Activation_Valide_EnvoieDeviceId() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; // Act await _service.ActivateAsync("CLIENT01", "LIC-001"); // Assert : le device id est transmis dans la requête. Assert.NotNull(_api.LastActivateRequest); Assert.Equal("CLIENT01", _api.LastActivateRequest.CodeClient); Assert.Equal("LIC-001", _api.LastActivateRequest.NumeroLicence); Assert.Equal("device-123", _api.LastActivateRequest.DeviceId); } [Fact] public async Task Activation_Invalide_NePersisteRien() { // Arrange _api.ActivateToReturn = new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = false, ErrorCode = "E1001", ErrorMessage = "Licence inconnue" } }; // Act LicenseResponse result = await _service.ActivateAsync("CLIENT01", "LIC-999"); // Assert : pas de token, pas de bootstrap, pas d'appel bootstrap. Assert.False(result.IsValid); Assert.Null(_tokenStore.Token); Assert.Equal(0, _tokenStore.SaveCalls); Assert.Equal(0, _api.BootstrapCalls); } [Fact] public async Task Activation_ChampsVides_LeveException() { // Act / Assert await Assert.ThrowsAsync(() => _service.ActivateAsync("", "LIC-001")); await Assert.ThrowsAsync(() => _service.ActivateAsync("CLIENT01", "")); } [Fact] public async Task Validation_SansSessionLocale_RetourneErreur() { // Act LicenseResponse result = await _service.ValidateAsync(); // Assert Assert.False(result.IsValid); Assert.Equal("E9000", result.ErrorCode); } [Fact] public async Task Validation_Valide_RafraichitTokenEtBootstrap() { // Arrange : session déjà présente. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Nouveau token pour la validation. _api.ValidateToReturn = new MobileLicenseSessionResponse { License = ValidLicense(), AccessToken = "token-rafraichi-456", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }; // Act LicenseResponse result = await _service.ValidateAsync(); // Assert : token rafraîchi, bootstrap rechargé. Assert.True(result.IsValid); Assert.Equal("token-rafraichi-456", _tokenStore.Token); Assert.NotNull(_api.LastValidateRequest); Assert.Equal("CLIENT01", _api.LastValidateRequest.CodeClient); Assert.Equal("device-123", _api.LastValidateRequest.DeviceId); } [Fact] public async Task SessionValide_HasValidSession_Vrai() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Act bool result = await _service.HasValidSessionAsync(); // Assert Assert.True(result); } [Fact] public async Task SessionExpiree_HorsGrace_HasExpiredSession_Vrai() { // Arrange : session avec licence invalide et grace nulle (0 jour). // Le repository force UpdatedAtUtc = UtcNow à chaque sauvegarde, donc la // période offline est nulle si OfflineGraceDays <= 0. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Rendre la licence invalide avec une grace nulle. var state = await BootstrapRepository.GetAsync(); state!.Licence = new LicenseResponse { IsValid = false, CodeClient = "CLIENT01", NumeroLicence = "LIC-001", OfflineGraceDays = 0 }; await BootstrapRepository.SaveAsync(state); // Act bool hasValid = await _service.HasValidSessionAsync(); bool hasExpired = await _service.HasExpiredSessionAsync(); // Assert Assert.False(hasValid); Assert.True(hasExpired); } [Fact] public async Task SessionExpiree_DansGrace_HasValidSession_Vrai() { // Arrange : licence invalide mais dans la période offline autorisée. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.Licence = new LicenseResponse { IsValid = false, CodeClient = "CLIENT01", NumeroLicence = "LIC-001", OfflineGraceDays = 7 }; state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-2); await BootstrapRepository.SaveAsync(state); // Act bool hasValid = await _service.HasValidSessionAsync(); bool hasExpired = await _service.HasExpiredSessionAsync(); // Assert : dans la grace, la session reste valide offline. Assert.True(hasValid); Assert.False(hasExpired); } [Fact] public async Task ClearSession_SupprimeTokenEtBootstrap_ConserveDonneesMetier() { // Arrange : session active + données métier. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"x"}""" }; await ClientRepository.UpsertAsync(client); // Act await _service.ClearSessionAsync(); // Assert : token et bootstrap supprimés, données métier conservées. Assert.Null(_tokenStore.Token); Assert.Equal(1, _tokenStore.ClearCalls); Assert.False(await _service.HasValidSessionAsync()); Assert.Equal(1, await ClientRepository.CountAsync()); } [Fact] public async Task GetAccessToken_RetourneToken() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Act string? token = await _service.GetAccessTokenAsync(); // Assert Assert.Equal("token-secret-123", token); } // ========================================================================= // Renouvellement de session (single-flight) // ========================================================================= 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"; } [Fact] public async Task EnsureValidSession_TokenNonExpire_NeValidePas() { // Arrange : session valide, token sans ExpiresAtUtc (non expiré localement). _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); int validateBefore = _api.ValidateCalls; // Act SessionRevalidationResult result = await _service.EnsureValidSessionAsync(); // Assert : aucun appel license/validate. Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome); Assert.Equal(validateBefore, _api.ValidateCalls); } [Fact] public async Task EnsureValidSession_TokenExpire_RevalideEtRenouvelle() { // Arrange : session valide puis token expiré en secure storage. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); await _tokenStore.SaveAsync(ExpiredToken()); _api.ValidateToReturn = new MobileLicenseSessionResponse { License = ValidLicense(), AccessToken = "token-renouvele-789", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }; // Act SessionRevalidationResult result = await _service.EnsureValidSessionAsync(); // Assert Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome); Assert.Equal(1, _api.ValidateCalls); Assert.Equal("token-renouvele-789", _tokenStore.Token); Assert.Equal("token-renouvele-789", _api.AccessToken); Assert.Equal("device-123", _api.LastValidateRequest!.DeviceId); } [Fact] public async Task RevalidateSession_Terminal_NeConservePasNouveauToken_EtCache() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateToReturn = new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = false, ErrorCode = "E3003", ErrorMessage = "Licence expirée." } }; // Act SessionRevalidationResult first = await _service.RevalidateSessionAsync(expired); SessionRevalidationResult second = await _service.RevalidateSessionAsync(expired); // Assert : rejet terminal, aucun nouveau token, un seul appel serveur. Assert.Equal(SessionRevalidationOutcome.InvalidLicense, first.Outcome); Assert.Equal("E3003", first.License!.ErrorCode); Assert.Equal(SessionRevalidationOutcome.InvalidLicense, second.Outcome); Assert.Equal(1, _api.ValidateCalls); Assert.Equal(expired, _tokenStore.Token); } [Fact] public async Task RevalidateSession_ErreurReseau_NePurgePas() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateException = new HttpRequestException("network down"); // Act SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired); // Assert : rien n'est purgé, issue transitoire. Assert.Equal(SessionRevalidationOutcome.TransientError, result.Outcome); Assert.Equal(expired, _tokenStore.Token); Assert.Equal(0, _tokenStore.ClearCalls); } [Fact] public async Task RevalidateSession_Concurrent_UnSeulValidate() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateToReturn = new MobileLicenseSessionResponse { License = ValidLicense(), AccessToken = "token-concurrent", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }; // Act : 4 renouvellements simultanés avec le même token expiré. Task[] tasks = Enumerable.Range(0, 4) .Select(_ => _service.RevalidateSessionAsync(expired)) .ToArray(); SessionRevalidationResult[] results = await Task.WhenAll(tasks); // Assert : un seul license/validate, tous les appels aboutissent. Assert.All(results, r => Assert.Equal(SessionRevalidationOutcome.Success, r.Outcome)); Assert.Equal(1, _api.ValidateCalls); Assert.Equal("token-concurrent", _tokenStore.Token); } // ========================================================================= // Offline grace basée sur la dernière validation licence (pas UpdatedAtUtc) // ========================================================================= private static readonly DateTimeOffset FixedValidation = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); [Fact] public async Task Activation_InitialiseLastLicenseValidation() { // Arrange / Act _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Assert : la date de validation est initialisée à ~maintenant. LocalBootstrapState? state = await BootstrapRepository.GetAsync(); Assert.NotNull(state!.LastLicenseValidationUtc); Assert.True(state.LastLicenseValidationUtc!.Value >= DateTimeOffset.UtcNow.AddMinutes(-1)); } [Fact] public async Task Revalidation_Reussie_MetAJourLastLicenseValidation() { // Arrange : validation ancienne (J1) + token expiré. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.LastLicenseValidationUtc = FixedValidation; await BootstrapRepository.SaveAsync(state); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateToReturn = new MobileLicenseSessionResponse { License = ValidLicense(), AccessToken = "token-j8", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }; // Act SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired); // Assert : la date a été repoussée à ~maintenant (validation réelle). Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome); LocalBootstrapState? updated = await BootstrapRepository.GetAsync(); Assert.NotNull(updated!.LastLicenseValidationUtc); Assert.True(updated.LastLicenseValidationUtc!.Value >= DateTimeOffset.UtcNow.AddMinutes(-1)); } [Fact] public async Task RefreshBootstrap_NeModifiePasLastLicenseValidation() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.LastLicenseValidationUtc = FixedValidation; await BootstrapRepository.SaveAsync(state); // Act : un simple refresh bootstrap (ex. après sync). await _service.RefreshBootstrapAsync(); // Assert : la date de validation licence n'a pas bougé. LocalBootstrapState? after = await BootstrapRepository.GetAsync(); Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc); } [Fact] public async Task ErreurReseau_NeModifiePasLastLicenseValidation() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.LastLicenseValidationUtc = FixedValidation; await BootstrapRepository.SaveAsync(state); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateException = new HttpRequestException("offline"); // Act SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired); // Assert Assert.Equal(SessionRevalidationOutcome.TransientError, result.Outcome); LocalBootstrapState? after = await BootstrapRepository.GetAsync(); Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc); } [Fact] public async Task LicenceInvalide_NeModifiePasLastLicenseValidation() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.LastLicenseValidationUtc = FixedValidation; await BootstrapRepository.SaveAsync(state); string expired = ExpiredToken(); await _tokenStore.SaveAsync(expired); _api.ValidateToReturn = new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = false, ErrorCode = "E3003", ErrorMessage = "Licence expirée." } }; // Act SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired); // Assert Assert.Equal(SessionRevalidationOutcome.InvalidLicense, result.Outcome); LocalBootstrapState? after = await BootstrapRepository.GetAsync(); Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc); } [Fact] public async Task Grace_AvantLimite_HasValidSession_Vrai() { // Arrange : licence localement invalide, validation à -1 jour, grace 7 jours. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.Licence = new LicenseResponse { IsValid = false, CodeClient = "CLIENT01", NumeroLicence = "LIC-001", OfflineGraceDays = 7 }; state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-1); await BootstrapRepository.SaveAsync(state); // Act / Assert Assert.True(await _service.HasValidSessionAsync()); Assert.False(await _service.HasExpiredSessionAsync()); } [Fact] public async Task Grace_ApresLimite_HasValidSession_Faux() { // Arrange : validation à -8 jours, grace 7 jours → hors grace. _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); var state = await BootstrapRepository.GetAsync(); state!.Licence = new LicenseResponse { IsValid = false, CodeClient = "CLIENT01", NumeroLicence = "LIC-001", OfflineGraceDays = 7 }; state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-8); await BootstrapRepository.SaveAsync(state); // Act / Assert Assert.False(await _service.HasValidSessionAsync()); Assert.True(await _service.HasExpiredSessionAsync()); } [Fact] public async Task Migration_InstallationExistante_InitialiseDepuisUpdatedAtUtc() { // Arrange : session présente, puis état "pré-migration" (LastLicenseValidationUtc // NULL, UpdatedAtUtc = J1). _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); await Database.ExecuteNonQueryAsync( """ UPDATE "LocalBootstrap" SET "LastLicenseValidationUtc" = NULL, "UpdatedAtUtc" = '2026-01-01T00:00:00.0000000+00:00' WHERE "Id" = 1 """); // Act : la lecture migre (backfill une seule fois) la date de validation. LocalBootstrapState? state = await BootstrapRepository.GetAsync(); // Assert : la date migrée vient d'UpdatedAtUtc (même fenêtre qu'avant). Assert.Equal(FixedValidation, state!.LastLicenseValidationUtc); } [Fact] public async Task ClearSession_SupprimeLastLicenseValidation() { // Arrange _api.ActivateToReturn = ValidSession(); _api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() }; await _service.ActivateAsync("CLIENT01", "LIC-001"); // Act await _service.ClearSessionAsync(); // Assert : logout → licence + date de validation effacées. LocalBootstrapState? state = await BootstrapRepository.GetAsync(); Assert.Null(state!.Licence); Assert.Null(state.LastLicenseValidationUtc); } }