erplibre/script/qemu_cache/proxy_test.go

511 lines
17 KiB
Go
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// amont monte un serveur qui compte ce qu'on lui demande. Le compteur EST la
// mesure de tous ces tests : le cache n'a d'intérêt que s'il fait baisser le
// nombre de requêtes qui sortent.
type amont struct {
srv *httptest.Server
appel int64
corps string
}
func nouvelAmont(t *testing.T, corps string) *amont {
t.Helper()
a := &amont{corps: corps}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&a.appel, 1)
w.Header().Set("Content-Type", "application/octet-stream")
io.WriteString(w, a.corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amont) appels() int64 { return atomic.LoadInt64(&a.appel) }
func (a *amont) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
func proxyDeTest(t *testing.T) *Proxy {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
return NewProxy(&Store{Dir: t.TempDir()}, alog)
}
// demande joue une requête telle que l'interception transparente la présente :
// une ligne de requête sans hôte, l'hôte venant de l'en-tête.
func demande(t *testing.T, p *Proxy, hote, chemin string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", chemin, nil)
r.Host = hote
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// Invariant 1 : un index n'est JAMAIS servi du cache tant que l'amont répond.
//
// C'est la règle qui empêche le cache de provoquer le « failed retrieving
// file … 404 » sur un paquet retiré des miroirs : servir un index périmé
// promet des fichiers qui n'existent plus.
func TestIndexJamaisServiQuandAmontRepond(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
for i := 1; i <= 3; i++ {
w := demande(t, p, a.hote(), "/arch/core/os/x86_64/core.db")
if w.Code != 200 {
t.Fatalf("requête %d : code %d", i, w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("requête %d : servie « %s », attendu « miss »", i, got)
}
}
if n := a.appels(); n != 3 {
t.Errorf("l'amont a reçu %d requêtes, attendu 3 : un index a été servi du cache", n)
}
}
// Invariant 2 : un fichier de paquet est servi du cache dès la seconde
// demande. C'est le gain que l'outil existe pour produire.
func TestPaquetServiDuCache(t *testing.T) {
a := nouvelAmont(t, "contenu du paquet")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
w1 := demande(t, p, a.hote(), chemin)
if w1.Header().Get("X-ERPLibre-Cache") != "miss" {
t.Fatalf("première demande servie du cache : %s",
w1.Header().Get("X-ERPLibre-Cache"))
}
w2 := demande(t, p, a.hote(), chemin)
if got := w2.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit {
t.Errorf("seconde demande servie « %s », attendu « %s »", got, OutcomeHit)
}
if w2.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w2.Body.String(), a.corps)
}
if n := a.appels(); n != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", n)
}
}
// Invariant 3 : l'amont muet fait sortir la copie stockée, index compris.
// C'est ce qui rend un déploiement possible sans réseau du tout.
func TestIndexServiQuandAmontMuet(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/core.db"
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("remplissage : code %d", w.Code)
}
hote := a.hote()
a.srv.Close() // l'amont devient injoignable
w := demande(t, p, hote, chemin)
if w.Code != 200 {
t.Fatalf("hors ligne : code %d, attendu 200", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
if w.Header().Get("X-ERPLibre-Cache-Date") == "" {
t.Error("la date de l'instantané manque : l'opérateur ne peut pas savoir de quand datent ses octets")
}
if w.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w.Body.String(), a.corps)
}
}
// Invariant 4 : hors ligne et sans copie, l'erreur NOMME le fichier.
//
// Un 404 nu ferait accuser le miroir : le client n'a aucun moyen de savoir
// qu'un cache s'est interposé.
func TestDefautHorsLigneNommeLeFichier(t *testing.T) {
a := nouvelAmont(t, "peu importe")
hote := a.hote()
a.srv.Close()
p := proxyDeTest(t)
w := demande(t, p, hote, "/arch/core/os/x86_64/jamais-vu.pkg.tar.zst")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("code %d, attendu %d", w.Code, http.StatusGatewayTimeout)
}
corps := w.Body.String()
for _, attendu := range []string{"jamais-vu.pkg.tar.zst", hote, "erplibre_go_qemu_cache"} {
if !strings.Contains(corps, attendu) {
t.Errorf("le message ne dit pas %q :\n%s", attendu, corps)
}
}
}
// Une requête partielle ne remplit pas le cache : un fragment ne sert à rien
// à la demande suivante, et le garder comme un corps entier servirait un
// paquet tronqué.
func TestRequetePartielleNonGardee(t *testing.T) {
a := nouvelAmont(t, "0123456789")
p := proxyDeTest(t)
chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst"
r := httptest.NewRequest("GET", chemin, nil)
r.Host = a.hote()
r.Header.Set("Range", "bytes=0-4")
p.serve(httptest.NewRecorder(), r, "http")
// Une demande entière ensuite doit ressortir à l'amont.
w := demande(t, p, a.hote(), chemin)
if got := w.Header().Get("X-ERPLibre-Cache"); got == OutcomeHit {
t.Error("un fragment est entré au cache et a été servi comme un corps entier")
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2", n)
}
}
// Un POST ne se cache pas : il change un état à l'amont.
func TestPostNonCache(t *testing.T) {
a := nouvelAmont(t, "réponse")
p := proxyDeTest(t)
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", "/api/quelque-chose.whl", strings.NewReader("x"))
r.Host = a.hote()
p.serve(httptest.NewRecorder(), r, "http")
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un POST a été caché", n)
}
}
// Un transport qui note le client par lequel la requête est passée.
type transportTemoin struct {
nom string
vues *[]string
}
func (t transportTemoin) RoundTrip(r *http.Request) (*http.Response, error) {
*t.vues = append(*t.vues, t.nom)
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
}
// Le délai court existe pour que le repli hors ligne arrive AVANT que le
// client renonce. Il prenait le calcul d'un serveur git pour un amont
// injoignable — mesuré à seize secondes avant le premier octet — et rendait un
// 504 sur un dépôt parfaitement joignable, faisant échouer « repo sync ».
func TestGitPasseParLeClientPatient(t *testing.T) {
var vues []string
p := &Proxy{
Client: &http.Client{
Transport: transportTemoin{"court", &vues},
},
ClientPatient: &http.Client{
Transport: transportTemoin{"patient", &vues},
},
}
cas := []struct {
brut string
attendu string
}{
{"https://git.example/o/d.git/info/refs?service=git-upload-pack",
"patient"},
{"https://git.example/o/d.git/git-upload-pack", "patient"},
{"https://miroir.example/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
"court"},
{"https://miroir.example/core/os/x86_64/core.db", "court"},
}
for _, c := range cas {
vues = nil
u, _ := url.Parse(c.brut)
r, _ := http.NewRequest("GET", c.brut, nil)
if _, err := p.fetch(r, u, false); err != nil {
t.Fatalf("%s : %v", c.brut, err)
}
if len(vues) != 1 || vues[0] != c.attendu {
t.Errorf("%s passé par %v, attendu « %s »",
c.brut, vues, c.attendu)
}
}
}
// Les deux clients ne se distinguent que par leur patience : un amont
// injoignable reste détecté à l'établissement, en quatre secondes, dans les
// deux cas.
func TestLeClientPatientLestUniquementSurLesEnTetes(t *testing.T) {
p := NewProxy(&Store{Dir: t.TempDir()}, nil)
court := p.Client.Transport.(*http.Transport)
patient := p.ClientPatient.Transport.(*http.Transport)
if patient.ResponseHeaderTimeout <= court.ResponseHeaderTimeout {
t.Errorf("le client patient n'attend pas plus : %v contre %v",
patient.ResponseHeaderTimeout, court.ResponseHeaderTimeout)
}
if patient.TLSHandshakeTimeout != court.TLSHandshakeTimeout {
t.Error("le délai de poignée de main ne doit pas changer")
}
}
// Un paquet publié derrière une redirection vers une URL SIGNÉE.
//
// La cible change à chaque requête — c'est ce que fait une signature à
// péremption. Rendre la redirection au client, en comptant qu'il la
// redemandera au travers du cache, suppose une cible stable : ici la machine
// suivante ne retrouve rien et retélécharge le paquet en entier.
func TestPaquetDerriereUneRedirectionSignee(t *testing.T) {
var telechargements, signature int64
contenu := strings.Repeat("charge utile", 64)
stockage := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&telechargements, 1)
io.WriteString(w, contenu)
}))
t.Cleanup(stockage.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt64(&signature, 1)
http.Redirect(w, r,
stockage.URL+"/objet?signature="+string(rune('a'+n)),
http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
p := proxyDeTest(t)
chemin := "/paquets/releases/download/1.0/outil_1.0_amd64.deb"
for i := 1; i <= 3; i++ {
w := demande(t, p, u.Host, chemin)
if w.Code != 200 {
t.Fatalf("requête %d : code %d, attendu 200", i, w.Code)
}
if w.Body.String() != contenu {
t.Fatalf("requête %d : corps de %d octets", i, w.Body.Len())
}
}
if n := atomic.LoadInt64(&telechargements); n != 1 {
t.Errorf("le paquet est descendu %d fois, attendu 1 :"+
" la redirection empêche le cache de servir", n)
}
}
// Un index, lui, garde l'ancien comportement : la redirection lui est rendue,
// et il la redemandera. Rien n'est gardé d'un index tant que l'amont répond,
// donc suivre la chaîne n'apporterait rien et masquerait au client l'hôte qui
// lui a réellement répondu.
func TestUnIndexRecoitSaRedirection(t *testing.T) {
cible := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "base")
}))
t.Cleanup(cible.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, cible.URL+"/vraie.db", http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
w := demande(t, proxyDeTest(t), u.Host, "/arch/core/os/x86_64/core.db")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 : la redirection a été suivie", w.Code)
}
}
// Une boucle de redirections doit rendre la main plutôt que tourner.
func TestUneBoucleDeRedirectionsSarrete(t *testing.T) {
var vues int64
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&vues, 1)
http.Redirect(w, r, srv.URL+"/tourne.deb", http.StatusFound)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
w := demande(t, proxyDeTest(t), u.Host, "/tourne.deb")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 au bout de la chaîne", w.Code)
}
if n := atomic.LoadInt64(&vues); n > maxRedirections+1 {
t.Errorf("%d requêtes pour une boucle, borne %d",
n, maxRedirections+1)
}
}
// proxyEtCasier rend un proxy et le répertoire de son casier : les contrôles
// qui suivent ont besoin de constater ce qui a été GARDÉ, pas seulement ce
// qui a été rendu.
func proxyEtCasier(t *testing.T) (*Proxy, string) {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
dir := t.TempDir()
return NewProxy(&Store{Dir: dir}, alog), dir
}
func corpsGardes(t *testing.T, dir string) int {
t.Helper()
n := 0
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() && strings.HasSuffix(p, ".body") {
n++
}
return nil
})
return n
}
// Une image cloud livre déjà l'index de sa suite de base. Son apt le REVALIDE
// au lieu de le télécharger, l'amont rend « 304 », et le cache n'a donc
// jamais de corps à garder pour cette ressource — aussi longtemps que ses
// clients en détiennent une copie, c'est-à-dire toujours.
//
// Hors ligne, la suite de base est alors la seule à manquer : ses voisines
// « -updates », « -security » et « -backports » sortent périmées du cache, et
// apt ne trouve plus un paquet de la base pendant que le reste marche.
func TestUneConditionnelleNeLaissePasLeCasierVide(t *testing.T) {
var recues []string
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
recues = append(recues, r.Header.Get("If-Modified-Since"))
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write([]byte("Origin: Ubuntu\nSuite: base\n"))
}))
defer srv.Close()
hote, _ := url.Parse(srv.URL)
p, dir := proxyEtCasier(t)
joue := func() *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = hote.Host
r.Header.Set("If-Modified-Since", "Wed, 01 Jan 2020 00:00:00 GMT")
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
if w := joue(); w.Code != http.StatusOK {
t.Fatalf("statut rendu %d, attendu 200", w.Code)
}
if len(recues) != 1 || recues[0] != "" {
t.Fatalf("la condition est partie à l'amont : %q", recues)
}
if corpsGardes(t, dir) == 0 {
t.Fatal("rien n'est gardé : le cache restera vide pour cette suite")
}
// Le corps est là : la condition peut repartir, et un « 304 » ne coûte
// alors plus rien — c'est ce qui économise la bande passante.
recues = nil
joue()
if len(recues) != 1 || recues[0] == "" {
t.Fatalf("la condition a encore été retirée : %q", recues)
}
}
// Amont muet et client qui détient déjà sa copie : lui rendre « 504 » le
// prive d'une suite entière qu'il pouvait lire. « 304 » le laisse garder la
// sienne, ce que « stale-if-error » veut dire pour une conditionnelle.
func TestAmontMuetEtClientQuiDetientDejaSaCopie(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
// Rien n'écoute : l'amont est injoignable.
r.Host = "127.0.0.1:1"
r.Header.Set("If-None-Match", `"abc"`)
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusNotModified {
t.Fatalf("statut rendu %d, attendu 304", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep {
t.Fatalf("issue %q, attendue %q", got, OutcomeKeep)
}
}
// Sans condition, le client n'a rien à garder : le 504 reste la réponse
// juste, et il dit pourquoi.
func TestAmontMuetSansCopieChezLeClient(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
}
// Un corps d'erreur finit régulièrement dans un shell : « curl … | bash »
// est l'idiome de la moitié des installateurs, et « curl » sans « -f » lui
// livre le corps d'un 504 comme s'il l'avait demandé.
//
// Le message doit donc ne RIEN faire une fois exécuté. Sans cela, l'échec
// se lit « bash: line 1: erplibre_go_qemu_cache: command not found » et la
// cause véritable, elle, ne se lit plus.
func TestLeMessageHorsLigneEstInerteDansUnShell(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/pyenv-installer", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
for i, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") {
if !strings.HasPrefix(l, "#") {
t.Fatalf("ligne %d exécutable par un shell : %q", i+1, l)
}
}
// Inerte ne veut pas dire muet : la cause reste lisible.
if !strings.Contains(w.Body.String(), "amont injoignable") {
t.Fatal("le message ne dit plus pourquoi")
}
}
// Une cause qui porte plusieurs lignes ne doit pas sortir du commentaire :
// il suffirait de la première pour rendre la suite exécutable.
func TestUneCauseMultiligneResteCommentee(t *testing.T) {
got := enCommentaire("premier\nseconde\ntroisième\n")
for _, l := range strings.Split(strings.TrimRight(got, "\n"), "\n") {
if !strings.HasPrefix(l, "# ") {
t.Fatalf("ligne non commentée : %q", l)
}
}
}