using System.Net; using System.Net.Http.Headers; 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.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Interfaces.Mobile; using Omika.Core.Observability.Diagnostic; using Omika.Core.Observability.Outbox; using Omika.Core.Runtime; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.Runtime; namespace Omika.Tests.Observability.Outbox; /// /// Tests d'intégration mobile → API (Phase 4) : authentification, validation stricte, /// persistance durable avant ACK, données interdites, anti-boucle, panne stockage. /// [Collection("ApplicationRuntimeContext")] public class DiagnosticIngestionIntegrationTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly string _localDir = CreateTempDirectory(); private readonly string _remoteDir = CreateTempDirectory(); private readonly WebApplicationFactory _factory; public DiagnosticIngestionIntegrationTests() { ApplicationRuntimeContext.Reset(); _factory = CreateFactory(_localDir, _remoteDir); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); DeleteDirectory(_localDir); DeleteDirectory(_remoteDir); } private string NewToken() { using IServiceScope scope = _factory.Services.CreateScope(); ITokenService tokenService = scope.ServiceProvider.GetRequiredService(); return tokenService.GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "device-1", UserId = "user-1", CollaborateurId = Guid.NewGuid().ToString(), NumeroLicence = "lic-1" }); } private static DiagnosticBatchDto ValidBatch(string component = "test") => new() { BatchId = Guid.NewGuid(), Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", TraceId = Guid.NewGuid(), SessionId = Guid.NewGuid(), Component = component } ] }; [Fact] public async Task WithoutToken_ReturnsUnauthorized() { using HttpClient client = _factory.CreateClient(); HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task ValidBatch_IsPersisted_AndAcknowledged() { using HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken()); DiagnosticBatchDto batch = ValidBatch("télémétrie"); HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", batch); Assert.Equal(HttpStatusCode.OK, response.StatusCode); DiagnosticBatchResponseDto? body = await response.Content.ReadFromJsonAsync(); Assert.Equal(batch.BatchId, body!.BatchId); Assert.Equal(1, body.AcceptedCount); await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl") .Any(f => ReadAllTextShared(f).Contains("\"eventId\"", StringComparison.Ordinal))); using JsonDocument document = JsonDocument.Parse(ReadFirstLine(_remoteDir)); JsonElement evt = document.RootElement.GetProperty("event"); Assert.Equal(batch.Events[0].EventId.ToString(), evt.GetProperty("eventId").GetString()); Assert.Equal(batch.Events[0].TraceId!.Value.ToString(), evt.GetProperty("traceId").GetString()); Assert.Equal(batch.Events[0].SessionId!.Value.ToString(), evt.GetProperty("sessionId").GetString()); Assert.Equal("télémétrie", evt.GetProperty("component").GetString()); } [Fact] public async Task ForbiddenMetadata_ReturnsBadRequest_NothingPersisted() { using HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken()); string json = """ { "schemaVersion": 1, "batchId": "00000000-0000-0000-0000-000000000001", "events": [{ "eventId": "00000000-0000-0000-0000-000000000002", "eventCode": "OMK-MOB-APP-001", "level": "Info", "priority": "P3", "metadata": { "password": "secret" } }] } """; HttpResponseMessage response = await client.PostAsync( "/api/diagnostics", new StringContent(json, Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Empty(Directory.EnumerateFiles(_remoteDir, "*.jsonl")); } [Fact] public async Task UnknownField_IsNotPersisted() { using HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken()); // Le champ "password" n'est pas dans le DTO : il est ignoré par la // désérialisation typée et ne doit jamais apparaître dans le fichier serveur. string json = """ { "schemaVersion": 1, "batchId": "00000000-0000-0000-0000-000000000001", "password": "secret-password-value", "events": [{ "eventId": "00000000-0000-0000-0000-000000000002", "eventCode": "OMK-MOB-APP-001", "level": "Info", "priority": "P3", "component": "ok" }] } """; HttpResponseMessage response = await client.PostAsync( "/api/diagnostics", new StringContent(json, Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl") .Any(f => ReadAllTextShared(f).Contains("\"component\"", StringComparison.Ordinal))); string content = string.Join("\n", Directory.EnumerateFiles(_remoteDir, "*.jsonl").Select(ReadAllTextShared)); Assert.DoesNotContain("secret-password-value", content, StringComparison.Ordinal); Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task StorageFailure_Returns503_DoesNotCrash() { string blocker = Path.Combine(_localDir, "blocker"); File.WriteAllText(blocker, "x"); string inaccessible = Path.Combine(blocker, "sub"); using var factory = CreateFactory(_localDir, inaccessible); using HttpClient client = factory.CreateClient(); string token; using (IServiceScope scope = factory.Services.CreateScope()) { token = scope.ServiceProvider.GetRequiredService() .GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "d", UserId = "u" }); } client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); } [Fact] public async Task DiagnosticsEndpoint_DoesNotEmitHttpEvents_OrLogBody() { using HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken()); HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch("MARKER-BODY-123")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); await Task.Delay(100); // Le fichier d'ingestion serveur contient la donnée (légitime). await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl") .Any(f => ReadAllTextShared(f).Contains("MARKER-BODY-123", StringComparison.Ordinal))); // Le journal local de l'API ne doit pas contenir le corps ni un événement // HTTP_REQUEST_* pour /api/diagnostics (anti-boucle, sans body). string local = string.Join("\n", Directory.EnumerateFiles(_localDir, "*.jsonl").Select(ReadAllTextShared)); Assert.DoesNotContain("MARKER-BODY-123", local, StringComparison.Ordinal); Assert.DoesNotContain("/api/diagnostics", local, StringComparison.Ordinal); } // ===== Helpers ===== private static WebApplicationFactory CreateFactory( string localDir, string remoteDir, 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()); services.Configure(options => options.Directory = localDir); services.Configure(options => options.Directory = remoteDir); configureServices?.Invoke(services); }); }); return factory; } private static string ReadFirstLine(string dir) { string file = Assert.Single(Directory.EnumerateFiles(dir, "*.jsonl")); string content = ReadAllTextShared(file); int newline = content.IndexOf('\n'); return (newline >= 0 ? content[..newline] : content).TrimEnd('\r'); } 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 string CreateTempDirectory() { string dir = Path.Combine(Path.GetTempPath(), "omika-diag-ingestion-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. } } 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 FakeLicenseService : ILicenseService { public Task ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new LicenseResponse()); 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 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; } }