Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,624 @@
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.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Domain.Entities.Mobile;
using Omika.Core.Domain.Entities.Security;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Back-Office WinDev
/// (POST /api/backoffice/auth/token).
///
/// Vérifie le contrat complet : codes HTTP, émission du Bearer token via le
/// mécanisme normal (ITokenService / MobileAuthMiddleware), contrôle d'accès
/// Back-Office (CO_AccesBO / UT_AccesBO), code client, et séparation avec le
/// droit A.LIC.MOB (qui n'est pas une condition d'émission du token).
///
/// Le hash utilisé est un placeholder volontairement faux : aucun vrai hash ni
/// vrai token n'apparaît dans ces tests.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class BackOfficeAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string ValidLogin = "DFO";
private const string ValidHash = "FAKE-HASH-0123456789ABCDEF0123456789ABCDEF";
private const long ValidUserId = 42;
private const long ValidCollaborateurId = 7;
private readonly FakeBackOfficeAuthRepository _authRepository = new();
private readonly FakeLicenceRepository _licenceRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public BackOfficeAuthEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IBackOfficeAuthRepository>();
services.AddScoped<IBackOfficeAuthRepository>(_ => _authRepository);
services.RemoveAll<ILicenceRepository>();
services.AddScoped<ILicenceRepository>(_ => _licenceRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== 1. Authentification réussie =====
[Fact]
public async Task Post_ValidCredentials_WithBoAccess_Returns200_AndValidToken()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
// Le token est valide et porte l'identité attendue (même mécanisme que le mobile).
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
MobileTokenIdentity identity = tokenService.ValidateToken(body.AccessToken)!;
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(ValidUserId.ToString(), identity.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), identity.CollaborateurId);
}
[Fact]
public async Task Post_LoginIsCaseInsensitive_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "dfo", // minuscules : reproduit le UPPER(UT_Login) = UPPER(login) WinDev
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 2/3. Identifiants incorrects =====
[Fact]
public async Task Post_WrongLogin_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Post_WrongPasswordHash_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = "AUTRE-HASH-FAUX"
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
// ===== 4. Hash vide =====
[Fact]
public async Task Post_EmptyPasswordHash_Returns400()
{
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = string.Empty
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 5. Accès Back-Office refusé =====
[Fact]
public async Task Post_UserWithoutBoAccess_Returns403()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, ValidCollaborateurId, ValidCollaborateurId,
UtilisateurAccesBO: false, CollaborateurAccesBO: false, CollaborateurActif: true);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Post_UserWithoutCollaborateur_ButUtAccesBoTrue_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 6. Collaborateur lié =====
[Fact]
public async Task Post_LinkedCollaborateur_ReturnsCorrectUserIdAndCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
}
// ===== 7. Utilisateur sans collaborateur =====
[Fact]
public async Task Post_UserWithoutCollaborateur_ReturnsNullCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Null(body.CollaborateurId);
}
// ===== 8/9. Code client =====
[Fact]
public async Task Post_EmptyCodeClient_Returns400()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = string.Empty,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
[Fact]
public async Task Post_UnknownCodeClient_Returns400()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = null; // client inconnu côté serveur
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = "INCONNU.99",
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 10/11/12. Token accepté par MobileAuthMiddleware + A.LIC.MOB =====
[Fact]
public async Task Post_ReturnedToken_IsAcceptedByMobileAuthMiddleware_OnProtectedEndpoint()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
// 1. Obtention du token Back-Office.
var authResponse = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)authResponse.StatusCode);
var session = await authResponse.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Le token est accepté par MobileAuthMiddleware sur un endpoint protégé.
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var protectedResponse = await protectedClient.GetAsync(
$"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)protectedResponse.StatusCode);
}
[Fact]
public async Task Post_UserWithLicenceMobilePermission_CanAccessLicenceMobileEndpoint()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
string token = await ObtainTokenAsync();
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
Assert.Equal(CodeClient, _licenseService.LastCodeClient);
}
[Fact]
public async Task Post_UserWithoutLicenceMobilePermission_TokenCreated_ButLicenceMobileDenied()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
string token = await ObtainTokenAsync();
Assert.False(string.IsNullOrWhiteSpace(token));
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
// ===== 13. Sécurité : aucun hash/token exposé =====
[Fact]
public async Task ErrorResponses_DoNotContainPasswordHash()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(ValidHash, body);
Assert.DoesNotContain(ValidHash.ToLowerInvariant(), body);
}
// ===== Helpers =====
private async Task<string> ObtainTokenAsync()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
return session.AccessToken;
}
private static BackOfficeAuthUser ValidUser() => new(
ValidUserId,
ValidLogin,
CollaborateurId: ValidCollaborateurId,
CollaborateurPkId: ValidCollaborateurId,
UtilisateurAccesBO: false,
CollaborateurAccesBO: true,
CollaborateurActif: true);
private static ClientBase ValidClient() => new()
{
Id = 1,
Code = CodeClient,
Nom = "Société Test"
};
// ===== Fakes =====
private sealed class FakeBackOfficeAuthRepository : IBackOfficeAuthRepository
{
public string ValidLogin { get; set; } = string.Empty;
public string ValidHash { get; set; } = string.Empty;
public BackOfficeAuthUser? UserToReturn { get; set; }
public Task<BackOfficeAuthUser?> GetByLoginAndPasswordHashAsync(
string login,
string passwordHash,
CancellationToken cancellationToken = default)
{
bool loginMatches = string.Equals(login, ValidLogin, StringComparison.OrdinalIgnoreCase);
bool hashMatches = string.Equals(passwordHash, ValidHash, StringComparison.Ordinal);
return Task.FromResult(loginMatches && hashMatches ? UserToReturn : null);
}
}
private sealed class FakeLicenceRepository : ILicenceRepository
{
public ClientBase? ClientToReturn { get; set; }
public Task<ClientBase?> GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(ClientToReturn);
public Task<Licence?> GetByNumeroAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<string?> GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<Licence?> GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<bool> AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult<CollaborateurLicenceDto?>(null);
}
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
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;
}
}
@@ -0,0 +1,359 @@
using System.Net.Http.Json;
using System.Reflection;
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.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Interfaces;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints Client. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
/// et un fake <see cref="IClientService"/> pour contrôler les réponses.
///
/// Les tests modifient le contexte statique <see cref="ApplicationRuntimeContext"/>,
/// donc ils sont sérialisés via la collection <c>"ApplicationRuntimeContext"</c>.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class ClientEndpointsTests : IDisposable
{
private readonly FakeClientService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public ClientEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Configuration minimale avec valeurs HFSQL factices
// requises par ValidateOnStart().
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db"
})
.Build();
services.AddSingleton<IConfiguration>(config);
// Remplacer le runtime initializer par un fake qui
// configure Batik comme version détectée (pas de HFSQL).
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
// Remplacer IHfsqlConnection par un fake (pas d'ODBC).
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Remplacer IClientService par un fake contrôlable.
services.RemoveAll<IClientService>();
services.AddScoped<IClientService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// =========================================================================
// GET /clients/{id}
// =========================================================================
[Fact]
public async Task GetClientById_NumericId_TransmittedAsString()
{
var client = _factory.CreateClient();
await client.GetAsync("/clients/12345");
Assert.Equal("12345", _fakeService.LastDetailsId);
}
[Fact]
public async Task GetClientById_GuidId_TransmittedAsString()
{
var client = _factory.CreateClient();
var guid = Guid.NewGuid().ToString("D");
await client.GetAsync($"/clients/{guid}");
Assert.Equal(guid, _fakeService.LastDetailsId);
}
[Fact]
public async Task GetClientById_Returns200WithDto()
{
_fakeService.DetailsResult = new ClientDetailsDto
{
Id = "test-id",
Code = "C001",
Nom = "Dupont"
};
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/42");
var body = await response.Content.ReadFromJsonAsync<ClientDetailsDto>();
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(body);
Assert.Equal("test-id", body.Id);
Assert.Equal("C001", body.Code);
Assert.Equal("Dupont", body.Nom);
}
[Fact]
public async Task GetClientById_NullResult_Returns404()
{
_fakeService.DetailsResult = null;
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/999");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task GetClientById_ServiceException_NotTransformedTo404()
{
_fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error");
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/42");
// L'exception n'est pas catchée dans le endpoint → 500.
Assert.Equal(500, (int)response.StatusCode);
}
// =========================================================================
// HEALTH
// =========================================================================
[Fact]
public async Task GetHealth_StillResponds()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(200, (int)response.StatusCode);
}
// =========================================================================
// ARCHITECTURE
// =========================================================================
[Fact]
public void Endpoints_DependsOnlyOnIClientService()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in methods)
{
var parameters = method.GetParameters();
// Les handlers doivent accepter IClientService, pas les repositories.
Assert.DoesNotContain(parameters, p =>
p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase)
&& p.ParameterType.Name != "IClientService");
}
}
[Fact]
public void Endpoints_HasNoOmikaDataReferences()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data"));
}
[Fact]
public void Endpoints_HasNoApplicationRuntimeContext()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Endpoints_HasNoHfsqlRowReferences()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
}
// =========================================================================
// HELPERS
// =========================================================================
private static IEnumerable<string> GetReferencedNamespaces(Type type)
{
var ns = new HashSet<string>();
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.DeclaringType?.Namespace is not null)
ns.Add(method.DeclaringType.Namespace);
if (method.ReturnType.Namespace is not null)
ns.Add(method.ReturnType.Namespace);
foreach (var p in method.GetParameters())
{
if (p.ParameterType.Namespace is not null)
ns.Add(p.ParameterType.Namespace);
}
}
return ns;
}
// =========================================================================
// FAKES
// =========================================================================
/// <summary>
/// Fake runtime initializer : configure Batik comme version détectée,
/// sans contacter de base HFSQL.
/// </summary>
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
// Configurer le contexte runtime avec Batik comme version détectée.
// Cela permet à AdaptiveClientRepository de fonctionner sans base réelle.
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
/// <summary>
/// Fake IClientService pour les tests HTTP : enregistre les appels
/// et retourne des données contrôlées.
/// </summary>
private sealed class FakeClientService : IClientService
{
public int SearchCallCount { get; private set; }
public int DetailsCallCount { get; private set; }
public int UpdateCallCount { get; private set; }
public int ArchiveCallCount { get; private set; }
public SearchClientsRequest? LastRequest { get; private set; }
public string? LastDetailsId { get; private set; }
public string? LastUpdateId { get; private set; }
public UpdateClientRequest? LastUpdateRequest { get; private set; }
public string? LastArchiveId { get; private set; }
public PagedResult<ClientSummaryDto>? SearchResult { get; set; }
public ClientDetailsDto? DetailsResult { get; set; }
public ClientDetailsDto? UpdateResult { get; set; }
public bool ArchiveResult { get; set; }
public Exception? ThrowOnSearch { get; set; }
public Exception? ThrowOnDetails { get; set; }
public Exception? ThrowOnUpdate { get; set; }
public Exception? ThrowOnArchive { get; set; }
public Task<PagedResult<ClientSummaryDto>> SearchAsync(
SearchClientsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(SearchResult
?? new PagedResult<ClientSummaryDto>([], 1, 20, 0, 0));
}
public Task<PagedResult<ClientDetailsDto>> SearchDetailsAsync(
SearchClientsRequest request, CancellationToken cancellationToken = default)
{
// Fake minimal : retourne un résultat vide paginé. Le snapshot mobile
// n'est pas exercé par ces tests HTTP (ils ciblent les endpoints CRUD).
return Task.FromResult(new PagedResult<ClientDetailsDto>([], 1, 20, 0, 0));
}
public Task<ClientDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
{
DetailsCallCount++;
LastDetailsId = id;
if (ThrowOnDetails is not null) throw ThrowOnDetails;
return Task.FromResult(DetailsResult);
}
public Task<ClientDetailsDto> CreateAsync(
CreateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<ClientDetailsDto?> UpdateAsync(
string id, UpdateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
UpdateCallCount++;
LastUpdateId = id;
LastUpdateRequest = request;
if (ThrowOnUpdate is not null) throw ThrowOnUpdate;
return Task.FromResult(UpdateResult);
}
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
{
ArchiveCallCount++;
LastArchiveId = id;
if (ThrowOnArchive is not null) throw ThrowOnArchive;
return Task.FromResult(ArchiveResult);
}
}
/// <summary>
/// Fake IHfsqlConnection minimal pour les tests HTTP.
/// </summary>
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;
}
}
@@ -0,0 +1,280 @@
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.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints administratifs de licence mobile
/// (GET/POST /collaborateurs/{id}/licence-mobile[/assign|/release]).
/// Vérifie l'authentification (Bearer) et l'autorisation (droit A.LIC.MOB),
/// ainsi que le contrat de réponse pour l'attribution, la consultation et la
/// libération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class CollaborateurLicenceEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public CollaborateurLicenceEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Consultation (GET) =====
[Fact]
public async Task Get_SansToken_Retourne401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-001", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Attribution (POST assign) =====
[Fact]
public async Task Assign_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Assign_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.AssignResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-002", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Libération (POST release) =====
[Fact]
public async Task Release_DroitLicenceMobilePresent_Retourne204()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.ReleaseResult = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/release", content: null);
Assert.Equal(204, (int)response.StatusCode);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public CollaborateurLicenceDto? AssignResult { get; set; }
public bool ReleaseResult { get; set; } = true;
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(AssignResult);
}
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(ReleaseResult);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
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;
}
}
@@ -0,0 +1,272 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Omika.Api.Endpoints;
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.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Development local
/// (<c>POST /api/dev/session</c>).
///
/// Vérifie :
/// - qu'un token signé valide est émis quand <c>DevelopmentAuth:Enabled</c> est vrai ;
/// - que l'endpoint est refusé (403) quand <c>DevelopmentAuth:Enabled</c> est faux ;
/// - que le token émis est un vrai token signé par le TokenService normal
/// (même issuer/audience/signature), utilisable sur les endpoints protégés.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DevelopmentAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public DevelopmentAuthEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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,
["DevelopmentAuth:Enabled"] = "true"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task DevSession_Enabled_ReturnsValidSignedToken()
{
var client = _factory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est un vrai token signé par le TokenService normal.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(DevelopmentAuthEndpoints.DevCodeClient, identity.CodeClient);
Assert.Equal(DevelopmentAuthEndpoints.DevUserId, identity.UserId);
Assert.Equal(DevelopmentAuthEndpoints.DevNumeroLicence, identity.NumeroLicence);
}
[Fact]
public async Task DevSession_Enabled_TokenAllowsProtectedEndpoint()
{
var client = _factory.CreateClient();
var sessionResponse = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)sessionResponse.StatusCode);
var session = await sessionResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
// Le token Development doit être accepté par l'authentification mobile
// normale (même mécanisme de signature). On vérifie sur un endpoint
// protégé : le bootstrap retourne 200 (pas 401).
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await protectedClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
[Fact]
public async Task DevSession_Disabled_ReturnsForbidden()
{
// Factory avec DevelopmentAuth:Enabled = false.
using var disabledFactory = 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,
["DevelopmentAuth:Enabled"] = "false"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
});
});
using (disabledFactory)
{
var client = disabledFactory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
// Endpoint non enregistré : 404 (route absente).
Assert.Equal(404, (int)response.StatusCode);
}
}
[Fact]
public async Task DevSession_WithoutCollaborateur_CollaborateurIdNull()
{
// Arrange : factory sans DevelopmentAuth:DevCollaborateurId.
var client = _factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : aucun identifiant Collaborateur inventé.
Assert.NotNull(identity);
Assert.Null(identity.CollaborateurId);
}
[Fact]
public async Task DevSession_WithCollaborateur_IdentityContainsCollaborateurId()
{
// Arrange : factory avec DevelopmentAuth:DevCollaborateurId renseigné.
const string collaborateurId = "bbbbbbbb-0000-0000-0000-000000000042";
using var 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,
["DevelopmentAuth:Enabled"] = "true",
["DevelopmentAuth:DevCollaborateurId"] = collaborateurId
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
using (factory)
{
var client = factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : l'identité porte le vrai CollaborateurId configuré.
Assert.NotNull(identity);
Assert.Equal(collaborateurId, identity.CollaborateurId);
}
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
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;
}
}
@@ -0,0 +1,484 @@
using System.Net;
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.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 13-C — Intégration du contexte de déploiement dans Omika.Api :
/// <list type="bullet">
/// <item>C1 : événement API interne → contexte du provider (ClientCode serveur).</item>
/// <item>C2/C3 : diagnostic mobile → ClientCode = JWT authentifié (autoritaire), jamais payload/provider.</item>
/// <item>C4/C5 : ServerInstance/Environment mobile = serveur ingérant (provider), jamais le téléphone.</item>
/// <item>C6 : payload falsifié (ClientId/ClientCode) ne remplace jamais le JWT.</item>
/// <item>C7 : requêtes concurrentes → aucune contamination inter-requêtes.</item>
/// <item>C8 : provider sans ClientCode → null sans exception (fail-open).</item>
/// <item>C9 : aucun secret dans ce que les producteurs transmettent.</item>
/// </list>
/// Provider et store sont des fakes en mémoire : l'objectif est de vérifier QUE le
/// bon <see cref="DiagnosticDeploymentContext"/> est transmis à
/// <see cref="IRemoteDiagnosticIngestionStore.StoreAsync"/> (le provider lui-même est
/// couvert par les tests D1–D16 de T13-D ; le JSONL par B1–B24 de T13-B).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeploymentContextApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeploymentContextApiTests()
{
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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Store fake : capture le contexte de déploiement transmis.
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
// Provider fake : contexte serveur déterministe (pas de HFSQL).
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// Active le forwarder serveur (4xx/5xx) pour C1/C8.
services.Configure<ServerDiagnosticOptions>(o => o.Enabled = true);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- C1 : événement API interne -----------------------
[Fact]
public async Task C1_InternalApiEvent_UsesProviderContext()
{
var client = _factory.CreateClient();
// 404 (hors /health et /api/diagnostics) → le ServerDiagnosticMiddleware
// enregistre un événement API_HTTP_4XX avec le contexte du provider.
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
AssertContext(call.Context, clientCode: "CLIENT-SERVEUR", serverInstance: "API-SRV-01", environment: "Production");
}
// --------------------------- C2 : diagnostic mobile ---------------------------
[Fact]
public async Task C2_MobileDiagnostic_UsesJwtCodeClientOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload mobile : ClientId falsifiable = FAUX-CLIENT.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C3 : priorité JWT > provider > payload -----------
[Fact]
public async Task C3_MobileDiagnostic_PriorityJwtOverProviderOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
// JWT gagne ; le provider ("CLIENT-SERVEUR") ne gagne PAS pour le mobile ;
// le payload ("FAUX-CLIENT") n'est JAMAIS candidat.
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
Assert.NotEqual("CLIENT-SERVEUR", call.Context.ClientCode);
Assert.NotEqual("FAUX-CLIENT", call.Context.ClientCode);
}
// --------------------------- C4 : ServerInstance mobile = provider ------------
[Fact]
public async Task C4_MobileDiagnostic_ServerInstanceFromProviderNotPhone()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// DeviceModel du téléphone : ne doit jamais devenir ServerInstance.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(deviceModel: "iPhone 15"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("API-SRV-01", call.Context!.ServerInstance);
Assert.NotEqual("iPhone 15", call.Context.ServerInstance);
}
// --------------------------- C5 : Environment mobile = provider ---------------
[Fact]
public async Task C5_MobileDiagnostic_EnvironmentFromProvider()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("Production", call.Context!.Environment);
}
// --------------------------- C6 : payload falsifié ----------------------------
[Fact]
public async Task C6_FalsifiedPayload_NeverReplacesJwtCodeClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload brut contenant clientId + un champ inconnu "clientCode" (ignoré par
// la désérialisation typée) : aucun des deux ne doit devenir le ClientCode.
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",
"clientId": "AUTRE-CLIENT",
"clientCode": "AUTRE-CLIENT"
}
]
}
""";
using var content = new StringContent(rawJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C7 : requêtes concurrentes -----------------------
[Fact]
public async Task C7_ConcurrentRequests_NoContextContamination()
{
var client = _factory.CreateClient();
(string Code, string EventCode)[] cases =
[
("CLIENT-A", "T7-A"),
("CLIENT-B", "T7-B"),
("CLIENT-C", "T7-C")
];
// Construction explicite des requêtes concurrentes avec des tokens distincts.
Task<HttpResponseMessage>[] requests = cases.Select(c =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/diagnostics")
{
Content = JsonContent.Create(ValidBatch(eventCode: c.EventCode))
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(c.Code));
return client.SendAsync(req);
}).ToArray();
HttpResponseMessage[] responses = await Task.WhenAll(requests);
Assert.All(responses, r => Assert.Equal(200, (int)r.StatusCode));
Assert.Equal(3, _store.Calls.Count);
foreach ((string Code, string EventCode) c in cases)
{
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call =
Assert.Single(_store.Calls, x => x.Batch.Events[0].EventCode == c.EventCode);
Assert.Equal(c.Code, call.Context!.ClientCode);
// ServerInstance/Environment identiques pour tous les clients.
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
}
// --------------------------- C8 : provider sans ClientCode --------------------
[Fact]
public async Task C8_ProviderWithoutClientCode_NullWithoutException()
{
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = null,
ServerInstance = "API-SRV-01",
Environment = "Production"
};
var client = _factory.CreateClient();
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
// Fail-open : le 404 est toujours produit, l'événement est enregistré avec
// ClientCode = null, aucune exception.
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Null(call.Context!.ClientCode);
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- C9 : absence de secret ---------------------------
[Fact]
public async Task C9_NoSecretsInProducedContextOrBatch()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
string contextJson = JsonSerializer.Serialize(call.Context!);
string batchJson = JsonSerializer.Serialize(call.Batch);
foreach (string secret in new[] { "Authorization", "Bearer", "HmacKey", "ConnectionString", "password", "CL_CleAPIInstall" })
{
Assert.DoesNotContain(secret, contextJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secret, batchJson, StringComparison.OrdinalIgnoreCase);
}
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(
string eventCode = "OMK-MOB-APP-001",
string? clientId = null,
string? deviceModel = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId,
DeviceModel = deviceModel
}
]
};
private string CreateToken(string codeClient)
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = "device-test",
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = "licence-test"
});
}
private static void AssertContext(
DiagnosticDeploymentContext? context,
string? clientCode,
string? serverInstance,
string? environment)
{
Assert.NotNull(context);
Assert.Equal(clientCode, context!.ClientCode);
Assert.Equal(serverInstance, context.ServerInstance);
Assert.Equal(environment, context.Environment);
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> 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));
}
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;
}
}
/// <summary>
/// Tâche 13-C C10 — Régression : les anciens appels <c>StoreAsync</c> (sans
/// <c>deploymentContext</c>) et les anciens JSONL restent compatibles. Utilise le
/// vrai <see cref="RemoteDiagnosticIngestionStore"/> (aucun HTTP requis).
/// </summary>
public class DiagnosticDeploymentContextBackwardCompatibilityTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-t13c-compat", Guid.NewGuid().ToString("N"));
public DiagnosticDeploymentContextBackwardCompatibilityTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
[Fact]
public async Task C10_LegacyStoreAsyncCall_WritesJsonlWithoutContextFields()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var evt = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3"
};
var batch = new DiagnosticBatchDto { Events = [evt] };
// Appel hérité : AUCUN deploymentContext.
int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow);
Assert.Equal(1, accepted);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
using JsonDocument doc = JsonDocument.Parse(content);
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
}
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();
}
}
@@ -0,0 +1,318 @@
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;
}
}
@@ -0,0 +1,83 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
using Omika.Api;
namespace Omika.Tests.Api;
/// <summary>
/// Tests de la configuration ForwardedHeaders (reverse proxy Apache local).
/// Vérifient le contenu exact des options (en-têtes, ForwardLimit, KnownProxies)
/// et l'absence de toute configuration « trust-all » (symétrie conservée,
/// KnownNetworks jamais vidé, aucun proxy non-loopback).
/// </summary>
public class ForwardedHeadersConfigurationTests
{
[Fact]
public void CreateOptions_ConfiguresForwardedHeaders()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(
ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost,
options.ForwardedHeaders);
}
[Fact]
public void CreateOptions_SetsForwardLimitToOne()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(1, options.ForwardLimit);
}
[Fact]
public void CreateOptions_AddsIpv4LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_AddsIpv6LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.IPv6Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_OnlyTrustsLoopbackProxies()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// Aucun wildcard (0.0.0.0 / [::]) : uniquement le proxy Apache local.
Assert.DoesNotContain(IPAddress.Any, options.KnownProxies);
Assert.DoesNotContain(IPAddress.IPv6Any, options.KnownProxies);
Assert.All(options.KnownProxies, ip => Assert.True(IPAddress.IsLoopback(ip)));
}
[Fact]
public void CreateOptions_KeepsRequireHeaderSymmetryEnabled()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// La symétrie des en-têtes reste activée (défaut sécurisé) : jamais
// désactivée pour une configuration permissive globale.
Assert.True(options.RequireHeaderSymmetry);
}
[Fact]
public void CreateOptions_DoesNotClearKnownNetworks()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// KnownIPNetworks (anciennement KnownNetworks) conserve ses réseaux par
// défaut (loopback) : jamais vidé.
Assert.NotEmpty(options.KnownIPNetworks);
// La confiance réseau reste bornée au loopback (127.0.0.1/8 et ::1/128).
Assert.All(options.KnownIPNetworks, network => Assert.True(IPAddress.IsLoopback(network.BaseAddress)));
}
}
@@ -0,0 +1,458 @@
using System.Net;
using System.Net.Http.Headers;
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.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Documents;
using Omika.Core.Domain;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints « courriel envoyé depuis une MESSAGERIE EXTERNE » :
/// <list type="bullet">
/// <item><c>POST /api/interventions/{id}/emails/envoye-externe</c> ;</item>
/// <item><c>PUT /api/interventions/{id}/emails/{emailId}/envoye-externe</c>.</item>
/// </list>
/// Ces endpoints n'envoient RIEN : ils enregistrent un courriel déclaré envoyé
/// par l'utilisateur depuis sa propre messagerie
/// (<c>ME_Envoyer=true</c>, <c>ME_EnvoyerMob=true</c>, <c>ME_Brouillon=false</c>),
/// et refusent l'appel quand la messagerie interne (Back-Office) est active.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class InterventionEmailExterneEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private const string InterventionId = "12345";
private readonly FakeInterventionService _interventionService = new();
private readonly FakePermissionService _permissionService = new();
private readonly FakeSmtpConfigurationService _smtpService = new();
private readonly WebApplicationFactory<Program> _factory;
public InterventionEmailExterneEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IInterventionService>();
services.AddScoped<IInterventionService>(_ => _interventionService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
services.RemoveAll<ISmtpConfigurationService>();
services.AddScoped<ISmtpConfigurationService>(_ => _smtpService);
services.RemoveAll<IMobileDocumentService>();
services.AddScoped<IMobileDocumentService, FakeMobileDocumentService>();
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Authentification / autorisation =====
/// <summary>Sans token : refusé avant toute écriture.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansToken_Retourne401()
{
var client = _factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sans le droit d'envoi d'email : refusé.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansDroitEnvoiEmail_Retourne403()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : enregistrement « envoyé » =====
/// <summary>
/// Messagerie externe : le courriel est enregistré avec les champs d'un
/// courriel ENVOYÉ (ME_Envoyer + ME_EnvoyerMob), sans envoi SMTP.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieExterne_EnregistreEnEnvoyeMob()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.False(enregistrement!.Brouillon);
Assert.True(enregistrement.Envoyer);
Assert.True(enregistrement.EnvoyerMob);
// Aucun expéditeur connu : l'envoi n'est pas passé par un compte Omika.
Assert.Null(enregistrement.AdrExp);
Assert.Equal("Intervention du jour", enregistrement.Objet);
}
/// <summary>
/// Messagerie interne (Back-Office) : l'endpoint refuse — l'envoi doit passer
/// par Omika, jamais être « déclaré » envoyé par le mobile.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieInterne_Retourne400EtRienNEstEcrit()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sujet et message vides : refusé (même règle que les autres endpoints).</summary>
[Fact]
public async Task PostEnvoyeExterne_SansSujetNiMessage_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
new CreateEmailInterventionRequest { Objet = " ", Message = " " });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : brouillon existant devenu envoyé =====
/// <summary>Un brouillon existant est marqué envoyé (même état que la création).</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieExterne_MarqueLeBrouillonEnvoye()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("email-42", _interventionService.DernierEmailId);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.True(enregistrement!.EnvoyerMob);
Assert.False(enregistrement.Brouillon);
}
/// <summary>Un courriel déjà envoyé ne peut pas être marqué une seconde fois (409).</summary>
[Fact]
public async Task PutEnvoyeExterne_CourrielDejaEnvoye_Retourne409()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
_interventionService.ErreurMajEmail = "Ce courriel a déjà été envoyé : il ne peut plus être modifié.";
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
/// <summary>Messagerie interne : le marquage externe est refusé.</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieInterne_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Aides =====
private static CreateEmailInterventionRequest Demande()
=> new()
{
Objet = "Intervention du jour",
Message = "Bonjour,\nIntervention terminée.",
AdrDestinataires = "client@exemple.fr",
AdrCC = "copie@exemple.fr"
};
private HttpClient ClientAuthentifie()
{
HttpClient client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", CreateValidToken());
return client;
}
private string CreateValidToken()
{
using IServiceScope scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeInterventionService : IInterventionService
{
public EmailEnregistrement? DernierEnregistrement { get; private set; }
public string? DernierEmailId { get; private set; }
/// <summary>Simule un courriel déjà envoyé (409).</summary>
public string? ErreurMajEmail { get; set; }
public Task<EmailInterventionDto> EnregistrerNouvelEmailAsync(
string interventionId,
EmailEnregistrement enregistrement,
AuditActor? auteur,
CancellationToken cancellationToken = default)
{
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = "email-nouveau" });
}
public Task<EmailInterventionDto> EnregistrerEmailExistantAsync(
string emailId,
EmailEnregistrement enregistrement,
CancellationToken cancellationToken = default)
{
if (ErreurMajEmail is not null)
{
throw new InvalidOperationException(ErreurMajEmail);
}
DernierEmailId = emailId;
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = emailId });
}
// ---- Membres de IInterventionService non exercés par ces tests ----
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionDetailsDto>([], 1, 20, 0, 0));
public Task<InterventionDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<InterventionDetailsDto> CreateAsync(
CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult(new InterventionDetailsDto { Id = "new" });
public Task<InterventionDetailsDto?> UpdateAsync(
string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new NoteInterneDto());
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
public Task<EmailInterventionDto> AddEmailAsync(
string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new EmailInterventionDto());
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeSmtpConfigurationService : ISmtpConfigurationService
{
/// <summary>Préférence de messagerie simulée (false = Back-Office interne).</summary>
public bool MessagerieExterne { get; set; }
public Task<IReadOnlyList<SmtpConfigurationDto>> GetListAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SmtpConfigurationDto>>([]);
public Task<SmtpConfiguration?> GetAsync(
string collaborateurId,
string configurationId,
CancellationToken cancellationToken = default)
=> Task.FromResult<SmtpConfiguration?>(null);
public Task<bool> EstMessagerieExterneAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(MessagerieExterne);
}
private sealed class FakeMobileDocumentService : IMobileDocumentService
{
public Task<DocumentDownloadResponse?> DownloadAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<DocumentDownloadResponse?>(null);
public Task<long> ResolveDocumentPkAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task<string?> ResolveDocumentFullPathAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<DocumentUploadResponse> UploadAsync(
DocumentUploadRequest request, string categorieCle, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionAsync(
string interventionId, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionsAsync(
IReadOnlyList<long> numericInterventionIds, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
Guid documentGuid, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
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;
}
}
@@ -0,0 +1,432 @@
using System.Net.Http.Json;
using System.Reflection;
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.Common;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Interventions;
using Omika.Core.Contracts.Planning;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints Intervention. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
/// et un fake <see cref="IInterventionService"/> pour contrôler les réponses.
///
/// Les tests modifient le contexte statique <see cref="ApplicationRuntimeContext"/>,
/// donc ils sont sérialisés via la collection <c>"ApplicationRuntimeContext"</c>.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class InterventionEndpointsTests : IDisposable
{
private readonly FakeInterventionService _fakeService = new();
private readonly FakeMobileInterventionSearchService _fakeMobileSearch = new();
private readonly WebApplicationFactory<Program> _factory;
public InterventionEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IInterventionService>();
services.AddScoped<IInterventionService>(_ => _fakeService);
services.RemoveAll<IMobileInterventionSearchService>();
services.AddScoped<IMobileInterventionSearchService>(_ => _fakeMobileSearch);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// =========================================================================
// GET /interventions/{id}
// =========================================================================
[Fact]
public async Task GetInterventionById_NumericId_TransmittedAsString()
{
var client = _factory.CreateClient();
await client.GetAsync("/interventions/12345");
Assert.Equal("12345", _fakeService.LastDetailsId);
}
[Fact]
public async Task GetInterventionById_GuidId_TransmittedAsString()
{
var client = _factory.CreateClient();
var guid = Guid.NewGuid().ToString("D");
await client.GetAsync($"/interventions/{guid}");
Assert.Equal(guid, _fakeService.LastDetailsId);
}
[Fact]
public async Task GetInterventionById_Returns200WithDto()
{
_fakeService.DetailsResult = new InterventionDetailsDto
{
Id = "test-id",
Reference = "INT-001",
Titre = "Réparation"
};
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/42");
var body = await response.Content.ReadFromJsonAsync<InterventionDetailsDto>();
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(body);
Assert.Equal("test-id", body.Id);
Assert.Equal("INT-001", body.Reference);
Assert.Equal("Réparation", body.Titre);
}
[Fact]
public async Task GetInterventionById_NullResult_Returns404()
{
_fakeService.DetailsResult = null;
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/999");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task GetInterventionById_ServiceException_NotTransformedTo404()
{
_fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error");
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/42");
// L'exception n'est pas catchée dans le endpoint → 500.
Assert.Equal(500, (int)response.StatusCode);
}
// =========================================================================
// HEALTH
// =========================================================================
[Fact]
public async Task GetHealth_StillResponds()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(200, (int)response.StatusCode);
}
// =========================================================================
// MOBILE SEARCH / BY-IDS — route enregistrée sous /api/interventions
// =========================================================================
[Fact]
public async Task MobileSearch_RouteRegistered_Returns401WithoutToken_Not404()
{
var client = _factory.CreateClient();
// Sans token, l'endpoint authentifié renvoie 401. S'il renvoyait 404,
// c'est que la route /api/interventions/search n'est pas enregistrée.
var response = await client.PostAsJsonAsync(
"/api/interventions/search", new MobileInterventionSearchRequest());
Assert.Equal(401, (int)response.StatusCode);
Assert.Equal(0, _fakeMobileSearch.SearchCallCount);
}
[Fact]
public async Task MobileByIds_RouteRegistered_Returns401WithoutToken_Not404()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/interventions/by-ids", new MobileInterventionsByIdsRequest { Ids = [] });
Assert.Equal(401, (int)response.StatusCode);
Assert.Equal(0, _fakeMobileSearch.GetByIdsCallCount);
}
// =========================================================================
// ARCHITECTURE
// =========================================================================
[Fact]
public void Endpoints_DependsOnlyOnIInterventionService()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in methods)
{
var parameters = method.GetParameters();
Assert.DoesNotContain(parameters, p =>
p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase)
&& p.ParameterType.Name != "IInterventionService");
}
}
[Fact]
public void Endpoints_HasNoOmikaDataReferences()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data"));
}
[Fact]
public void Endpoints_HasNoApplicationRuntimeContext()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Endpoints_HasNoHfsqlRowReferences()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
}
// =========================================================================
// HELPERS
// =========================================================================
private static IEnumerable<string> GetReferencedNamespaces(Type type)
{
var ns = new HashSet<string>();
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.DeclaringType?.Namespace is not null)
ns.Add(method.DeclaringType.Namespace);
if (method.ReturnType.Namespace is not null)
ns.Add(method.ReturnType.Namespace);
foreach (var p in method.GetParameters())
{
if (p.ParameterType.Namespace is not null)
ns.Add(p.ParameterType.Namespace);
}
}
return ns;
}
// =========================================================================
// FAKES
// =========================================================================
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeInterventionService : IInterventionService
{
public int SearchCallCount { get; private set; }
public int DetailsCallCount { get; private set; }
public int CreateCallCount { get; private set; }
public int UpdateCallCount { get; private set; }
public int ArchiveCallCount { get; private set; }
public SearchInterventionsRequest? LastRequest { get; private set; }
public string? LastDetailsId { get; private set; }
public CreateInterventionRequest? LastCreateRequest { get; private set; }
public string? LastUpdateId { get; private set; }
public UpdateInterventionRequest? LastUpdateRequest { get; private set; }
public string? LastArchiveId { get; private set; }
public PagedResult<InterventionSummaryDto>? SearchResult { get; set; }
public InterventionDetailsDto? DetailsResult { get; set; }
public InterventionDetailsDto? CreateResult { get; set; }
public InterventionDetailsDto? UpdateResult { get; set; }
public bool ArchiveResult { get; set; }
public Exception? ThrowOnSearch { get; set; }
public Exception? ThrowOnDetails { get; set; }
public Exception? ThrowOnCreate { get; set; }
public Exception? ThrowOnUpdate { get; set; }
public Exception? ThrowOnArchive { get; set; }
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(SearchResult
?? new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
}
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(new PagedResult<InterventionDetailsDto>(
DetailsResult is null ? [] : [DetailsResult], 1, 20, DetailsResult is null ? 0 : 1, 1));
}
public Task<InterventionDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
{
DetailsCallCount++;
LastDetailsId = id;
if (ThrowOnDetails is not null) throw ThrowOnDetails;
return Task.FromResult(DetailsResult);
}
public Task<InterventionDetailsDto> CreateAsync(
CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
CreateCallCount++;
LastCreateRequest = request;
if (ThrowOnCreate is not null) throw ThrowOnCreate;
return Task.FromResult(CreateResult
?? new InterventionDetailsDto { Id = "new" });
}
public Task<InterventionDetailsDto?> UpdateAsync(
string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
UpdateCallCount++;
LastUpdateId = id;
LastUpdateRequest = request;
if (ThrowOnUpdate is not null) throw ThrowOnUpdate;
return Task.FromResult(UpdateResult);
}
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
{
ArchiveCallCount++;
LastArchiveId = id;
if (ThrowOnArchive is not null) throw ThrowOnArchive;
return Task.FromResult(ArchiveResult);
}
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new NoteInterneDto());
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
public Task<EmailInterventionDto> AddEmailAsync(
string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new EmailInterventionDto());
}
private sealed class FakeMobileInterventionSearchService : IMobileInterventionSearchService
{
public int SearchCallCount { get; private set; }
public int GetByIdsCallCount { get; private set; }
public Task<IReadOnlyList<MobileInterventionSearchResult>> SearchAsync(MobileInterventionSearchRequest request, MobileTokenIdentity? identity, IReadOnlyDictionary<string, bool> permissions, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
public Task<IReadOnlyList<MobileInterventionSearchResult>> GetByIdsAsync(
IReadOnlyList<string> ids, CancellationToken cancellationToken = default)
{
GetByIdsCallCount++;
return Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
}
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>([]);
}
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;
}
}
@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'API dédiée de téléchargement des modèles de langue (ONNX).
/// Accès anonyme : vérifie le manifeste, le streaming de fichier et les 404.
/// </summary>
public class LanguageEndpointsTests : IDisposable
{
private const string ManifestJson = """
{
"packs": [
{
"code": "es",
"libelle": "Espagnol",
"version": "1.0.0",
"fichiers": [
{ "sens": "versFrancais", "nomFichier": "encoder.onnx", "tailleOctets": 16, "sha256": "abc" }
]
}
]
}
""";
private readonly string _root;
private readonly WebApplicationFactory<Omika.Langues.Api.Program> _factory;
public LanguageEndpointsTests()
{
_root = CreateRoot();
Populate(_root);
_factory = CreateFactory(_root);
}
public void Dispose()
{
_factory.Dispose();
Directory.Delete(_root, true);
}
[Fact]
public async Task Langues_Manifest_RetournePacksEtUrls()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(200, (int)response.StatusCode);
var manifest = await response.Content.ReadFromJsonAsync<LanguageManifestResponse>();
Assert.NotNull(manifest);
var pack = Assert.Single(manifest.Packs);
Assert.Equal("es", pack.Code);
Assert.Equal("Espagnol", pack.Libelle);
var fichier = Assert.Single(pack.Fichiers);
Assert.Equal("/api/langues/es/versFrancais/encoder.onnx", fichier.Url);
}
[Fact]
public async Task Langues_Fichier_RetourneContenu()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/encoder.onnx");
Assert.Equal(200, (int)response.StatusCode);
string content = await response.Content.ReadAsStringAsync();
Assert.Equal("fake-model-bytes", content);
}
[Fact]
public async Task Langues_Fichier_Introuvable_Retourne404()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/absent.onnx");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task Langues_ManifestAbsent_Retourne404()
{
string emptyRoot = CreateRoot();
using var emptyFactory = CreateFactory(emptyRoot);
try
{
var client = emptyFactory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(404, (int)response.StatusCode);
}
finally
{
Directory.Delete(emptyRoot, true);
}
}
// ===== Helpers =====
private static string CreateRoot()
{
string root = Path.Combine(Path.GetTempPath(), "omika-langues-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static void Populate(string root)
{
Directory.CreateDirectory(Path.Combine(root, "es", "versFrancais"));
File.WriteAllText(Path.Combine(root, "manifest.json"), ManifestJson);
File.WriteAllText(Path.Combine(root, "es", "versFrancais", "encoder.onnx"), "fake-model-bytes");
}
private static WebApplicationFactory<Omika.Langues.Api.Program> CreateFactory(string root)
=> new WebApplicationFactory<Omika.Langues.Api.Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Langues:RootPath"] = root,
["Langues:ManifestFileName"] = "manifest.json"
})
.Build();
services.AddSingleton<IConfiguration>(config);
});
});
}
@@ -0,0 +1,380 @@
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.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de licence mobile.
/// Vérifie l'émission du Bearer token après activation/validation réussie,
/// l'absence de token en cas d'échec, la chaîne activation → bootstrap,
/// et le renouvellement du token après validation.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class LicenseEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3";
private const string DeviceId = "device-abc-123";
private readonly FakeLicenseService _licenseService = new();
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public LicenseEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== ACTIVATE =====
[Fact]
public async Task Activate_Success_ReturnsToken_ValidIdentity_AndExpiration()
{
_licenseService.ActivateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est valide et porte l'identité attendue.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal("42", identity.UserId);
Assert.Equal("7", identity.CollaborateurId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// L'expiration retournée est cohérente avec la durée de vie configurée (24 h).
var expected = tokenService.GetTokenExpirationUtc();
Assert.True(body.AccessTokenExpiresAtUtc <= expected.AddMinutes(1));
Assert.True(body.AccessTokenExpiresAtUtc >= expected.AddMinutes(-1));
}
[Fact]
public async Task Activate_Failure_ReturnsNoToken()
{
_licenseService.ActivateResult = InvalidLicenseResponse(LicenseErrorCodes.LicenceInconnue);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(LicenseErrorCodes.LicenceInconnue, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== VALIDATE =====
[Fact]
public async Task Validate_Success_ReturnsNewToken()
{
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
}
[Theory]
[InlineData(LicenseErrorCodes.LicenceInconnue)]
[InlineData(LicenseErrorCodes.LicenceInactive)]
[InlineData(LicenseErrorCodes.LicenceExpiree)]
[InlineData(LicenseErrorCodes.LicenceAttribuee)]
public async Task Validate_Failure_ReturnsNoToken(string errorCode)
{
_licenseService.ValidateResult = InvalidLicenseResponse(errorCode);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(errorCode, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== CHAÎNE COMPLÈTE : activation → bootstrap =====
[Fact]
public async Task Activate_Then_Bootstrap_WithBearer_Returns200()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_bootstrapService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "42", Login = "user-42" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
// 1. Activation → récupération du Bearer token.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)activateResponse.StatusCode);
var session = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Bootstrap avec le vrai format Bearer.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
var bootstrap = await bootstrapResponse.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(bootstrap);
Assert.Equal("42", bootstrap.Utilisateur.Id);
}
// ===== RENOUVELLEMENT APRÈS EXPIRATION (conceptuel) =====
[Fact]
public async Task Validate_After_Activate_Produces_New_Valid_Token()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
// T1 via activation.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
var session1 = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session1);
// T2 via validation (renouvellement).
var validateResponse = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)validateResponse.StatusCode);
var session2 = await validateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session2);
// T2 est un nouveau token, valide, avec une nouvelle expiration.
Assert.False(string.IsNullOrWhiteSpace(session2.AccessToken));
Assert.True(session2.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(session2.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// Le nouveau token permet l'accès aux endpoints protégés.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session2.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
// ===== Helpers =====
private static LicenseResponse ValidLicenseResponse() => new()
{
IsValid = true,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
NomSociete = "Société Test",
UserNom = "Dupont",
UserPrenom = "Jean",
UserId = "42",
CollaborateurId = "7",
OfflineGraceDays = 10,
ServerTimeUtc = DateTimeOffset.UtcNow,
ErrorCode = LicenseErrorCodes.Ok
};
private static LicenseResponse InvalidLicenseResponse(string errorCode) => new()
{
IsValid = false,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
ErrorCode = errorCode,
ErrorMessage = "Licence invalide",
ServerTimeUtc = DateTimeOffset.UtcNow
};
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public LicenseResponse ActivateResult { get; set; } = new();
public LicenseResponse ValidateResult { get; set; } = new();
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ActivateResult);
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ValidateResult);
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
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;
}
}
@@ -0,0 +1,185 @@
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.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de bootstrap mobile.
/// Vérifie l'authentification (sans token, token invalide) et le contrat retourné.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobileBootstrapEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobileBootstrapEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Bootstrap_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_InvalidToken_Returns401()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid.token.value");
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_DeviceMismatch_Returns403_Not500()
{
_fakeService.ThrowDeviceMismatch = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_ValidToken_Returns200WithContract()
{
_fakeService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "user-1", Login = "user-1" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(body);
Assert.Equal("user-1", body.Utilisateur.Id);
Assert.Equal("BATIK", body.Profil.Code);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public bool ThrowDeviceMismatch { get; set; }
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
if (ThrowDeviceMismatch)
{
throw new UnauthorizedAccessException(
"Le DeviceId authentifié ne correspond pas à la licence attribuée.");
}
return Task.FromResult(Result);
}
}
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;
}
}
@@ -0,0 +1,232 @@
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.Constants;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests d'intégration de la résolution réelle des permissions :
/// token → MobileAuthMiddleware → MobileTokenIdentity → IMobilePermissionService
/// → IMobileIdentityProvider → IMobileIdentityRepository → A.LIC.MOB.
///
/// Utilise le vrai <see cref="MobilePermissionService"/> et le vrai
/// <see cref="Omika.Core.Services.Mobile.MobileIdentityProvider"/>, avec un faux
/// repository d'identité. Vérifie qu'un grand identifiant 64 bits traverse la
/// chaîne sans troncature et que A.LIC.MOB est résolu dynamiquement.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobilePermissionResolutionIntegrationTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string CollaborateurId = "7";
private const string BigUserId = "9000000000"; // > Int32.MaxValue, aucun risque de troncature
private readonly FakeMobileIdentityRepository _identityRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobilePermissionResolutionIntegrationTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Réel IMobilePermissionService + IMobileIdentityProvider,
// avec un faux repository d'identité (résolution contrôlée).
services.RemoveAll<IMobileIdentityRepository>();
services.AddScoped<IMobileIdentityRepository>(_ => _identityRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task PermissionAccordee_EndpointLicenceNonRefuse_UserIdGrandIntact()
{
_identityRepository.GrantLicenceMobile = true;
_licenseService.GetResult = new CollaborateurLicenceDto { Numero = "LIC-001", CodeClient = CodeClient, Active = true };
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
// 200 (licence trouvée) ou 404 (licence absente), mais jamais 401 pour manque de droit.
Assert.NotEqual(401, (int)response.StatusCode);
// Le UserId du token (grand identifiant 64 bits) est transmis intact à la résolution.
Assert.Equal(BigUserId, _identityRepository.LastUserId);
}
[Fact]
public async Task PermissionRefusee_EndpointLicence_Retourne401()
{
_identityRepository.GrantLicenceMobile = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task PermissionAbsente_EndpointLicence_Retourne401()
{
// Aucune permission A.LIC.MOB dans le catalogue retourné.
_identityRepository.GrantLicenceMobile = null;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
private string CreateToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = CodeClient,
DeviceId = "backoffice-windev",
UserId = BigUserId,
CollaborateurId = CollaborateurId,
NumeroLicence = string.Empty
});
}
// ===== Fakes =====
private sealed class FakeMobileIdentityRepository : IMobileIdentityRepository
{
/// <summary>true = accordée, false = refusée, null = absente.</summary>
public bool? GrantLicenceMobile { get; set; }
public string? LastUserId { get; private set; }
public Task<MobileProfileDto> GetProfileAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult(new MobileProfileDto { Code = "ROLE", Nom = "ROLE" });
}
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
var permissions = MobilePermissionKeys.Empty();
if (GrantLicenceMobile is { } granted)
{
permissions[MobilePermissionKeys.LicenceMobile] = granted;
}
return Task.FromResult<IReadOnlyDictionary<string, bool>>(permissions);
}
public Task<string?> GetLoginByUserIdAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult<string?>("TEST_USER");
}
public Task<long?> GetUtilisateurPkIdByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<long?>(null);
public Task<string?> GetLoginByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(GetResult);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
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;
}
}
@@ -0,0 +1,298 @@
using Omika.Api.Services;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Organisation;
using Omika.Core.Contracts.Planning;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests du filtre de périmètre <see cref="RdvScopeService"/> : compatibilité
/// Omika (GUID + PK numérique résolu) sans régression Batik (comparaison par
/// PK numérique). Le contrôle des droits n'est jamais contourné : un RDV hors
/// périmètre reste filtré.
/// </summary>
public class RdvScopeServiceTests
{
// Le token porte le PK numérique du collaborateur (CO_PKID Omika), comme
// produit par LicenseService.BuildSuccessAsync.
private static readonly MobileTokenIdentity IdentityPk42 = new()
{
CodeClient = "CLI",
DeviceId = "dev",
UserId = "1",
CollaborateurId = "42"
};
[Fact]
public async Task Cas1_UtilisateurAvecAcces_RendezVousVisible()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas2_UtilisateurSansAcces_RendezVousFiltre()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task Cas3_DroitSocietePrincipale_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 100]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 100)],
IdentityPk42,
Droits(societePrincipale: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, true, false, false), service.LastCall);
}
[Fact]
public async Task Cas4_DroitSociete_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 200]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 200)],
IdentityPk42,
Droits(societes: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, true, false), service.LastCall);
}
[Fact]
public async Task Cas5_DroitEquipe_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 300]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 300)],
IdentityPk42,
Droits(equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, false, true), service.LastCall);
}
[Fact]
public async Task Cas6_CollaborateurOmikaIdentifieParGuid_RendezVousAutoriseVisible()
{
// Modèle Omika : l'affectation porte le GUID ET le PK numérique résolu.
// Le RDV autorisé ne doit plus disparaître.
Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e");
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42, guid: guid)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7_Batik_DansPerimetre_Visible()
{
// Affectation Batik : référence numérique uniquement (FromLegacyId).
// Le collaborateur courant (PK 42) est toujours inclus → visible.
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "42",
SourceReference = LegacyEntityReference.FromLegacyId(42)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7b_Batik_HorsPerimetre_Filtre()
{
// Comportement attendu Batik (aligné sur MobileBootstrapService) : sans les
// 3 droits A.C.PL.*, un RDV affecté à un collaborateur hors périmètre est
// filtré (le filtre par PK numérique s'applique). Avant le correctif, ce
// cas était silencieusement fail-open (RDV visible à tort).
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "99",
SourceReference = LegacyEntityReference.FromLegacyId(99)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task TroisDroits_AucunFiltre()
{
// Les 3 droits présents → aucune restriction (comportement historique).
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(societePrincipale: true, societes: true, equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Null(service.LastCall);
}
private static RendezVousDetailsDto RdvOmika(long numericId, Guid? guid = null)
=> new()
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = guid?.ToString("D") ?? numericId.ToString(),
SourceReference = new LegacyEntityReference(
DatabaseSchemaVersion.Omika,
NumericId: numericId,
EntityGuid: guid)
}
]
};
private static IReadOnlyDictionary<string, bool> Droits(
bool societePrincipale = false,
bool societes = false,
bool equipe = false)
=> new Dictionary<string, bool>
{
[MobilePermissionKeys.PlanningSocietePrincipale] = societePrincipale,
[MobilePermissionKeys.PlanningSocietes] = societes,
[MobilePermissionKeys.PlanningEquipe] = equipe
};
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
{
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
=> physical.Guid ?? Guid.NewGuid();
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
=> new PhysicalEntityIdentity { Guid = publicGuid };
public bool IsCanonical(string entityType, Guid publicGuid)
=> publicGuid != Guid.Empty;
}
private sealed class FakeCollaborateurService : ICollaborateurService
{
private readonly IReadOnlyList<long> _allowed;
public FakeCollaborateurService(IReadOnlyList<long> allowed)
=> _allowed = allowed;
public (long CollaborateurId, bool SocietePrincipale, bool Societes, bool Equipe)? LastCall { get; private set; }
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(
long collaborateurId,
bool includeSocietePrincipale,
bool includeSocietes,
bool includeEquipe,
CancellationToken cancellationToken = default)
{
LastCall = (collaborateurId, includeSocietePrincipale, includeSocietes, includeEquipe);
return Task.FromResult(_allowed);
}
public Task<PagedResult<CollaborateurSummaryDto>> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<PagedResult<CollaborateurDetailsDto>> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousForCollaborateursAsync(IReadOnlyList<string> collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,118 @@
namespace Omika.Tests.Api;
/// <summary>
/// Tests de sécurité du reverse proxy Apache local : analyse la source
/// d'Omika.Api (Program.cs + ForwardedHeadersConfiguration.cs) pour garantir
/// l'ordre du middleware, l'absence de configuration « trust-all », l'absence
/// de certificat Kestrel et l'absence de bind sur 0.0.0.0.
/// </summary>
public class ReverseProxySecurityTests
{
private const string ProgramFile = "src/Omika.Api/Program.cs";
private const string ForwardedHeadersFile = "src/Omika.Api/ForwardedHeadersConfiguration.cs";
[Fact]
public void Program_UsesForwardedHeadersBeforeHttpsRedirection()
{
string source = ReadRepoFile(ProgramFile);
int forwarded = source.IndexOf("app.UseForwardedHeaders(", StringComparison.Ordinal);
int https = source.IndexOf("app.UseHttpsRedirection(", StringComparison.Ordinal);
Assert.True(forwarded >= 0, "UseForwardedHeaders doit être présent.");
Assert.True(https >= 0, "UseHttpsRedirection doit être présent.");
Assert.True(
forwarded < https,
"UseForwardedHeaders doit s'exécuter AVANT UseHttpsRedirection.");
}
[Fact]
public void ReverseProxy_DoesNotClearProxyTrustLists()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
Assert.DoesNotContain("KnownNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownIPNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownProxies.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("RequireHeaderSymmetry = false", combined, StringComparison.Ordinal);
}
[Fact]
public void ReverseProxy_DoesNotAddWildcardProxyTrust()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
// Aucune adresse « any » (0.0.0.0 / [::]) ni réseau wildcard dans la confiance.
Assert.DoesNotContain("IPAddress.Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("0.0.0.0", combined, StringComparison.Ordinal);
}
[Fact]
public void OmikaApi_DoesNotConfigureKestrelCertificate()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
// Pas de terminaison TLS dans Kestrel (certificat / HTTPS) :
// c'est Apache qui termine TLS.
Assert.DoesNotContain("UseHttps(", content, StringComparison.Ordinal);
Assert.DoesNotContain("AddHttps", content, StringComparison.Ordinal);
Assert.DoesNotContain("X509Certificate", content, StringComparison.Ordinal);
Assert.DoesNotContain(".pfx", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HttpsConnectionAdapterOptions", content, StringComparison.Ordinal);
}
}
[Fact]
public void OmikaApi_DoesNotBindToAnyAddress()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
Assert.DoesNotContain("0.0.0.0", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("ListenAnyIP", content, StringComparison.Ordinal);
}
}
[Fact]
public void Program_DoesNotHardcodeKestrelPort()
{
string source = ReadRepoFile(ProgramFile);
// Le port (5080) et l'URL doivent rester dans appsettings.Production.json,
// jamais en dur dans Program.cs.
Assert.DoesNotContain("5080", source, StringComparison.Ordinal);
Assert.DoesNotContain("UseUrls", source, StringComparison.Ordinal);
}
private static string ReadRepoFile(string relativePath)
{
return File.ReadAllText(Path.Combine(RepoRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)));
}
private static IEnumerable<string> EnumerateOmikaApiSourceFiles()
{
string apiRoot = Path.Combine(RepoRoot(), "src", "Omika.Api");
return Directory.EnumerateFiles(apiRoot, "*.cs", SearchOption.AllDirectories)
.Where(f =>
!f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) &&
!f.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar));
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
+200
View File
@@ -0,0 +1,200 @@
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.Contracts.Mobile.Sync;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de synchronisation mobile.
/// Vérifie l'authentification (sans token) et la réception d'une opération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class SyncEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeSyncOperationService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public SyncEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_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
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ISyncOperationService>();
services.AddScoped<ISyncOperationService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Receive_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Receive_ValidOperation_Returns200Received()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<SyncOperationReceipt>();
Assert.NotNull(body);
Assert.Equal("RECEIVED", body.Status);
}
[Fact]
public async Task Receive_ValidOperation_PassesIdentityFromToken()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(_fakeService.LastIdentity);
Assert.Equal("BATIK.01", _fakeService.LastIdentity.CodeClient);
Assert.Equal("device-abc-123", _fakeService.LastIdentity.DeviceId);
Assert.Equal("user-1", _fakeService.LastIdentity.UserId);
}
private static SyncOperation ValidOperation()
{
return new SyncOperation
{
OperationId = Guid.NewGuid(),
DeviceId = "device-abc-123",
EntityType = "Client",
OperationType = SyncOperationType.Create,
EntityGuid = Guid.NewGuid(),
Payload = "{}"
};
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeSyncOperationService : ISyncOperationService
{
public SyncOperationReceipt Receipt { get; set; } = new();
public MobileTokenIdentity? LastIdentity { get; private set; }
public Task<SyncOperationReceipt> ReceiveAsync(
SyncOperation operation,
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
LastIdentity = identity;
return Task.FromResult(Receipt);
}
public Task<SyncStatusResponse> GetStatusAsync(
Guid operationId,
string authenticatedDeviceId,
CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncStatusResponse
{
OperationId = operationId,
Status = SyncStatus.StatusNotAvailable,
DeviceId = authenticatedDeviceId
});
}
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;
}
}