[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
// © 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"
|
[FIX] hors ligne : HEAD servi, attente NTP levée, étape nommée
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
2026-09-16 03:00:57 -04:00
|
|
|
"strconv"
|
[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[FIX] hors ligne : HEAD servi, attente NTP levée, étape nommée
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
2026-09-16 03:00:57 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|