using System.Net; using MaBibli.Api.Data; using MaBibli.Api.Services.Couvertures; using MaBibli.Shared.Entites; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace MaBibli.Tests; /// /// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est /// tout ce que le proxy refuse d'aller chercher. /// /// /// ⚠️ Un proxy dont le bornage se relâche ne « marche moins bien » pas : il devient un moyen de /// faire lire au serveur ce que l'appelant ne peut pas atteindre lui-même. Le service écoute sur /// 127.0.0.1 et cohabite avec les autres applications YunoHost du serveur — c'est /// exactement ce que la contrainte d'écoute cherche à empêcher. /// public class ServiceCouverturesTests : IDisposable { private readonly SqliteConnection _connexion; private readonly MaBibliDbContext _db; private const string UrlConnue = "https://editions-exemple.fr/couverture.jpg"; public ServiceCouverturesTests() { _connexion = new SqliteConnection("Data Source=:memory:"); _connexion.Open(); _db = new MaBibliDbContext(new DbContextOptionsBuilder() .UseSqlite(_connexion).Options); _db.Database.EnsureCreated(); var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue }; livre.RecalculerFormes(); _db.Livres.Add(livre); _db.SaveChanges(); } /// /// Client qui échoue à tout coup : ces tests portent sur le bornage, et un refus doit /// tomber avant tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait — /// c'est précisément ce qu'on veut constater. /// private ServiceCouvertures Service() => new(new HttpClient(new HandlerQuiRefuse()), _db); private sealed class HandlerQuiRefuse : HttpMessageHandler { public bool AEteAppele { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { AEteAppele = true; throw new HttpRequestException("Aucun appel réseau ne devrait avoir lieu."); } } // ── Verrou nº1 : l'URL doit déjà être en base ──────────────────────────── [Fact] public async Task Une_url_inconnue_de_la_base_est_refusee() { var handler = new HandlerQuiRefuse(); var service = new ServiceCouvertures(new HttpClient(handler), _db); var resultat = await service.ObtenirAsync("https://ailleurs.example/image.jpg", default); Assert.False(resultat.Trouvee); // Le point important : le refus tombe AVANT la requête. Sans ce verrou, le proxy irait // chercher n'importe quelle URL passée en paramètre. Assert.False(handler.AEteAppele); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("pas une url")] [InlineData("file:///etc/passwd")] [InlineData("http://editions-exemple.fr/couverture.jpg")] // https uniquement public async Task Les_schemas_et_les_saisies_invalides_sont_refuses(string? url) { Assert.False((await Service().ObtenirAsync(url, default)).Trouvee); } [Fact] public async Task Une_url_connue_franchit_le_bornage_et_va_jusqu_au_reseau() { var handler = new HandlerQuiRefuse(); var service = new ServiceCouvertures(new HttpClient(handler), _db); // Le réseau échoue (handler), donc pas de couverture — mais l'appel a bien eu lieu : // c'est ce qui distingue « refusé par le bornage » de « injoignable ». var resultat = await service.ObtenirAsync(UrlConnue, default); Assert.False(resultat.Trouvee); Assert.True(handler.AEteAppele); } [Fact] public async Task Une_couverture_d_ENVIE_autorise_aussi_le_relais() { // Les deux tables comptent : une envie porte sa couverture comme un livre, et elle // s'affiche dans la liste d'envies — donc elle doit pouvoir se mettre en cache. const string urlEnvie = "https://editions-exemple.fr/envie.jpg"; var souhait = new LivreSouhaite { Utilisateur = "mathieu", Titre = "Une envie", Auteur = "Quelqu'un", CoverUrl = urlEnvie, }; souhait.RecalculerFormes(); _db.LivresSouhaites.Add(souhait); await _db.SaveChangesAsync(); var handler = new HandlerQuiRefuse(); var service = new ServiceCouvertures(new HttpClient(handler), _db); await service.ObtenirAsync(urlEnvie, default); Assert.True(handler.AEteAppele); } /// /// ⚠️ Le cas qu'on oublie : un numéro de revue porte lui aussi une couverture depuis le lot /// O1. L'omettre du bornage n'aurait produit aucune erreur visible — seulement une image /// qui s'affiche en ligne et jamais hors-ligne, la mise en cache étant refusée par le /// relais. C'est exactement le défaut corrigé le 2026-08-20 pour les hébergeurs sans CORS. /// [Fact] public async Task Une_couverture_de_NUMERO_de_revue_autorise_aussi_le_relais() { const string urlNumero = "https://editions-exemple.fr/medor-43.jpg"; var revue = new Revue { Titre = "Médor", Issn = "2466-6718" }; revue.RecalculerFormes(); var numero = new NumeroRevue { Revue = revue, Numero = "43", CoverUrl = urlNumero }; numero.RecalculerFormes(); _db.NumerosRevue.Add(numero); await _db.SaveChangesAsync(); var handler = new HandlerQuiRefuse(); var service = new ServiceCouvertures(new HttpClient(handler), _db); await service.ObtenirAsync(urlNumero, default); Assert.True(handler.AEteAppele); } public void Dispose() { _db.Dispose(); _connexion.Dispose(); GC.SuppressFinalize(this); } } /// /// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir. /// /// /// C'est le verrou qui tient même si une URL interne a été enregistrée en base dans une /// fiche : la valeur serait connue, la connexion serait refusée quand même. /// public class GardeAdressesTests { [Theory] [InlineData("127.0.0.1")] // bouclage : le service lui-même [InlineData("127.1.2.3")] [InlineData("0.0.0.0")] [InlineData("10.0.0.5")] // privé [InlineData("172.16.0.1")] [InlineData("172.31.255.254")] [InlineData("192.168.1.1")] [InlineData("169.254.169.254")] // lien-local : le point de métadonnées des hébergeurs [InlineData("100.64.0.1")] // CGNAT [InlineData("224.0.0.1")] // multicast [InlineData("::1")] // bouclage IPv6 [InlineData("::")] [InlineData("fe80::1")] // lien-local IPv6 [InlineData("fc00::1")] // adresses locales uniques [InlineData("fd12:3456::1")] [InlineData("::ffff:127.0.0.1")] // bouclage déguisé en IPv6 [InlineData("::ffff:192.168.0.1")] public void Les_adresses_internes_sont_refusees(string adresse) { Assert.False(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse))); } [Theory] [InlineData("1.1.1.1")] [InlineData("172.15.0.1")] // juste HORS de 172.16.0.0/12 [InlineData("172.32.0.1")] // juste au-dessus [InlineData("100.63.255.255")] // juste sous le CGNAT [InlineData("100.128.0.1")] // juste au-dessus [InlineData("192.167.0.1")] [InlineData("2606:4700:4700::1111")] public void Les_adresses_publiques_sont_acceptees(string adresse) { Assert.True(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse))); } }