v0.6.0 aba298d

upstream_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6	"fmt"
7	"io"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"path/filepath"
12	"strings"
13	"sync/atomic"
14	"testing"
15	"time"
16)
17
18// A blob body has no total deadline: a transfer that keeps moving may last
19// far longer than RequestTimeout and BlobIdleTimeout.
20func TestBlobTransferOutlivesRequestTimeout(t *testing.T) {
21	body := strings.Repeat("layer", 4000)
22	srv := upstreamWith(t, slowBlob(body, 20, 25*time.Millisecond))
23	p := proxyWith(t, srv, Config{RequestTimeout: 100 * time.Millisecond, BlobIdleTimeout: 150 * time.Millisecond})
24	start := time.Now()
25	w := request(t, p, http.MethodGet, "/v2/library/alpine/blobs/"+sum(body))
26	if w.Code != http.StatusOK || w.Body.String() != body {
27		t.Fatalf("blob: %d", w.Code)
28	}
29	if d := time.Since(start); d < 300*time.Millisecond {
30		t.Fatalf("transfer took %v, not longer than the timeouts", d)
31	}
32}
33
34// A blob body that stops moving is cut after BlobIdleTimeout, and auth,
35// manifests and HEAD keep their total RequestTimeout.
36func TestStalledUpstreamTimesOut(t *testing.T) {
37	body := strings.Repeat("layer", 4000)
38	srv := upstreamWith(t, func(w http.ResponseWriter, r *http.Request) {
39		if strings.Contains(r.URL.Path, "/blobs/") && r.Method == http.MethodGet {
40			w.Header().Set("Content-Length", fmt.Sprint(len(body)))
41			_, _ = io.WriteString(w, body[:100])
42			w.(http.Flusher).Flush()
43		}
44		select { // a stalled body, or headers that never come
45		case <-r.Context().Done():
46		case <-time.After(5 * time.Second):
47		}
48	})
49	p := proxyWith(t, srv, Config{RequestTimeout: 100 * time.Millisecond, BlobIdleTimeout: 150 * time.Millisecond})
50	for _, tc := range []struct{ method, path string }{
51		{http.MethodGet, "/v2/library/alpine/blobs/" + sum(body)},
52		{http.MethodHead, "/v2/library/alpine/blobs/" + sum(body)},
53		{http.MethodGet, "/v2/library/alpine/manifests/3"},
54	} {
55		start := time.Now()
56		if w := request(t, p, tc.method, tc.path); w.Code != http.StatusBadGateway {
57			t.Errorf("%s %s: %d", tc.method, tc.path, w.Code)
58		}
59		if d := time.Since(start); d > 2*time.Second {
60			t.Errorf("%s %s: cut after %v", tc.method, tc.path, d)
61		}
62	}
63	if _, err := os.Stat(filepath.Join(p.s.dir, kindBlob.key(sum(body)[7:]))); !os.IsNotExist(err) {
64		t.Fatal("partial blob stored")
65	}
66}
67
68// Auth and manifest requests never follow a redirect.
69func TestMetadataRedirectsNotFollowed(t *testing.T) {
70	var elsewhere atomic.Int32
71	other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72		elsewhere.Add(1)
73		_, _ = io.WriteString(w, `{"token":"public"}`)
74	}))
75	t.Cleanup(other.Close)
76	for _, redirectAuth := range []bool{true, false} {
77		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78			if r.URL.Path == "/token" && !redirectAuth {
79				_, _ = io.WriteString(w, `{"token":"public"}`)
80				return
81			}
82			http.Redirect(w, r, other.URL+r.URL.Path, http.StatusTemporaryRedirect)
83		}))
84		p := proxyWith(t, srv, Config{})
85		for _, ref := range []string{"3", sum(manifestBody)} {
86			if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/"+ref); w.Code != http.StatusBadGateway {
87				t.Errorf("auth redirect %v, ref %s: %d", redirectAuth, ref, w.Code)
88			}
89		}
90		srv.Close()
91	}
92	if elsewhere.Load() != 0 {
93		t.Fatalf("%d redirects followed", elsewhere.Load())
94	}
95}
96
97// Blob requests, HEAD included, follow the CDN redirect without the bearer
98// token.
99func TestBlobRedirectStripsBearer(t *testing.T) {
100	var cdnHits atomic.Int32
101	cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102		cdnHits.Add(1)
103		if r.Header.Get("Authorization") != "" {
104			t.Error("bearer sent to the CDN")
105		}
106		w.Header().Set("Content-Length", fmt.Sprint(len(blobBody)))
107		if r.Method == http.MethodGet {
108			_, _ = io.WriteString(w, blobBody)
109		}
110	}))
111	t.Cleanup(cdn.Close)
112	srv := upstreamWith(t, func(w http.ResponseWriter, r *http.Request) {
113		if r.Header.Get("Authorization") != "Bearer public" {
114			t.Error("no bearer on the registry")
115		}
116		http.Redirect(w, r, cdn.URL+"/layer", http.StatusTemporaryRedirect)
117	})
118	p := proxyWith(t, srv, Config{})
119	path := "/v2/library/alpine/blobs/" + sum(blobBody)
120	if w := request(t, p, http.MethodHead, path); w.Code != http.StatusOK || w.Header().Get("Content-Length") != fmt.Sprint(len(blobBody)) {
121		t.Fatalf("HEAD: %d %v", w.Code, w.Header())
122	}
123	if w := request(t, p, http.MethodGet, path); w.Code != http.StatusOK || w.Body.String() != blobBody {
124		t.Fatalf("blob: %d", w.Code)
125	}
126	if cdnHits.Load() != 2 {
127		t.Fatalf("CDN hits %d", cdnHits.Load())
128	}
129}
130
131// A 401 or 403 is retried once with a fresh token; a second one is a 404, so
132// that dockerd falls back to Docker Hub with the job's credentials.
133func TestUnauthorizedRetriedOnce(t *testing.T) {
134	for _, path := range []string{"/v2/library/alpine/manifests/3", "/v2/library/alpine/blobs/" + sum(blobBody)} {
135		f := fake(t)
136		p := proxyFor(t, f)
137		f.status.Store(http.StatusUnauthorized)
138		if w := request(t, p, http.MethodGet, path); w.Code != http.StatusNotFound {
139			t.Fatalf("%s: %d", path, w.Code)
140		}
141		if f.tokens.Load() != 2 {
142			t.Fatalf("%s: %d token requests, want a fresh one", path, f.tokens.Load())
143		}
144	}
145}
146
147// When upstream is down, fails or rate-limits (429), a known tag is served
148// stale; an unknown one is an error.
149func TestStaleTagWhenUpstreamUnavailable(t *testing.T) {
150	for _, tc := range []struct {
151		name   string
152		status int32 // 0: the server is closed
153	}{{"rate limited", http.StatusTooManyRequests}, {"failing", http.StatusServiceUnavailable}, {"down", 0}} {
154		t.Run(tc.name, func(t *testing.T) {
155			f := fake(t)
156			p := proxyFor(t, f)
157			if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/3"); w.Code != http.StatusOK {
158				t.Fatal(w.Code)
159			}
160			if tc.status == 0 {
161				f.server.Close()
162			}
163			f.status.Store(tc.status)
164			if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/3"); w.Code != http.StatusOK || w.Body.String() != manifestBody {
165				t.Fatalf("stale: %d %s", w.Code, w.Body)
166			}
167			if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/unknown"); w.Code != http.StatusBadGateway {
168				t.Fatalf("unknown: %d", w.Code)
169			}
170		})
171	}
172}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close