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.Api.Services; using Omika.Core.Abstractions; using Omika.Core.Compatibility; using Omika.Core.Constants; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Contracts.Security; using Omika.Core.Domain.Entities.Mobile; using Omika.Core.Domain.Entities.Security; using Omika.Core.Interfaces; 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 de l'endpoint d'authentification Back-Office WinDev /// (POST /api/backoffice/auth/token). /// /// Vérifie le contrat complet : codes HTTP, émission du Bearer token via le /// mécanisme normal (ITokenService / MobileAuthMiddleware), contrôle d'accès /// Back-Office (CO_AccesBO / UT_AccesBO), code client, et séparation avec le /// droit A.LIC.MOB (qui n'est pas une condition d'émission du token). /// /// Le hash utilisé est un placeholder volontairement faux : aucun vrai hash ni /// vrai token n'apparaît dans ces tests. /// [Collection("ApplicationRuntimeContext")] public class BackOfficeAuthEndpointsTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private const string CodeClient = "BATIK.01"; private const string ValidLogin = "DFO"; private const string ValidHash = "FAKE-HASH-0123456789ABCDEF0123456789ABCDEF"; private const long ValidUserId = 42; private const long ValidCollaborateurId = 7; private readonly FakeBackOfficeAuthRepository _authRepository = new(); private readonly FakeLicenceRepository _licenceRepository = new(); private readonly FakeLicenseService _licenseService = new(); private readonly FakePermissionService _permissionService = new(); private readonly WebApplicationFactory _factory; public BackOfficeAuthEndpointsTests() { 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(_ => _authRepository); services.RemoveAll(); services.AddScoped(_ => _licenceRepository); services.RemoveAll(); services.AddScoped(_ => _licenseService); services.RemoveAll(); services.AddScoped(_ => _permissionService); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // ===== 1. Authentification réussie ===== [Fact] public async Task Post_ValidCredentials_WithBoAccess_Returns200_AndValidToken() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(string.IsNullOrWhiteSpace(body.AccessToken)); Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow); Assert.Equal(ValidUserId.ToString(), body.UserId); Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId); // Le token est valide et porte l'identité attendue (même mécanisme que le mobile). using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); MobileTokenIdentity identity = tokenService.ValidateToken(body.AccessToken)!; Assert.NotNull(identity); Assert.Equal(CodeClient, identity.CodeClient); Assert.Equal(ValidUserId.ToString(), identity.UserId); Assert.Equal(ValidCollaborateurId.ToString(), identity.CollaborateurId); } [Fact] public async Task Post_LoginIsCaseInsensitive_Returns200() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = "dfo", // minuscules : reproduit le UPPER(UT_Login) = UPPER(login) WinDev PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); } // ===== 2/3. Identifiants incorrects ===== [Fact] public async Task Post_WrongLogin_Returns401_GenericMessage() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = "INCONNU", PasswordHash = ValidHash }); Assert.Equal(401, (int)response.StatusCode); Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync()); } [Fact] public async Task Post_WrongPasswordHash_Returns401_GenericMessage() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = "AUTRE-HASH-FAUX" }); Assert.Equal(401, (int)response.StatusCode); Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync()); } // ===== 4. Hash vide ===== [Fact] public async Task Post_EmptyPasswordHash_Returns400() { _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = string.Empty }); Assert.Equal(400, (int)response.StatusCode); } // ===== 5. Accès Back-Office refusé ===== [Fact] public async Task Post_UserWithoutBoAccess_Returns403() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = new BackOfficeAuthUser( ValidUserId, ValidLogin, ValidCollaborateurId, ValidCollaborateurId, UtilisateurAccesBO: false, CollaborateurAccesBO: false, CollaborateurActif: true); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(403, (int)response.StatusCode); } [Fact] public async Task Post_UserWithoutCollaborateur_ButUtAccesBoTrue_Returns200() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = new BackOfficeAuthUser( ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0, UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); } // ===== 6. Collaborateur lié ===== [Fact] public async Task Post_LinkedCollaborateur_ReturnsCorrectUserIdAndCollaborateurId() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(ValidUserId.ToString(), body.UserId); Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId); } // ===== 7. Utilisateur sans collaborateur ===== [Fact] public async Task Post_UserWithoutCollaborateur_ReturnsNullCollaborateurId() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = new BackOfficeAuthUser( ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0, UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(ValidUserId.ToString(), body.UserId); Assert.Null(body.CollaborateurId); } // ===== 8/9. Code client ===== [Fact] public async Task Post_EmptyCodeClient_Returns400() { var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = string.Empty, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(400, (int)response.StatusCode); } [Fact] public async Task Post_UnknownCodeClient_Returns400() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = null; // client inconnu côté serveur var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = "INCONNU.99", Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(400, (int)response.StatusCode); } // ===== 10/11/12. Token accepté par MobileAuthMiddleware + A.LIC.MOB ===== [Fact] public async Task Post_ReturnedToken_IsAcceptedByMobileAuthMiddleware_OnProtectedEndpoint() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); _permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true; _licenseService.GetResult = new CollaborateurLicenceDto { Numero = "LIC-001", CodeClient = CodeClient, Active = true, QrCode = "omika-enroll-v1:cGF5bG9hZA.sig" }; var client = _factory.CreateClient(); // 1. Obtention du token Back-Office. var authResponse = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)authResponse.StatusCode); var session = await authResponse.Content.ReadFromJsonAsync(); Assert.NotNull(session); Assert.False(string.IsNullOrWhiteSpace(session.AccessToken)); // 2. Le token est accepté par MobileAuthMiddleware sur un endpoint protégé. var protectedClient = _factory.CreateClient(); protectedClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken); var protectedResponse = await protectedClient.GetAsync( $"/collaborateurs/{ValidCollaborateurId}/licence-mobile"); Assert.Equal(200, (int)protectedResponse.StatusCode); } [Fact] public async Task Post_UserWithLicenceMobilePermission_CanAccessLicenceMobileEndpoint() { _permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true; _licenseService.GetResult = new CollaborateurLicenceDto { Numero = "LIC-002", CodeClient = CodeClient, Active = true, QrCode = "omika-enroll-v1:cGF5bG9hZA.sig" }; string token = await ObtainTokenAsync(); var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal(CodeClient, _licenseService.LastCodeClient); } [Fact] public async Task Post_UserWithoutLicenceMobilePermission_TokenCreated_ButLicenceMobileDenied() { _permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false; string token = await ObtainTokenAsync(); Assert.False(string.IsNullOrWhiteSpace(token)); var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile"); Assert.Equal(401, (int)response.StatusCode); } // ===== 13. Sécurité : aucun hash/token exposé ===== [Fact] public async Task ErrorResponses_DoNotContainPasswordHash() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = "INCONNU", PasswordHash = ValidHash }); Assert.Equal(401, (int)response.StatusCode); string body = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain(ValidHash, body); Assert.DoesNotContain(ValidHash.ToLowerInvariant(), body); } // ===== Helpers ===== private async Task ObtainTokenAsync() { _authRepository.ValidLogin = ValidLogin; _authRepository.ValidHash = ValidHash; _authRepository.UserToReturn = ValidUser(); _licenceRepository.ClientToReturn = ValidClient(); var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest { CodeClient = CodeClient, Login = ValidLogin, PasswordHash = ValidHash }); Assert.Equal(200, (int)response.StatusCode); var session = await response.Content.ReadFromJsonAsync(); Assert.NotNull(session); return session.AccessToken; } private static BackOfficeAuthUser ValidUser() => new( ValidUserId, ValidLogin, CollaborateurId: ValidCollaborateurId, CollaborateurPkId: ValidCollaborateurId, UtilisateurAccesBO: false, CollaborateurAccesBO: true, CollaborateurActif: true); private static ClientBase ValidClient() => new() { Id = 1, Code = CodeClient, Nom = "Société Test" }; // ===== Fakes ===== private sealed class FakeBackOfficeAuthRepository : IBackOfficeAuthRepository { public string ValidLogin { get; set; } = string.Empty; public string ValidHash { get; set; } = string.Empty; public BackOfficeAuthUser? UserToReturn { get; set; } public Task GetByLoginAndPasswordHashAsync( string login, string passwordHash, CancellationToken cancellationToken = default) { bool loginMatches = string.Equals(login, ValidLogin, StringComparison.OrdinalIgnoreCase); bool hashMatches = string.Equals(passwordHash, ValidHash, StringComparison.Ordinal); return Task.FromResult(loginMatches && hashMatches ? UserToReturn : null); } } private sealed class FakeLicenceRepository : ILicenceRepository { public ClientBase? ClientToReturn { get; set; } public Task GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(ClientToReturn); public Task GetByNumeroAsync(string numero, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeLicenseService : ILicenseService { public CollaborateurLicenceDto? GetResult { get; set; } public string? LastCodeClient { get; private set; } public Task ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new LicenseResponse()); public Task ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new LicenseResponse()); public Task GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default) { LastCodeClient = codeClient; return Task.FromResult(GetResult); } public Task AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default) { LastCodeClient = codeClient; return Task.FromResult(null); } public Task ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(true); } private sealed class FakePermissionService : IMobilePermissionService { public Dictionary Permissions { get; } = new(); public MobileTokenIdentity? Identity => null; public Task> GetPermissionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Permissions); public Task HasPermissionAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed); } 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; } }