733 lines
27 KiB
C#
733 lines
27 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string?> 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<string> 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<MobileLicenseSessionResponse> ActivateAsync(
|
|
LicenseActivateRequest request, CancellationToken ct = default)
|
|
{
|
|
LastActivateRequest = request;
|
|
return Task.FromResult(ActivateToReturn);
|
|
}
|
|
|
|
public Task<MobileLicenseSessionResponse> ValidateAsync(
|
|
LicenseValidateRequest request, CancellationToken ct = default)
|
|
{
|
|
LastValidateRequest = request;
|
|
ValidateCalls++;
|
|
if (ValidateException is not null)
|
|
{
|
|
throw ValidateException;
|
|
}
|
|
|
|
return Task.FromResult(ValidateToReturn);
|
|
}
|
|
|
|
public Task<MobileLicenseSessionResponse> GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<MobileBootstrapResponse> GetBootstrapAsync(CancellationToken ct = default)
|
|
|
|
{
|
|
BootstrapCalls++;
|
|
return Task.FromResult(BootstrapToReturn);
|
|
}
|
|
|
|
public Task<SyncOperationReceipt> SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<SyncStatusResponse> GetStatusAsync(Guid operationId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<DocumentDownloadResponse> DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<DocumentUploadResponse> 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<MobileSessionService>.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<ArgumentException>(() => _service.ActivateAsync("", "LIC-001"));
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _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<SessionRevalidationResult>[] 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);
|
|
}
|
|
}
|