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 and layers", 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 regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(404)
315 })
316
317 t.Run("deleting a blob unlinks it from the image", func(t *testing.T) {
318 d := regUpload(t, e, "reg-repo", layerBlob)
319 regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/blobs/"+d, nil).mustStatus(202)
320 r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+d, nil).mustStatus(404)
321 if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" {
322 t.Errorf("error code = %q", code)
323 }
324 })
325
326 t.Run("a repository cannot be named v2", func(t *testing.T) {
327 r := admin.post("/new", url.Values{"name": {"v2"}, "default_branch": {"main"}}).mustStatus(200)
328 if !r.Contains("Invalid repository name") {
329 t.Error("error message missing")
330 }
331 if r.Location() != "" {
332 t.Errorf("redirected to %q", r.Location())
333 }
334 })
335
336 t.Run("blob uploads ignore the request body limit", func(t *testing.T) {
337 // MAX_UPLOAD_BYTES defaults to 10 MiB. A layer is routinely larger.
338 big := bytes.Repeat([]byte("0123456789abcdef"), 11<<20/16)
339 digest := regUpload(t, e, "reg-repo", big)
340 head := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+digest, nil).mustStatus(200)
341 if got := head.Header.Get("Content-Length"); got != strconv.Itoa(len(big)) {
342 t.Errorf("Content-Length = %q, want %d", got, len(big))
343 }
344 })
345
346 t.Run("image names are matched case-insensitively", func(t *testing.T) {
347 e.createRepo(admin, "MixedCase")
348 d := regUpload(t, e, "mixedcase", []byte("mixed"))
349 regAdmin(t, e, http.MethodHead, "/v2/mixedcase/blobs/"+d, nil).mustStatus(200)
350 if got := regTags(t, e, "mixedcase", ""); len(got) != 0 {
351 t.Errorf("tags = %v", got)
352 }
353 })
354
355 t.Run("segments that clash with the path keywords are rejected", func(t *testing.T) {
356 regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/blobs/uploads/", nil).mustStatus(404)
357 })
358}
359
360// TestRegistryPullUsers checks REGISTRY_PULL=users: any signed-in user may
361// pull a public repo's images, anonymous clients may not.
362func TestRegistryPullUsers(t *testing.T) {
363 e := newEnv(t, "REGISTRY_PULL", "users")
364 admin := e.admin()
365 e.createRepo(admin, "pub-repo")
366 e.createRepo(admin, "priv-repo", "is_private", "1")
367 e.register("alice", "password123")
368
369 _, _, manifest := pushImage(t, e, "pub-repo", "v1", "shared")
370 pushImage(t, e, "priv-repo", "v1", "shared")
371
372 t.Run("a signed-in user may pull a public image", func(t *testing.T) {
373 r := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "alice", "password123").
374 mustStatus(200)
375 if regDigest(r.Body) != manifest {
376 t.Errorf("manifest = %q", r.BodyString())
377 }
378 regReq(t, e, http.MethodGet, "/v2/", nil, "alice", "password123").mustStatus(200)
379 })
380
381 t.Run("an anonymous client is challenged", func(t *testing.T) {
382 regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(401)
383 regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(401)
384 })
385
386 t.Run("a private repository looks unknown to other users", func(t *testing.T) {
387 // Same answer as for a repo that does not exist, so the name cannot
388 // be probed through the status code.
389 r := regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "alice", "password123").
390 mustStatus(404)
391 if code := regErrCode(r); code != "NAME_UNKNOWN" {
392 t.Errorf("error code = %q, body %s", code, r.BodyString())
393 }
394 regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "alice", "password123").mustStatus(404)
395 regAdmin(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil).mustStatus(200)
396 })
397}
398
399// TestRegistryPullPublic checks REGISTRY_PULL=public: anyone may pull a
400// public repo's images without credentials.
401func TestRegistryPullPublic(t *testing.T) {
402 e := newEnv(t, "REGISTRY_PULL", "public")
403 admin := e.admin()
404 e.createRepo(admin, "pub-repo")
405 e.createRepo(admin, "priv-repo", "is_private", "1")
406
407 _, layerDigest, manifest := pushImage(t, e, "pub-repo", "v1", "shared")
408 pushImage(t, e, "priv-repo", "v1", "shared")
409
410 t.Run("the version check needs no credentials", func(t *testing.T) {
411 regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(200)
412 })
413
414 t.Run("anyone may pull a public image", func(t *testing.T) {
415 m := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(200)
416 if regDigest(m.Body) != manifest {
417 t.Errorf("manifest = %q", m.BodyString())
418 }
419 b := regReq(t, e, http.MethodGet, "/v2/pub-repo/blobs/"+layerDigest, nil, "", "").mustStatus(200)
420 if regDigest(b.Body) != layerDigest {
421 t.Errorf("blob = %q", b.BodyString())
422 }
423 })
424
425 t.Run("a private repository looks unknown to anonymous", func(t *testing.T) {
426 regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "", "").mustStatus(404)
427 regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "", "").mustStatus(404)
428 })
429
430 t.Run("pushing still needs the admin", func(t *testing.T) {
431 regReq(t, e, http.MethodPost, "/v2/pub-repo/blobs/uploads/", nil, "", "").mustStatus(401)
432 })
433}
434