319 lines
14 KiB
C#
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;
|
|
}
|
|
}
|