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