Files
Omika/tests/Omika.Tests/Api/DiagnosticDeviceLicenseApiTests.cs
2026-10-23 18:46:50 +02:00

319 lines
14 KiB
C#

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;
/// <summary>
/// Tâche 17-B — Autorité serveur de LicenseNumber / DeviceKey dans Omika.Api :
/// <list type="bullet">
/// <item>D1 : LicenseNumber = NumeroLicence du token authentifié (jamais le payload).</item>
/// <item>D2 : DeviceKey = HMAC(CodeClient + DeviceId) calculé serveur (jamais choisi par le mobile).</item>
/// <item>D3 : un payload falsifié (deviceKey/licenseNumber/deviceId) ne remplace jamais l'identité serveur.</item>
/// <item>D4 : ClientCode reste autoritaire (protection T13-C intacte).</item>
/// <item>D5 : token sans numéro de licence → LicenseNumber null, aucun rejet (fail-open).</item>
/// </list>
/// Provider et store sont des fakes en mémoire ; la DeviceKey est calculée par le VRAI
/// <see cref="IDiagnosticDeviceKeyProvider"/> enregistré dans Program (clé de test déterministe).
/// </summary>
[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<Program> _factory;
public DiagnosticDeviceLicenseApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_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,
["DiagnosticsIdentity:HmacKey"] = HmacKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_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<IDiagnosticDeviceKeyProvider>();
services.AddSingleton<IDiagnosticDeviceKeyProvider>(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<ITokenService>();
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<int> 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<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
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;
}
}