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