NOISSUE - Add property based testing to bootstrap API (#2095)

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>
This commit is contained in:
b1ackd0t
2024-04-12 18:05:03 +03:00
committed by GitHub
parent cdf18dc972
commit 3d0678725e
6 changed files with 169 additions and 149 deletions
+12 -1
View File
@@ -32,6 +32,7 @@ env:
THINGS_URL: http://localhost:9000
INVITATIONS_URL: http://localhost:9020
AUTH_URL: http://localhost:8189
BOOTSTRAP_URL: http://localhost:9013
jobs:
api-test:
@@ -50,7 +51,7 @@ jobs:
run: make all -j $(nproc) && make dockers_dev -j $(nproc)
- name: Start containers
run: make run up args="-d" && sleep 10
run: make run up args="-d" && make run_addons up args="-d"
- name: Set access token
run: |
@@ -159,6 +160,16 @@ jobs:
report: false
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links'
- name: Run Bootstrap API tests
if: steps.changes.outputs.bootstrap == 'true'
uses: schemathesis/action@v1
with:
schema: api/openapi/bootstrap.yml
base-url: ${{ env.BOOTSTRAP_URL }}
checks: all
report: false
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links'
- name: Stop containers
if: always()
run: make run down args="-v"
+1
View File
@@ -154,6 +154,7 @@ test_api_users: TEST_API_URL := http://localhost:9002
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):
$(call test_api_service,$(@),$(TEST_API_URL))
+134 -64
View File
@@ -25,10 +25,11 @@ tags:
externalDocs:
description: Find out more about Configs
url: https://docs.magistrala.abstractmachines.fr/
paths:
/things/configs:
post:
operationId: createConfig
summary: Adds new config
description: |
Adds new config to the list of config owned by user identified using
@@ -38,17 +39,28 @@ paths:
requestBody:
$ref: "#/components/requestBodies/ConfigCreateReq"
responses:
'201':
"201":
$ref: "#/components/responses/ConfigCreateRes"
'400':
"400":
description: Failed due to malformed JSON.
'401':
"401":
description: Missing or invalid access token provided.
'415':
"403":
description: Failed to perform authorization over the entity.
"404":
description: A non-existent entity request.
"409":
description: Failed due to using an existing identity.
"415":
description: Missing or invalid content type.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
"503":
description: Failed to receive response from the things service.
get:
operationId: getConfigs
summary: Retrieves managed configs
description: |
Retrieves a list of managed configs. Due to performance concerns, data
@@ -63,31 +75,37 @@ paths:
- $ref: "#/components/parameters/State"
- $ref: "#/components/parameters/Name"
responses:
'200':
"200":
$ref: "#/components/responses/ConfigListRes"
'400':
"400":
description: Failed due to malformed query parameters.
'401':
"401":
description: Missing or invalid access token provided.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/configs/{configId}:
get:
operationId: getConfig
summary: Retrieves config info (with channels).
tags:
- configs
parameters:
- $ref: "#/components/parameters/ConfigId"
responses:
'200':
"200":
$ref: "#/components/responses/ConfigRes"
'401':
"401":
description: Missing or invalid access token provided.
'404':
"404":
description: Config does not exist.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
put:
operationId: updateConfig
summary: Updates config info
description: |
Update is performed by replacing the current resource data with values
@@ -98,21 +116,24 @@ paths:
parameters:
- $ref: "#/components/parameters/ConfigId"
requestBody:
$ref: "#/components/requestBodies/ConfigUpdateReq"
$ref: "#/components/requestBodies/ConfigUpdateReq"
responses:
'200':
"200":
description: Config updated.
'400':
"400":
description: Failed due to malformed JSON.
'401':
"401":
description: Missing or invalid access token provided.
'404':
"404":
description: Config does not exist.
'415':
"415":
description: Missing or invalid content type.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
delete:
operationId: removeConfig
summary: Removes a Config
description: |
Removes a Config. In case of successful removal the service will ensure
@@ -122,16 +143,19 @@ paths:
parameters:
- $ref: "#/components/parameters/ConfigId"
responses:
'204':
"204":
description: Config removed.
'400':
"400":
description: Failed due to malformed config ID.
'401':
"401":
description: Missing or invalid access token provided.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/configs/certs/{configId}:
patch:
operationId: updateConfigCerts
summary: Updates certs
description: |
Update is performed by replacing the current certificate data with values
@@ -143,21 +167,24 @@ paths:
requestBody:
$ref: "#/components/requestBodies/ConfigCertUpdateReq"
responses:
'200':
"200":
description: Config updated.
$ref: "#/components/responses/ConfigUpdateCertsRes"
'400':
"400":
description: Failed due to malformed JSON.
'401':
"401":
description: Missing or invalid access token provided.
'404':
"404":
description: Config does not exist.
'415':
"415":
description: Missing or invalid content type.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/configs/connections/{configId}:
put:
operationId: updateConfigConnections
summary: Updates channels the thing is connected to
description: |
Update connections performs update of the channel list corresponding
@@ -169,20 +196,23 @@ paths:
requestBody:
$ref: "#/components/requestBodies/ConfigConnUpdateReq"
responses:
'200':
"200":
description: Config updated.
'400':
"400":
description: Failed due to malformed JSON.
'401':
"401":
description: Missing or invalid access token provided.
'404':
"404":
description: Config does not exist.
'415':
"415":
description: Missing or invalid content type.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/bootstrap/{externalId}:
get:
operationId: getBootstrapConfig
summary: Retrieves configuration.
description: |
Retrieves a configuration with given external ID and external key.
@@ -193,18 +223,21 @@ paths:
parameters:
- $ref: "#/components/parameters/ExternalId"
responses:
'200':
"200":
$ref: "#/components/responses/BootstrapConfigRes"
'400':
"400":
description: Failed due to malformed JSON.
'401':
"401":
description: Missing or invalid external key provided.
'404':
"404":
description: Failed to retrieve corresponding config.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/bootstrap/secure/{externalId}:
get:
operationId: getSecureBootstrapConfig
summary: Retrieves configuration.
description: |
Retrieves a configuration with given external ID and encrypted external key.
@@ -215,15 +248,22 @@ paths:
parameters:
- $ref: "#/components/parameters/ExternalId"
responses:
'200':
"200":
$ref: "#/components/responses/BootstrapConfigRes"
'404':
"400":
description: Failed due to malformed JSON.
"401":
description: Missing or invalid access token provided.
"404":
description: |
Failed to retrieve corresponding config.
'500':
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/things/state/{configId}:
put:
operationId: updateConfigState
summary: Updates Config state.
description: |
Updating state represents enabling/disabling Config, i.e. connecting
@@ -233,15 +273,21 @@ paths:
parameters:
- $ref: "#/components/parameters/ConfigId"
requestBody:
$ref: '#/components/requestBodies/ConfigStateUpdateReq'
$ref: "#/components/requestBodies/ConfigStateUpdateReq"
responses:
'204':
"204":
description: Config removed.
'400':
"400":
description: Failed due to malformed config's ID.
'401':
"401":
description: Missing or invalid access token provided.
'500':
"404":
description: A non-existent entity request.
"415":
description: Missing or invalid content type.
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/health:
get:
@@ -249,9 +295,9 @@ paths:
tags:
- health
responses:
'200':
"200":
$ref: "#/components/responses/HealthRes"
'500':
"500":
$ref: "#/components/responses/ServiceError"
components:
@@ -453,12 +499,14 @@ components:
description: External key.
thing_id:
type: string
format: uuid
description: ID of the corresponding Magistrala Thing.
channels:
type: array
minItems: 0
items:
type: string
format: uuid
content:
type: string
name:
@@ -468,7 +516,7 @@ components:
description: Thing Certificate.
client_key:
type: string
description: Thing Private Key.
description: Thing Private Key.
ca_cert:
type: string
required:
@@ -513,6 +561,7 @@ components:
minItems: 0
items:
type: string
format: uuid
ConfigStateUpdateReq:
description: Update the state of the Config.
content:
@@ -525,14 +574,14 @@ components:
responses:
ConfigCreateRes:
description: Config registered.
headers:
Location:
content:
text/plain:
schema:
type: string
description: Created configuration's relative URL (i.e. /things/configs/{configId}).
description: Config registered.
headers:
Location:
content:
text/plain:
schema:
type: string
description: Created configuration's relative URL (i.e. /things/configs/{configId}).
ConfigListRes:
description: Data retrieved. Configs from this list don't contain channels.
content:
@@ -545,10 +594,31 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Config"
links:
update:
operationId: updateConfig
parameters:
configId: $response.body#/id
updateCerts:
operationId: updateConfigCerts
parameters:
configId: $response.body#/id
updateConnections:
operationId: updateConfigConnections
parameters:
configId: $response.body#/id
updateState:
operationId: updateConfigState
parameters:
configId: $response.body#/id
delete:
operationId: removeConfig
parameters:
configId: $response.body#/id
BootstrapConfigRes:
description: |
Data retrieved. If secure, a response is encrypted using
the secret key, so the response is in the binary form.
Data retrieved. If secure, a response is encrypted using
the secret key, so the response is in the binary form.
content:
application/json:
schema:
@@ -558,7 +628,7 @@ components:
HealthRes:
description: Service Health Check.
content:
application/json:
application/health+json:
schema:
$ref: "./schemas/HealthInfo.yml"
ConfigUpdateCertsRes:
+1 -1
View File
@@ -1162,7 +1162,7 @@ func TestBootstrap(t *testing.T) {
desc: "bootstrap a Thing with an empty key",
externalID: c.ExternalID,
externalKey: "",
status: http.StatusUnauthorized,
status: http.StatusBadRequest,
res: missingKeyRes,
secure: false,
err: errors.Wrap(bootstrap.ErrBootstrap, svcerr.ErrAuthentication),
+12 -80
View File
@@ -13,9 +13,9 @@ import (
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/internal/api"
"github.com/absmach/magistrala/internal/apiutil"
"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"
@@ -41,7 +41,7 @@ var (
// MakeHandler returns a HTTP handler for API endpoints.
func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *slog.Logger, instanceID string) http.Handler {
opts := []kithttp.ServerOption{
kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, encodeError)),
kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)),
}
r := chi.NewRouter()
@@ -51,43 +51,43 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s
r.Post("/", otelhttp.NewHandler(kithttp.NewServer(
addEndpoint(svc),
decodeAddRequest,
encodeResponse,
api.EncodeResponse,
opts...), "add").ServeHTTP)
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
listEndpoint(svc),
decodeListRequest,
encodeResponse,
api.EncodeResponse,
opts...), "list").ServeHTTP)
r.Get("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
viewEndpoint(svc),
decodeEntityRequest,
encodeResponse,
api.EncodeResponse,
opts...), "view").ServeHTTP)
r.Put("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
updateEndpoint(svc),
decodeUpdateRequest,
encodeResponse,
api.EncodeResponse,
opts...), "update").ServeHTTP)
r.Delete("/{configID}", otelhttp.NewHandler(kithttp.NewServer(
removeEndpoint(svc),
decodeEntityRequest,
encodeResponse,
api.EncodeResponse,
opts...), "remove").ServeHTTP)
r.Patch("/certs/{certID}", otelhttp.NewHandler(kithttp.NewServer(
updateCertEndpoint(svc),
decodeUpdateCertRequest,
encodeResponse,
api.EncodeResponse,
opts...), "update_cert").ServeHTTP)
r.Put("/connections/{connID}", otelhttp.NewHandler(kithttp.NewServer(
updateConnEndpoint(svc),
decodeUpdateConnRequest,
encodeResponse,
api.EncodeResponse,
opts...), "update_connections").ServeHTTP)
})
@@ -95,12 +95,12 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, false),
decodeBootstrapRequest,
encodeResponse,
api.EncodeResponse,
opts...), "bootstrap").ServeHTTP)
r.Get("/{externalID}", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, false),
decodeBootstrapRequest,
encodeResponse,
api.EncodeResponse,
opts...), "bootstrap").ServeHTTP)
r.Get("/secure/{externalID}", otelhttp.NewHandler(kithttp.NewServer(
bootstrapEndpoint(svc, reader, true),
@@ -112,7 +112,7 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s
r.Put("/state/{thingID}", otelhttp.NewHandler(kithttp.NewServer(
stateEndpoint(svc),
decodeStateRequest,
encodeResponse,
api.EncodeResponse,
opts...), "update_state").ServeHTTP)
})
r.Get("/health", magistrala.Health("bootstrap", instanceID))
@@ -242,23 +242,6 @@ func decodeEntityRequest(_ context.Context, r *http.Request) (interface{}, error
return req, nil
}
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 encodeSecureRes(_ context.Context, w http.ResponseWriter, response interface{}) error {
w.Header().Set("Content-Type", byteContentType)
w.WriteHeader(http.StatusOK)
@@ -270,57 +253,6 @@ func encodeSecureRes(_ context.Context, w http.ResponseWriter, response interfac
return 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),
errors.Contains(err, apiutil.ErrBearerKey):
w.WriteHeader(http.StatusUnauthorized)
case errors.Contains(err, apiutil.ErrUnsupportedContentType):
w.WriteHeader(http.StatusUnsupportedMediaType)
case errors.Contains(err, apiutil.ErrInvalidQueryParams),
errors.Contains(err, svcerr.ErrMalformedEntity),
errors.Contains(err, apiutil.ErrMissingID),
errors.Contains(err, apiutil.ErrBootstrapState),
errors.Contains(err, apiutil.ErrLimitSize):
w.WriteHeader(http.StatusBadRequest)
case errors.Contains(err, svcerr.ErrNotFound):
w.WriteHeader(http.StatusNotFound)
case errors.Contains(err, bootstrap.ErrExternalKey),
errors.Contains(err, bootstrap.ErrExternalKeySecure),
errors.Contains(err, svcerr.ErrAuthorization):
w.WriteHeader(http.StatusForbidden)
case errors.Contains(err, bootstrap.ErrThings):
w.WriteHeader(http.StatusServiceUnavailable)
case errors.Contains(err, svcerr.ErrConflict):
w.WriteHeader(http.StatusConflict)
case errors.Contains(err, svcerr.ErrCreateEntity),
errors.Contains(err, svcerr.ErrUpdateEntity),
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)
}
}
}
func parseFilter(values url.Values) bootstrap.Filter {
ret := bootstrap.Filter{
FullMatch: make(map[string]string),
+9 -3
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/bootstrap"
"github.com/absmach/magistrala/internal/apiutil"
mgclients "github.com/absmach/magistrala/pkg/clients"
"github.com/absmach/magistrala/pkg/errors"
@@ -129,10 +130,11 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) {
errors.Contains(err, apiutil.ErrMissingRelation),
errors.Contains(err, apiutil.ErrPasswordFormat),
errors.Contains(err, apiutil.ErrInvalidLevel),
errors.Contains(err, apiutil.ErrInvalidQueryParams),
errors.Contains(err, apiutil.ErrMalformedPolicy),
errors.Contains(err, apiutil.ErrInvalidAPIKey),
errors.Contains(err, apiutil.ErrMissingName):
errors.Contains(err, apiutil.ErrMissingName),
errors.Contains(err, apiutil.ErrBootstrapState),
errors.Contains(err, apiutil.ErrInvalidQueryParams):
w.WriteHeader(http.StatusBadRequest)
case errors.Contains(err, svcerr.ErrAuthentication),
errors.Contains(err, svcerr.ErrLogin),
@@ -144,7 +146,9 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) {
errors.Contains(err, errors.ErrStatusAlreadyAssigned):
w.WriteHeader(http.StatusConflict)
case errors.Contains(err, svcerr.ErrAuthorization),
errors.Contains(err, svcerr.ErrDomainAuthorization):
errors.Contains(err, svcerr.ErrDomainAuthorization),
errors.Contains(err, bootstrap.ErrExternalKey),
errors.Contains(err, bootstrap.ErrExternalKeySecure):
w.WriteHeader(http.StatusForbidden)
case errors.Contains(err, apiutil.ErrUnsupportedContentType):
w.WriteHeader(http.StatusUnsupportedMediaType)
@@ -156,6 +160,8 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) {
errors.Contains(err, svcerr.ErrDeletePolicies),
errors.Contains(err, svcerr.ErrRemoveEntity):
w.WriteHeader(http.StatusUnprocessableEntity)
case errors.Contains(err, bootstrap.ErrThings):
w.WriteHeader(http.StatusServiceUnavailable)
default:
w.WriteHeader(http.StatusInternalServerError)
}