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; /// /// 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. /// 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.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.Instance); }