477 lines
19 KiB
C#
477 lines
19 KiB
C#
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;
|
|
}
|
|
}
|