using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Omika.Core.Abstractions; using Omika.Core.Compatibility; using Omika.Core.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Contracts.Security; using Omika.Core.Interfaces.Mobile; using Omika.Core.Runtime; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.Runtime; namespace Omika.Tests.Api; /// /// Tests HTTP des endpoints de licence mobile. /// Vérifie l'émission du Bearer token après activation/validation réussie, /// l'absence de token en cas d'échec, la chaîne activation → bootstrap, /// et le renouvellement du token après validation. /// [Collection("ApplicationRuntimeContext")] public class LicenseEndpointsTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private const string CodeClient = "BATIK.01"; private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"; private const string DeviceId = "device-abc-123"; private readonly FakeLicenseService _licenseService = new(); private readonly FakeBootstrapService _bootstrapService = new(); private readonly WebApplicationFactory _factory; public LicenseEndpointsTests() { ApplicationRuntimeContext.Reset(); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Hfsql:Server"] = "test-server", ["Hfsql:DatabaseName"] = "test-db", ["MobileAuth:SigningKey"] = SigningKey }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => _licenseService); services.RemoveAll(); services.AddScoped(_ => _bootstrapService); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // ===== ACTIVATE ===== [Fact] public async Task Activate_Success_ReturnsToken_ValidIdentity_AndExpiration() { _licenseService.ActivateResult = ValidLicenseResponse(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body.License.IsValid); Assert.False(string.IsNullOrWhiteSpace(body.AccessToken)); Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow); // Le token est valide et porte l'identité attendue. using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(body.AccessToken); Assert.NotNull(identity); Assert.Equal(CodeClient, identity.CodeClient); Assert.Equal(DeviceId, identity.DeviceId); Assert.Equal("42", identity.UserId); Assert.Equal("7", identity.CollaborateurId); Assert.Equal(NumeroLicence, identity.NumeroLicence); // L'expiration retournée est cohérente avec la durée de vie configurée (24 h). var expected = tokenService.GetTokenExpirationUtc(); Assert.True(body.AccessTokenExpiresAtUtc <= expected.AddMinutes(1)); Assert.True(body.AccessTokenExpiresAtUtc >= expected.AddMinutes(-1)); } [Fact] public async Task Activate_Failure_ReturnsNoToken() { _licenseService.ActivateResult = InvalidLicenseResponse(LicenseErrorCodes.LicenceInconnue); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(400, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body.License.IsValid); Assert.Equal(LicenseErrorCodes.LicenceInconnue, body.License.ErrorCode); Assert.True(string.IsNullOrWhiteSpace(body.AccessToken)); } // ===== VALIDATE ===== [Fact] public async Task Validate_Success_ReturnsNewToken() { _licenseService.ValidateResult = ValidLicenseResponse(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body.License.IsValid); Assert.False(string.IsNullOrWhiteSpace(body.AccessToken)); Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow); using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(body.AccessToken); Assert.NotNull(identity); Assert.Equal(CodeClient, identity.CodeClient); Assert.Equal(DeviceId, identity.DeviceId); Assert.Equal(NumeroLicence, identity.NumeroLicence); } [Theory] [InlineData(LicenseErrorCodes.LicenceInconnue)] [InlineData(LicenseErrorCodes.LicenceInactive)] [InlineData(LicenseErrorCodes.LicenceExpiree)] [InlineData(LicenseErrorCodes.LicenceAttribuee)] public async Task Validate_Failure_ReturnsNoToken(string errorCode) { _licenseService.ValidateResult = InvalidLicenseResponse(errorCode); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(400, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body.License.IsValid); Assert.Equal(errorCode, body.License.ErrorCode); Assert.True(string.IsNullOrWhiteSpace(body.AccessToken)); } // ===== CHAÎNE COMPLÈTE : activation → bootstrap ===== [Fact] public async Task Activate_Then_Bootstrap_WithBearer_Returns200() { _licenseService.ActivateResult = ValidLicenseResponse(); _bootstrapService.Result = new MobileBootstrapResponse { Utilisateur = new CurrentUserDto { Id = "42", Login = "user-42" }, Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" } }; var client = _factory.CreateClient(); // 1. Activation → récupération du Bearer token. var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(200, (int)activateResponse.StatusCode); var session = await activateResponse.Content.ReadFromJsonAsync(); Assert.NotNull(session); Assert.False(string.IsNullOrWhiteSpace(session.AccessToken)); // 2. Bootstrap avec le vrai format Bearer. var bootstrapClient = _factory.CreateClient(); bootstrapClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken); var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap"); Assert.Equal(200, (int)bootstrapResponse.StatusCode); var bootstrap = await bootstrapResponse.Content.ReadFromJsonAsync(); Assert.NotNull(bootstrap); Assert.Equal("42", bootstrap.Utilisateur.Id); } // ===== RENOUVELLEMENT APRÈS EXPIRATION (conceptuel) ===== [Fact] public async Task Validate_After_Activate_Produces_New_Valid_Token() { _licenseService.ActivateResult = ValidLicenseResponse(); _licenseService.ValidateResult = ValidLicenseResponse(); var client = _factory.CreateClient(); // T1 via activation. var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); var session1 = await activateResponse.Content.ReadFromJsonAsync(); Assert.NotNull(session1); // T2 via validation (renouvellement). var validateResponse = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest { CodeClient = CodeClient, NumeroLicence = NumeroLicence, DeviceId = DeviceId }); Assert.Equal(200, (int)validateResponse.StatusCode); var session2 = await validateResponse.Content.ReadFromJsonAsync(); Assert.NotNull(session2); // T2 est un nouveau token, valide, avec une nouvelle expiration. Assert.False(string.IsNullOrWhiteSpace(session2.AccessToken)); Assert.True(session2.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow); using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(session2.AccessToken); Assert.NotNull(identity); Assert.Equal(CodeClient, identity.CodeClient); Assert.Equal(DeviceId, identity.DeviceId); Assert.Equal(NumeroLicence, identity.NumeroLicence); // Le nouveau token permet l'accès aux endpoints protégés. var bootstrapClient = _factory.CreateClient(); bootstrapClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session2.AccessToken); var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap"); Assert.Equal(200, (int)bootstrapResponse.StatusCode); } // ===== Helpers ===== private static LicenseResponse ValidLicenseResponse() => new() { IsValid = true, CodeClient = CodeClient, NumeroLicence = NumeroLicence, NomSociete = "Société Test", UserNom = "Dupont", UserPrenom = "Jean", UserId = "42", CollaborateurId = "7", OfflineGraceDays = 10, ServerTimeUtc = DateTimeOffset.UtcNow, ErrorCode = LicenseErrorCodes.Ok }; private static LicenseResponse InvalidLicenseResponse(string errorCode) => new() { IsValid = false, CodeClient = CodeClient, NumeroLicence = NumeroLicence, ErrorCode = errorCode, ErrorMessage = "Licence invalide", ServerTimeUtc = DateTimeOffset.UtcNow }; // ===== Fakes ===== private sealed class FakeLicenseService : ILicenseService { public LicenseResponse ActivateResult { get; set; } = new(); public LicenseResponse ValidateResult { get; set; } = new(); public Task ActivateAsync( LicenseActivateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(ActivateResult); public Task ValidateAsync( LicenseValidateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(ValidateResult); public Task GetMobileLicenceForCollaborateurAsync( string collaborateurId, string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AssignMobileLicenceAsync( string collaborateurId, string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ReleaseMobileLicenceAsync( string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(true); } private sealed class FakeBootstrapService : IMobileBootstrapService { public MobileBootstrapResponse Result { get; set; } = new(); public Task GetBootstrapAsync( MobileTokenIdentity identity, CancellationToken cancellationToken = default) => Task.FromResult(Result); } private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer { public Task InitializeAsync(CancellationToken cancellationToken = default) { ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); return Task.FromResult(Result.Success()); } } private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable { public HfsqlOptions Options => new(); public HfsqlConnectionState State => HfsqlConnectionState.Closed; public void Dispose() { } public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask; public Task CloseAsync() => Task.CompletedTask; public Task ExecuteScalarAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(null); public Task ExecuteNonQueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(0); public Task>> QueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult>>([]); public Task> GetColumnsAsync(string tableName, CancellationToken ct = default) => Task.FromResult>([]); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }