Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class ApiTelemetryContextEnricherTests
|
||||
{
|
||||
private sealed class RecordingTelemetry : IOmikaTelemetry
|
||||
{
|
||||
public OmikaEvent? Last { get; private set; }
|
||||
|
||||
public void Track(TelemetryEvent telemetryEvent) => Last = telemetryEvent;
|
||||
|
||||
public void Log(DiagnosticEvent diagnosticEvent) => Last = diagnosticEvent;
|
||||
}
|
||||
|
||||
private static DeviceInfoSnapshot Snapshot() => new()
|
||||
{
|
||||
AppVersion = "1.0.0.0",
|
||||
Platform = "Windows",
|
||||
OperatingSystem = "Windows 11",
|
||||
OperatingSystemVersion = "10.0"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Log_WithinRequestContext_EnrichesTraceSessionIdentityAndDevice()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
|
||||
using (traceContext.BeginTrace(traceId))
|
||||
using (requestContext.Begin(traceId, sessionId))
|
||||
{
|
||||
requestContext.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
enricher.Log(new DiagnosticEvent { EventCode = TelemetryEventCodes.ApiHttpRequestStarted });
|
||||
}
|
||||
|
||||
var @event = recorder.Last!;
|
||||
Assert.Equal(traceId, @event.TraceId);
|
||||
Assert.Equal(sessionId, @event.SessionId);
|
||||
Assert.Equal("device-1", @event.DeviceId);
|
||||
Assert.Equal("CLIENT01", @event.ClientId);
|
||||
Assert.Equal("42", @event.UserId);
|
||||
Assert.Equal("7", @event.CollaborateurId);
|
||||
Assert.Equal("1.0.0.0", @event.AppVersion);
|
||||
Assert.Equal("Windows", @event.Platform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_OutsideRequestContext_StillEnrichesDeviceInfo()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
enricher.Track(new TelemetryEvent { EventCode = TelemetryEventCodes.ApiHttpRequestStarted });
|
||||
|
||||
var @event = recorder.Last!;
|
||||
Assert.Null(@event.TraceId);
|
||||
Assert.Null(@event.SessionId);
|
||||
Assert.Equal("1.0.0.0", @event.AppVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_ExplicitTraceId_IsNotOverwritten()
|
||||
{
|
||||
var recorder = new RecordingTelemetry();
|
||||
var traceContext = new TraceContext();
|
||||
var requestContext = new RequestDiagnosticContext();
|
||||
var enricher = new ApiTelemetryContextEnricher(recorder, traceContext, requestContext, Snapshot());
|
||||
|
||||
Guid explicitTrace = Guid.NewGuid();
|
||||
enricher.Log(new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.ApiHttpRequestFailed,
|
||||
TraceId = explicitTrace
|
||||
});
|
||||
|
||||
Assert.Equal(explicitTrace, recorder.Last!.TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class CorrelationHeadersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Headers_HaveExpectedNames()
|
||||
{
|
||||
Assert.Equal("X-Omika-TraceId", CorrelationHeaders.TraceId);
|
||||
Assert.Equal("X-Omika-SessionId", CorrelationHeaders.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseTraceId_ValidGuid_ReturnsTrue()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
Assert.True(CorrelationHeaders.TryParseTraceId(guid.ToString("D"), out Guid parsed));
|
||||
Assert.Equal(guid, parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("2ddc12ae")]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("2ddc12ae-4f1b-4c3a-zzzz-123456789012")]
|
||||
[InlineData(null)]
|
||||
public void TryParseTraceId_Invalid_ReturnsFalse(string? value)
|
||||
{
|
||||
Assert.False(CorrelationHeaders.TryParseTraceId(value, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseSessionId_ValidGuid_ReturnsTrue()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
Assert.True(CorrelationHeaders.TryParseSessionId(guid.ToString("D"), out Guid parsed));
|
||||
Assert.Equal(guid, parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData(null)]
|
||||
public void TryParseSessionId_Invalid_ReturnsFalse(string? value)
|
||||
{
|
||||
Assert.False(CorrelationHeaders.TryParseSessionId(value, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<Program> _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<HttpResponseMessage> taskA = client.SendAsync(requestA);
|
||||
Task<HttpResponseMessage> 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<ILicenseService>();
|
||||
services.AddScoped<ILicenseService>(_ => 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<IOmikaTelemetry>();
|
||||
services.AddSingleton<IOmikaTelemetry>(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<IOmikaTelemetry>();
|
||||
services.RemoveAll<IEventTracker>();
|
||||
services.RemoveAll<IDiagnosticLogger>();
|
||||
services.AddSingleton<IOmikaTelemetry>(NullTelemetryService.Instance);
|
||||
services.AddSingleton<IEventTracker>(NullTelemetryService.Instance);
|
||||
services.AddSingleton<IDiagnosticLogger>(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<Program> CreateFactory(
|
||||
string directory,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Hfsql:Server"] = "test-server",
|
||||
["Hfsql:DatabaseName"] = "test-db",
|
||||
["MobileAuth:SigningKey"] = SigningKey
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
services.RemoveAll<IDatabaseRuntimeInitializer>();
|
||||
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
|
||||
|
||||
services.RemoveAll<IHfsqlConnection>();
|
||||
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
|
||||
|
||||
services.RemoveAll<ILicenseService>();
|
||||
services.AddScoped<ILicenseService>(_ => new FakeLicenseService());
|
||||
|
||||
services.RemoveAll<IMobileBootstrapService>();
|
||||
services.AddScoped<IMobileBootstrapService>(_ => new FakeBootstrapService());
|
||||
|
||||
// Dossier de diagnostic propre au test (remplace le défaut serveur).
|
||||
services.Configure<LocalDiagnosticOptions>(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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lit un fichier JSONL en cohabitation avec l'exporteur encore actif. Le writer
|
||||
/// ouvre avec <c>FileAccess.Write, FileShare.Read</c> (un seul writer, lectures
|
||||
/// autorisées) ; un lecteur doit donc ouvrir avec <c>FileShare.ReadWrite</c>.
|
||||
/// </summary>
|
||||
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<bool> 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<Task<LicenseResponse>>? ActivateHandler { get; set; }
|
||||
|
||||
public Task<LicenseResponse> ActivateAsync(
|
||||
LicenseActivateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ActivateHandler is null
|
||||
? Task.FromResult(new LicenseResponse())
|
||||
: ActivateHandler();
|
||||
|
||||
public Task<LicenseResponse> ValidateAsync(
|
||||
LicenseValidateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new LicenseResponse());
|
||||
|
||||
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
|
||||
string collaborateurId,
|
||||
string codeClient,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
|
||||
string collaborateurId,
|
||||
string codeClient,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
||||
|
||||
public Task<bool> ReleaseMobileLicenceAsync(
|
||||
string collaborateurId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class FakeBootstrapService : IMobileBootstrapService
|
||||
{
|
||||
public Task<MobileBootstrapResponse> GetBootstrapAsync(
|
||||
MobileTokenIdentity identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new MobileBootstrapResponse());
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
|
||||
{
|
||||
public Task<Result> 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<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class RequestDiagnosticContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Begin_SetsContext_AndRestoresOnDispose()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
Assert.Null(context.SessionId);
|
||||
Assert.Null(context.StartedAtUtc);
|
||||
|
||||
using (context.Begin(traceId, sessionId))
|
||||
{
|
||||
Assert.Equal(traceId, context.TraceId);
|
||||
Assert.Equal(sessionId, context.SessionId);
|
||||
Assert.NotNull(context.StartedAtUtc);
|
||||
}
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
Assert.Null(context.SessionId);
|
||||
Assert.Null(context.StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetIdentity_WithinScope_SetsFields()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
using (context.Begin(Guid.NewGuid()))
|
||||
{
|
||||
context.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
|
||||
Assert.Equal("device-1", context.DeviceId);
|
||||
Assert.Equal("CLIENT01", context.ClientId);
|
||||
Assert.Equal("42", context.UserId);
|
||||
Assert.Equal("7", context.CollaborateurId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetIdentity_OutsideScope_IsNoOp()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
context.SetIdentity("device-1", "CLIENT01", "42", "7");
|
||||
|
||||
Assert.Null(context.DeviceId);
|
||||
Assert.Null(context.ClientId);
|
||||
Assert.Null(context.UserId);
|
||||
Assert.Null(context.CollaborateurId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NestedScopes_AreIsolated_AndRestored()
|
||||
{
|
||||
var context = new RequestDiagnosticContext();
|
||||
Guid a = Guid.NewGuid();
|
||||
Guid b = Guid.NewGuid();
|
||||
|
||||
using (context.Begin(a))
|
||||
{
|
||||
await Task.Yield();
|
||||
Assert.Equal(a, context.TraceId);
|
||||
|
||||
using (context.Begin(b))
|
||||
{
|
||||
Assert.Equal(b, context.TraceId);
|
||||
}
|
||||
|
||||
Assert.Equal(a, context.TraceId);
|
||||
}
|
||||
|
||||
Assert.Null(context.TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
|
||||
namespace Omika.Tests.Observability.Correlation;
|
||||
|
||||
public class TraceIdPropagationHandlerTests
|
||||
{
|
||||
private sealed class FakeInnerHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_NoCurrentTrace_GeneratesTraceIdHeader()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
await client.GetAsync("http://test.local/");
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
Assert.True(inner.LastRequest.Headers.TryGetValues(CorrelationHeaders.TraceId, out var values));
|
||||
Assert.True(Guid.TryParse(Assert.Single(values), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_CurrentTrace_UsesIt()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
Guid expected = Guid.NewGuid();
|
||||
var inner = new FakeInnerHandler();
|
||||
|
||||
using (traceContext.BeginTrace(expected))
|
||||
{
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
await client.GetAsync("http://test.local/");
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
expected.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.TraceId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithSession_AddsSessionHeader()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var session = new DiagnosticSession();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, session, inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
await client.GetAsync("http://test.local/");
|
||||
|
||||
Assert.Equal(
|
||||
session.SessionId.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.SessionId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotModifyAuthorization()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "http://test.local/");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-abc");
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
Assert.Equal("Bearer", inner.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("token-abc", inner.LastRequest.Headers.Authorization!.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ExistingTraceHeader_NotOverwritten()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
Guid existing = Guid.NewGuid();
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "http://test.local/");
|
||||
request.Headers.Add(CorrelationHeaders.TraceId, existing.ToString("D"));
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(
|
||||
existing.ToString("D"),
|
||||
inner.LastRequest!.Headers.GetValues(CorrelationHeaders.TraceId).Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotModifyPayload()
|
||||
{
|
||||
var traceContext = new TraceContext();
|
||||
var inner = new FakeInnerHandler();
|
||||
var handler = new TraceIdPropagationHandler(traceContext, innerHandler: inner);
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "http://test.local/");
|
||||
request.Content = new StringContent("{\"a\":1}", System.Text.Encoding.UTF8, "application/json");
|
||||
await client.SendAsync(request);
|
||||
|
||||
Assert.NotNull(inner.LastRequest);
|
||||
string body = await inner.LastRequest!.Content!.ReadAsStringAsync();
|
||||
Assert.Equal("{\"a\":1}", body);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user