Trois défauts qui empêchaient une VM hors ligne d'aboutir. zypper vérifie chaque dépôt par un HEAD, jamais gardé sous une clé portable : le dépôt était déclaré invalide alors que le magasin tenait son index, et un HEAD reçoit désormais les en-têtes du corps du GET gardé. Une image qui attend la synchronisation NTP avant son étape finale ne démarrait jamais son serveur ssh, faute de serveur de temps joignable ; l'attente est levée sur une VM déployée hors ligne. Enfin, une troisième VM en échec nomme l'étape qui l'a arrêtée, là où le résumé d'une série n'affichait qu'un verdict nu. --- EN --- Three faults that kept an offline VM from finishing. zypper checks each repository with a HEAD, never stored under a portable key: the repository was declared invalid while the store held its index, and a HEAD now gets the headers of the stored GET body. An image waiting for NTP synchronisation before its final stage never started its ssh server, no time server being reachable; that wait is lifted on a VM deployed offline. Last, a failing third VM names the step that stopped it, where a series summary showed a bare verdict. Assisted-by: Claude Opus 5
688 lines
23 KiB
Go
688 lines
23 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// amontScripte monte un amont dont la réponse est dictée par le test, et qui
|
|
// compte ce qu'on lui demande. La réponse peut changer en cours de test :
|
|
// c'est ce qui fait rendre un 200 puis un 404 à la même URL.
|
|
type amontScripte struct {
|
|
srv *httptest.Server
|
|
appel int64
|
|
mu sync.Mutex
|
|
h http.HandlerFunc
|
|
}
|
|
|
|
func nouvelAmontScripte(t *testing.T, h http.HandlerFunc) *amontScripte {
|
|
t.Helper()
|
|
a := &amontScripte{h: h}
|
|
a.srv = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&a.appel, 1)
|
|
a.mu.Lock()
|
|
h := a.h
|
|
a.mu.Unlock()
|
|
h(w, r)
|
|
}))
|
|
t.Cleanup(a.srv.Close)
|
|
return a
|
|
}
|
|
|
|
func (a *amontScripte) repondre(h http.HandlerFunc) {
|
|
a.mu.Lock()
|
|
a.h = h
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *amontScripte) appels() int64 { return atomic.LoadInt64(&a.appel) }
|
|
|
|
func (a *amontScripte) hote() string {
|
|
u, _ := url.Parse(a.srv.URL)
|
|
return u.Host
|
|
}
|
|
|
|
// couper rend l'amont injoignable et rend son adresse, qui refuse désormais
|
|
// toute connexion.
|
|
func (a *amontScripte) couper() string {
|
|
h := a.hote()
|
|
a.srv.Close()
|
|
return h
|
|
}
|
|
|
|
func rediriger(code int, cible string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", cible)
|
|
w.Header().Set("Set-Cookie", "session=temoin")
|
|
w.WriteHeader(code)
|
|
io.WriteString(w, "déplacé")
|
|
}
|
|
}
|
|
|
|
func refuser(code int) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(code)
|
|
io.WriteString(w, "introuvable")
|
|
}
|
|
}
|
|
|
|
func servir(corps string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
io.WriteString(w, corps)
|
|
}
|
|
}
|
|
|
|
// joue fait une requête de méthode et d'en-têtes choisis, telle que
|
|
// l'interception transparente la présente. Les en-têtes vont par paires :
|
|
// nom, valeur.
|
|
func joue(
|
|
t *testing.T, p *Proxy, methode, hote, chemin string, entetes ...string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest(methode, chemin, nil)
|
|
r.Host = hote
|
|
for i := 0; i+1 < len(entetes); i += 2 {
|
|
r.Header.Set(entetes[i], entetes[i+1])
|
|
}
|
|
w := httptest.NewRecorder()
|
|
p.serve(w, r, "http")
|
|
return w
|
|
}
|
|
|
|
// journalDeTest branche un journal d'accès sur fichier et rend son chemin.
|
|
func journalDeTest(t *testing.T, p *Proxy) string {
|
|
t.Helper()
|
|
chemin := filepath.Join(t.TempDir(), "acces.jsonl")
|
|
alog, err := OpenAccessLog(chemin)
|
|
if err != nil {
|
|
t.Fatalf("journal : %v", err)
|
|
}
|
|
t.Cleanup(alog.Close)
|
|
p.Log = alog
|
|
return chemin
|
|
}
|
|
|
|
func lignesDuJournal(t *testing.T, chemin string) []accessLine {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(chemin)
|
|
if err != nil {
|
|
t.Fatalf("journal illisible : %v", err)
|
|
}
|
|
var out []accessLine
|
|
for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
|
if l == "" {
|
|
continue
|
|
}
|
|
var a accessLine
|
|
if err := json.Unmarshal([]byte(l), &a); err != nil {
|
|
t.Fatalf("ligne illisible %q : %v", l, err)
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Une redirection volatile est gardée, mais ne sort QUE l'amont muet : tant
|
|
// qu'il répond, chaque requête lui revient, et le client reçoit ce que
|
|
// l'amont dit aujourd'hui.
|
|
func TestUneRedirectionVolatileNeSortQueHorsLigne(t *testing.T) {
|
|
const cible = "https://ailleurs.example/bootstrap.sh"
|
|
a := nouvelAmontScripte(t, rediriger(http.StatusFound, cible))
|
|
p := proxyDeTest(t)
|
|
journal := journalDeTest(t, p)
|
|
|
|
for i := 1; i <= 2; i++ {
|
|
w := joue(t, p, "GET", a.hote(), "/installer.sh")
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("en ligne %d : code %d, attendu 302", i, w.Code)
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
|
|
t.Errorf("en ligne %d : servi « %s », attendu « miss »", i, got)
|
|
}
|
|
}
|
|
if n := a.appels(); n != 2 {
|
|
t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un statut gardé"+
|
|
" est sorti alors qu'il répondait", n)
|
|
}
|
|
|
|
hote := a.couper()
|
|
w := joue(t, p, "GET", hote, "/installer.sh")
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("hors ligne : code %d, attendu 302", w.Code)
|
|
}
|
|
if got := w.Header().Get("Location"); got != cible {
|
|
t.Errorf("Location %q, attendu %q", got, cible)
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus)
|
|
}
|
|
if w.Header().Get("X-ERPLibre-Cache-Date") == "" {
|
|
t.Error("la date du statut rejoué manque")
|
|
}
|
|
if w.Body.Len() != 0 {
|
|
t.Errorf("un statut rejoué porte un corps de %d octets", w.Body.Len())
|
|
}
|
|
if got := w.Header().Get("Set-Cookie"); got != "" {
|
|
t.Errorf("le témoin d'une autre machine est rejoué : %q", got)
|
|
}
|
|
if got := w.Header().Get("Content-Length"); got != "" {
|
|
t.Errorf("une longueur %q est annoncée sans corps", got)
|
|
}
|
|
|
|
// Le journal dit le VRAI statut, et sous des noms qui ne se confondent
|
|
// pas avec ceux d'un corps.
|
|
var garde, rejoue bool
|
|
for _, l := range lignesDuJournal(t, journal) {
|
|
switch l.Outcome {
|
|
case OutcomeStoredStatus:
|
|
garde = l.Status == http.StatusFound && l.Upstream
|
|
case OutcomeStaleStatus:
|
|
rejoue = l.Status == http.StatusFound && !l.Upstream
|
|
case OutcomeStored, OutcomeStale:
|
|
t.Errorf("un statut seul est journalisé « %s »", l.Outcome)
|
|
}
|
|
}
|
|
if !garde || !rejoue {
|
|
t.Errorf("journal : gardé=%v rejoué=%v, attendu les deux avec 302",
|
|
garde, rejoue)
|
|
}
|
|
}
|
|
|
|
// Un refus définitif ressort tel quel : un client qui sonde un fichier
|
|
// optionnel s'arrête sur le 404 et poursuit, là où un 504 l'arrête net.
|
|
func TestUnRefusDefinitifSeRejoueHorsLigne(t *testing.T) {
|
|
for _, code := range []int{http.StatusNotFound, http.StatusGone} {
|
|
t.Run(fmt.Sprint(code), func(t *testing.T) {
|
|
a := nouvelAmontScripte(t, refuser(code))
|
|
p := proxyDeTest(t)
|
|
if w := joue(t, p, "GET", a.hote(), "/sonde/2.root.json"); w.Code != code {
|
|
t.Fatalf("en ligne : code %d, attendu %d", w.Code, code)
|
|
}
|
|
hote := a.couper()
|
|
w := joue(t, p, "GET", hote, "/sonde/2.root.json")
|
|
if w.Code != code {
|
|
t.Fatalf("hors ligne : code %d, attendu %d", w.Code, code)
|
|
}
|
|
if w.Body.Len() != 0 {
|
|
t.Errorf("corps de %d octets rejoué", w.Body.Len())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Un refus n'écrase jamais un corps : un amont qui perd un fichier un jour
|
|
// ne doit pas priver le hors-ligne de la copie qu'il avait rendue.
|
|
func TestUnRefusNeRemplacePasUnCorps(t *testing.T) {
|
|
a := nouvelAmontScripte(t, servir("corps publié"))
|
|
p := proxyDeTest(t)
|
|
chemin := "/metadonnees/etat.json"
|
|
|
|
joue(t, p, "GET", a.hote(), chemin)
|
|
a.repondre(refuser(http.StatusNotFound))
|
|
if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusNotFound {
|
|
t.Fatalf("en ligne, le refus de l'amont n'est pas relayé : %d", w.Code)
|
|
}
|
|
|
|
hote := a.couper()
|
|
w := joue(t, p, "GET", hote, chemin)
|
|
if w.Code != http.StatusOK || w.Body.String() != "corps publié" {
|
|
t.Fatalf("hors ligne : %d %q, attendu le corps gardé", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
|
|
}
|
|
}
|
|
|
|
// Un corps, lui, l'emporte sur un statut seul — y compris quand le client
|
|
// pose une condition : le statut ne vaut pas détention, la condition est donc
|
|
// retirée, et l'amont rend le corps entier au lieu d'un 304.
|
|
func TestUnCorpsRemplaceUnStatutSeul(t *testing.T) {
|
|
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
|
|
p := proxyDeTest(t)
|
|
chemin := "/metadonnees/etat.json"
|
|
joue(t, p, "GET", a.hote(), chemin)
|
|
|
|
a.repondre(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("If-None-Match") != "" {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
io.WriteString(w, "corps publié")
|
|
})
|
|
w := joue(t, p, "GET", a.hote(), chemin, "If-None-Match", `"v1"`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code %d, attendu 200 : la condition est partie à l'amont"+
|
|
" alors que le cache n'a qu'un statut", w.Code)
|
|
}
|
|
|
|
hote := a.couper()
|
|
w = joue(t, p, "GET", hote, chemin)
|
|
if w.Code != http.StatusOK || w.Body.String() != "corps publié" {
|
|
t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Un statut seul vit sous sa propre clé. Un lecteur qui ne connaît que les
|
|
// corps — qui ne calcule que Key — n'y trouve rien, et ne peut donc pas le
|
|
// servir en « 200 » vide. Le rejeu, lui, le trouve ; et un corps gardé plus
|
|
// tard sous la clé du corps coexiste avec lui et sort en premier.
|
|
func TestUnStatutSeulResteHorsDesClesDeCorps(t *testing.T) {
|
|
a := nouvelAmontScripte(t, rediriger(http.StatusFound,
|
|
"https://ailleurs.example/bootstrap.sh"))
|
|
p := proxyDeTest(t)
|
|
p.Muets = NouvelleJoignabilite()
|
|
chemin := "/installer.sh"
|
|
u, _ := url.Parse("http://" + a.hote() + chemin)
|
|
joue(t, p, "GET", a.hote(), chemin)
|
|
|
|
if _, err := p.Store.LireMeta(Key("GET", u.String())); err == nil {
|
|
t.Error("un statut seul est lisible sous la clé d'un corps")
|
|
}
|
|
if !p.Store.TientStatut(CleStatut("GET", u)) {
|
|
t.Fatal("le statut seul n'est pas sous sa propre clé")
|
|
}
|
|
|
|
// L'hôte retenu muet : le repli sort sans composer, et c'est le rejeu.
|
|
p.Muets.Echec(a.hote(), refusEtablissement())
|
|
w := joue(t, p, "GET", a.hote(), chemin)
|
|
if w.Code != http.StatusFound ||
|
|
w.Header().Get("X-ERPLibre-Cache") != OutcomeStaleStatus {
|
|
t.Fatalf("rejeu : %d « %s », attendu 302 « %s »", w.Code,
|
|
w.Header().Get("X-ERPLibre-Cache"), OutcomeStaleStatus)
|
|
}
|
|
p.Muets.Reussite(a.hote())
|
|
|
|
a.repondre(servir("echo bonjour"))
|
|
if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusOK {
|
|
t.Fatalf("en ligne : code %d, attendu 200", w.Code)
|
|
}
|
|
if !p.Store.Detient(Key("GET", u.String())) {
|
|
t.Fatal("le corps n'est pas gardé sous la clé du corps")
|
|
}
|
|
if !p.Store.TientStatut(CleStatut("GET", u)) {
|
|
t.Error("le statut seul a disparu : il devait coexister avec le corps")
|
|
}
|
|
|
|
hote := a.couper()
|
|
w = joue(t, p, "GET", hote, chemin)
|
|
if w.Code != http.StatusOK || w.Body.String() != "echo bonjour" {
|
|
t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
|
|
}
|
|
}
|
|
|
|
// Un statut passager ne se garde pas : le rejouer figerait une panne d'un
|
|
// moment, et le hors-ligne dirait 403 ou 503 pour toujours.
|
|
func TestUnStatutPassagerNestPasGarde(t *testing.T) {
|
|
for _, code := range []int{403, 429, 500, 503} {
|
|
t.Run(fmt.Sprint(code), func(t *testing.T) {
|
|
a := nouvelAmontScripte(t, refuser(code))
|
|
p, dir := proxyEtCasier(t)
|
|
joue(t, p, "GET", a.hote(), "/api/etat")
|
|
hote := a.couper()
|
|
if w := joue(t, p, "GET", hote, "/api/etat"); w.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("hors ligne : code %d, attendu 504", w.Code)
|
|
}
|
|
if n := corpsGardes(t, dir); n != 0 {
|
|
t.Errorf("%d objet(s) gardé(s) pour un %d", n, code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Une clé portable est partagée par tous les miroirs : le refus d'un miroir
|
|
// en retard y prendrait la place de l'index qu'un autre a rendu.
|
|
func TestUnCheminPortableNeGardeNiRedirectionNiRefus(t *testing.T) {
|
|
for nom, h := range map[string]http.HandlerFunc{
|
|
"redirection": rediriger(http.StatusFound, "https://ailleurs.example/core.db"),
|
|
"refus": refuser(http.StatusNotFound),
|
|
} {
|
|
t.Run(nom, func(t *testing.T) {
|
|
a := nouvelAmontScripte(t, h)
|
|
p, dir := proxyEtCasier(t)
|
|
joue(t, p, "GET", a.hote(), "/arch/core/os/x86_64/core.db")
|
|
hote := a.couper()
|
|
w := joue(t, p, "GET", hote, "/arch/core/os/x86_64/core.db")
|
|
if w.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("hors ligne : code %d, attendu 504", w.Code)
|
|
}
|
|
if n := corpsGardes(t, dir); n != 0 {
|
|
t.Errorf("%d objet(s) gardé(s) sous une clé portable", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// L'immuable ne garde pas son refus : servi du disque sans jamais
|
|
// redemander, un 404 masquerait le fichier publié ensuite.
|
|
//
|
|
// L'index par empreinte est le cas qui isole la règle : immuable mais
|
|
// attaché à son hôte, la borne des clés portables ne l'écarte pas.
|
|
func TestLImmuableNeGardePasSonRefus(t *testing.T) {
|
|
for _, chemin := range []string{
|
|
"/outils/outil-2.0.tar.gz",
|
|
"/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32),
|
|
} {
|
|
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
|
|
p, dir := proxyEtCasier(t)
|
|
joue(t, p, "GET", a.hote(), chemin)
|
|
hote := a.couper()
|
|
if w := joue(t, p, "GET", hote, chemin); w.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("%s hors ligne : code %d, attendu 504", chemin, w.Code)
|
|
}
|
|
if n := corpsGardes(t, dir); n != 0 {
|
|
t.Errorf("%s : %d objet(s) gardé(s) pour un 404 immuable", chemin, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hors ligne, un HEAD sur un index dont le GET a son corps en réserve reçoit
|
|
// les en-têtes de ce corps, sans corps : c'est ainsi que zypper juge un dépôt
|
|
// valide avant de le lire. Sous une clé portable, aucun statut de HEAD ne se
|
|
// garde, et sans ce repli le dépôt entier était déclaré invalide.
|
|
func TestHorsLigneUnHeadSortDuCorpsDuGet(t *testing.T) {
|
|
const corps = "<repomd>index</repomd>"
|
|
const chemin = "/distribution/leap/16.0/repo/oss/x86_64/repodata/repomd.xml"
|
|
a := nouvelAmontScripte(t, servir(corps))
|
|
p := proxyDeTest(t)
|
|
u, _ := url.Parse("http://" + a.hote() + chemin)
|
|
if Classify(u) != ClassVolatile || !PortableParChemin(u) {
|
|
t.Fatalf("%s n'est plus un index volatil portable", chemin)
|
|
}
|
|
joue(t, p, "GET", a.hote(), chemin)
|
|
joue(t, p, "HEAD", a.hote(), chemin)
|
|
base := "http://" + a.hote()
|
|
|
|
hote := a.couper()
|
|
w := joue(t, p, "HEAD", hote, chemin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("HEAD hors ligne : code %d, attendu 200", w.Code)
|
|
}
|
|
if w.Body.Len() != 0 {
|
|
t.Errorf("HEAD hors ligne avec un corps de %d octets", w.Body.Len())
|
|
}
|
|
if got := w.Header().Get("Content-Length"); got != strconv.Itoa(len(corps)) {
|
|
t.Errorf("longueur %q, attendu celle du corps gardé (%d)", got, len(corps))
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
|
|
}
|
|
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictGarde {
|
|
t.Errorf("--detient dit %q pour un HEAD que le service sert", got)
|
|
}
|
|
// Sans GET gardé, rien ne change : le HEAD reste un 504.
|
|
if w := joue(t, p, "HEAD", hote, "/distribution/autre/repodata/repomd.xml"); w.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("HEAD sans corps gardé : code %d, attendu 504", w.Code)
|
|
}
|
|
}
|
|
|
|
// Un HEAD garde son statut : un installateur lit la version publiée dans le
|
|
// « Location » d'un HEAD, et n'a besoin de rien d'autre.
|
|
//
|
|
// L'amont annonce une longueur sans corps — c'est ce que fait un HEAD. La
|
|
// comparer au corps écrit ferait refuser l'objet comme tronqué.
|
|
func TestUnHeadGardeSonStatut(t *testing.T) {
|
|
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/o/d/releases/latest" {
|
|
rediriger(http.StatusFound, "/o/d/releases/tag/v1.2.3")(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Length", "1234")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
p := proxyDeTest(t)
|
|
joue(t, p, "HEAD", a.hote(), "/o/d/releases/latest")
|
|
joue(t, p, "HEAD", a.hote(), "/api/bonjour")
|
|
|
|
hote := a.couper()
|
|
w := joue(t, p, "HEAD", hote, "/o/d/releases/latest")
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("HEAD hors ligne : code %d, attendu 302", w.Code)
|
|
}
|
|
if got := w.Header().Get("Location"); got != "/o/d/releases/tag/v1.2.3" {
|
|
t.Errorf("Location %q : une cible relative doit ressortir telle quelle", got)
|
|
}
|
|
w = joue(t, p, "HEAD", hote, "/api/bonjour")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("HEAD 200 hors ligne : code %d, attendu 200", w.Code)
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus)
|
|
}
|
|
// La longueur d'un HEAD est celle du corps que rendrait le GET : elle
|
|
// ressort telle que l'amont l'a annoncée, comme en ligne.
|
|
if got := w.Header().Get("Content-Length"); got != "1234" {
|
|
t.Errorf("HEAD rejoué avec la longueur %q, attendu « 1234 »", got)
|
|
}
|
|
// Le HEAD n'a rien donné à garder pour un GET.
|
|
if w := joue(t, p, "GET", hote, "/api/bonjour"); w.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("GET hors ligne : code %d, attendu 504", w.Code)
|
|
}
|
|
}
|
|
|
|
// Un client qui détient sa copie garde son 304, même quand un statut seul est
|
|
// en réserve : le lui remplacer par un refus lui ferait jeter une copie
|
|
// valide.
|
|
func TestUneConditionnelleGardeSaCopieMalgreUnStatutSeul(t *testing.T) {
|
|
for nom, h := range map[string]http.HandlerFunc{
|
|
"refus": refuser(http.StatusNotFound),
|
|
"redirection": rediriger(http.StatusFound, "https://ailleurs.example/x"),
|
|
} {
|
|
t.Run(nom, func(t *testing.T) {
|
|
a := nouvelAmontScripte(t, h)
|
|
p := proxyDeTest(t)
|
|
joue(t, p, "GET", a.hote(), "/depot/etat")
|
|
hote := a.couper()
|
|
|
|
w := joue(t, p, "GET", hote, "/depot/etat", "If-None-Match", `"v1"`)
|
|
if w.Code != http.StatusNotModified {
|
|
t.Fatalf("code %d, attendu 304", w.Code)
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep {
|
|
t.Errorf("servi « %s », attendu « %s »", got, OutcomeKeep)
|
|
}
|
|
// Sans condition, le statut ressort.
|
|
if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code == http.StatusGatewayTimeout {
|
|
t.Error("sans condition, le statut gardé ne ressort pas")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Le chemin de l'immuable ne sert qu'un corps 200 : un méta de statut sous
|
|
// une clé immuable sortirait sinon en « 200 » vide, même l'amont joignable.
|
|
func TestLeCheminDeLImmuableRefuseUnStatut(t *testing.T) {
|
|
a := nouvelAmontScripte(t, servir("vrai corps"))
|
|
p, _ := proxyEtCasier(t)
|
|
chemin := "/outils/outil-1.0.tar.gz"
|
|
u, _ := url.Parse("http://" + a.hote() + chemin)
|
|
cw, err := p.Store.NewWriter(CleDe("GET", u), Meta{
|
|
URL: u.String(), Method: "GET", Status: http.StatusFound,
|
|
Header: http.Header{"Location": []string{"https://ailleurs.example/"}},
|
|
Class: ClassImmutable.String(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cw.Commit(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
w := joue(t, p, "GET", a.hote(), chemin)
|
|
if w.Code != http.StatusOK || w.Body.String() != "vrai corps" {
|
|
t.Fatalf("code %d, corps %q : le statut a été servi comme un corps",
|
|
w.Code, w.Body.String())
|
|
}
|
|
if a.appels() != 1 {
|
|
t.Errorf("l'amont a reçu %d requêtes, attendu 1", a.appels())
|
|
}
|
|
}
|
|
|
|
// clientIntercepte rend un client HTTP dont toute connexion aboutit au cache,
|
|
// quelle que soit l'adresse demandée : c'est ce que fait le détournement du
|
|
// 80 pour une VM.
|
|
func clientIntercepte(t *testing.T, p *Proxy) *http.Client {
|
|
t.Helper()
|
|
front := httptest.NewServer(p.handler("http"))
|
|
t.Cleanup(front.Close)
|
|
cache := front.Listener.Addr().String()
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(
|
|
ctx context.Context, reseau, _ string,
|
|
) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, reseau, cache)
|
|
},
|
|
DisableKeepAlives: true,
|
|
},
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Sur le fil, un refus rejoué est une réponse BIEN FORMÉE : une longueur
|
|
// annoncée sans corps laisse le client attendre des octets qui ne viennent
|
|
// pas, et il échoue sur une fin de flux inattendue.
|
|
func TestLeRejeuDUnRefusEstBienFormeSurLeFil(t *testing.T) {
|
|
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
|
|
p := proxyDeTest(t)
|
|
client := clientIntercepte(t, p)
|
|
adresse := a.srv.URL + "/sonde/2.root.json"
|
|
|
|
rep, err := client.Get(adresse)
|
|
if err != nil {
|
|
t.Fatalf("en ligne : %v", err)
|
|
}
|
|
io.Copy(io.Discard, rep.Body)
|
|
rep.Body.Close()
|
|
|
|
a.couper()
|
|
rep, err = client.Get(adresse)
|
|
if err != nil {
|
|
t.Fatalf("hors ligne : %v", err)
|
|
}
|
|
defer rep.Body.Close()
|
|
corps, err := io.ReadAll(rep.Body)
|
|
if err != nil {
|
|
t.Fatalf("lecture du corps : %v", err)
|
|
}
|
|
if rep.StatusCode != http.StatusNotFound || len(corps) != 0 {
|
|
t.Errorf("reçu %d et %d octets, attendu 404 sans corps",
|
|
rep.StatusCode, len(corps))
|
|
}
|
|
}
|
|
|
|
// Hors ligne, un vrai client suit la redirection rejouée jusqu'au corps
|
|
// gardé de sa cible : c'est la chaîne entière d'un installateur publié
|
|
// derrière une redirection qui redevient servable.
|
|
func TestHorsLigneLeClientSuitLaRedirectionJusquAuCorps(t *testing.T) {
|
|
cible := nouvelAmontScripte(t, servir("echo bonjour"))
|
|
pub := nouvelAmontScripte(t, rediriger(http.StatusFound, cible.srv.URL+"/bootstrap.sh"))
|
|
p := proxyDeTest(t)
|
|
client := clientIntercepte(t, p)
|
|
|
|
lire := func(quand string) string {
|
|
t.Helper()
|
|
rep, err := client.Get(pub.srv.URL + "/installer.sh")
|
|
if err != nil {
|
|
t.Fatalf("%s : %v", quand, err)
|
|
}
|
|
defer rep.Body.Close()
|
|
corps, _ := io.ReadAll(rep.Body)
|
|
if rep.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s : code %d", quand, rep.StatusCode)
|
|
}
|
|
return string(corps)
|
|
}
|
|
if got := lire("en ligne"); got != "echo bonjour" {
|
|
t.Fatalf("en ligne : corps %q", got)
|
|
}
|
|
cible.couper()
|
|
pub.couper()
|
|
if got := lire("hors ligne"); got != "echo bonjour" {
|
|
t.Errorf("hors ligne : corps %q, attendu celui de la cible", got)
|
|
}
|
|
}
|
|
|
|
// Une redirection suivie par le cache repart SANS la condition du client :
|
|
// sinon la cible répond « 304 » et il n'y a rien à garder. Et les
|
|
// identifiants confiés à l'hôte demandé ne suivent pas vers un autre hôte.
|
|
func TestLesRedirectionsSuiviesPartentSansConditionNiIdentifiants(t *testing.T) {
|
|
contenu := strings.Repeat("charge utile", 32)
|
|
var mu sync.Mutex
|
|
var conditions, identites []string
|
|
stockage := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
conditions = append(conditions, r.Header.Get("If-None-Match"))
|
|
identites = append(identites, r.Header.Get("Authorization"))
|
|
mu.Unlock()
|
|
if r.Header.Get("If-None-Match") != "" {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
io.WriteString(w, contenu)
|
|
})
|
|
pub := nouvelAmontScripte(t,
|
|
rediriger(http.StatusFound, stockage.srv.URL+"/objet?signature=a"))
|
|
p, dir := proxyEtCasier(t)
|
|
|
|
w := joue(t, p, "GET", pub.hote(), "/paquets/outil_1.0_amd64.deb",
|
|
"If-None-Match", `"v0"`, "Authorization", "Bearer jeton-de-test")
|
|
if w.Code != http.StatusOK || w.Body.String() != contenu {
|
|
t.Fatalf("code %d, %d octets : le corps n'est pas venu", w.Code, w.Body.Len())
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(conditions) != 1 || conditions[0] != "" {
|
|
t.Errorf("la cible a reçu la condition : %q", conditions)
|
|
}
|
|
if len(identites) != 1 || identites[0] != "" {
|
|
t.Errorf("l'identifiant a suivi vers un autre hôte : %q", identites)
|
|
}
|
|
if corpsGardes(t, dir) != 1 {
|
|
t.Error("rien n'est gardé derrière la redirection")
|
|
}
|
|
}
|
|
|
|
// Sur le MÊME hôte, l'identifiant suit : c'est à lui qu'il a été confié.
|
|
func TestUneRedirectionSurLeMemeHoteGardeLIdentifiant(t *testing.T) {
|
|
var recu atomic.Value
|
|
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/a/outil_1.0_amd64.deb" {
|
|
rediriger(http.StatusFound, "/b/outil_1.0_amd64.deb")(w, r)
|
|
return
|
|
}
|
|
recu.Store(r.Header.Get("Authorization"))
|
|
io.WriteString(w, "paquet")
|
|
})
|
|
p := proxyDeTest(t)
|
|
w := joue(t, p, "GET", a.hote(), "/a/outil_1.0_amd64.deb",
|
|
"Authorization", "Bearer jeton-de-test")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code %d", w.Code)
|
|
}
|
|
if got, _ := recu.Load().(string); got != "Bearer jeton-de-test" {
|
|
t.Errorf("identifiant reçu %q sur le même hôte", got)
|
|
}
|
|
}
|