From 57242a0c3b4ee18fcc8cb2bf850781fb821f9d02 Mon Sep 17 00:00:00 2001 From: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com> Date: Fri, 15 Dec 2023 19:05:16 +0300 Subject: [PATCH] Add property based testing to certs API using schemathesis Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com> --- .github/workflows/api-tests.yml | 11 +++++ Makefile | 1 + api/openapi/certs.yml | 85 ++++++++++++++++++++++----------- certs/api/transport.go | 71 +++------------------------ internal/api/common.go | 2 + pkg/sdk/go/certs_test.go | 8 ++-- 6 files changed, 82 insertions(+), 96 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 731aa5f8a..55586fd9c 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -51,6 +51,7 @@ env: INVITATIONS_URL: http://localhost:9020 AUTH_URL: http://localhost:8189 BOOTSTRAP_URL: http://localhost:9013 + CERTS_URL: http://localhost:9019 jobs: api-test: @@ -188,6 +189,16 @@ jobs: report: false args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-unique-data --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links' + - name: Run Certs API tests + if: steps.changes.outputs.certs == 'true' + uses: schemathesis/action@v1 + with: + schema: api/openapi/certs.yml + base-url: ${{ env.CERTS_URL }} + checks: all + report: false + args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-unique-data --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links' + - name: Stop containers if: always() run: make run down args="-v" diff --git a/Makefile b/Makefile index f11577e5b..34daa5e94 100644 --- a/Makefile +++ b/Makefile @@ -160,6 +160,7 @@ test_api_things: TEST_API_URL := http://localhost:9000 test_api_invitations: TEST_API_URL := http://localhost:9020 test_api_auth: TEST_API_URL := http://localhost:8189 test_api_bootstrap: TEST_API_URL := http://localhost:9013 +test_api_certs: TEST_API_URL := http://localhost:9019 $(TEST_API): $(call test_api_service,$(@),$(TEST_API_URL)) diff --git a/api/openapi/certs.yml b/api/openapi/certs.yml index c23f00dee..d6cc178b6 100644 --- a/api/openapi/certs.yml +++ b/api/openapi/certs.yml @@ -29,6 +29,7 @@ tags: paths: /certs: post: + operationId: createCert summary: Creates a certificate for thing description: Creates a certificate for thing tags: @@ -36,16 +37,23 @@ paths: requestBody: $ref: "#/components/requestBodies/CertReq" responses: - '201': + "201": description: Created - '400': + "400": description: Failed due to malformed JSON. "401": description: Missing or invalid access token provided. - '500': - description: Unexpected server-side error ocurred. + "403": + description: Failed to perform authorization over the entity. + "415": + description: Missing or invalid content type. + "422": + description: Database can't process request. + "500": + $ref: "#/components/responses/ServiceError" /certs/{certID}: get: + operationId: getCert summary: Retrieves a certificate description: | Retrieves a certificate for a given cert ID. @@ -54,18 +62,23 @@ paths: parameters: - $ref: "#/components/parameters/CertID" responses: - '200': + "200": $ref: "#/components/responses/CertRes" - '400': + "400": description: Failed due to malformed query parameters. "401": description: Missing or invalid access token provided. - '404': + "403": + description: Failed to perform authorization over the entity. + "404": description: | Failed to retrieve corresponding certificate. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" delete: + operationId: revokeCert summary: Revokes a certificate description: | Revokes a certificate for a given cert ID. @@ -74,17 +87,22 @@ paths: parameters: - $ref: "#/components/parameters/CertID" responses: - '200': + "200": $ref: "#/components/responses/RevokeRes" "401": description: Missing or invalid access token provided. - '404': + "403": + description: Failed to perform authorization over the entity. + "404": description: | Failed to revoke corresponding certificate. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /serials/{thingID}: get: + operationId: getSerials summary: Retrieves certificates' serial IDs description: | Retrieves a list of certificates' serial IDs for a given thing ID. @@ -93,16 +111,20 @@ paths: parameters: - $ref: "#/components/parameters/ThingID" responses: - '200': + "200": $ref: "#/components/responses/SerialsPageRes" - '400': + "400": description: Failed due to malformed query parameters. "401": description: Missing or invalid access token provided. - '404': + "403": + description: Failed to perform authorization over the entity. + "404": description: | Failed to retrieve corresponding certificates. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /health: get: @@ -110,9 +132,9 @@ paths: tags: - health responses: - '200': + "200": $ref: "#/components/responses/HealthRes" - '500': + "500": $ref: "#/components/responses/ServiceError" components: @@ -210,9 +232,9 @@ components: requestBodies: CertReq: description: | - Issues a certificate that is required for mTLS. To create a certificate for a thing - provide a thing id, data identifying particular thing will be embedded into the Certificate. - x509 and ECC certificates are supported when using when Vault is used as PKI. + Issues a certificate that is required for mTLS. To create a certificate for a thing + provide a thing id, data identifying particular thing will be embedded into the Certificate. + x509 and ECC certificates are supported when using when Vault is used as PKI. content: application/json: schema: @@ -221,12 +243,12 @@ components: - thing_id - ttl properties: - thing_id: - type: string - format: uuid - ttl: - type: string - example: "10h" + thing_id: + type: string + format: uuid + ttl: + type: string + example: "10h" responses: ServiceError: @@ -237,6 +259,15 @@ components: application/json: schema: $ref: "#/components/schemas/Cert" + links: + serial: + operationId: getSerials + parameters: + thingID: $response.body#/thing_id + delete: + operationId: revokeCert + parameters: + certID: $response.body#/serial CertsPageRes: description: Certificates page. content: @@ -258,7 +289,7 @@ components: HealthRes: description: Service Health Check. content: - application/json: + application/health+json: schema: $ref: "./schemas/HealthInfo.yml" diff --git a/certs/api/transport.go b/certs/api/transport.go index bda23121d..13280cd65 100644 --- a/certs/api/transport.go +++ b/certs/api/transport.go @@ -10,10 +10,10 @@ import ( "github.com/absmach/magistrala" "github.com/absmach/magistrala/certs" + "github.com/absmach/magistrala/internal/api" "github.com/absmach/magistrala/internal/apiutil" mglog "github.com/absmach/magistrala/logger" "github.com/absmach/magistrala/pkg/errors" - svcerr "github.com/absmach/magistrala/pkg/errors/service" "github.com/go-chi/chi/v5" kithttp "github.com/go-kit/kit/transport/http" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -31,7 +31,7 @@ const ( // MakeHandler returns a HTTP handler for API endpoints. func MakeHandler(svc certs.Service, logger mglog.Logger, instanceID string) http.Handler { opts := []kithttp.ServerOption{ - kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, encodeError)), + kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)), } r := chi.NewRouter() @@ -40,26 +40,26 @@ func MakeHandler(svc certs.Service, logger mglog.Logger, instanceID string) http r.Post("/", otelhttp.NewHandler(kithttp.NewServer( issueCert(svc), decodeCerts, - encodeResponse, + api.EncodeResponse, opts..., ), "issue").ServeHTTP) r.Get("/{certID}", otelhttp.NewHandler(kithttp.NewServer( viewCert(svc), decodeViewCert, - encodeResponse, + api.EncodeResponse, opts..., ), "view").ServeHTTP) r.Delete("/{certID}", otelhttp.NewHandler(kithttp.NewServer( revokeCert(svc), decodeRevokeCerts, - encodeResponse, + api.EncodeResponse, opts..., ), "revoke").ServeHTTP) }) r.Get("/serials/{thingID}", otelhttp.NewHandler(kithttp.NewServer( listSerials(svc), decodeListCerts, - encodeResponse, + api.EncodeResponse, opts..., ), "list_serials").ServeHTTP) @@ -69,24 +69,6 @@ func MakeHandler(svc certs.Service, logger mglog.Logger, instanceID string) http return r } -func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error { - w.Header().Set("Content-Type", contentType) - - if ar, ok := response.(magistrala.Response); ok { - for k, v := range ar.Headers() { - w.Header().Set(k, v) - } - - w.WriteHeader(ar.Code()) - - if ar.Empty() { - return nil - } - } - - return json.NewEncoder(w).Encode(response) -} - func decodeListCerts(_ context.Context, r *http.Request) (interface{}, error) { l, err := apiutil.ReadUintQuery(r, limitKey, defLimit) if err != nil { @@ -136,44 +118,3 @@ func decodeRevokeCerts(_ context.Context, r *http.Request) (interface{}, error) return req, nil } - -func encodeError(_ context.Context, err error, w http.ResponseWriter) { - var wrapper error - if errors.Contains(err, apiutil.ErrValidation) { - wrapper, err = errors.Unwrap(err) - } - - switch { - case errors.Contains(err, svcerr.ErrAuthentication), - errors.Contains(err, apiutil.ErrBearerToken): - w.WriteHeader(http.StatusUnauthorized) - case errors.Contains(err, apiutil.ErrUnsupportedContentType): - w.WriteHeader(http.StatusUnsupportedMediaType) - case errors.Contains(err, svcerr.ErrMalformedEntity), - errors.Contains(err, apiutil.ErrMissingID), - errors.Contains(err, apiutil.ErrMissingCertData), - errors.Contains(err, apiutil.ErrInvalidCertData), - errors.Contains(err, apiutil.ErrLimitSize): - w.WriteHeader(http.StatusBadRequest) - case errors.Contains(err, svcerr.ErrConflict): - w.WriteHeader(http.StatusConflict) - case errors.Contains(err, svcerr.ErrCreateEntity), - errors.Contains(err, svcerr.ErrViewEntity), - errors.Contains(err, svcerr.ErrRemoveEntity): - w.WriteHeader(http.StatusInternalServerError) - - default: - w.WriteHeader(http.StatusInternalServerError) - } - - if wrapper != nil { - err = errors.Wrap(wrapper, err) - } - - if errorVal, ok := err.(errors.Error); ok { - w.Header().Set("Content-Type", contentType) - if err := json.NewEncoder(w).Encode(errorVal); err != nil { - w.WriteHeader(http.StatusInternalServerError) - } - } -} diff --git a/internal/api/common.go b/internal/api/common.go index 39eb21405..c0cb57eae 100644 --- a/internal/api/common.go +++ b/internal/api/common.go @@ -130,6 +130,8 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) { errors.Contains(err, errors.ErrPasswordFormat), errors.Contains(err, apiutil.ErrInvalidLevel), errors.Contains(err, apiutil.ErrBootstrapState), + errors.Contains(err, apiutil.ErrMissingCertData), + errors.Contains(err, apiutil.ErrInvalidCertData), errors.Contains(err, apiutil.ErrInvalidQueryParams): w.WriteHeader(http.StatusBadRequest) case errors.Contains(err, svcerr.ErrAuthentication), diff --git a/pkg/sdk/go/certs_test.go b/pkg/sdk/go/certs_test.go index b7e27ba9f..4f37424b0 100644 --- a/pkg/sdk/go/certs_test.go +++ b/pkg/sdk/go/certs_test.go @@ -202,7 +202,7 @@ func TestViewCert(t *testing.T) { desc: "get non-existent cert", certID: "43", token: token, - err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, svcerr.ErrNotFound), http.StatusInternalServerError), + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, svcerr.ErrNotFound), http.StatusNotFound), response: sdk.Subscription{}, }, { @@ -266,7 +266,7 @@ func TestViewCertByThing(t *testing.T) { desc: "get non-existent cert", thingID: "43", token: token, - err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, errors.ErrNotFound), http.StatusInternalServerError), + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, errors.ErrNotFound), http.StatusNotFound), response: sdk.Subscription{}, }, { @@ -328,7 +328,7 @@ func TestRevokeCert(t *testing.T) { desc: "revoke non-existing cert", thingID: "2", token: token, - err: errors.NewSDKErrorWithStatus(errors.Wrap(certs.ErrFailedCertRevocation, svcerr.ErrNotFound), http.StatusInternalServerError), + err: errors.NewSDKErrorWithStatus(errors.Wrap(certs.ErrFailedCertRevocation, svcerr.ErrNotFound), http.StatusNotFound), }, { desc: "revoke cert with empty token", @@ -346,7 +346,7 @@ func TestRevokeCert(t *testing.T) { desc: "revoke deleted cert", thingID: thingID, token: token, - err: errors.NewSDKErrorWithStatus(errors.Wrap(certs.ErrFailedToRemoveCertFromDB, svcerr.ErrNotFound), http.StatusInternalServerError), + err: errors.NewSDKErrorWithStatus(errors.Wrap(certs.ErrFailedToRemoveCertFromDB, svcerr.ErrNotFound), http.StatusNotFound), }, }