using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.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.Interfaces.Mobile; using Omika.Core.Observability; using Omika.Core.Observability.Diagnostic; using Omika.Core.Runtime; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.Runtime; namespace Omika.Tests.Observability.Correlation; /// /// Tests HTTP de la corrélation Phase 3 (Mobile → API) : propagation du TraceId, /// génération si absent, rejet d'un header invalide, isolation concurrente, /// réponse d'erreur contrôlée avec TraceId, fail-open (observabilité en panne, /// NullTelemetry) et absence de données interdites dans les fichiers diagnostics. /// [Collection("ApplicationRuntimeContext")] public class CorrelationMiddlewareIntegrationTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private readonly string _dir = CreateTempDirectory(); private readonly WebApplicationFactory _factory; public CorrelationMiddlewareIntegrationTests() { ApplicationRuntimeContext.Reset(); _factory = CreateFactory(_dir); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); DeleteDirectory(_dir); } // ===== Propagation (spec §35) ===== [Fact] public async Task Propagation_SentTraceId_IsReusedAndEchoedBack() { Guid traceId = Guid.NewGuid(); using HttpClient client = _factory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/health"); request.Headers.Add(CorrelationHeaders.TraceId, traceId.ToString("D")); HttpResponseMessage response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( traceId.ToString("D"), Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId))); } // ===== Header absent (spec §36) ===== [Fact] public async Task AbsentTraceId_GeneratesNewGuid() { using HttpClient client = _factory.CreateClient(); HttpResponseMessage response = await client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)); Assert.True(Guid.TryParse(value, out _)); } // ===== Header invalide (spec §37) ===== [Fact] public async Task InvalidTraceId_IsIgnored_GeneratesNewGuid_BusinessExecutes() { using HttpClient client = _factory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/health"); request.Headers.Add(CorrelationHeaders.TraceId, "INVALID"); HttpResponseMessage response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)); Assert.True(Guid.TryParse(value, out _)); Assert.NotEqual("INVALID", value); } // ===== SessionId (spec §38) ===== [Fact] public async Task SessionId_Valid_IsEchoedBack() { Guid sessionId = Guid.NewGuid(); using HttpClient client = _factory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/health"); request.Headers.Add(CorrelationHeaders.SessionId, sessionId.ToString("D")); HttpResponseMessage response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal( sessionId.ToString("D"), Assert.Single(response.Headers.GetValues(CorrelationHeaders.SessionId))); } [Fact] public async Task SessionId_Invalid_IsIgnored_RequestContinues() { using HttpClient client = _factory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/health"); request.Headers.Add(CorrelationHeaders.SessionId, "NOT-A-GUID"); HttpResponseMessage response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.False(response.Headers.Contains(CorrelationHeaders.SessionId)); } // ===== Concurrence (spec §39) ===== [Fact] public async Task Concurrent_Requests_NoTraceLeak() { Guid traceA = Guid.NewGuid(); Guid traceB = Guid.NewGuid(); using HttpClient client = _factory.CreateClient(); var requestA = new HttpRequestMessage(HttpMethod.Get, "/health"); requestA.Headers.Add(CorrelationHeaders.TraceId, traceA.ToString("D")); var requestB = new HttpRequestMessage(HttpMethod.Get, "/health"); requestB.Headers.Add(CorrelationHeaders.TraceId, traceB.ToString("D")); Task taskA = client.SendAsync(requestA); Task taskB = client.SendAsync(requestB); await Task.WhenAll(taskA, taskB); HttpResponseMessage responseA = await taskA; HttpResponseMessage responseB = await taskB; Assert.Equal( traceA.ToString("D"), Assert.Single(responseA.Headers.GetValues(CorrelationHeaders.TraceId))); Assert.Equal( traceB.ToString("D"), Assert.Single(responseB.Headers.GetValues(CorrelationHeaders.TraceId))); } // ===== Réponse d'erreur (spec §40) ===== [Fact] public async Task UnhandledException_ReturnsControlledError_WithTraceId_NoLeak() { var licenseService = new FakeLicenseService { ActivateHandler = () => throw new InvalidOperationException( "Secret ConnectionString=Server=prod;Password=hidden") }; using var factory = CreateFactory(_dir, services => { services.RemoveAll(); services.AddScoped(_ => licenseService); }); using HttpClient client = factory.CreateClient(); Guid traceId = Guid.NewGuid(); var request = new HttpRequestMessage(HttpMethod.Post, "/api/license/activate"); request.Headers.Add(CorrelationHeaders.TraceId, traceId.ToString("D")); request.Content = JsonContent.Create(new LicenseActivateRequest { CodeClient = "BATIK.01", NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3", DeviceId = "device-abc-123" }); HttpResponseMessage response = await client.SendAsync(request); string body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); Assert.Equal( traceId.ToString("D"), Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId))); using JsonDocument document = JsonDocument.Parse(body); Assert.True(document.RootElement.TryGetProperty("traceId", out JsonElement traceIdElement)); Assert.Equal(traceId.ToString("D"), traceIdElement.GetString()); Assert.True(document.RootElement.TryGetProperty("message", out JsonElement messageElement)); Assert.Equal("Une erreur interne est survenue.", messageElement.GetString()); // Aucune fuite : ni message interne, ni secret, ni stack trace. Assert.DoesNotContain("ConnectionString", body, StringComparison.Ordinal); Assert.DoesNotContain("Password", body, StringComparison.Ordinal); Assert.DoesNotContain("Secret", body, StringComparison.Ordinal); Assert.DoesNotContain(" at ", body, StringComparison.Ordinal); } // ===== Observabilité en panne (spec §41) ===== [Fact] public async Task ExporterBroken_InaccessibleDirectory_HttpResultIdentical() { string blocker = Path.Combine(_dir, "blocker"); File.WriteAllText(blocker, "x"); string inaccessibleDir = Path.Combine(blocker, "sub"); using var factory = CreateFactory(inaccessibleDir); using HttpClient client = factory.CreateClient(); HttpResponseMessage response = await client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)); Assert.True(Guid.TryParse(value, out _)); } // ===== Sécurité des fichiers (spec §42) ===== [Fact] public async Task DiagnosticFiles_ContainNoForbiddenData() { using HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "some-secret-bearer-token"); var request = new HttpRequestMessage(HttpMethod.Get, "/health"); request.Headers.Add(CorrelationHeaders.TraceId, Guid.NewGuid().ToString("D")); await client.SendAsync(request); await client.GetAsync("/health"); // Attend de façon déterministe que l'exporteur ait réellement écrit et flushé // au moins un événement JSONL (on synchronise sur le contenu, pas sur un délai). await WaitUntilAsync(() => Directory.EnumerateFiles(_dir, "*.jsonl") .Any(file => ReadAllTextShared(file).Contains("\"eventCode\"", StringComparison.Ordinal))); // Lit le contenu pendant que l'exporteur peut encore écrire. Le writer ouvre // avec FileAccess.Write + FileShare.Read, donc la lecture doit ouvrir avec // FileShare.ReadWrite (jamais File.ReadAllText, dont le FileShare.Read rejette // l'écriture concurrente et provoque « used by another process »). string content = string.Join( "\n", Directory.EnumerateFiles(_dir, "*.jsonl").Select(ReadAllTextShared)); foreach (string forbidden in new[] { "Authorization", "Bearer", "Password", "Token", "Cookie", "ConnectionString", "RequestBody", "ResponseBody", "some-secret-bearer-token" }) { Assert.DoesNotContain(forbidden, content, StringComparison.OrdinalIgnoreCase); } } // ===== IOmikaTelemetry qui lève (spec §34) ===== [Fact] public async Task ThrowingTelemetry_MiddlewareStillProducesResponse() { using var factory = CreateFactory(_dir, services => { services.RemoveAll(); services.AddSingleton(new ThrowingTelemetry()); }); using HttpClient client = factory.CreateClient(); HttpResponseMessage response = await client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)); Assert.True(Guid.TryParse(value, out _)); } // ===== NullTelemetry (spec §43) ===== [Fact] public async Task NullTelemetry_RoutesStillWork() { using var factory = CreateFactory(_dir, services => { services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(NullTelemetryService.Instance); services.AddSingleton(NullTelemetryService.Instance); services.AddSingleton(NullTelemetryService.Instance); }); using HttpClient client = factory.CreateClient(); HttpResponseMessage response = await client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); string value = Assert.Single(response.Headers.GetValues(CorrelationHeaders.TraceId)); Assert.True(Guid.TryParse(value, out _)); } // ===== Helpers ===== private static WebApplicationFactory CreateFactory( string directory, Action? configureServices = null) { 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 }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => new FakeLicenseService()); services.RemoveAll(); services.AddScoped(_ => new FakeBootstrapService()); // Dossier de diagnostic propre au test (remplace le défaut serveur). services.Configure(options => options.Directory = directory); configureServices?.Invoke(services); }); }); return factory; } private static string CreateTempDirectory() { string dir = Path.Combine(Path.GetTempPath(), "omika-correlation-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } private static void DeleteDirectory(string dir) { try { if (Directory.Exists(dir)) { Directory.Delete(dir, recursive: true); } } catch { // Best-effort. } } /// /// Lit un fichier JSONL en cohabitation avec l'exporteur encore actif. Le writer /// ouvre avec FileAccess.Write, FileShare.Read (un seul writer, lectures /// autorisées) ; un lecteur doit donc ouvrir avec FileShare.ReadWrite. /// private static string ReadAllTextShared(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); return reader.ReadToEnd(); } private static async Task WaitUntilAsync(Func condition, int timeoutMs = 3000) { DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMilliseconds(timeoutMs); while (!condition() && DateTimeOffset.UtcNow < deadline) { await Task.Delay(10); } Assert.True(condition(), "La condition n'a pas été atteinte dans le délai imparti."); } // ===== Fakes ===== private sealed class ThrowingTelemetry : IOmikaTelemetry { public void Track(TelemetryEvent telemetryEvent) => throw new InvalidOperationException("telemetry down"); public void Log(DiagnosticEvent diagnosticEvent) => throw new InvalidOperationException("telemetry down"); } private sealed class FakeLicenseService : ILicenseService { public Func>? ActivateHandler { get; set; } public Task ActivateAsync( LicenseActivateRequest request, CancellationToken cancellationToken = default) => ActivateHandler is null ? Task.FromResult(new LicenseResponse()) : ActivateHandler(); public Task ValidateAsync( LicenseValidateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new LicenseResponse()); 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 Task GetBootstrapAsync( MobileTokenIdentity identity, CancellationToken cancellationToken = default) => Task.FromResult(new MobileBootstrapResponse()); } 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; } }