diff --git a/cmd/mock.go b/cmd/mock.go index 79ed765..5ce1a1e 100644 --- a/cmd/mock.go +++ b/cmd/mock.go @@ -17,7 +17,6 @@ limitations under the License. package cmd import ( - "errors" "os" "os/signal" "syscall" @@ -29,37 +28,29 @@ import ( type mockOption struct { port int prefix string - files []string } func createMockCmd() (c *cobra.Command) { opt := &mockOption{} c = &cobra.Command{ - Use: "mock", - Short: "Start a mock server", - PreRunE: opt.preRunE, - RunE: opt.runE, + Use: "mock", + Short: "Start a mock server", + Args: cobra.ExactArgs(1), + RunE: opt.runE, } flags := c.Flags() flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port") flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix") - flags.StringSliceVarP(&opt.files, "files", "", nil, "The mock config files") - return -} - -func (o *mockOption) preRunE(c *cobra.Command, args []string) (err error) { - if len(o.files) == 0 { - err = errors.New("at least one file is required") - } return } func (o *mockOption) runE(c *cobra.Command, args []string) (err error) { - reader := mock.NewLocalFileReader(o.files[0]) + reader := mock.NewLocalFileReader(args[0]) server := mock.NewInMemoryServer(o.port) + c.Println("start listen", o.port) if err = server.Start(reader, o.prefix); err != nil { return } diff --git a/docs/api-testing-schema.json b/docs/api-testing-schema.json index eea7ef7..be74e6b 100644 --- a/docs/api-testing-schema.json +++ b/docs/api-testing-schema.json @@ -204,7 +204,8 @@ "POST", "PUT", "PATCH", - "DELETE" + "DELETE", + "HEAD" ] }, "query": { diff --git a/docs/mock-server.md b/docs/mock-server.md new file mode 100644 index 0000000..175e12a --- /dev/null +++ b/docs/mock-server.md @@ -0,0 +1,13 @@ +## Get started + +You can start a mock server of [container registry](https://distribution.github.io/distribution/) with below command: + +```shell +atest mock --prefix / mock/image-registry.yaml +``` + +then, you can pull images from it: + +```shell +docker pull localhost:6060/repo/name:tag +``` diff --git a/docs/mock/image-registry.yaml b/docs/mock/image-registry.yaml new file mode 100644 index 0000000..2d585d6 --- /dev/null +++ b/docs/mock/image-registry.yaml @@ -0,0 +1,87 @@ +items: +- name: config + request: + path: /v2/{repo}/{name}/blobs/sha256:4e53321e14aaf87b17329102a21d4388fd9bea986277a78a8aa13bd300c9e3f9 + response: + header: + Accept-Ranges: bytes + Content-Type: application/octet-stream + Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}" + Docker-Distribution-Api-Version: registry/2.0 + body: | + {"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"OnBuild":null},"created":"2024-01-27T00:30:48.743965523Z","history":[{"created":"2024-01-27T00:30:48.624602109Z","created_by":"/bin/sh -c #(nop) ADD file:37a76ec18f9887751cd8473744917d08b7431fc4085097bb6a09d81b41775473 in / "},{"created":"2024-01-27T00:30:48.743965523Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/sh\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:d4fc045c9e3a848011de66f34b81f052d4f2c15a17bb196d637e526349601820"]}} +- name: digest + request: + path: /v2/{repo}/{name}/manifests/sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83 + response: + header: + Content-Type: application/vnd.oci.image.manifest.v1+json + Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}" + Docker-Distribution-Api-Version: registry/2.0 + body: | + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:4e53321e14aaf87b17329102a21d4388fd9bea986277a78a8aa13bd300c9e3f9", + "size": 600 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:4abcf20661432fb2d719aaf90656f55c287f8ca915dc1c92ec14ff61e67fbaf8", + "size": 3408729 + } + ] + } +- name: manifests + request: + path: /v2/{repo}/{name}/manifests/{digest} + method: GET,HEAD + response: + header: + Content-Type: application/vnd.oci.image.index.v1+json + Docker-Distribution-Api-Version: registry/2.0 + Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}" + body: | + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83", + "size": 480, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:d19bf9196ad6eb527acdbabfd664954ccb7a0752f218312b6c910b8590d170e2", + "size": 566, + "annotations": { + "vnd.docker.reference.digest": "sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] + } +- name: layer + request: + path: /v2/{repo}/{name}/blobs/{digest} + response: + header: + Accept-Ranges: bytes + Cache-Control: max-age=31536000 + Content-Type: application/octet-stream + Docker-Content-Digest: "sha256:{{ sha256sumBytes .Response.BodyData }}" + Docker-Distribution-Api-Version: registry/2.0 + body: https://gitee.com/linuxsuren/api-testing-hub/raw/master/data/alpine-3.19.1.tar.gz + encoder: url diff --git a/pkg/mock/in_memory.go b/pkg/mock/in_memory.go index 3d09091..cd04966 100644 --- a/pkg/mock/in_memory.go +++ b/pkg/mock/in_memory.go @@ -17,6 +17,7 @@ package mock import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -108,7 +109,9 @@ func (s *inMemoryServer) Start(reader Reader, prefix string) (err error) { return } - s.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.port)) + if s.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.port)); err != nil { + return + } go func() { err = http.Serve(s.listener, handler) }() @@ -247,24 +250,66 @@ func (s *inMemoryServer) startObject(obj Object) { } func (s *inMemoryServer) startItem(item Item) { - s.mux.HandleFunc(item.Request.Path, func(w http.ResponseWriter, req *http.Request) { - item.Response.Header[headerMockServer] = fmt.Sprintf("api-testing: %s", version.GetVersion()) - item.Response.Header["content-length"] = fmt.Sprintf("%d", len(item.Response.Body)) - for k, v := range item.Response.Header { - w.Header().Set(k, v) + method := util.EmptyThenDefault(item.Request.Method, http.MethodGet) + memLogger.Info("register mock service", "method", method, "path", item.Request.Path, "encoder", item.Response.Encoder) + + var headerSlices []string + for k, v := range item.Request.Header { + headerSlices = append(headerSlices, k, v) + } + + adHandler := &advanceHandler{item: &item} + s.mux.HandleFunc(item.Request.Path, adHandler.handle).Methods(strings.Split(method, ",")...).Headers(headerSlices...) +} + +type advanceHandler struct { + item *Item +} + +func (h *advanceHandler) handle(w http.ResponseWriter, req *http.Request) { + memLogger.Info("receiving mock request", "name", h.item.Name, "method", req.Method, "path", req.URL.Path, + "encoder", h.item.Response.Encoder) + + var err error + if h.item.Response.Encoder == "base64" { + h.item.Response.BodyData, err = base64.StdEncoding.DecodeString(h.item.Response.Body) + } else if h.item.Response.Encoder == "url" { + var resp *http.Response + if resp, err = http.Get(h.item.Response.Body); err == nil { + h.item.Response.BodyData, err = io.ReadAll(resp.Body) } - body, err := render.RenderAsBytes("start-item", item.Response.Body, req) - writeResponse(w, body, err) - w.WriteHeader(util.ZeroThenDefault(item.Response.StatusCode, http.StatusOK)) - }).Methods(util.EmptyThenDefault(item.Request.Method, http.MethodGet)) + } else { + h.item.Response.BodyData, err = render.RenderAsBytes("start-item", h.item.Response.Body, h.item) + } + + if err == nil { + h.item.Param = mux.Vars(req) + if h.item.Response.Header == nil { + h.item.Response.Header = make(map[string]string) + } + h.item.Response.Header[headerMockServer] = fmt.Sprintf("api-testing: %s", version.GetVersion()) + h.item.Response.Header["content-length"] = fmt.Sprintf("%d", len(h.item.Response.BodyData)) + for k, v := range h.item.Response.Header { + hv, hErr := render.Render("mock-server-header", v, &h.item) + if hErr != nil { + hv = v + memLogger.Error(hErr, "failed render mock-server-header", "value", v) + } + + w.Header().Set(k, hv) + } + w.WriteHeader(util.ZeroThenDefault(h.item.Response.StatusCode, http.StatusOK)) + } + + writeResponse(w, h.item.Response.BodyData, err) } func writeResponse(w http.ResponseWriter, data []byte, err error) { if err == nil { w.Write(data) } else { - w.Write([]byte(err.Error())) w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) } } diff --git a/pkg/mock/in_memory_test.go b/pkg/mock/in_memory_test.go index d87900d..6417987 100644 --- a/pkg/mock/in_memory_test.go +++ b/pkg/mock/in_memory_test.go @@ -129,15 +129,36 @@ func TestInMemoryServer(t *testing.T) { }) t.Run("mock item", func(t *testing.T) { - resp, err := http.Get(api + "/v1/repos/test/prs") + req, err := http.NewRequest(http.MethodGet, api+"/v1/repos/test/prs", nil) assert.NoError(t, err) + req.Header.Set("name", "rick") + + resp, err = http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "mock", resp.Header.Get("server")) + assert.Equal(t, "176", resp.Header.Get("Content-Length")) + assert.Equal(t, "mock", resp.Header.Get("Server")) assert.NotEmpty(t, resp.Header.Get(headerMockServer)) data, _ := io.ReadAll(resp.Body) + assert.True(t, strings.Contains(string(data), `"message": "mock"`), string(data)) + }) - assert.True(t, strings.Contains(string(data), `"message": "gzip"`), string(data)) + t.Run("miss match header", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, api+"/v1/repos/test/prs", nil) + assert.NoError(t, err) + + resp, err = http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("base64 encoder", func(t *testing.T) { + resp, err = http.Get(api + "/v1/base64") + assert.NoError(t, err) + data, _ := io.ReadAll(resp.Body) + assert.Equal(t, "hello", string(data)) }) t.Run("not found config file", func(t *testing.T) { diff --git a/pkg/mock/testdata/api.yaml b/pkg/mock/testdata/api.yaml index d6303c7..aae0602 100644 --- a/pkg/mock/testdata/api.yaml +++ b/pkg/mock/testdata/api.yaml @@ -22,24 +22,17 @@ objects: "color": "{{ randEnum "blue" "read" "pink" }}" } items: - - name: repoList + - name: base64 request: - path: /v1/teams/{team}/repos - method: GET + path: /v1/base64 response: - statusCode: 200 - body: | - { - "status": 0, - "count": 1, - "items": [{ - "name": "fake-name", - "url": "https://fake.com" - }] - } + body: aGVsbG8= + encoder: base64 - name: prList request: path: /v1/repos/{repo}/prs + header: + name: rick response: header: server: mock @@ -49,7 +42,7 @@ items: "items": [{ "title": "fix: there is a bug on page {{ randEnum "one" }}", "number": 123, - "message": "{{index (index .Header "Accept-Encoding") 0}}", + "message": "{{.Response.Header.server}}", "author": "someone", "status": "success" }] diff --git a/pkg/mock/types.go b/pkg/mock/types.go index 62b8b16..58dec61 100644 --- a/pkg/mock/types.go +++ b/pkg/mock/types.go @@ -31,6 +31,7 @@ type Item struct { Name string `yaml:"name"` Request Request `yaml:"request"` Response Response `yaml:"response"` + Param map[string]string } type Request struct { @@ -41,9 +42,11 @@ type Request struct { } type Response struct { + Encoder string `yaml:"encoder"` Body string `yaml:"body"` Header map[string]string `yaml:"header"` StatusCode int `yaml:"statusCode"` + BodyData []byte } type Webhook struct { diff --git a/pkg/render/template.go b/pkg/render/template.go index 9a09b8f..51e41ec 100644 --- a/pkg/render/template.go +++ b/pkg/render/template.go @@ -19,6 +19,7 @@ import ( "bytes" "context" "crypto/md5" + "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" @@ -147,6 +148,13 @@ var advancedFuncs = []AdvancedFunc{{ return err.Error() } }, +}, { + FuncName: "sha256sumBytes", + Func: func(data []byte) string { + h := sha256.New() + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) + }, }, { FuncName: "randEnum", Func: func(items ...string) string { diff --git a/pkg/render/template_test.go b/pkg/render/template_test.go index d58648d..cec3a0e 100644 --- a/pkg/render/template_test.go +++ b/pkg/render/template_test.go @@ -89,6 +89,15 @@ func TestRender(t *testing.T) { assert.Contains(t, s, "@") assert.Contains(t, s, ".com") }, + }, { + name: "sha256 bytes", + text: `{{sha256sumBytes .data}}`, + ctx: map[string]interface{}{ + "data": []byte("hello"), + }, + verify: func(t *testing.T, s string) { + assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", s) + }, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/runner/http.go b/pkg/runner/http.go index b83e2d0..598d6cb 100644 --- a/pkg/runner/http.go +++ b/pkg/runner/http.go @@ -369,7 +369,7 @@ func runJob(job *testing.Job, ctx interface{}, current interface{}) (err error) // isNonBinaryContent detect if the content belong to binary func isNonBinaryContent(contentType string) bool { switch contentType { - case util.JSON, util.YAML, util.Plain: + case util.JSON, util.YAML, util.Plain, util.OCIImageIndex: return true default: return false diff --git a/pkg/runner/http_test.go b/pkg/runner/http_test.go index d5b2731..84eb2fd 100644 --- a/pkg/runner/http_test.go +++ b/pkg/runner/http_test.go @@ -572,6 +572,9 @@ func TestIsStructContent(t *testing.T) { }, { contentType: util.OctetStream, expectOk: false, + }, { + contentType: util.OCIImageIndex, + expectOk: true, }} for _, tt := range tests { t.Run(tt.contentType, func(t *testing.T) { diff --git a/pkg/util/default.go b/pkg/util/default.go index 0b3082f..71dee28 100644 --- a/pkg/util/default.go +++ b/pkg/util/default.go @@ -73,6 +73,7 @@ const ( MultiPartFormData = "multipart/form-data" Form = "application/x-www-form-urlencoded" JSON = "application/json" + OCIImageIndex = "application/vnd.oci.image.index.v1+json" YAML = "application/yaml" ZIP = "application/zip" OctetStream = "application/octet-stream"