Files
mabibli/MaBibli.Tests/ServiceCouverturesTests.cs
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

212 lines
7.8 KiB
C#

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;
/// <summary>
/// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est
/// <b>tout ce que le proxy refuse d'aller chercher</b>.
/// </summary>
/// <remarks>
/// ⚠️ 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
/// <c>127.0.0.1</c> et cohabite avec les autres applications YunoHost du serveur — c'est
/// exactement ce que la contrainte d'écoute cherche à empêcher.
/// </remarks>
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<MaBibliDbContext>()
.UseSqlite(_connexion).Options);
_db.Database.EnsureCreated();
var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue };
livre.RecalculerFormes();
_db.Livres.Add(livre);
_db.SaveChanges();
}
/// <summary>
/// Client qui échoue à tout coup : ces tests portent sur le <b>bornage</b>, et un refus doit
/// tomber <i>avant</i> tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait —
/// c'est précisément ce qu'on veut constater.
/// </summary>
private ServiceCouvertures Service() =>
new(new HttpClient(new HandlerQuiRefuse()), _db);
private sealed class HandlerQuiRefuse : HttpMessageHandler
{
public bool AEteAppele { get; private set; }
protected override Task<HttpResponseMessage> 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);
}
/// <summary>
/// ⚠️ 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.
/// </summary>
[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);
}
}
/// <summary>
/// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir.
/// </summary>
/// <remarks>
/// C'est le verrou qui tient <b>même si</b> une URL interne a été enregistrée en base dans une
/// fiche : la valeur serait connue, la connexion serait refusée quand même.
/// </remarks>
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)));
}
}