using System.Net; using System.Net.Http.Json; using System.Text; 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.Configuration; using Omika.Core.Interfaces.Mobile; using Omika.Core.Observability.Deployment; using Omika.Core.Observability.Identity; using Omika.Core.Observability.Outbox; using Omika.Core.Runtime; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.Runtime; namespace Omika.Tests.Api; /// /// Tâche 13-C — Intégration du contexte de déploiement dans Omika.Api : /// /// C1 : événement API interne → contexte du provider (ClientCode serveur). /// C2/C3 : diagnostic mobile → ClientCode = JWT authentifié (autoritaire), jamais payload/provider. /// C4/C5 : ServerInstance/Environment mobile = serveur ingérant (provider), jamais le téléphone. /// C6 : payload falsifié (ClientId/ClientCode) ne remplace jamais le JWT. /// C7 : requêtes concurrentes → aucune contamination inter-requêtes. /// C8 : provider sans ClientCode → null sans exception (fail-open). /// C9 : aucun secret dans ce que les producteurs transmettent. /// /// Provider et store sont des fakes en mémoire : l'objectif est de vérifier QUE le /// bon est transmis à /// (le provider lui-même est /// couvert par les tests D1–D16 de T13-D ; le JSONL par B1–B24 de T13-B). /// [Collection("ApplicationRuntimeContext")] public class DiagnosticDeploymentContextApiTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private readonly FakeIngestionStore _store = new(); private readonly FakeDeploymentContextProvider _provider = new(); private readonly WebApplicationFactory _factory; public DiagnosticDeploymentContextApiTests() { ApplicationRuntimeContext.Reset(); _provider.Context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-SERVEUR", ServerInstance = "API-SRV-01", Environment = "Production" }; _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(); // Store fake : capture le contexte de déploiement transmis. services.RemoveAll(); services.AddSingleton(_store); // Provider fake : contexte serveur déterministe (pas de HFSQL). services.RemoveAll(); services.AddSingleton(_provider); // Active le forwarder serveur (4xx/5xx) pour C1/C8. services.Configure(o => o.Enabled = true); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // --------------------------- C1 : événement API interne ----------------------- [Fact] public async Task C1_InternalApiEvent_UsesProviderContext() { var client = _factory.CreateClient(); // 404 (hors /health et /api/diagnostics) → le ServerDiagnosticMiddleware // enregistre un événement API_HTTP_4XX avec le contexte du provider. var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}"); Assert.Equal(404, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); AssertContext(call.Context, clientCode: "CLIENT-SERVEUR", serverInstance: "API-SRV-01", environment: "Production"); } // --------------------------- C2 : diagnostic mobile --------------------------- [Fact] public async Task C2_MobileDiagnostic_UsesJwtCodeClientOverPayload() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); // Payload mobile : ClientId falsifiable = FAUX-CLIENT. var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT")); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); Assert.Equal("CLIENT-JWT", call.Context!.ClientCode); } // --------------------------- C3 : priorité JWT > provider > payload ----------- [Fact] public async Task C3_MobileDiagnostic_PriorityJwtOverProviderOverPayload() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT")); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); // JWT gagne ; le provider ("CLIENT-SERVEUR") ne gagne PAS pour le mobile ; // le payload ("FAUX-CLIENT") n'est JAMAIS candidat. Assert.Equal("CLIENT-JWT", call.Context!.ClientCode); Assert.NotEqual("CLIENT-SERVEUR", call.Context.ClientCode); Assert.NotEqual("FAUX-CLIENT", call.Context.ClientCode); } // --------------------------- C4 : ServerInstance mobile = provider ------------ [Fact] public async Task C4_MobileDiagnostic_ServerInstanceFromProviderNotPhone() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); // DeviceModel du téléphone : ne doit jamais devenir ServerInstance. var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(deviceModel: "iPhone 15")); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); Assert.Equal("API-SRV-01", call.Context!.ServerInstance); Assert.NotEqual("iPhone 15", call.Context.ServerInstance); } // --------------------------- C5 : Environment mobile = provider --------------- [Fact] public async Task C5_MobileDiagnostic_EnvironmentFromProvider() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); Assert.Equal("Production", call.Context!.Environment); } // --------------------------- C6 : payload falsifié ---------------------------- [Fact] public async Task C6_FalsifiedPayload_NeverReplacesJwtCodeClient() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); // Payload brut contenant clientId + un champ inconnu "clientCode" (ignoré par // la désérialisation typée) : aucun des deux ne doit devenir le ClientCode. string rawJson = $$""" { "schemaVersion": 1, "batchId": "{{Guid.NewGuid():D}}", "sentAtUtc": "2025-01-01T00:00:00+00:00", "events": [ { "eventId": "{{Guid.NewGuid():D}}", "eventCode": "OMK-MOB-APP-001", "timestampUtc": "2025-01-01T00:00:00+00:00", "level": "Info", "priority": "P3", "clientId": "AUTRE-CLIENT", "clientCode": "AUTRE-CLIENT" } ] } """; using var content = new StringContent(rawJson, Encoding.UTF8, "application/json"); var response = await client.PostAsync("/api/diagnostics", content); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); Assert.Equal("CLIENT-JWT", call.Context!.ClientCode); } // --------------------------- C7 : requêtes concurrentes ----------------------- [Fact] public async Task C7_ConcurrentRequests_NoContextContamination() { var client = _factory.CreateClient(); (string Code, string EventCode)[] cases = [ ("CLIENT-A", "T7-A"), ("CLIENT-B", "T7-B"), ("CLIENT-C", "T7-C") ]; // Construction explicite des requêtes concurrentes avec des tokens distincts. Task[] requests = cases.Select(c => { var req = new HttpRequestMessage(HttpMethod.Post, "/api/diagnostics") { Content = JsonContent.Create(ValidBatch(eventCode: c.EventCode)) }; req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(c.Code)); return client.SendAsync(req); }).ToArray(); HttpResponseMessage[] responses = await Task.WhenAll(requests); Assert.All(responses, r => Assert.Equal(200, (int)r.StatusCode)); Assert.Equal(3, _store.Calls.Count); foreach ((string Code, string EventCode) c in cases) { (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls, x => x.Batch.Events[0].EventCode == c.EventCode); Assert.Equal(c.Code, call.Context!.ClientCode); // ServerInstance/Environment identiques pour tous les clients. Assert.Equal("API-SRV-01", call.Context.ServerInstance); Assert.Equal("Production", call.Context.Environment); } } // --------------------------- C8 : provider sans ClientCode -------------------- [Fact] public async Task C8_ProviderWithoutClientCode_NullWithoutException() { _provider.Context = new DiagnosticDeploymentContext { ClientCode = null, ServerInstance = "API-SRV-01", Environment = "Production" }; var client = _factory.CreateClient(); var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}"); // Fail-open : le 404 est toujours produit, l'événement est enregistré avec // ClientCode = null, aucune exception. Assert.Equal(404, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); Assert.Null(call.Context!.ClientCode); Assert.Equal("API-SRV-01", call.Context.ServerInstance); Assert.Equal("Production", call.Context.Environment); } // --------------------------- C9 : absence de secret --------------------------- [Fact] public async Task C9_NoSecretsInProducedContextOrBatch() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT")); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls); string contextJson = JsonSerializer.Serialize(call.Context!); string batchJson = JsonSerializer.Serialize(call.Batch); foreach (string secret in new[] { "Authorization", "Bearer", "HmacKey", "ConnectionString", "password", "CL_CleAPIInstall" }) { Assert.DoesNotContain(secret, contextJson, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(secret, batchJson, StringComparison.OrdinalIgnoreCase); } } // --------------------------- Helpers ------------------------------------------ private static DiagnosticBatchDto ValidBatch( string eventCode = "OMK-MOB-APP-001", string? clientId = null, string? deviceModel = null) => new() { BatchId = Guid.NewGuid(), Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", ClientId = clientId, DeviceModel = deviceModel } ] }; private string CreateToken(string codeClient) { using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); return tokenService.GenerateToken(new MobileTokenIdentity { CodeClient = codeClient, DeviceId = "device-test", UserId = "user-test", CollaborateurId = "7", NumeroLicence = "licence-test" }); } private static void AssertContext( DiagnosticDeploymentContext? context, string? clientCode, string? serverInstance, string? environment) { Assert.NotNull(context); Assert.Equal(clientCode, context!.ClientCode); Assert.Equal(serverInstance, context.ServerInstance); Assert.Equal(environment, context.Environment); } // ===== Fakes ===== private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore { private readonly object _gate = new(); private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = []; public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls { get { lock (_gate) { return _calls.ToArray(); } } } public Task StoreAsync( DiagnosticBatchDto batch, DiagnosticActorKey actorKey, string ingestionTraceId, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken = default, DiagnosticDeploymentContext? deploymentContext = null, DiagnosticDeviceKey? deviceKey = null, string? licenseNumber = null) { lock (_gate) { _calls.Add((batch, deploymentContext)); } return Task.FromResult(batch.Events.Count); } } private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider { public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty; public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(Context); } 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; } } /// /// Tâche 13-C C10 — Régression : les anciens appels StoreAsync (sans /// deploymentContext) et les anciens JSONL restent compatibles. Utilise le /// vrai (aucun HTTP requis). /// public class DiagnosticDeploymentContextBackwardCompatibilityTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-t13c-compat", Guid.NewGuid().ToString("N")); public DiagnosticDeploymentContextBackwardCompatibilityTests() => Directory.CreateDirectory(_dir); public void Dispose() { try { if (Directory.Exists(_dir)) { Directory.Delete(_dir, recursive: true); } } catch { // Best-effort. } } [Fact] public async Task C10_LegacyStoreAsyncCall_WritesJsonlWithoutContextFields() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); var evt = new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3" }; var batch = new DiagnosticBatchDto { Events = [evt] }; // Appel hérité : AUCUN deploymentContext. int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow); Assert.Equal(1, accepted); string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); using JsonDocument doc = JsonDocument.Parse(content); Assert.False(doc.RootElement.TryGetProperty("clientCode", out _)); Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _)); Assert.False(doc.RootElement.TryGetProperty("environment", out _)); } 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(); } }