registry_test.go
⎇
Raw
1package e2e
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "testing"
14
15 "hearthforge/internal/db"
16)
17
18// The registry suite drives the OCI Distribution endpoints under /v2/.
19// Registry clients use HTTP Basic auth, not the session cookie, so these
20// tests build their own requests instead of using a session.
21
22const (
23 ociManifestType = "application/vnd.oci.image.manifest.v1+json"
24 registryRealm = `Basic realm="Hearthforge registry"`
25)
26
27// regReq sends one registry request and reads the whole response. An empty
28// user sends no Authorization header. Redirects are not followed.
29func regReq(t *testing.T, e *env, method, path string, body []byte, user, pass string, headers ...string) *response {
30 t.Helper()
31 var rd io.Reader
32 if body != nil {
33 rd = bytes.NewReader(body)
34 }
35 req, err := http.NewRequest(method, e.Base+path, rd)
36 if err != nil {
37 t.Fatal(err)
38 }
39 if user != "" {
40 req.SetBasicAuth(user, pass)
41 }
42 for i := 0; i+1 < len(headers); i += 2 {
43 req.Header.Set(headers[i], headers[i+1])
44 }
45 res, err := e.anon().client.Do(req)
46 if err != nil {
47 t.Fatalf("%s %s: %v", method, path, err)
48 }
49 defer res.Body.Close()
50 data, err := io.ReadAll(res.Body)
51 if err != nil {
52 t.Fatal(err)
53 }
54 return &response{t: t, Code: res.StatusCode, Header: res.Header, Body: data}
55}
56
57// regAdmin sends a request signed in as the admin.
58func regAdmin(t *testing.T, e *env, method, path string, body []byte, headers ...string) *response {
59 t.Helper()
60 return regReq(t, e, method, path, body, db.AdminUsername, adminPass, headers...)
61}
62
63// regDigest is the content digest the registry expects.
64func regDigest(b []byte) string {
65 sum := sha256.Sum256(b)
66 return "sha256:" + hex.EncodeToString(sum[:])
67}
68
69// regErrCode returns the first error code of a registry error envelope.
70func regErrCode(r *response) string {
71 var body struct {
72 Errors []struct{ Code string } `json:"errors"`
73 }
74 if err := json.Unmarshal(r.Body, &body); err != nil || len(body.Errors) == 0 {
75 return ""
76 }
77 return body.Errors[0].Code
78}
79
80// regUpload pushes one blob to an image in a single request.
81func regUpload(t *testing.T, e *env, image string, data []byte) string {
82 t.Helper()
83 d := regDigest(data)
84 regAdmin(t, e, http.MethodPost, "/v2/"+image+"/blobs/uploads/?digest="+d, data).mustStatus(201)
85 return d
86}
87
88// pushImage uploads two blobs and a manifest under image:tag. It returns the
89// digests of the config blob, layer blob and manifest.
90func pushImage(t *testing.T, e *env, image, tag, seed string) (config, layer, manifest string) {
91 t.Helper()
92 cfg := []byte(`{"cfg":"` + seed + `"}`)
93 lay := []byte("layer-" + seed)
94 config = regUpload(t, e, image, cfg)
95 layer = regUpload(t, e, image, lay)
96 body := ociManifest(config, len(cfg), layer, len(lay))
97 regAdmin(t, e, http.MethodPut, "/v2/"+image+"/manifests/"+tag, body,
98 "Content-Type", ociManifestType).mustStatus(201)
99 return config, layer, regDigest(body)
100}
101
102// regTags reads the tag list. query is appended to the URL, e.g. "?n=1".
103func regTags(t *testing.T, e *env, image, query string) []string {
104 t.Helper()
105 r := regAdmin(t, e, http.MethodGet, "/v2/"+image+"/tags/list"+query, nil).mustStatus(200)
106 var body struct {
107 Name string `json:"name"`
108 Tags []string `json:"tags"`
109 }
110 if err := json.Unmarshal(r.Body, &body); err != nil {
111 t.Fatalf("tags list %q: %v (%s)", image, err, r.BodyString())
112 }
113 if body.Name != image {
114 t.Errorf("tags list name = %q, want %q", body.Name, image)
115 }
116 return body.Tags
117}
118
119// ociManifest builds a minimal image manifest pointing at two blobs.
120func ociManifest(config string, configSize int, layer string, layerSize int) []byte {
121 return fmt.Appendf(nil,
122 `{"schemaVersion":2,"mediaType":%q,`+
123 `"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":%d},`+
124 `"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":%q,"size":%d}]}`,
125 ociManifestType, config, configSize, layer, layerSize)
126}
127
128func TestRegistry(t *testing.T) {
129 e := newEnv(t)
130 admin := e.admin()
131 e.createRepo(admin, "reg-repo")
132 e.register("alice", "password123")
133
134 configBlob := []byte(`{"architecture":"amd64","os":"linux"}`)
135 layerBlob := []byte("layer-bytes-0123456789")
136 configDigest := regDigest(configBlob)
137 layerDigest := regDigest(layerBlob)
138 manifest := ociManifest(configDigest, len(configBlob), layerDigest, len(layerBlob))
139 manifestDigest := regDigest(manifest)
140
141 t.Run("version check challenges an anonymous client", func(t *testing.T) {
142 r := regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(401)
143 if got := r.Header.Get("WWW-Authenticate"); got != registryRealm {
144 t.Errorf("WWW-Authenticate = %q, want %q", got, registryRealm)
145 }
146 if got := r.Header.Get("Docker-Distribution-API-Version"); got != "registry/2.0" {
147 t.Errorf("API version header = %q", got)
148 }
149 if code := regErrCode(r); code != "UNAUTHORIZED" {
150 t.Errorf("error code = %q, body %s", code, r.BodyString())
151 }
152 })
153
154 t.Run("version check succeeds for the admin", func(t *testing.T) {
155 regAdmin(t, e, http.MethodGet, "/v2/", nil).mustStatus(200)
156 })
157
158 t.Run("a chunked upload stores a blob", func(t *testing.T) {
159 start := regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil).mustStatus(202)
160 loc := start.Header.Get("Location")
161 if loc == "" || start.Header.Get("Docker-Upload-UUID") == "" {
162 t.Fatalf("Location = %q, UUID = %q", loc, start.Header.Get("Docker-Upload-UUID"))
163 }
164 patch := regAdmin(t, e, http.MethodPatch, loc, configBlob).mustStatus(202)
165 wantRange := "0-" + strconv.Itoa(len(configBlob)-1)
166 if got := patch.Header.Get("Range"); got != wantRange {
167 t.Errorf("Range = %q, want %q", got, wantRange)
168 }
169 done := regAdmin(t, e, http.MethodPut, loc+"?digest="+configDigest, nil).mustStatus(201)
170 if got := done.Header.Get("Docker-Content-Digest"); got != configDigest {
171 t.Errorf("Docker-Content-Digest = %q, want %q", got, configDigest)
172 }
173
174 head := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+configDigest, nil).mustStatus(200)
175 if got := head.Header.Get("Content-Length"); got != strconv.Itoa(len(configBlob)) {
176 t.Errorf("Content-Length = %q, want %d", got, len(configBlob))
177 }
178 if got := head.Header.Get("Docker-Content-Digest"); got != configDigest {
179 t.Errorf("Docker-Content-Digest = %q", got)
180 }
181 })
182
183 t.Run("a monolithic upload stores a blob", func(t *testing.T) {
184 if got := regUpload(t, e, "reg-repo", layerBlob); got != layerDigest {
185 t.Fatalf("digest = %q", got)
186 }
187 r := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(200)
188 if !bytes.Equal(r.Body, layerBlob) {
189 t.Errorf("blob body = %q", r.BodyString())
190 }
191 })
192
193 t.Run("a manifest is readable by tag and by digest", func(t *testing.T) {
194 put := regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/v1", manifest,
195 "Content-Type", ociManifestType).mustStatus(201)
196 if got := put.Header.Get("Docker-Content-Digest"); got != manifestDigest {
197 t.Errorf("Docker-Content-Digest = %q, want %q", got, manifestDigest)
198 }
199
200 byTag := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil).mustStatus(200)
201 if !bytes.Equal(byTag.Body, manifest) {
202 t.Errorf("manifest body = %q", byTag.BodyString())
203 }
204 if got := byTag.Header.Get("Content-Type"); got != ociManifestType {
205 t.Errorf("Content-Type = %q, want %q", got, ociManifestType)
206 }
207 if got := byTag.Header.Get("Docker-Content-Digest"); got != manifestDigest {
208 t.Errorf("Docker-Content-Digest = %q, want %q", got, manifestDigest)
209 }
210 byDigest := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/"+manifestDigest, nil).mustStatus(200)
211 if !bytes.Equal(byDigest.Body, manifest) {
212 t.Errorf("manifest by digest = %q", byDigest.BodyString())
213 }
214 regAdmin(t, e, http.MethodHead, "/v2/reg-repo/manifests/v1", nil).mustStatus(200)
215 regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/nosuchtag", nil).mustStatus(404)
216 })
217
218 t.Run("the tag list is sorted and paginated", func(t *testing.T) {
219 if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"v1"}) {
220 t.Fatalf("tags = %v", got)
221 }
222 regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/latest", manifest,
223 "Content-Type", ociManifestType).mustStatus(201)
224 if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"latest", "v1"}) {
225 t.Errorf("tags = %v", got)
226 }
227 if got := regTags(t, e, "reg-repo", "?n=1"); !eqStrings(got, []string{"latest"}) {
228 t.Errorf("tags?n=1 = %v", got)
229 }
230 if got := regTags(t, e, "reg-repo", "?last=latest"); !eqStrings(got, []string{"v1"}) {
231 t.Errorf("tags?last=latest = %v", got)
232 }
233 })
234
235 t.Run("a manifest referencing an unknown blob is rejected", func(t *testing.T) {
236 missing := regDigest([]byte("never uploaded"))
237 bad := ociManifest(configDigest, len(configBlob), missing, 14)
238 r := regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/broken", bad,
239 "Content-Type", ociManifestType).mustStatus(400)
240 if code := regErrCode(r); code != "MANIFEST_BLOB_UNKNOWN" {
241 t.Errorf("error code = %q, body %s", code, r.BodyString())
242 }
243 regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/broken", nil).mustStatus(404)
244 })
245
246 t.Run("a wrong digest discards the upload", func(t *testing.T) {
247 data := []byte("content that does not match")
248 claimed := regDigest([]byte("something else"))
249 loc := regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil).
250 mustStatus(202).Header.Get("Location")
251 regAdmin(t, e, http.MethodPatch, loc, data).mustStatus(202)
252 r := regAdmin(t, e, http.MethodPut, loc+"?digest="+claimed, nil).mustStatus(400)
253 if code := regErrCode(r); code != "DIGEST_INVALID" {
254 t.Errorf("error code = %q, body %s", code, r.BodyString())
255 }
256 regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+claimed, nil).mustStatus(404)
257 regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+regDigest(data), nil).mustStatus(404)
258 })
259
260 t.Run("a sub-path image keeps its own blobs", func(t *testing.T) {
261 r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/frontend/blobs/"+configDigest, nil).mustStatus(404)
262 if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" {
263 t.Errorf("error code = %q", code)
264 }
265 regUpload(t, e, "reg-repo/frontend", configBlob)
266 regAdmin(t, e, http.MethodHead, "/v2/reg-repo/frontend/blobs/"+configDigest, nil).mustStatus(200)
267 if got := regTags(t, e, "reg-repo/frontend", ""); len(got) != 0 {
268 t.Errorf("sub-image tags = %v", got)
269 }
270 })
271
272 t.Run("an unknown repository is not found", func(t *testing.T) {
273 r := regAdmin(t, e, http.MethodGet, "/v2/nope/tags/list", nil).mustStatus(404)
274 if code := regErrCode(r); code != "NAME_UNKNOWN" {
275 t.Errorf("error code = %q, body %s", code, r.BodyString())
276 }
277 })
278
279 t.Run("only the admin may push", func(t *testing.T) {
280 r := regReq(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil, "alice", "password123").
281 mustStatus(403)
282 if code := regErrCode(r); code != "DENIED" {
283 t.Errorf("error code = %q, body %s", code, r.BodyString())
284 }
285 regReq(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil, "", "").mustStatus(401)
286 })
287
288 t.Run("pull is admin only by default", func(t *testing.T) {
289 r := regReq(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil, "alice", "password123").
290 mustStatus(403)
291 if code := regErrCode(r); code != "DENIED" {
292 t.Errorf("error code = %q, body %s", code, r.BodyString())
293 }
294 anon := regReq(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil, "", "").mustStatus(401)
295 if got := anon.Header.Get("WWW-Authenticate"); got != registryRealm {
296 t.Errorf("WWW-Authenticate = %q", got)
297 }
298 })
299
300 t.Run("deleting a tag keeps the other tags", func(t *testing.T) {
301 regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/latest", nil).mustStatus(202)
302 if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"v1"}) {
303 t.Errorf("tags = %v", got)
304 }
305 regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/latest", nil).mustStatus(404)
306 })
307
308 t.Run("deleting a manifest by digest drops its tags", func(t *testing.T) {
309 regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/"+manifestDigest, nil).mustStatus(202)
310 regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil).mustStatus(404)
311 if got := regTags(t, e, "reg-repo", ""); len(got) != 0 {
312 t.Errorf("tags = %v", got)
313 }
314 })
315
316 t.Run("deleting a blob unlinks it from the image", func(t *testing.T) {
317 regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(202)
318 r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(404)
319 if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" {
320 t.Errorf("error code = %q", code)
321 }
322 })
323
324 t.Run("a repository cannot be named v2", func(t *testing.T) {
325 r := admin.post("/new", url.Values{"name": {"v2"}, "default_branch": {"main"}}).mustStatus(200)
326 if !r.Contains("Invalid repository name") {
327 t.Error("error message missing")
328 }
329 if r.Location() != "" {
330 t.Errorf("redirected to %q", r.Location())
331 }
332 })
333
334 t.Run("blob uploads ignore the request body limit", func(t *testing.T) {
335 // MAX_UPLOAD_BYTES defaults to 10 MiB. A layer is routinely larger.
336 big := bytes.Repeat([]byte("0123456789abcdef"), 11<<20/16)
337 digest := regUpload(t, e, "reg-repo", big)
338 head := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+digest, nil).mustStatus(200)
339 if got := head.Header.Get("Content-Length"); got != strconv.Itoa(len(big)) {
340 t.Errorf("Content-Length = %q, want %d", got, len(big))
341 }
342 })
343
344 t.Run("image names are matched case-insensitively", func(t *testing.T) {
345 e.createRepo(admin, "MixedCase")
346 d := regUpload(t, e, "mixedcase", []byte("mixed"))
347 regAdmin(t, e, http.MethodHead, "/v2/mixedcase/blobs/"+d, nil).mustStatus(200)
348 if got := regTags(t, e, "mixedcase", ""); len(got) != 0 {
349 t.Errorf("tags = %v", got)
350 }
351 })
352
353 t.Run("segments that clash with the path keywords are rejected", func(t *testing.T) {
354 regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/blobs/uploads/", nil).mustStatus(404)
355 })
356}
357
358// TestRegistryPullUsers checks REGISTRY_PULL=users: any signed-in user may
359// pull a public repo's images, anonymous clients may not.
360func TestRegistryPullUsers(t *testing.T) {
361 e := newEnv(t, "REGISTRY_PULL", "users")
362 admin := e.admin()
363 e.createRepo(admin, "pub-repo")
364 e.createRepo(admin, "priv-repo", "is_private", "1")
365 e.register("alice", "password123")
366
367 _, _, manifest := pushImage(t, e, "pub-repo", "v1", "shared")
368 pushImage(t, e, "priv-repo", "v1", "shared")
369
370 t.Run("a signed-in user may pull a public image", func(t *testing.T) {
371 r := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "alice", "password123").
372 mustStatus(200)
373 if regDigest(r.Body) != manifest {
374 t.Errorf("manifest = %q", r.BodyString())
375 }
376 regReq(t, e, http.MethodGet, "/v2/", nil, "alice", "password123").mustStatus(200)
377 })
378
379 t.Run("an anonymous client is challenged", func(t *testing.T) {
380 regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(401)
381 regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(401)
382 })
383
384 t.Run("a private repository looks unknown to other users", func(t *testing.T) {
385 // Same answer as for a repo that does not exist, so the name cannot
386 // be probed through the status code.
387 r := regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "alice", "password123").
388 mustStatus(404)
389 if code := regErrCode(r); code != "NAME_UNKNOWN" {
390 t.Errorf("error code = %q, body %s", code, r.BodyString())
391 }
392 regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "alice", "password123").mustStatus(404)
393 regAdmin(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil).mustStatus(200)
394 })
395}
396
397// TestRegistryPullPublic checks REGISTRY_PULL=public: anyone may pull a
398// public repo's images without credentials.
399func TestRegistryPullPublic(t *testing.T) {
400 e := newEnv(t, "REGISTRY_PULL", "public")
401 admin := e.admin()
402 e.createRepo(admin, "pub-repo")
403 e.createRepo(admin, "priv-repo", "is_private", "1")
404
405 _, layerDigest, manifest := pushImage(t, e, "pub-repo", "v1", "shared")
406 pushImage(t, e, "priv-repo", "v1", "shared")
407
408 t.Run("the version check needs no credentials", func(t *testing.T) {
409 regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(200)
410 })
411
412 t.Run("anyone may pull a public image", func(t *testing.T) {
413 m := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(200)
414 if regDigest(m.Body) != manifest {
415 t.Errorf("manifest = %q", m.BodyString())
416 }
417 b := regReq(t, e, http.MethodGet, "/v2/pub-repo/blobs/"+layerDigest, nil, "", "").mustStatus(200)
418 if regDigest(b.Body) != layerDigest {
419 t.Errorf("blob = %q", b.BodyString())
420 }
421 })
422
423 t.Run("a private repository looks unknown to anonymous", func(t *testing.T) {
424 regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "", "").mustStatus(404)
425 regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "", "").mustStatus(404)
426 })
427
428 t.Run("pushing still needs the admin", func(t *testing.T) {
429 regReq(t, e, http.MethodPost, "/v2/pub-repo/blobs/uploads/", nil, "", "").mustStatus(401)
430 })
431}
432