using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Omika.Api.Endpoints; 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 de l'endpoint d'authentification Development local /// (POST /api/dev/session). /// /// Vérifie : /// - qu'un token signé valide est émis quand DevelopmentAuth:Enabled est vrai ; /// - que l'endpoint est refusé (403) quand DevelopmentAuth:Enabled est faux ; /// - que le token émis est un vrai token signé par le TokenService normal /// (même issuer/audience/signature), utilisable sur les endpoints protégés. /// [Collection("ApplicationRuntimeContext")] public class DevelopmentAuthEndpointsTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private readonly FakeBootstrapService _bootstrapService = new(); private readonly WebApplicationFactory _factory; public DevelopmentAuthEndpointsTests() { 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, ["DevelopmentAuth:Enabled"] = "true" }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => _bootstrapService); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } [Fact] public async Task DevSession_Enabled_ReturnsValidSignedToken() { var client = _factory.CreateClient(); var response = await client.PostAsync("/api/dev/session", content: null); 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 un vrai token signé par le TokenService normal. using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(body.AccessToken); Assert.NotNull(identity); Assert.Equal(DevelopmentAuthEndpoints.DevCodeClient, identity.CodeClient); Assert.Equal(DevelopmentAuthEndpoints.DevUserId, identity.UserId); Assert.Equal(DevelopmentAuthEndpoints.DevNumeroLicence, identity.NumeroLicence); } [Fact] public async Task DevSession_Enabled_TokenAllowsProtectedEndpoint() { var client = _factory.CreateClient(); var sessionResponse = await client.PostAsync("/api/dev/session", content: null); Assert.Equal(200, (int)sessionResponse.StatusCode); var session = await sessionResponse.Content.ReadFromJsonAsync(); Assert.NotNull(session); // Le token Development doit être accepté par l'authentification mobile // normale (même mécanisme de signature). On vérifie sur un endpoint // protégé : le bootstrap retourne 200 (pas 401). var protectedClient = _factory.CreateClient(); protectedClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken); var bootstrapResponse = await protectedClient.GetAsync("/api/mobile/bootstrap"); Assert.Equal(200, (int)bootstrapResponse.StatusCode); } [Fact] public async Task DevSession_Disabled_ReturnsForbidden() { // Factory avec DevelopmentAuth:Enabled = false. using var disabledFactory = 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, ["DevelopmentAuth:Enabled"] = "false" }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); }); }); using (disabledFactory) { var client = disabledFactory.CreateClient(); var response = await client.PostAsync("/api/dev/session", content: null); // Endpoint non enregistré : 404 (route absente). Assert.Equal(404, (int)response.StatusCode); } } [Fact] public async Task DevSession_WithoutCollaborateur_CollaborateurIdNull() { // Arrange : factory sans DevelopmentAuth:DevCollaborateurId. var client = _factory.CreateClient(); // Act var response = await client.PostAsync("/api/dev/session", content: null); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(body!.AccessToken); // Assert : aucun identifiant Collaborateur inventé. Assert.NotNull(identity); Assert.Null(identity.CollaborateurId); } [Fact] public async Task DevSession_WithCollaborateur_IdentityContainsCollaborateurId() { // Arrange : factory avec DevelopmentAuth:DevCollaborateurId renseigné. const string collaborateurId = "bbbbbbbb-0000-0000-0000-000000000042"; using var 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, ["DevelopmentAuth:Enabled"] = "true", ["DevelopmentAuth:DevCollaborateurId"] = collaborateurId }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => _bootstrapService); }); }); using (factory) { var client = factory.CreateClient(); // Act var response = await client.PostAsync("/api/dev/session", content: null); Assert.Equal(200, (int)response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); using var scope = factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); var identity = tokenService.ValidateToken(body!.AccessToken); // Assert : l'identité porte le vrai CollaborateurId configuré. Assert.NotNull(identity); Assert.Equal(collaborateurId, identity.CollaborateurId); } } // ===== Fakes ===== 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; } }