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

206 lines
6.4 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Omika.Core.Configuration;
using Omika.Core.Contracts.Mobile.Languages;
using Omika.Core.Services.Mobile;
namespace Omika.Tests.Mobile;
/// <summary>
/// Tests du service de téléchargement des modèles de langue (ONNX).
/// Vérifie le chargement du manifeste, le remplissage des URLs relatives et la
/// résolution de fichiers avec protection anti path traversal.
/// </summary>
public class LanguageModelServiceTests
{
[Fact]
public async Task GetManifest_Absent_RetourneNull()
{
string root = CreateRoot();
try
{
var service = CreateService(root);
var result = await service.GetManifestAsync();
Assert.Null(result);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task GetManifest_Present_RemplitUrlsRelatives()
{
string root = CreateRoot();
try
{
string manifest = """
{
"packs": [
{
"code": "es",
"libelle": "Espagnol",
"version": "1.0.0",
"fichiers": [
{ "sens": "versFrancais", "nomFichier": "encoder.onnx", "tailleOctets": 123, "sha256": "abc" }
]
}
]
}
""";
await File.WriteAllTextAsync(Path.Combine(root, "manifest.json"), manifest);
var service = CreateService(root);
var result = await service.GetManifestAsync();
Assert.NotNull(result);
var pack = Assert.Single(result.Packs);
Assert.Equal("es", pack.Code);
Assert.Equal("Espagnol", pack.Libelle);
Assert.Equal("1.0.0", pack.Version);
var fichier = Assert.Single(pack.Fichiers);
Assert.Equal("/api/langues/es/versFrancais/encoder.onnx", fichier.Url);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task ResolveFile_Existant_RetourneCheminEtContenu()
{
string root = CreateRoot();
try
{
string dir = Path.Combine(root, "es", "versFrancais");
Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(Path.Combine(dir, "encoder.onnx"), "fake-model");
var service = CreateService(root);
var result = await service.ResolveFileAsync("es", "versFrancais", "encoder.onnx");
Assert.NotNull(result);
Assert.Equal("encoder.onnx", result.FileName);
Assert.Equal("application/octet-stream", result.ContentType);
Assert.Equal("fake-model", await File.ReadAllTextAsync(result.FullPath));
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task ResolveFile_RootPathVide_RetombeSurDossierApplication()
{
// Une RootPath vide (section « Langues:RootPath » laissée vide) ne doit pas
// résoudre relativement au répertoire de travail courant (System32 en service
// Windows), mais relativement au dossier de l'application.
var service = new LanguageModelService(
Options.Create(new LanguageModelOptions { RootPath = "", ManifestFileName = "manifest.json" }),
NullLogger<LanguageModelService>.Instance);
// Sans manifeste ni fichier, le service doit retourner null (pas de throw),
// ce qui prouve qu'il a bien utilisé un dossier de repli déterministe.
var manifest = await service.GetManifestAsync();
var fichier = await service.ResolveFileAsync("es", "versFrancais", "encoder.onnx");
Assert.Null(manifest);
Assert.Null(fichier);
}
[Fact]
public async Task ResolveFile_Introuvable_RetourneNull()
{
string root = CreateRoot();
try
{
var service = CreateService(root);
var result = await service.ResolveFileAsync("es", "versFrancais", "absent.onnx");
Assert.Null(result);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task ResolveFile_CodeLangueTraversal_RetourneNull()
{
string root = CreateRoot();
try
{
var service = CreateService(root);
// Segment « .. » ou contenant un séparateur → rejeté avant toute résolution.
var result = await service.ResolveFileAsync("..", "versFrancais", "encoder.onnx");
Assert.Null(result);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task ResolveFile_SensInvalide_RetourneNull()
{
string root = CreateRoot();
try
{
var service = CreateService(root);
var result = await service.ResolveFileAsync("es", "../depuisFrancais", "encoder.onnx");
Assert.Null(result);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public async Task ResolveFile_FichierHorsRacine_RetourneNull()
{
string root = CreateRoot();
string sibling = CreateRoot();
try
{
await File.WriteAllTextAsync(Path.Combine(sibling, "secret.txt"), "secret");
var service = CreateService(root);
// Un nom de fichier portant des segments « .. » est réduit au nom seul :
// il ne peut jamais remonter hors de la racine configurée.
var result = await service.ResolveFileAsync("es", "versFrancais", "..\\..\\secret.txt");
Assert.Null(result);
}
finally
{
Directory.Delete(root, true);
Directory.Delete(sibling, true);
}
}
private static string CreateRoot()
{
string root = Path.Combine(Path.GetTempPath(), "omika-langues-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static LanguageModelService CreateService(string root)
=> new(
Options.Create(new LanguageModelOptions { RootPath = root, ManifestFileName = "manifest.json" }),
NullLogger<LanguageModelService>.Instance);
}