// © 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) } } }