cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociserver/registry_test.go (about)

     1  // Copyright 2018 Google LLC All Rights Reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //    http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ociserver_test
    16  
    17  import (
    18  	"fmt"
    19  	"io"
    20  	"net/http"
    21  	"net/http/httptest"
    22  	"strings"
    23  	"testing"
    24  
    25  	"cuelabs.dev/go/oci/ociregistry/ocimem"
    26  	"cuelabs.dev/go/oci/ociregistry/ociserver"
    27  	"github.com/go-quicktest/qt"
    28  	"github.com/opencontainers/go-digest"
    29  )
    30  
    31  const (
    32  	weirdIndex = `{
    33    "manifests": [
    34  	  {
    35  	  		"size": 3,
    36  			"digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
    37  			"mediaType":"application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"
    38  		},{
    39  	  		"size": 3,
    40  			"digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
    41  			"mediaType":"application/xml"
    42  		},{
    43  	  		"size": 3,
    44  			"digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
    45  			"mediaType":"application/vnd.oci.image.manifest.v1+json"
    46  		}
    47  	]
    48  }`
    49  )
    50  
    51  func TestCalls(t *testing.T) {
    52  	tcs := []struct {
    53  		skip bool
    54  
    55  		Description string
    56  
    57  		// Request / setup
    58  		Method        string
    59  		Body          string // request body to send
    60  		URL           string
    61  		Digests       map[string]string
    62  		Manifests     map[string]string
    63  		BlobStream    map[string]string
    64  		RequestHeader map[string]string
    65  
    66  		// Response
    67  		WantCode   int
    68  		WantHeader map[string]string
    69  		WantBody   string // response body to expect
    70  	}{
    71  		{
    72  			Description: "v2_returns_200",
    73  			Method:      "GET",
    74  			URL:         "/v2",
    75  			WantCode:    http.StatusOK,
    76  			WantHeader:  map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
    77  		},
    78  		{
    79  			Description: "v2_slash_returns_200",
    80  			Method:      "GET",
    81  			URL:         "/v2/",
    82  			WantCode:    http.StatusOK,
    83  			WantHeader:  map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
    84  		},
    85  		{
    86  			Description: "v2_bad_returns_404",
    87  			Method:      "GET",
    88  			URL:         "/v2/bad",
    89  			WantCode:    http.StatusNotFound,
    90  			WantHeader:  map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
    91  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"page not found"}]}`,
    92  		},
    93  		{
    94  			Description: "GET_non_existent_blob",
    95  			Method:      "GET",
    96  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
    97  			WantCode:    http.StatusNotFound,
    98  			WantBody:    `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`,
    99  		},
   100  		{
   101  			Description: "HEAD_non_existent_blob",
   102  			Method:      "HEAD",
   103  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   104  			WantCode:    http.StatusNotFound,
   105  		},
   106  		{
   107  			Description: "GET_bad_digest",
   108  			Method:      "GET",
   109  			URL:         "/v2/foo/blobs/sha256:asd",
   110  			WantCode:    http.StatusBadRequest,
   111  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`,
   112  		},
   113  		{
   114  			Description: "HEAD_bad_digest",
   115  			Method:      "HEAD",
   116  			URL:         "/v2/foo/blobs/sha256:asd",
   117  			WantCode:    http.StatusBadRequest,
   118  		},
   119  		{
   120  			Description: "bad_blob_verb",
   121  			Method:      "FOO",
   122  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   123  			WantCode:    http.StatusMethodNotAllowed,
   124  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"method not allowed"}]}`,
   125  		},
   126  		{
   127  			Description: "GET_containerless_blob",
   128  			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
   129  			Method:      "GET",
   130  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   131  			WantCode:    http.StatusOK,
   132  			WantHeader:  map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
   133  			WantBody:    "foo",
   134  		},
   135  		{
   136  			Description: "GET_blob",
   137  			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
   138  			Method:      "GET",
   139  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   140  			WantCode:    http.StatusOK,
   141  			WantHeader:  map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
   142  			WantBody:    "foo",
   143  		},
   144  		{
   145  			Description: "GET_blob_range_defined_range",
   146  			Digests: map[string]string{
   147  				"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9": "hello world",
   148  			},
   149  			Method: "GET",
   150  			RequestHeader: map[string]string{
   151  				"Range": "bytes=1-4",
   152  			},
   153  			URL:      "/v2/foo/blobs/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
   154  			WantCode: http.StatusPartialContent,
   155  			WantHeader: map[string]string{
   156  				"Docker-Content-Digest": "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
   157  				"Content-Length":        "4",
   158  				"Content-Range":         "bytes 1-4/11",
   159  			},
   160  			WantBody: "ello",
   161  		},
   162  		{
   163  			Description: "GET_blob_range_undefined_range_end",
   164  			Digests: map[string]string{
   165  				"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9": "hello world",
   166  			},
   167  			Method: "GET",
   168  			RequestHeader: map[string]string{
   169  				"Range": "bytes=3-",
   170  			},
   171  			URL:      "/v2/foo/blobs/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
   172  			WantCode: http.StatusPartialContent,
   173  			WantHeader: map[string]string{
   174  				"Docker-Content-Digest": "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
   175  				"Content-Length":        "8",
   176  				"Content-Range":         "bytes 3-10/11",
   177  			},
   178  			WantBody: "lo world",
   179  		},
   180  		{
   181  			Description: "GET_blob_range_invalid-range-start",
   182  			Digests: map[string]string{
   183  				"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9": "hello world",
   184  			},
   185  			Method: "GET",
   186  			RequestHeader: map[string]string{
   187  				"Range": "bytes=20-30",
   188  			},
   189  			URL: "/v2/foo/blobs/sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
   190  			// TODO change ocimem to return an error that results in a 416 status.
   191  			WantCode: http.StatusInternalServerError,
   192  			WantBody: `{"errors":[{"code":"UNKNOWN","message":"invalid range [20, 11]; have [0, 11]"}]}`,
   193  		},
   194  		{
   195  			Description: "HEAD_blob",
   196  			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
   197  			Method:      "HEAD",
   198  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   199  			WantCode:    http.StatusOK,
   200  			WantHeader: map[string]string{
   201  				"Content-Length":        "3",
   202  				"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   203  			},
   204  		},
   205  		{
   206  			Description: "DELETE_blob",
   207  			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
   208  			Method:      "DELETE",
   209  			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   210  			WantCode:    http.StatusAccepted,
   211  		},
   212  		{
   213  			Description: "blob_url_with_no_container",
   214  			Method:      "GET",
   215  			URL:         "/v2/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   216  			WantCode:    http.StatusNotFound,
   217  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"page not found"}]}`,
   218  		},
   219  		{
   220  			Description: "uploadurl",
   221  			Method:      "POST",
   222  			URL:         "/v2/foo/blobs/uploads/",
   223  			WantCode:    http.StatusAccepted,
   224  			WantHeader:  map[string]string{"Range": "0-0"},
   225  		},
   226  		{
   227  			Description: "uploadurl",
   228  			Method:      "POST",
   229  			URL:         "/v2/foo/blobs/uploads/",
   230  			WantCode:    http.StatusAccepted,
   231  			WantHeader:  map[string]string{"Range": "0-0"},
   232  		},
   233  		{
   234  			Description: "upload_put_missing_digest",
   235  			Method:      "PUT",
   236  			URL:         "/v2/foo/blobs/uploads/MQ",
   237  			WantCode:    http.StatusBadRequest,
   238  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`,
   239  		},
   240  		{
   241  			Description: "monolithic_upload_good_digest",
   242  			Method:      "POST",
   243  			URL:         "/v2/foo/blobs/uploads?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   244  			WantCode:    http.StatusCreated,
   245  			Body:        "foo",
   246  			WantHeader:  map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
   247  		},
   248  		{
   249  			Description: "monolithic_upload_bad_digest",
   250  			Method:      "POST",
   251  			URL:         "/v2/foo/blobs/uploads?digest=sha256:fake",
   252  			Body:        "foo",
   253  			WantCode:    http.StatusBadRequest,
   254  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`,
   255  		},
   256  		{
   257  			Description: "upload_good_digest",
   258  			Method:      "PUT",
   259  			URL:         "/v2/foo/blobs/uploads/MQ?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   260  			WantCode:    http.StatusCreated,
   261  			Body:        "foo",
   262  			WantHeader:  map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
   263  		},
   264  		{
   265  			Description: "upload_bad_digest",
   266  			Method:      "PUT",
   267  			URL:         "/v2/foo/blobs/uploads/MQ?digest=sha256:baddigest",
   268  			WantCode:    http.StatusBadRequest,
   269  			Body:        "foo",
   270  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`,
   271  		},
   272  		{
   273  			Description: "stream_upload",
   274  			Method:      "PATCH",
   275  			URL:         "/v2/foo/blobs/uploads/MQ",
   276  			WantCode:    http.StatusAccepted,
   277  			Body:        "foo",
   278  			RequestHeader: map[string]string{
   279  				"Content-Range": "0-2",
   280  			},
   281  			WantHeader: map[string]string{
   282  				"Range":    "0-2",
   283  				"Location": "/v2/foo/blobs/uploads/MQ",
   284  			},
   285  		},
   286  		{
   287  			skip:        true,
   288  			Description: "stream_duplicate_upload",
   289  			Method:      "PATCH",
   290  			URL:         "/v2/foo/blobs/uploads/MQ",
   291  			WantCode:    http.StatusBadRequest,
   292  			Body:        "foo",
   293  			BlobStream:  map[string]string{"MQ": "foo"},
   294  		},
   295  		{
   296  			Description: "stream_finish_upload",
   297  			Method:      "PUT",
   298  			URL:         "/v2/foo/blobs/uploads/MQ?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
   299  			BlobStream:  map[string]string{"MQ": "foo"},
   300  			WantCode:    http.StatusCreated,
   301  			WantHeader:  map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
   302  		},
   303  		{
   304  			Description: "get_missing_manifest",
   305  			Method:      "GET",
   306  			URL:         "/v2/foo/manifests/latest",
   307  			WantCode:    http.StatusNotFound,
   308  			WantBody:    `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`,
   309  		},
   310  		{
   311  			Description: "head_missing_manifest",
   312  			Method:      "HEAD",
   313  			URL:         "/v2/foo/manifests/latest",
   314  			WantCode:    http.StatusNotFound,
   315  		},
   316  		{
   317  			Description: "get_missing_manifest_good_container",
   318  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   319  			Method:      "GET",
   320  			URL:         "/v2/foo/manifests/bar",
   321  			WantCode:    http.StatusNotFound,
   322  			WantBody:    `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown to registry"}]}`,
   323  		},
   324  		{
   325  			Description: "head_missing_manifest_good_container",
   326  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   327  			Method:      "HEAD",
   328  			URL:         "/v2/foo/manifests/bar",
   329  			WantCode:    http.StatusNotFound,
   330  		},
   331  		{
   332  			Description: "get_manifest_by_tag",
   333  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   334  			Method:      "GET",
   335  			URL:         "/v2/foo/manifests/latest",
   336  			WantCode:    http.StatusOK,
   337  			WantBody:    "foo",
   338  		},
   339  		{
   340  			Description: "get_manifest_by_digest",
   341  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   342  			Method:      "GET",
   343  			URL:         "/v2/foo/manifests/" + digestOf("foo"),
   344  			WantCode:    http.StatusOK,
   345  			WantBody:    "foo",
   346  		},
   347  		{
   348  			Description: "head_manifest",
   349  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   350  			Method:      "HEAD",
   351  			URL:         "/v2/foo/manifests/latest",
   352  			WantCode:    http.StatusOK,
   353  		},
   354  		{
   355  			Description: "create_manifest",
   356  			Method:      "PUT",
   357  			URL:         "/v2/foo/manifests/latest",
   358  			WantCode:    http.StatusCreated,
   359  			Body:        "foo",
   360  		},
   361  		{
   362  			Description: "create_index",
   363  			Method:      "PUT",
   364  			URL:         "/v2/foo/manifests/latest",
   365  			WantCode:    http.StatusCreated,
   366  			Body:        weirdIndex,
   367  			RequestHeader: map[string]string{
   368  				"Content-Type": "application/vnd.oci.image.index.v1+json",
   369  			},
   370  			Manifests: map[string]string{"foo/manifests/image": "foo"},
   371  		},
   372  		{
   373  			skip:        true,
   374  			Description: "create_index_missing_child",
   375  			Method:      "PUT",
   376  			URL:         "/v2/foo/manifests/latest",
   377  			WantCode:    http.StatusNotFound,
   378  			Body:        weirdIndex,
   379  			RequestHeader: map[string]string{
   380  				"Content-Type": "application/vnd.oci.image.index.v1+json",
   381  			},
   382  		},
   383  		{
   384  			skip:        true,
   385  			Description: "bad_index_body",
   386  			Method:      "PUT",
   387  			URL:         "/v2/foo/manifests/latest",
   388  			WantCode:    http.StatusBadRequest,
   389  			Body:        "foo",
   390  			RequestHeader: map[string]string{
   391  				"Content-Type": "application/vnd.oci.image.index.v1+json",
   392  			},
   393  		},
   394  		{
   395  			Description: "bad_manifest_method",
   396  			Method:      "BAR",
   397  			URL:         "/v2/foo/manifests/latest",
   398  			WantCode:    http.StatusMethodNotAllowed,
   399  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"method not allowed"}]}`,
   400  		},
   401  		{
   402  			Description:   "Chunk_upload_start",
   403  			Method:        "PATCH",
   404  			URL:           "/v2/foo/blobs/uploads/MQ",
   405  			RequestHeader: map[string]string{"Content-Range": "0-2"},
   406  			WantCode:      http.StatusAccepted,
   407  			Body:          "foo",
   408  			WantHeader: map[string]string{
   409  				"Range":    "0-2",
   410  				"Location": "/v2/foo/blobs/uploads/MQ",
   411  			},
   412  		},
   413  		{
   414  			Description:   "Chunk_upload_bad_content_range",
   415  			Method:        "PATCH",
   416  			URL:           "/v2/foo/blobs/uploads/MQ",
   417  			RequestHeader: map[string]string{"Content-Range": "0-bar"},
   418  			// TODO the original had 405 response here. Which is correct?
   419  			WantCode: http.StatusBadRequest,
   420  			Body:     "foo",
   421  			WantBody: `{"errors":[{"code":"UNSUPPORTED","message":"we don't understand your Content-Range"}]}`,
   422  		},
   423  		{
   424  			Description:   "Chunk_upload_overlaps_previous_data",
   425  			Method:        "PATCH",
   426  			URL:           "/v2/foo/blobs/uploads/MQ",
   427  			BlobStream:    map[string]string{"MQ": "foo"},
   428  			RequestHeader: map[string]string{"Content-Range": "2-4"},
   429  			WantCode:      http.StatusRequestedRangeNotSatisfiable,
   430  			Body:          "bar",
   431  			WantBody:      `{"errors":[{"code":"RANGE_INVALID","message":"cannot copy blob data: invalid offset 2 in resumed upload (actual offset 3): range invalid: invalid content range"}]}`,
   432  		},
   433  		{
   434  			Description:   "Chunk_upload_after_previous_data",
   435  			Method:        "PATCH",
   436  			URL:           "/v2/foo/blobs/uploads/MQ",
   437  			BlobStream:    map[string]string{"MQ": "foo"},
   438  			RequestHeader: map[string]string{"Content-Range": "3-5"},
   439  			WantCode:      http.StatusAccepted,
   440  			Body:          "bar",
   441  			WantHeader: map[string]string{
   442  				"Range":    "0-5",
   443  				"Location": "/v2/foo/blobs/uploads/MQ",
   444  			},
   445  		},
   446  		{
   447  			Description: "DELETE_Unknown_name",
   448  			Method:      "DELETE",
   449  			URL:         "/v2/test/honk/manifests/latest",
   450  			WantCode:    http.StatusNotFound,
   451  			WantBody:    `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`,
   452  		},
   453  		{
   454  			Description: "DELETE_Unknown_manifest",
   455  			Manifests:   map[string]string{"honk/manifests/latest": "honk"},
   456  			Method:      "DELETE",
   457  			URL:         "/v2/honk/manifests/tag-honk",
   458  			WantCode:    http.StatusNotFound,
   459  			WantBody:    `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown to registry: tag does not exist"}]}`,
   460  		},
   461  		{
   462  			Description: "DELETE_existing_manifest",
   463  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   464  			Method:      "DELETE",
   465  			URL:         "/v2/foo/manifests/latest",
   466  			WantCode:    http.StatusAccepted,
   467  		},
   468  		{
   469  			Description: "DELETE_existing_manifest_by_digest",
   470  			Manifests:   map[string]string{"foo/manifests/latest": "foo"},
   471  			Method:      "DELETE",
   472  			URL:         "/v2/foo/manifests/" + digestOf("foo"),
   473  			WantCode:    http.StatusAccepted,
   474  		},
   475  		{
   476  			Description: "list_tags",
   477  			Manifests:   map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
   478  			Method:      "GET",
   479  			URL:         "/v2/foo/tags/list?n=1000",
   480  			WantCode:    http.StatusOK,
   481  			WantBody:    `{"name":"foo","tags":["latest","tag1"]}`,
   482  		},
   483  		{
   484  			Description: "limit_tags",
   485  			Manifests:   map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
   486  			Method:      "GET",
   487  			URL:         "/v2/foo/tags/list?n=1",
   488  			WantCode:    http.StatusOK,
   489  			WantBody:    `{"name":"foo","tags":["latest"]}`,
   490  		},
   491  		{
   492  			Description: "offset_tags",
   493  			Manifests:   map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
   494  			Method:      "GET",
   495  			URL:         "/v2/foo/tags/list?last=latest",
   496  			WantCode:    http.StatusOK,
   497  			WantBody:    `{"name":"foo","tags":["tag1"]}`,
   498  		},
   499  		{
   500  			Description: "list_non_existing_tags",
   501  			Method:      "GET",
   502  			URL:         "/v2/foo/tags/list?n=1000",
   503  			WantCode:    http.StatusNotFound,
   504  			WantBody:    `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`,
   505  		},
   506  		{
   507  			Description: "list_repos",
   508  			Manifests:   map[string]string{"foo/manifests/latest": "foo", "bar/manifests/latest": "bar"},
   509  			Method:      "GET",
   510  			URL:         "/v2/_catalog?n=1000",
   511  			WantCode:    http.StatusOK,
   512  			WantBody:    `{"repositories":["bar","foo"]}`,
   513  		},
   514  		{
   515  			Description: "fetch_references",
   516  			Method:      "GET",
   517  			URL:         "/v2/foo/referrers/" + digestOf("foo"),
   518  			WantCode:    http.StatusOK,
   519  			Manifests: map[string]string{
   520  				"foo/manifests/image":           "foo",
   521  				"foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"" + digestOf("foo") + "\"}}",
   522  			},
   523  			WantBody: `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":null}`,
   524  		},
   525  		{
   526  			Description: "fetch_references,_subject_pointing_elsewhere",
   527  			Method:      "GET",
   528  			URL:         "/v2/foo/referrers/" + digestOf("foo"),
   529  			WantCode:    http.StatusOK,
   530  			Manifests: map[string]string{
   531  				"foo/manifests/image":           "foo",
   532  				"foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"" + digestOf("nonexistant") + "\"}}",
   533  			},
   534  			WantBody: `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":null}`,
   535  		},
   536  		{
   537  			Description: "fetch_references,_no_results",
   538  			Method:      "GET",
   539  			URL:         "/v2/foo/referrers/" + digestOf("foo"),
   540  			WantCode:    http.StatusOK,
   541  			Manifests: map[string]string{
   542  				"foo/manifests/image": "foo",
   543  			},
   544  			WantBody: `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":null}`,
   545  		},
   546  		{
   547  			Description: "fetch_references,_missing_repo",
   548  			Method:      "GET",
   549  			URL:         "/v2/does-not-exist/referrers/" + digestOf("foo"),
   550  			WantCode:    http.StatusNotFound,
   551  			WantBody:    `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`,
   552  		},
   553  		{
   554  			Description: "fetch_references,_bad_target_(tag_vs._digest)",
   555  			Method:      "GET",
   556  			URL:         "/v2/foo/referrers/latest",
   557  			WantCode:    http.StatusBadRequest,
   558  			WantBody:    `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`,
   559  		},
   560  		{
   561  			skip:        true,
   562  			Description: "fetch_references,_bad_method",
   563  			Method:      "POST",
   564  			URL:         "/v2/foo/referrers/" + digestOf("foo"),
   565  			WantCode:    http.StatusBadRequest,
   566  		},
   567  	}
   568  
   569  	for _, tc := range tcs {
   570  
   571  		testf := func(t *testing.T) {
   572  			if tc.skip {
   573  				t.Skip("skipping")
   574  			}
   575  			r := ociserver.New(ocimem.New(), nil)
   576  			s := httptest.NewServer(r)
   577  			defer s.Close()
   578  
   579  			for manifest, contents := range tc.Manifests {
   580  				req, _ := http.NewRequest("PUT", s.URL+"/v2/"+manifest, strings.NewReader(contents))
   581  				req.Header.Set("Content-Type", "application/octet-stream") // TODO better media type
   582  				t.Log(req.Method, req.URL)
   583  				resp, err := s.Client().Do(req)
   584  				if err != nil {
   585  					t.Fatalf("Error uploading manifest: %v", err)
   586  				}
   587  				if resp.StatusCode != http.StatusCreated {
   588  					body, _ := io.ReadAll(resp.Body)
   589  					t.Fatalf("Error uploading manifest got status: %d %s", resp.StatusCode, body)
   590  				}
   591  				t.Logf("created manifest with digest %v", resp.Header.Get("Docker-Content-Digest"))
   592  			}
   593  
   594  			for digest, contents := range tc.Digests {
   595  				req, _ := http.NewRequest(
   596  					"POST",
   597  					fmt.Sprintf("%s/v2/foo/blobs/uploads/?digest=%s", s.URL, digest),
   598  					strings.NewReader(contents),
   599  				)
   600  				req.Header.Set("Content-Length", fmt.Sprint(len(contents))) // TODO better media type
   601  				t.Log(req.Method, req.URL)
   602  				resp, err := s.Client().Do(req)
   603  				if err != nil {
   604  					t.Fatalf("Error uploading digest: %v", err)
   605  				}
   606  				if resp.StatusCode != http.StatusCreated {
   607  					body, _ := io.ReadAll(resp.Body)
   608  					t.Fatalf("Error uploading digest got status: %d %s", resp.StatusCode, body)
   609  				}
   610  			}
   611  
   612  			for upload, contents := range tc.BlobStream {
   613  				req, err := http.NewRequest(
   614  					"PATCH",
   615  					fmt.Sprintf("%s/v2/foo/blobs/uploads/%s", s.URL, upload),
   616  					io.NopCloser(strings.NewReader(contents)),
   617  				)
   618  				if err != nil {
   619  					t.Fatal(err)
   620  				}
   621  				req.Header.Add("Content-Range", fmt.Sprintf("0-%d", len(contents)-1))
   622  				t.Log(req.Method, req.URL)
   623  				resp, err := s.Client().Do(req)
   624  				if err != nil {
   625  					t.Fatalf("Error streaming blob: %v", err)
   626  				}
   627  				if resp.StatusCode != http.StatusAccepted {
   628  					body, _ := io.ReadAll(resp.Body)
   629  					t.Fatalf("Error streaming blob: %d %s", resp.StatusCode, body)
   630  				}
   631  
   632  			}
   633  
   634  			req, err := http.NewRequest(tc.Method, s.URL+tc.URL, strings.NewReader(tc.Body))
   635  			qt.Assert(t, qt.IsNil(err))
   636  			for k, v := range tc.RequestHeader {
   637  				req.Header.Set(k, v)
   638  			}
   639  			t.Logf("%s %v", req.Method, req.URL)
   640  			resp, err := s.Client().Do(req)
   641  			if err != nil {
   642  				t.Fatalf("Error getting %q: %v", tc.URL, err)
   643  			}
   644  			defer resp.Body.Close()
   645  			body, err := io.ReadAll(resp.Body)
   646  			if err != nil {
   647  				t.Errorf("Reading response body: %v", err)
   648  			}
   649  			if resp.StatusCode != tc.WantCode {
   650  				t.Fatalf("Incorrect status code, got %d, want %d; body: %s", resp.StatusCode, tc.WantCode, body)
   651  			}
   652  
   653  			for k, v := range tc.WantHeader {
   654  				r := resp.Header.Get(k)
   655  				if r != v {
   656  					t.Errorf("Incorrect header %q received, got %q, want %q", k, r, v)
   657  				}
   658  			}
   659  
   660  			if string(body) != tc.WantBody {
   661  				t.Logf("\n		WantBody: `%s`,", body)
   662  				t.Errorf("Incorrect response body.\ngot:\n\t%q\n\twant:\n\t%q", body, tc.WantBody)
   663  			}
   664  		}
   665  		t.Run(tc.Description, testf)
   666  	}
   667  }
   668  
   669  func digestOf(s string) string {
   670  	return string(digest.FromString(s))
   671  }