using System.Net; using System.Net.Http.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 17-B — Autorité serveur de LicenseNumber / DeviceKey dans Omika.Api : /// /// D1 : LicenseNumber = NumeroLicence du token authentifié (jamais le payload). /// D2 : DeviceKey = HMAC(CodeClient + DeviceId) calculé serveur (jamais choisi par le mobile). /// D3 : un payload falsifié (deviceKey/licenseNumber/deviceId) ne remplace jamais l'identité serveur. /// D4 : ClientCode reste autoritaire (protection T13-C intacte). /// D5 : token sans numéro de licence → LicenseNumber null, aucun rejet (fail-open). /// /// Provider et store sont des fakes en mémoire ; la DeviceKey est calculée par le VRAI /// enregistré dans Program (clé de test déterministe). /// [Collection("ApplicationRuntimeContext")] public class DiagnosticDeviceLicenseApiTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private static readonly string HmacKey = Convert.ToBase64String(new byte[32]); private readonly FakeIngestionStore _store = new(); private readonly FakeDeploymentContextProvider _provider = new(); private readonly WebApplicationFactory _factory; public DiagnosticDeviceLicenseApiTests() { 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, ["DiagnosticsIdentity:HmacKey"] = HmacKey }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddSingleton(_store); services.RemoveAll(); services.AddSingleton(_provider); // DeviceKey provider déterministe (clé de test) : le provider réel de // Program capture builder.Configuration (non surchargé ici), donc on // remplace explicitement le provider pour obtenir un HMAC stable. services.RemoveAll(); services.AddSingleton(new DiagnosticDeviceKeyProvider(HmacKey)); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // --------------------------- D1 : LicenseNumber = token ---------------------- [Fact] public async Task D1_LicenseNumber_ComesFromToken() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: "LIC-AAAA")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call = Assert.Single(_store.Calls); Assert.Equal("LIC-AAAA", call.LicenseNumber); } // --------------------------- D2 : DeviceKey = HMAC serveur ------------------- [Fact] public async Task D2_DeviceKey_IsComputedServerSide() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call = Assert.Single(_store.Calls); // La DeviceKey reçue doit être EXACTEMENT le HMAC serveur attendu, jamais une // valeur transmise par le client (le DTO ne l'expose pas). DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA"); Assert.True(expected.HasValue); Assert.Equal(expected.Value, call.DeviceKey!.Value); Assert.Equal(expected.Version, call.DeviceKey.Version); } // --------------------------- D3 : payload falsifié ignoré -------------------- [Fact] public async Task D3_FalsifiedPayload_NeverReplacesDeviceOrLicenseIdentity() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA", numeroLicence: "LIC-AAAA")); // Le payload tente d'injecter deviceId / deviceKey / licenseNumber : aucun de // ces champs n'est un champ structuré du DTO (deviceId est épuré, deviceKey et // licenseNumber sont ignorés à la désérialisation). 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", "deviceId": "FAUX-DEVICE", "deviceKey": "FAUX-DEVICE-KEY", "licenseNumber": "FAUX-LICENCE" } ] } """; using var content = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("/api/diagnostics", content); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call = Assert.Single(_store.Calls); Assert.Equal("LIC-AAAA", call.LicenseNumber); // token, pas FAUX-LICENCE DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA"); Assert.Equal(expected.Value, call.DeviceKey!.Value); // HMAC serveur, pas FAUX-DEVICE-KEY Assert.DoesNotContain("FAUX-DEVICE-KEY", call.DeviceKey.Value, StringComparison.Ordinal); } // --------------------------- D4 : ClientCode reste autoritaire --------------- [Fact] public async Task D4_ClientCode_ProtectionStillIntact() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT")); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call = Assert.Single(_store.Calls); Assert.Equal("CLIENT-JWT", call.Context!.ClientCode); } // --------------------------- D5 : token sans licence → null, pas de rejet ----- [Fact] public async Task D5_TokenWithoutLicense_NullLicenseNumber_NoRejection() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: "")); var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch()); Assert.Equal(200, (int)response.StatusCode); (DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call = Assert.Single(_store.Calls); Assert.Null(call.LicenseNumber); } // --------------------------- Helpers ------------------------------------------ private static DiagnosticBatchDto ValidBatch(string? clientId = null) => new() { BatchId = Guid.NewGuid(), Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", ClientId = clientId } ] }; private string CreateToken(string codeClient, string deviceId = "device-test", string numeroLicence = "licence-test") { using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); return tokenService.GenerateToken(new MobileTokenIdentity { CodeClient = codeClient, DeviceId = deviceId, UserId = "user-test", CollaborateurId = "7", NumeroLicence = numeroLicence }); } // ===== Fakes ===== private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore { private readonly object _gate = new(); private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> _calls = []; public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> 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, deviceKey, licenseNumber)); } 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; } }