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>
212 lines
7.8 KiB
C#
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)));
|
|
}
|
|
}
|