Files
Omika/tests/Omika.Tests/Mobile/Local/EnrollmentEndToEndTests.cs
T
2026-10-23 18:46:50 +02:00

187 lines
7.9 KiB
C#

using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Repositories;
using Omika.App.Local.Services;
using Omika.App.Local.Sqlite;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Services.Mobile;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests de bout en bout de l'inscription sans réseau réel : QR → licence → API →
/// stockage → redémarrage → bootstrap, ainsi que le scénario multi-client et le
/// reset de changement de client (aucune contamination d'URL).
/// </summary>
public class EnrollmentEndToEndTests : LocalTestBase
{
private const string Kid = "test-kid-1";
private sealed class FakeLicenseEnrollmentClient : ILicenseEnrollmentClient
{
public MobileLicenseSessionResponse ActivateResponse { get; set; } = new();
public Task<MobileLicenseSessionResponse> ActivateAsync(
string licenseServiceUrl, LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(ActivateResponse);
public Task<MobileLicenseSessionResponse> ValidateAsync(
string licenseServiceUrl, LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new MobileLicenseSessionResponse());
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public Task<string> GetDeviceIdAsync(CancellationToken cancellationToken = default)
=> Task.FromResult("device-1");
}
private sealed class BootstrapCapturingHandler : HttpMessageHandler
{
public string? LastUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastUri = request.RequestUri?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
});
}
}
private static (string qr, IReadOnlyDictionary<string, string> registry) CreateQr(string code, string licenceUrl)
{
using var rsa = RSA.Create(2048);
var registry = new Dictionary<string, string> { [Kid] = rsa.ToXmlString(false) };
string qr = EnrollmentQrCodec.Sign(
EnrollmentQrCodec.BuildPayloadJson(Kid, code, "LIC-001", licenceUrl),
rsa.ToXmlString(true));
return (qr, registry);
}
private MobileEnrollmentService CreateService(
MobileLicenseSessionResponse licenceResponse,
MobileEnrollmentStore store,
MobileApiClient apiClient,
IReadOnlyDictionary<string, string> registry)
=> new(
new FakeLicenseEnrollmentClient { ActivateResponse = licenceResponse },
store,
new FakeDeviceIdProvider(),
new StubSessionService(),
apiClient,
registry);
private static MobileLicenseSessionResponse ValidLicence(string apiUrl)
=> new()
{
License = new LicenseResponse { IsValid = true, ApiUrl = apiUrl },
AccessToken = "token-e2e"
};
[Fact]
public async Task E2E_UrlBootstrap_Egale_UrlRetourneeParLicence()
{
var store = new MobileEnrollmentStore(Database);
var handler = new BootstrapCapturingHandler();
var apiClient = new MobileApiClient(new HttpClient(handler));
var (qr, registry) = CreateQr("BATIK.01", "https://licence-a.example.com");
var service = CreateService(ValidLicence("https://api-a.example.com"), store, apiClient, registry);
EnrollmentResult result = await service.EnrollAsync(qr);
Assert.True(result.Success);
// Redémarrage simulé : nouvelle instance de service, même base locale.
var restartApiClient = new MobileApiClient(new HttpClient(handler));
var restartService = CreateService(ValidLicence("https://api-a.example.com"), store, restartApiClient, registry);
MobileEnrollmentConfiguration? loaded = await restartService.LoadAsync();
Assert.NotNull(loaded);
restartApiClient.BaseUrl = loaded!.OmikaApiUrl;
restartApiClient.AccessToken = "token-e2e";
await restartApiClient.GetBootstrapAsync();
Assert.Equal("https://api-a.example.com/api/mobile/bootstrap", handler.LastUri);
}
[Fact]
public async Task MultiClient_ClientA_ClientB_UrlsDistinctesSansContamination()
{
// Deux bases locales distinctes (deux appareils) : même code, même binaire.
using var dbA = new LocalDatabase(new LocalDatabaseOptions());
using var dbB = new LocalDatabase(new LocalDatabaseOptions());
dbA.InitializeSchemaAsync().GetAwaiter().GetResult();
dbB.InitializeSchemaAsync().GetAwaiter().GetResult();
var storeA = new MobileEnrollmentStore(dbA);
var storeB = new MobileEnrollmentStore(dbB);
var apiA = new MobileApiClient(new HttpClient());
var apiB = new MobileApiClient(new HttpClient());
var (qrA, regA) = CreateQr("CLIENT.A", "https://licence-a.example.com");
var (qrB, regB) = CreateQr("CLIENT.B", "https://licence-b.example.com");
EnrollmentResult resA = await CreateService(ValidLicence("https://api-a.example.com"), storeA, apiA, regA).EnrollAsync(qrA);
EnrollmentResult resB = await CreateService(ValidLicence("https://api-b.example.com"), storeB, apiB, regB).EnrollAsync(qrB);
Assert.True(resA.Success);
Assert.True(resB.Success);
Assert.Equal("https://api-a.example.com", apiA.BaseUrl);
Assert.Equal("https://api-b.example.com", apiB.BaseUrl);
Assert.Equal("https://api-a.example.com", (await storeA.LoadAsync())!.OmikaApiUrl);
Assert.Equal("https://api-b.example.com", (await storeB.LoadAsync())!.OmikaApiUrl);
}
[Fact]
public async Task Reset_ClientA_Vers_ClientB_AucuneReutilisationApiA()
{
var store = new MobileEnrollmentStore(Database);
var apiClient = new MobileApiClient(new HttpClient());
var (qrA, regA) = CreateQr("CLIENT.A", "https://licence-a.example.com");
// Inscription Client A.
await CreateService(ValidLicence("https://api-a.example.com"), store, apiClient, regA).EnrollAsync(qrA);
Assert.Equal("https://api-a.example.com", apiClient.BaseUrl);
// Reset complet (désinscription) : table + token + client API réinitialisés.
var tokenStore = new FakeTokenStore();
var maintenance = new LocalDataMaintenanceService(Database, "unused", tokenStore);
await maintenance.ResetAsync();
apiClient.BaseUrl = null;
apiClient.AccessToken = null;
Assert.Null(await store.LoadAsync());
Assert.Null(apiClient.BaseUrl);
Assert.True(tokenStore.Cleared);
// Inscription Client B.
var (qrB, regB) = CreateQr("CLIENT.B", "https://licence-b.example.com");
EnrollmentResult resB = await CreateService(ValidLicence("https://api-b.example.com"), store, apiClient, regB).EnrollAsync(qrB);
Assert.True(resB.Success);
Assert.Equal("https://api-b.example.com", apiClient.BaseUrl);
Assert.Equal("https://api-b.example.com", (await store.LoadAsync())!.OmikaApiUrl);
Assert.NotEqual("https://api-a.example.com", apiClient.BaseUrl);
}
private sealed class FakeTokenStore : ISecureTokenStore
{
public bool Cleared { get; private set; }
public Task SaveAsync(string token, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<string?> GetAsync(CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
public Task ClearAsync(CancellationToken cancellationToken = default)
{
Cleared = true;
return Task.CompletedTask;
}
}
}