// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) package main import ( "bytes" "context" "errors" "log" "net" "net/http" "net/http/cgi" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "syscall" "testing" "time" ) // refusEtablissement est l'erreur que rend un établissement refusé : un // « *net.OpError » d'opération « dial » portant ECONNREFUSED. func refusEtablissement() error { return &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{ Syscall: "connect", Err: syscall.ECONNREFUSED, }} } // Seul un échec d'ÉTABLISSEMENT fait tenir un amont pour muet. Un serveur // lent, une poignée de main manquée ou un nom inexistant disent autre chose. func TestEstEchecDEtablissement(t *testing.T) { enveloppe := func(err error) error { return &url.Error{Op: "Get", URL: "http://amont.example/x", Err: err} } muets := []error{ refusEtablissement(), enveloppe(refusEtablissement()), &net.OpError{Op: "dial", Err: os.ErrDeadlineExceeded}, &net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.EHOSTUNREACH}}, &net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.ENETUNREACH}}, } for _, err := range muets { if !estEchecDEtablissement(err) { t.Errorf("%v n'est pas reconnu comme un établissement manqué", err) } } vivants := []error{ nil, // Un serveur lent à répondre est LÀ : le tenir pour muet servirait // une copie périmée à la place d'une réponse qui venait. &net.OpError{Op: "read", Err: os.ErrDeadlineExceeded}, enveloppe(errors.New("net/http: timeout awaiting response headers")), errors.New("net/http: TLS handshake timeout"), errors.New("remote error: tls: handshake failure"), &net.OpError{Op: "dial", Err: &net.DNSError{ Err: "no such host", Name: "absent.example.invalid", IsNotFound: true, }}, &net.OpError{Op: "dial", Err: context.Canceled}, } for _, err := range vivants { if estEchecDEtablissement(err) { t.Errorf("%v pris pour un amont muet", err) } } } // La mémoire tient une fenêtre, puis rend l'amont à sa première chance. func TestLaMemoireDUnAmontMuetSEteintAvecLaFenetre(t *testing.T) { maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) j := &Joignabilite{ Fenetre: 20 * time.Second, Maintenant: func() time.Time { return maintenant }, } const a = "amont.example:443" if !j.Echec(a, refusEtablissement()) || !j.ConnuMuet(a) { t.Fatal("un établissement refusé n'est pas retenu") } maintenant = maintenant.Add(19 * time.Second) if !j.ConnuMuet(a) { t.Error("l'amont est oublié avant la fin de la fenêtre") } maintenant = maintenant.Add(time.Second) if j.ConnuMuet(a) { t.Error("l'amont reste muet au-delà de la fenêtre") } j.Echec(a, refusEtablissement()) j.Reussite(a) if j.ConnuMuet(a) { t.Error("une réussite n'efface pas l'échec") } if j.Echec(a, errors.New("net/http: timeout awaiting response headers")) || j.ConnuMuet(a) { t.Error("une erreur qui n'est pas un établissement est retenue") } var nulle *Joignabilite nulle.Echec(a, refusEtablissement()) if nulle.ConnuMuet(a) { t.Error("une mémoire nulle retient quelque chose") } } // La levée d'une coupure touche un témoin : les amonts notés muets pendant // qu'elle tenait méritent leur chance tout de suite, sans attendre la fin de // leur fenêtre. Rien d'autre ne le dit au service, qui n'a aucun canal. func TestLeTemoinDeLeveeOublieLesAmontsMuets(t *testing.T) { temoin := filepath.Join(t.TempDir(), SentinelleAmonts) maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) j := &Joignabilite{ Fenetre: 20 * time.Second, Maintenant: func() time.Time { return maintenant }, Sentinelle: temoin, } const a = "amont.example:443" j.Echec(a, refusEtablissement()) if !j.ConnuMuet(a) { t.Fatal("l'échec n'est pas retenu") } if err := os.WriteFile(temoin, nil, 0o644); err != nil { t.Fatal(err) } // Un témoin ANTÉRIEUR à l'échec ne dit rien : c'est une levée d'avant, et // l'amont a été trouvé muet depuis. vieux := maintenant.Add(-time.Hour) if err := os.Chtimes(temoin, vieux, vieux); err != nil { t.Fatal(err) } if !j.ConnuMuet(a) { t.Error("un témoin plus vieux que l'échec l'efface") } // Touché APRÈS : la coupure vient d'être levée. neuf := maintenant.Add(time.Hour) if err := os.Chtimes(temoin, neuf, neuf); err != nil { t.Fatal(err) } if j.ConnuMuet(a) { t.Error("le témoin ne rend pas sa chance à l'amont") } // Un témoin absent laisse la fenêtre faire son office, sans erreur. j2 := &Joignabilite{ Fenetre: 20 * time.Second, Maintenant: func() time.Time { return maintenant }, Sentinelle: filepath.Join(t.TempDir(), "jamais-cree"), } j2.Echec(a, refusEtablissement()) if !j2.ConnuMuet(a) { t.Error("un témoin absent efface la mémoire") } } // Un amont muet n'est pas recomposé à chaque requête : la première paie le // délai d'établissement, les suivantes vont droit au repli — et le repli // sert la copie gardée comme avant. func TestUnAmontMuetNEstPasRecomposeAChaqueRequete(t *testing.T) { a := nouvelAmontScripte(t, servir("index courant")) p := proxyDeTest(t) maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) p.Muets = &Joignabilite{Maintenant: func() time.Time { return maintenant }} var coupe atomic.Bool var composes int64 cible := a.hote() tr := p.Client.Transport.(*http.Transport) tr.Proxy = nil // Chaque requête compose : une connexion gardée ouverte depuis le // remplissage répondrait à la place de l'amont coupé. tr.DisableKeepAlives = true tr.DialContext = func(ctx context.Context, reseau, _ string) (net.Conn, error) { if coupe.Load() { atomic.AddInt64(&composes, 1) return nil, refusEtablissement() } return (&net.Dialer{}).DialContext(ctx, reseau, cible) } const hote = "miroir.example.invalid" if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code != http.StatusOK { t.Fatalf("remplissage : code %d", w.Code) } coupe.Store(true) for i := 1; i <= 3; i++ { w := joue(t, p, "GET", hote, "/depot/etat") if w.Code != http.StatusOK || w.Body.String() != "index courant" { t.Fatalf("hors ligne %d : %d %q", i, w.Code, w.Body.String()) } } if n := atomic.LoadInt64(&composes); n != 1 { t.Errorf("%d tentatives d'établissement pour trois requêtes, attendu 1", n) } // Sans copie, rien ne remplace l'amont : la requête compose malgré la // mémoire, et le 504 dit la vraie cause, pas un amont non retenté. w := joue(t, p, "GET", hote, "/jamais-vu") if w.Code != http.StatusGatewayTimeout { t.Fatalf("code %d, attendu 504", w.Code) } if strings.Contains(w.Body.String(), "aucune connexion") { t.Errorf("le message dit l'amont non retenté alors qu'il l'a été :\n%s", w.Body.String()) } if n := atomic.LoadInt64(&composes); n != 2 { t.Errorf("%d tentatives, attendu 2 : une requête sans repli n'a pas"+ " composé", n) } // La fenêtre passée, l'amont a de nouveau sa chance, copie ou non. maintenant = maintenant.Add(FenetreMuetParDefaut) joue(t, p, "GET", hote, "/depot/etat") if n := atomic.LoadInt64(&composes); n != 3 { t.Errorf("%d tentatives après la fenêtre, attendu 3", n) } } // La mémoire ne fait sauter l'amont qu'aux requêtes qui ont un repli. Ici // l'amont répond pendant tout le test : seul l'hôte est retenu comme muet, // ce que laisse un établissement manqué une fois. // // Avec un repli — corps gardé, copie du client, statut gardé —, la réponse // sort sans composer. Sans repli — rien en réserve, méthode d'écriture —, la // requête compose, et l'amont la sert : un aléa passager ne devient pas un // « 504 » pour tout ce que le magasin n'a pas. func TestUnAmontConnuMuetNEstSauteQuAvecUnRepli(t *testing.T) { a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/refus" { refuser(http.StatusNotFound)(w, r) return } servir("réponse de l'amont")(w, r) }) p := proxyDeTest(t) p.Muets = NouvelleJoignabilite() hote := a.hote() for _, c := range []string{"/depot/etat", "/refus"} { joue(t, p, "GET", hote, c) } muet := func() { p.Muets.Echec(hote, refusEtablissement()) } muet() avant := a.appels() for _, c := range []struct { chemin, issue string code int entetes []string }{ {"/depot/etat", OutcomeStale, http.StatusOK, nil}, {"/refus", OutcomeStaleStatus, http.StatusNotFound, nil}, {"/autre/etat", OutcomeKeep, http.StatusNotModified, []string{"If-None-Match", `"v1"`}}, } { w := joue(t, p, "GET", hote, c.chemin, c.entetes...) if w.Code != c.code || w.Header().Get("X-ERPLibre-Cache") != c.issue { t.Errorf("%s : %d « %s », attendu %d « %s »", c.chemin, w.Code, w.Header().Get("X-ERPLibre-Cache"), c.code, c.issue) } } if n := a.appels() - avant; n != 0 { t.Errorf("%d requête(s) vers un amont connu muet, alors qu'un repli"+ " existait", n) } w := joue(t, p, "GET", hote, "/jamais-vu") if w.Code != http.StatusOK || w.Body.String() != "réponse de l'amont" { t.Errorf("sans repli : %d %q, attendu la réponse de l'amont", w.Code, w.Body.String()) } if p.Muets.ConnuMuet(hote) { t.Error("la réponse de l'amont n'a pas effacé l'hôte de la mémoire") } muet() avant = a.appels() if w := joue(t, p, "POST", hote, "/api/envoi"); w.Code != http.StatusOK { t.Errorf("POST : code %d, attendu la réponse de l'amont", w.Code) } if n := a.appels() - avant; n != 1 { t.Errorf("POST : %d requête(s) à l'amont, attendu 1", n) } } // Un serveur lent n'est pas un serveur mort : l'attente des en-têtes échoue, // mais la requête suivante le retente. func TestUnServeurLentNEstPasTenuPourMuet(t *testing.T) { var recues int64 libere := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&recues, 1) <-libere })) t.Cleanup(srv.Close) t.Cleanup(func() { close(libere) }) p := proxyDeTest(t) p.Muets = NouvelleJoignabilite() p.Client.Transport.(*http.Transport).ResponseHeaderTimeout = 200 * time.Millisecond u, _ := url.Parse(srv.URL) for i := 0; i < 2; i++ { joue(t, p, "GET", u.Host, "/api/lente") } if n := atomic.LoadInt64(&recues); n != 2 { t.Errorf("le serveur a reçu %d requêtes, attendu 2 : un serveur lent"+ " a été tenu pour muet", n) } } // amontGitCompte monte un amont git qui compte ses requêtes, et qu'on peut // mettre en panne en cours de test. func amontGitCompte(t *testing.T) (string, *int64, *atomic.Bool) { t.Helper() depot, _, n, panne := amontGitEnPanne(t) return depot, n, panne } // amontGitEnPanne est amontGitCompte qui rend aussi le chemin du dépôt nu // servi, pour y pousser des commits en cours de test. func amontGitEnPanne(t *testing.T) (string, string, *int64, *atomic.Bool) { t.Helper() if TrouverBackend() == "" { t.Skip("git-http-backend absent de cette machine") } nu := depotDEssai(t) racine := filepath.Dir(nu) h := &cgi.Handler{ Path: TrouverBackend(), Dir: racine, Env: []string{ "GIT_PROJECT_ROOT=" + racine, "GIT_HTTP_EXPORT_ALL=1", }, InheritEnv: []string{"PATH"}, } var n int64 var panne atomic.Bool srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&n, 1) if panne.Load() { http.Error(w, "panne", http.StatusInternalServerError) return } h.ServeHTTP(w, r) })) t.Cleanup(srv.Close) return srv.URL + "/" + filepath.Base(nu), nu, &n, &panne } // Un amont connu muet ne fait pas payer au miroir le délai d'une mise à jour // vouée à l'échec : le miroir existant sert tel quel. func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) { depot, appels, _ := amontGitCompte(t) g := &GitMirror{ PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(), } if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir n'a pas pu être créé") } avant := atomic.LoadInt64(appels) g.Muets.Echec(hoteDe(t, depot), refusEtablissement()) g.Frais = 0 if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir existant n'est pas servi") } if n := atomic.LoadInt64(appels); n != avant { t.Errorf("%d requête(s) vers un amont connu muet", n-avant) } } // Une sonde refusée retient l'hôte et sert le miroir sans le rafraîchir ; le // dépôt suivant du même hôte ne sonde même plus. func TestUneSondeRefuseeRetientLHoteEtSertLeMiroir(t *testing.T) { depot, appels, _ := amontGitCompte(t) g := &GitMirror{ PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(), } if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir n'a pas pu être créé") } avant := atomic.LoadInt64(appels) var sondes int64 g.Sonder = func(context.Context, string) error { atomic.AddInt64(&sondes, 1) return refusEtablissement() } g.Frais = 0 for i := 0; i < 2; i++ { if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir existant n'est pas servi") } } if n := atomic.LoadInt64(appels); n != avant { t.Errorf("%d requête(s) vers un amont qui refuse la sonde", n-avant) } if n := atomic.LoadInt64(&sondes); n != 1 { t.Errorf("%d sondes, attendu 1 : l'échec n'a pas été retenu", n) } if !g.Muets.ConnuMuet(hoteDe(t, depot)) { t.Error("l'hôte n'est pas retenu comme muet") } } // sansMandataire : aucune URL ne sort par un mandataire, quel que soit // l'environnement du test. func sansMandataire(*http.Request) (*url.URL, error) { return nil, nil } // miroirCree tient un miroir du dépôt dans un répertoire neuf et le rend. func miroirCree(t *testing.T, depot string) string { t.Helper() dir := t.TempDir() creation := &GitMirror{ PlancherLibre: plancherDEssai, Dir: dir, Delai: 30 * time.Second} if _, pret := creation.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir n'a pas pu être créé") } return dir } // Une mise à jour manquée sur un amont qui accepte la connexion — un 5xx, une // poignée de main coupée — n'est pas notée : la requête suivante retente, et // l'hôte n'est pas tenu pour muet. func TestUnRafraichissementManqueSurUnAmontJoignableEstRetente(t *testing.T) { depot, appels, panne := amontGitCompte(t) g := &GitMirror{ PlancherLibre: plancherDEssai, Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, Muets: NouvelleJoignabilite(), Sonder: func(context.Context, string) error { return nil }, } panne.Store(true) g.Assurer(context.Background(), depot) avant := atomic.LoadInt64(appels) if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir existant n'est pas servi") } if n := atomic.LoadInt64(appels); n == avant { t.Error("la mise à jour manquée sur un amont joignable n'est pas" + " retentée : elle a été notée comme un rafraîchissement") } if g.Muets.ConnuMuet(hoteDe(t, depot)) { t.Error("un 500 a fait tenir l'hôte pour muet") } } // Une mise à jour manquée sur un amont qui refuse désormais la connexion est // notée, et l'hôte retenu : pendant « Frais », le dépôt ne la retente pas. func TestUnRafraichissementManqueSurUnAmontQuiRefuseEstNote(t *testing.T) { depot, appels, panne := amontGitCompte(t) var sondes int64 g := &GitMirror{ PlancherLibre: plancherDEssai, Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, Muets: NouvelleJoignabilite(), // La sonde d'avant la mise à jour passe ; celle d'après trouve // l'amont tombé entre les deux. Sonder: func(context.Context, string) error { if atomic.AddInt64(&sondes, 1) == 1 { return nil } return refusEtablissement() }, } panne.Store(true) chemin, _ := g.Assurer(context.Background(), depot) if n := atomic.LoadInt64(&sondes); n != 2 { t.Errorf("%d sonde(s), attendu 2 : l'échec de la mise à jour n'a pas"+ " été sondé", n) } if !g.recent(chemin) { t.Error("la mise à jour manquée sur un amont qui refuse n'est pas notée") } if !g.Muets.ConnuMuet(hoteDe(t, depot)) { t.Error("l'hôte qui refuse n'est pas retenu comme muet") } avant := atomic.LoadInt64(appels) if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir existant n'est pas servi") } if n := atomic.LoadInt64(appels); n != avant { t.Errorf("%d requête(s) dans la fraîcheur d'un amont qui refuse", n-avant) } } // pousserUnCommit ajoute un commit au dépôt nu que sert l'amont et rend son // empreinte. Le dépôt de travail est celui que depotDEssai laisse à côté. func pousserUnCommit(t *testing.T, nu string) string { t.Helper() travail := filepath.Join(filepath.Dir(nu), "travail") lancer := func(args ...string) string { t.Helper() c := exec.Command("git", args...) c.Dir = travail c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") out, err := c.CombinedOutput() if err != nil { t.Fatalf("git %v : %v : %s", args, err, out) } return strings.TrimSpace(string(out)) } if err := os.WriteFile( filepath.Join(travail, "g.txt"), []byte("suite"), 0o644, ); err != nil { t.Fatal(err) } lancer("add", "g.txt") lancer("commit", "-qm", "second") lancer("push", "-q", nu, "HEAD:refs/heads/main") return lancer("rev-parse", "HEAD") } // Un 500 de la forge ne fige pas le miroir : l'amont publie un commit juste // après, et la requête suivante le voit, sans attendre la fin de « Frais ». // La sonde est la vraie, vers l'amont local qui accepte la connexion. func TestUnRafraichissementEn500NeFigePasLeMiroir(t *testing.T) { depot, nu, _, panne := amontGitEnPanne(t) g := &GitMirror{ PlancherLibre: plancherDEssai, Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, Muets: NouvelleJoignabilite(), Mandataire: sansMandataire, } panne.Store(true) if _, pret := g.Assurer(context.Background(), depot); !pret { t.Fatal("le miroir existant n'est pas servi pendant la panne") } panne.Store(false) attendu := pousserUnCommit(t, nu) chemin, pret := g.Assurer(context.Background(), depot) if !pret { t.Fatal("le miroir n'est pas servi") } out, err := exec.Command( "git", "--git-dir", chemin, "rev-parse", "refs/heads/main", ).CombinedOutput() if err != nil { t.Fatalf("rev-parse : %v : %s", err, out) } if got := strings.TrimSpace(string(out)); got != attendu { t.Errorf("le miroir sert %s, attendu %s : il est resté figé après"+ " le 500", got, attendu) } } // Quand un mandataire porte l'URL du dépôt, aucune sonde directe : git passe // par lui, et la connexion directe, refusée sur un hôte dont c'est la seule // sortie, figerait le miroir pour toujours. func TestUnMandataireDispenseDeLaSonde(t *testing.T) { var sondes int64 g := &GitMirror{ Sonder: func(context.Context, string) error { atomic.AddInt64(&sondes, 1) return refusEtablissement() }, Mandataire: func(*http.Request) (*url.URL, error) { return url.Parse("http://mandataire.example:3128") }, } const depot = "https://forge.example/o/d.git" ctx := context.Background() if !g.amontJoignable(ctx, depot) || g.sondeRefusee(ctx, depot) { t.Error("l'amont derrière un mandataire est tenu pour injoignable") } if n := atomic.LoadInt64(&sondes); n != 0 { t.Errorf("%d sonde(s) directe(s) malgré le mandataire", n) } g.Mandataire = sansMandataire if g.amontJoignable(ctx, depot) { t.Error("sans mandataire, une sonde refusée laisse l'amont joignable") } if n := atomic.LoadInt64(&sondes); n != 1 { t.Errorf("%d sonde(s) sans mandataire, attendu 1", n) } } // Une sonde manquée se dit au journal UNE fois par panne et par hôte : un // miroir servi sans rafraîchissement doit se voir, sans une ligne par dépôt. // La réponse de l'hôte clôt la panne ; la suivante se dit de nouveau. func TestUneSondeManqueeSeDitUneFoisParPanne(t *testing.T) { var journal bytes.Buffer avant := log.Writer() log.SetOutput(&journal) t.Cleanup(func() { log.SetOutput(avant) }) var enPanne atomic.Bool enPanne.Store(true) g := &GitMirror{ Mandataire: sansMandataire, Sonder: func(context.Context, string) error { if enPanne.Load() { return refusEtablissement() } return nil }, } const depot = "https://forge.example/o/d.git" const adresse = "forge.example:443" ctx := context.Background() for i := 0; i < 3; i++ { g.amontJoignable(ctx, depot) } if n := strings.Count(journal.String(), adresse); n != 1 { t.Errorf("%d ligne(s) pour trois sondes manquées, attendu 1 :\n%s", n, journal.String()) } enPanne.Store(false) g.amontJoignable(ctx, depot) enPanne.Store(true) g.amontJoignable(ctx, depot) if n := strings.Count(journal.String(), adresse); n != 2 { t.Errorf("%d ligne(s) après une seconde panne, attendu 2", n) } }