Premiere commit - Omika
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user