From 42bff043b46252105c57094c6fe6f60f15b3831b Mon Sep 17 00:00:00 2001 From: Andres Uribe Date: Fri, 9 Dec 2022 16:02:06 -0500 Subject: [PATCH] [OSE-177] Added GetOperation and ReviewSubmission impl (#201) * Added GetOperation and ReviewSubmission impl * Minor improvements * spec * validation for id * Moving stuff and added validation * Making things transactional. * Preventing multiple updates to the same submission. * PR comments * cleanup in the db setup * Use temp files to avoid timeouts and contention * Merge from main fixes. * docs --- doc/swagger.yaml | 150 +++++++++--- pkg/server/router/operation.go | 42 +++- pkg/server/router/presentation.go | 55 ++++- pkg/server/router/presentation_test.go | 15 +- pkg/server/server_operation_test.go | 127 +++++++++- pkg/server/server_presentation_test.go | 111 ++++++--- pkg/service/operation/model.go | 20 +- pkg/service/operation/service.go | 55 ++++- pkg/service/operation/storage/bolt.go | 28 +-- pkg/service/operation/storage/storage.go | 4 +- pkg/service/presentation/{ => model}/model.go | 27 +- pkg/service/presentation/service.go | 47 ++-- pkg/service/presentation/storage/bolt.go | 61 ++++- pkg/service/presentation/storage/storage.go | 12 +- pkg/storage/bolt.go | 103 ++++++++ pkg/storage/bolt_test.go | 230 ++++++++++++++++-- sip/sips/sip6/README.md | 6 +- 17 files changed, 906 insertions(+), 187 deletions(-) rename pkg/service/presentation/{ => model}/model.go (71%) diff --git a/doc/swagger.yaml b/doc/swagger.yaml index 0052fcb23..5e84b498d 100644 --- a/doc/swagger.yaml +++ b/doc/swagger.yaml @@ -766,8 +766,11 @@ definitions: type: array id: type: string + reason: + description: The reason why the submission was approved or denied. + type: string status: - description: One of {`pending`, `done`}. + description: One of {`pending`, `approved`, `denied`}. type: string required: - definition_id @@ -831,8 +834,25 @@ definitions: type: object github.com_tbd54566975_ssi-service_pkg_server_router.ReviewSubmissionResponse: properties: - submission: - $ref: '#/definitions/exchange.PresentationSubmission' + definition_id: + type: string + descriptor_map: + items: + $ref: '#/definitions/exchange.SubmissionDescriptor' + type: array + id: + type: string + reason: + description: The reason why the submission was approved or denied. + type: string + status: + description: One of {`pending`, `approved`, `denied`}. + type: string + required: + - definition_id + - descriptor_map + - id + - status type: object github.com_tbd54566975_ssi-service_pkg_server_router.StoreKeyRequest: properties: @@ -1318,8 +1338,11 @@ definitions: type: array id: type: string + reason: + description: The reason why the submission was approved or denied. + type: string status: - description: One of {`pending`, `done`}. + description: One of {`pending`, `approved`, `denied`}. type: string required: - definition_id @@ -1383,8 +1406,25 @@ definitions: type: object pkg_server_router.ReviewSubmissionResponse: properties: - submission: - $ref: '#/definitions/exchange.PresentationSubmission' + definition_id: + type: string + descriptor_map: + items: + $ref: '#/definitions/exchange.SubmissionDescriptor' + type: array + id: + type: string + reason: + description: The reason why the submission was approved or denied. + type: string + status: + description: One of {`pending`, `approved`, `denied`}. + type: string + required: + - definition_id + - descriptor_map + - id + - status type: object pkg_server_router.StoreKeyRequest: properties: @@ -1471,8 +1511,11 @@ definitions: type: array id: type: string + reason: + description: The reason why the submission was approved or denied. + type: string status: - description: One of {`pending`, `done`}. + description: One of {`pending`, `approved`, `denied`}. type: string required: - definition_id @@ -1871,7 +1914,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/pkg_server_router.GetDIDMethodsResponse' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.GetDIDMethodsResponse' summary: Get DID Methods tags: - DecentralizedIdentityAPI @@ -1892,7 +1935,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/pkg_server_router.GetDIDsByMethodResponse' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.GetDIDsByMethodResponse' "400": description: Bad request schema: @@ -1910,7 +1953,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/pkg_server_router.CreateDIDByMethodRequest' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.CreateDIDByMethodRequest' - description: Method in: path name: method @@ -1922,7 +1965,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/pkg_server_router.CreateDIDByMethodResponse' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.CreateDIDByMethodResponse' "400": description: Bad request schema: @@ -1945,7 +1988,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/pkg_server_router.CreateDIDByMethodRequest' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.CreateDIDByMethodRequest' - description: Method in: path name: method @@ -1962,7 +2005,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/pkg_server_router.GetDIDByMethodResponse' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.GetDIDByMethodResponse' "400": description: Bad request schema: @@ -1987,7 +2030,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/pkg_server_router.ResolveDIDResponse' + $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.ResolveDIDResponse' "400": description: Bad request schema: @@ -2492,52 +2535,55 @@ paths: summary: Get PresentationDefinition tags: - PresentationDefinitionAPI - /v1/presentations/submission/{id}: + /v1/presentations/submissions: get: consumes: - application/json - description: Get a submission by its ID + description: List existing submissions according to a filtering query. The `filter` + field follows the syntax described in https://google.aip.dev/160. parameters: - - description: ID - in: path - name: id + - description: request body + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/pkg_server_router.ListSubmissionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/pkg_server_router.GetSubmissionResponse' + $ref: '#/definitions/pkg_server_router.ListSubmissionResponse' "400": description: Bad request schema: type: string - summary: Get Submission + "500": + description: Internal server error + schema: + type: string + summary: List Submissions tags: - SubmissionAPI - /v1/presentations/submissions: - get: + put: consumes: - application/json - description: Reviews a pending submission. After this method is called, the - operation with `id==presentations/submissions/{submission_id}` will be updated - with the result of this invocation. + description: Creates a submission in this server ready to be reviewed. parameters: - description: request body in: body name: request required: true schema: - $ref: '#/definitions/pkg_server_router.ReviewSubmissionRequest' + $ref: '#/definitions/pkg_server_router.CreateSubmissionRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: The type of response is Submission once the operation has finished. schema: - $ref: '#/definitions/pkg_server_router.ReviewSubmissionResponse' + $ref: '#/definitions/pkg_server_router.Operation' "400": description: Bad request schema: @@ -2546,27 +2592,55 @@ paths: description: Internal server error schema: type: string - summary: Review a pending submissions + summary: Create Submission + tags: + - SubmissionAPI + /v1/presentations/submissions/{id}: + get: + consumes: + - application/json + description: Get a submission by its ID + parameters: + - description: ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_server_router.GetSubmissionResponse' + "400": + description: Bad request + schema: + type: string + summary: Get Submission tags: - SubmissionAPI + /v1/presentations/submissions/{id}/review: put: consumes: - application/json - description: Creates a submission in this server ready to be reviewed. + description: Reviews a pending submission. After this method is called, the + operation with `id==presentations/submissions/{submission_id}` will be updated + with the result of this invocation. parameters: - description: request body in: body name: request required: true schema: - $ref: '#/definitions/pkg_server_router.CreateSubmissionRequest' + $ref: '#/definitions/pkg_server_router.ReviewSubmissionRequest' produces: - application/json responses: - "201": - description: The type of response is Submission once the operation has finished. + "200": + description: OK schema: - $ref: '#/definitions/pkg_server_router.Operation' + $ref: '#/definitions/pkg_server_router.ReviewSubmissionResponse' "400": description: Bad request schema: @@ -2575,7 +2649,7 @@ paths: description: Internal server error schema: type: string - summary: Create Submission + summary: Review a pending submissions tags: - SubmissionAPI /v1/schemas: diff --git a/pkg/server/router/operation.go b/pkg/server/router/operation.go index 20c65357b..f4d841051 100644 --- a/pkg/server/router/operation.go +++ b/pkg/server/router/operation.go @@ -39,8 +39,20 @@ func NewOperationRouter(s svcframework.Service) (*OperationRouter, error) { // @Failure 400 {string} string "Bad request" // @Failure 500 {string} string "Internal server error" // @Router /v1/operations/{id} [get] -func (pdr OperationRouter) GetOperation(ctx context.Context, w http.ResponseWriter, r *http.Request) error { - return nil +func (o OperationRouter) GetOperation(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + id := framework.GetParam(ctx, IDParam) + if id == nil { + return framework.NewRequestError( + util.LoggingNewError("get operation request requires id"), http.StatusBadRequest) + } + + op, err := o.service.GetOperation(operation.GetOperationRequest{ID: *id}) + + if err != nil { + return framework.NewRequestError( + util.LoggingErrorMsg(err, "failed getting operation"), http.StatusInternalServerError) + } + return framework.Respond(ctx, w, routerModel(op), http.StatusOK) } type GetOperationsRequest struct { @@ -108,7 +120,7 @@ type GetOperationsResponse struct { // @Failure 400 {string} string "Bad request" // @Failure 500 {string} string "Internal server error" // @Router /v1/operations [get] -func (pdr OperationRouter) GetOperations(ctx context.Context, w http.ResponseWriter, r *http.Request) error { +func (o OperationRouter) GetOperations(ctx context.Context, w http.ResponseWriter, r *http.Request) error { var request GetOperationsRequest if err := framework.Decode(r, &request); err != nil { return framework.NewRequestError( @@ -126,21 +138,29 @@ func (pdr OperationRouter) GetOperations(ctx context.Context, w http.ResponseWri util.LoggingErrorMsg(err, "invalid get operations request"), http.StatusBadRequest) } - ops, err := pdr.service.GetOperations(req) + ops, err := o.service.GetOperations(req) if err != nil { logrus.WithError(err).Error("getting operations from service") return framework.NewRequestError(err, http.StatusInternalServerError) } - resp := GetOperationsResponse{Operations: make([]Operation, len(ops.Operations))} - for i, op := range ops.Operations { - resp.Operations[i].ID = op.ID - resp.Operations[i].Done = op.Done - resp.Operations[i].Result.Error = op.Result.Error - resp.Operations[i].Result.Response = op.Result.Response + resp := GetOperationsResponse{Operations: make([]Operation, 0, len(ops.Operations))} + for _, op := range ops.Operations { + resp.Operations = append(resp.Operations, routerModel(op)) } return framework.Respond(ctx, w, resp, http.StatusOK) } +func routerModel(op operation.Operation) Operation { + return Operation{ + ID: op.ID, + Done: op.Done, + Result: OperationResult{ + Error: op.Result.Error, + Response: op.Result.Response, + }, + } +} + // CancelOperation godoc // @Summary Cancel an ongoing operation // @Description Cancels an ongoing operation, if possible. @@ -152,6 +172,6 @@ func (pdr OperationRouter) GetOperations(ctx context.Context, w http.ResponseWri // @Failure 400 {string} string "Bad request" // @Failure 500 {string} string "Internal server error" // @Router /v1/operations [get] -func (pdr OperationRouter) CancelOperation(ctx context.Context, w http.ResponseWriter, r *http.Request) error { +func (o OperationRouter) CancelOperation(ctx context.Context, w http.ResponseWriter, r *http.Request) error { return nil } diff --git a/pkg/server/router/presentation.go b/pkg/server/router/presentation.go index c41c05017..bc1d2e705 100644 --- a/pkg/server/router/presentation.go +++ b/pkg/server/router/presentation.go @@ -14,6 +14,7 @@ import ( "github.com/tbd54566975/ssi-service/pkg/server/framework" svcframework "github.com/tbd54566975/ssi-service/pkg/service/framework" "github.com/tbd54566975/ssi-service/pkg/service/presentation" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/model" "go.einride.tech/aip/filtering" "net/http" ) @@ -74,7 +75,7 @@ func (pr PresentationRouter) CreatePresentationDefinition(ctx context.Context, w logrus.WithError(err).Error(errMsg) return framework.NewRequestError(errors.Wrap(err, errMsg), http.StatusBadRequest) } - serviceResp, err := pr.service.CreatePresentationDefinition(presentation.CreatePresentationDefinitionRequest{PresentationDefinition: *def}) + serviceResp, err := pr.service.CreatePresentationDefinition(model.CreatePresentationDefinitionRequest{PresentationDefinition: *def}) if err != nil { logrus.WithError(err).Error(errMsg) return framework.NewRequestError(errors.Wrap(err, errMsg), http.StatusInternalServerError) @@ -139,7 +140,7 @@ func (pr PresentationRouter) GetPresentationDefinition(ctx context.Context, w ht return framework.NewRequestErrorMsg(errMsg, http.StatusBadRequest) } - def, err := pr.service.GetPresentationDefinition(presentation.GetPresentationDefinitionRequest{ID: *id}) + def, err := pr.service.GetPresentationDefinition(model.GetPresentationDefinitionRequest{ID: *id}) if err != nil { errMsg := fmt.Sprintf("could not get presentation with id: %s", *id) logrus.WithError(err).Error(errMsg) @@ -171,7 +172,7 @@ func (pr PresentationRouter) DeletePresentationDefinition(ctx context.Context, w return framework.NewRequestErrorMsg(errMsg, http.StatusBadRequest) } - if err := pr.service.DeletePresentationDefinition(presentation.DeletePresentationDefinitionRequest{ID: *id}); err != nil { + if err := pr.service.DeletePresentationDefinition(model.DeletePresentationDefinitionRequest{ID: *id}); err != nil { errMsg := fmt.Sprintf("could not delete presentation with id: %s", *id) logrus.WithError(err).Error(errMsg) return framework.NewRequestError(errors.Wrap(err, errMsg), http.StatusInternalServerError) @@ -184,7 +185,7 @@ type CreateSubmissionRequest struct { SubmissionJWT keyaccess.JWT `json:"submissionJwt" validate:"required"` } -func (r CreateSubmissionRequest) toServiceRequest() (*presentation.CreateSubmissionRequest, error) { +func (r CreateSubmissionRequest) toServiceRequest() (*model.CreateSubmissionRequest, error) { sdkVP, err := signing.ParseVerifiablePresentationFromJWT(r.SubmissionJWT.String()) if err != nil { return nil, errors.Wrap(err, "parsing presentation from jwt") @@ -211,7 +212,7 @@ func (r CreateSubmissionRequest) toServiceRequest() (*presentation.CreateSubmiss return nil, errors.Wrap(err, "parsing verifiable credential array") } - return &presentation.CreateSubmissionRequest{ + return &model.CreateSubmissionRequest{ Presentation: *sdkVP, SubmissionJWT: r.SubmissionJWT, Submission: s, @@ -274,7 +275,7 @@ func (pr PresentationRouter) CreateSubmission(ctx context.Context, w http.Respon } type GetSubmissionResponse struct { - *presentation.Submission + *model.Submission } // GetSubmission godoc @@ -286,7 +287,7 @@ type GetSubmissionResponse struct { // @Param id path string true "ID" // @Success 200 {object} GetSubmissionResponse // @Failure 400 {string} string "Bad request" -// @Router /v1/presentations/submission/{id} [get] +// @Router /v1/presentations/submissions/{id} [get] func (pr PresentationRouter) GetSubmission(ctx context.Context, w http.ResponseWriter, r *http.Request) error { id := framework.GetParam(ctx, IDParam) if id == nil { @@ -294,7 +295,7 @@ func (pr PresentationRouter) GetSubmission(ctx context.Context, w http.ResponseW util.LoggingNewError("get submission request requires id"), http.StatusBadRequest) } - submission, err := pr.service.GetSubmission(presentation.GetSubmissionRequest{ID: *id}) + submission, err := pr.service.GetSubmission(model.GetSubmissionRequest{ID: *id}) if err != nil { return framework.NewRequestError( @@ -317,7 +318,7 @@ func (l ListSubmissionRequest) GetFilter() string { } type ListSubmissionResponse struct { - Submissions []presentation.Submission `json:"submissions"` + Submissions []model.Submission `json:"submissions"` } // ListSubmissions godoc @@ -363,7 +364,7 @@ func (pr PresentationRouter) ListSubmissions(ctx context.Context, w http.Respons return framework.NewRequestError( util.LoggingErrorMsg(err, "invalid filter"), http.StatusBadRequest) } - resp, err := pr.service.ListSubmissions(presentation.ListSubmissionRequest{ + resp, err := pr.service.ListSubmissions(model.ListSubmissionRequest{ Filter: filter, }) if err != nil { @@ -378,8 +379,16 @@ type ReviewSubmissionRequest struct { Reason string `json:"reason"` } +func (r ReviewSubmissionRequest) toServiceRequest(id string) model.ReviewSubmissionRequest { + return model.ReviewSubmissionRequest{ + ID: id, + Approved: r.Approved, + Reason: r.Reason, + } +} + type ReviewSubmissionResponse struct { - Submission exchange.PresentationSubmission `json:"submission"` + *model.Submission } // ReviewSubmission godoc @@ -392,7 +401,27 @@ type ReviewSubmissionResponse struct { // @Success 200 {object} ReviewSubmissionResponse // @Failure 400 {string} string "Bad request" // @Failure 500 {string} string "Internal server error" -// @Router /v1/presentations/submissions [get] +// @Router /v1/presentations/submissions/{id}/review [put] func (pr PresentationRouter) ReviewSubmission(ctx context.Context, w http.ResponseWriter, r *http.Request) error { - return framework.Respond(ctx, w, ReviewSubmissionResponse{}, http.StatusOK) + id := framework.GetParam(ctx, IDParam) + if id == nil { + return framework.NewRequestError( + util.LoggingNewError("review submission request requires id"), http.StatusBadRequest) + } + + var request ReviewSubmissionRequest + if err := framework.Decode(r, &request); err != nil { + return framework.NewRequestError( + util.LoggingErrorMsg(err, "invalid review submissions request"), http.StatusBadRequest) + } + + req := request.toServiceRequest(*id) + submission, err := pr.service.ReviewSubmission(req) + if err != nil { + return framework.NewRequestError( + util.LoggingErrorMsg(err, "failed reviewing submission"), http.StatusInternalServerError) + } + return framework.Respond(ctx, w, ReviewSubmissionResponse{ + Submission: submission, + }, http.StatusOK) } diff --git a/pkg/server/router/presentation_test.go b/pkg/server/router/presentation_test.go index 3f51be5ed..2a705225d 100644 --- a/pkg/server/router/presentation_test.go +++ b/pkg/server/router/presentation_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/tbd54566975/ssi-service/config" "github.com/tbd54566975/ssi-service/pkg/service/presentation" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/model" "github.com/tbd54566975/ssi-service/pkg/storage" "os" "testing" @@ -44,17 +45,17 @@ func TestPresentationDefinitionService(t *testing.T) { t.Run("Create returns the created definition", func(t *testing.T) { pd := createPresentationDefinition(t) - created, err := service.CreatePresentationDefinition(presentation.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) + created, err := service.CreatePresentationDefinition(model.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) assert.NoError(t, err) assert.Equal(t, pd, &created.PresentationDefinition) }) t.Run("Get returns the created definition", func(t *testing.T) { pd := createPresentationDefinition(t) - _, err := service.CreatePresentationDefinition(presentation.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) + _, err := service.CreatePresentationDefinition(model.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) assert.NoError(t, err) - getPd, err := service.GetPresentationDefinition(presentation.GetPresentationDefinitionRequest{ID: pd.ID}) + getPd, err := service.GetPresentationDefinition(model.GetPresentationDefinitionRequest{ID: pd.ID}) assert.NoError(t, err) assert.Equal(t, pd.ID, getPd.ID) @@ -63,17 +64,17 @@ func TestPresentationDefinitionService(t *testing.T) { t.Run("Get does not return after deletion", func(t *testing.T) { pd := createPresentationDefinition(t) - _, err := service.CreatePresentationDefinition(presentation.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) + _, err := service.CreatePresentationDefinition(model.CreatePresentationDefinitionRequest{PresentationDefinition: *pd}) assert.NoError(t, err) - assert.NoError(t, service.DeletePresentationDefinition(presentation.DeletePresentationDefinitionRequest{ID: pd.ID})) + assert.NoError(t, service.DeletePresentationDefinition(model.DeletePresentationDefinitionRequest{ID: pd.ID})) - _, err = service.GetPresentationDefinition(presentation.GetPresentationDefinitionRequest{ID: pd.ID}) + _, err = service.GetPresentationDefinition(model.GetPresentationDefinitionRequest{ID: pd.ID}) assert.Error(t, err) }) t.Run("Delete can be called with any ID", func(t *testing.T) { - err := service.DeletePresentationDefinition(presentation.DeletePresentationDefinitionRequest{ID: "some crazy ID"}) + err := service.DeletePresentationDefinition(model.DeletePresentationDefinitionRequest{ID: "some crazy ID"}) assert.NoError(t, err) }) } diff --git a/pkg/server/server_operation_test.go b/pkg/server/server_operation_test.go index 99235eeac..aca2abcc5 100644 --- a/pkg/server/server_operation_test.go +++ b/pkg/server/server_operation_test.go @@ -1,11 +1,13 @@ package server import ( + "fmt" "github.com/TBD54566975/ssi-sdk/credential/exchange" "github.com/goccy/go-json" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/tbd54566975/ssi-service/pkg/server/router" "github.com/tbd54566975/ssi-service/pkg/service/operation" "github.com/tbd54566975/ssi-service/pkg/storage" @@ -16,10 +18,83 @@ import ( ) func TestOperationsAPI(t *testing.T) { + t.Run("Marks operation as done after reviewing submission", func(t *testing.T) { + s := setupTestDB(t) + pRouter := setupPresentationRouter(t, s) + opRouter := setupOperationsRouter(t, s) + + holderSigner, holderDID := getSigner(t) + definition := createPresentationDefinition(t, pRouter) + submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner) + submission := reviewSubmission(t, pRouter, operation.SubmissionID(submissionOp.ID)) + + createdID := submissionOp.ID + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("https://ssi-service.com/v1/operations/%s", createdID), + nil) + w := httptest.NewRecorder() + + err := opRouter.GetOperation(newRequestContextWithParams(map[string]string{"id": createdID}), w, req) + + assert.NoError(t, err) + var resp router.Operation + assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.True(t, resp.Done) + assert.Empty(t, resp.Result.Error) + data, err := json.Marshal(submission) + assert.NoError(t, err) + var responseAsMap map[string]any + assert.NoError(t, json.Unmarshal(data, &responseAsMap)) + assert.Equal(t, responseAsMap, resp.Result.Response) + }) + + t.Run("GetOperation", func(t *testing.T) { + t.Run("Returns operation after submission", func(t *testing.T) { + s := setupTestDB(t) + pRouter := setupPresentationRouter(t, s) + opRouter := setupOperationsRouter(t, s) + + holderSigner, holderDID := getSigner(t) + definition := createPresentationDefinition(t, pRouter) + submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner) + + createdID := submissionOp.ID + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("https://ssi-service.com/v1/operations/%s", createdID), + nil) + w := httptest.NewRecorder() + + err := opRouter.GetOperation(newRequestContextWithParams(map[string]string{"id": createdID}), w, req) + + assert.NoError(t, err) + var resp router.Operation + assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.False(t, resp.Done) + assert.Contains(t, resp.ID, "/presentations/submissions/") + }) + + t.Run("Returns error when id doesn't exist", func(t *testing.T) { + s := setupTestDB(t) + opRouter := setupOperationsRouter(t, s) + + req := httptest.NewRequest( + http.MethodPut, + "https://ssi-service.com/v1/operations/some_fake_id", + nil) + w := httptest.NewRecorder() + + err := opRouter.GetOperation(newRequestContextWithParams(map[string]string{"id": "some_fake_id"}), w, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "operation not found with id") + }) + }) + t.Run("GetOperations", func(t *testing.T) { t.Run("Returns empty when no operations stored", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) opRouter := setupOperationsRouter(t, s) request := router.GetOperationsRequest{ @@ -37,8 +112,7 @@ func TestOperationsAPI(t *testing.T) { }) t.Run("Returns one operation for every submission", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) opRouter := setupOperationsRouter(t, s) @@ -73,8 +147,7 @@ func TestOperationsAPI(t *testing.T) { }) t.Run("Returns operation when filtering to include", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) opRouter := setupOperationsRouter(t, s) @@ -99,8 +172,7 @@ func TestOperationsAPI(t *testing.T) { }) t.Run("Returns zero operations when filtering to exclude", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) opRouter := setupOperationsRouter(t, s) @@ -124,8 +196,7 @@ func TestOperationsAPI(t *testing.T) { }) t.Run("Returns zero operations when wrong parent is specified", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) opRouter := setupOperationsRouter(t, s) @@ -150,11 +221,41 @@ func TestOperationsAPI(t *testing.T) { } -func setupOperationsRouter(t *testing.T, s storage.ServiceStorage) *router.OperationRouter { +func setupTestDB(t *testing.T) storage.ServiceStorage { + file, err := os.CreateTemp("", "bolt") + require.NoError(t, err) + name := file.Name() + s, err := storage.NewBoltDBWithFile(name) + require.NoError(t, err) t.Cleanup(func() { - assert.NoError(t, s.Close()) - assert.NoError(t, os.Remove(storage.DBFile)) + _ = s.Close() + _ = os.Remove(name) }) + return s +} + +func reviewSubmission(t *testing.T, pRouter *router.PresentationRouter, submissionID string) router.ReviewSubmissionResponse { + request := router.ReviewSubmissionRequest{ + Approved: true, + Reason: "because I want to", + } + + value := newRequestValue(t, request) + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s/review", submissionID), + value) + w := httptest.NewRecorder() + + err := pRouter.ReviewSubmission(newRequestContextWithParams(map[string]string{"id": submissionID}), w, req) + + assert.NoError(t, err) + var resp router.ReviewSubmissionResponse + assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + return resp +} + +func setupOperationsRouter(t *testing.T, s storage.ServiceStorage) *router.OperationRouter { svc, err := operation.NewOperationService(s) assert.NoError(t, err) opRouter, err := router.NewOperationRouter(svc) diff --git a/pkg/server/server_presentation_test.go b/pkg/server/server_presentation_test.go index fad3665bd..b754b2181 100644 --- a/pkg/server/server_presentation_test.go +++ b/pkg/server/server_presentation_test.go @@ -18,10 +18,10 @@ import ( "github.com/tbd54566975/ssi-service/pkg/server/router" "github.com/tbd54566975/ssi-service/pkg/service/operation" "github.com/tbd54566975/ssi-service/pkg/service/presentation" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/model" "github.com/tbd54566975/ssi-service/pkg/storage" "net/http" "net/http/httptest" - "os" "testing" ) @@ -45,8 +45,7 @@ func TestPresentationAPI(t *testing.T) { assert.NoError(t, err) t.Run("Create, Get, and Delete PresentationDefinition", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) var createdID string @@ -106,8 +105,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("Create returns error without input descriptors", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) request := router.CreatePresentationDefinitionRequest{} value := newRequestValue(t, request) @@ -120,8 +118,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("Get without an ID returns error", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://ssi-service.com/v1/presentations/definitions/%s", pd.ID), nil) w := httptest.NewRecorder() @@ -130,8 +127,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("Delete without an ID returns error", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("https://ssi-service.com/v1/presentations/definitions/%s", pd.ID), nil) @@ -143,8 +139,7 @@ func TestPresentationAPI(t *testing.T) { t.Run("Submission endpoints", func(t *testing.T) { t.Run("Get non-existing ID returns error", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions/myrandomid", nil) @@ -153,8 +148,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("Get returns submission after creation", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) holderSigner, holderDID := getSigner(t) @@ -181,8 +175,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("Create well formed submission returns operation", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) holderSigner, holderDID := getSigner(t) @@ -210,9 +203,68 @@ func TestPresentationAPI(t *testing.T) { assert.Zero(t, resp.Result) }) - t.Run("List submissions returns empty when there are none", func(t *testing.T) { - s, err := storage.NewBoltDB() + t.Run("Review submission returns approved submission", func(t *testing.T) { + s := setupTestDB(t) + pRouter := setupPresentationRouter(t, s) + + holderSigner, holderDID := getSigner(t) + definition := createPresentationDefinition(t, pRouter) + submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner) + + request := router.ReviewSubmissionRequest{ + Approved: true, + Reason: "because I want to", + } + + value := newRequestValue(t, request) + createdID := operation.SubmissionID(submissionOp.ID) + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s/review", createdID), + value) + w := httptest.NewRecorder() + + err = pRouter.ReviewSubmission(newRequestContextWithParams(map[string]string{"id": createdID}), w, req) + assert.NoError(t, err) + var resp router.ReviewSubmissionResponse + assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, "because I want to", resp.Reason) + assert.NotEmpty(t, resp.ID) + assert.Equal(t, "approved", resp.Status) + assert.Equal(t, definition.PresentationDefinition.ID, resp.DefinitionID) + }) + + t.Run("Review submission twice fails", func(t *testing.T) { + s := setupTestDB(t) + pRouter := setupPresentationRouter(t, s) + + holderSigner, holderDID := getSigner(t) + definition := createPresentationDefinition(t, pRouter) + submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner) + createdID := operation.SubmissionID(submissionOp.ID) + _ = reviewSubmission(t, pRouter, createdID) + + request := router.ReviewSubmissionRequest{ + Approved: true, + Reason: "because I want to review again", + } + + value := newRequestValue(t, request) + req := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s/review", createdID), + value) + w := httptest.NewRecorder() + + err = pRouter.ReviewSubmission(newRequestContextWithParams(map[string]string{"id": createdID}), w, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "operation already marked as done") + }) + + t.Run("List submissions returns empty when there are none", func(t *testing.T) { + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) request := router.ListSubmissionRequest{} @@ -230,8 +282,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("List submissions returns many submissions", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) holderSigner, holderDID := getSigner(t) @@ -267,7 +318,7 @@ func TestPresentationAPI(t *testing.T) { assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) assert.Len(t, resp.Submissions, 2) - expectedSubmissions := []presentation.Submission{ + expectedSubmissions := []model.Submission{ { Status: "pending", PresentationSubmission: &exchange.PresentationSubmission{ @@ -285,7 +336,7 @@ func TestPresentationAPI(t *testing.T) { } diff := cmp.Diff(expectedSubmissions, resp.Submissions, cmpopts.IgnoreFields(exchange.PresentationSubmission{}, "DescriptorMap"), - cmpopts.SortSlices(func(l, r presentation.Submission) bool { + cmpopts.SortSlices(func(l, r model.Submission) bool { return l.PresentationSubmission.ID < r.PresentationSubmission.ID }), ) @@ -295,8 +346,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("bad filter returns error", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) request := router.ListSubmissionRequest{ Filter: `im a baaad filter that's trying to break a lot of stuff'`, @@ -312,8 +362,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("List submissions filters based on status", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) holderSigner, holderDID := getSigner(t) @@ -341,7 +390,7 @@ func TestPresentationAPI(t *testing.T) { var resp router.ListSubmissionResponse assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) - expectedSubmissions := []presentation.Submission{ + expectedSubmissions := []model.Submission{ { Status: "pending", PresentationSubmission: &exchange.PresentationSubmission{ @@ -352,7 +401,7 @@ func TestPresentationAPI(t *testing.T) { } diff := cmp.Diff(expectedSubmissions, resp.Submissions, cmpopts.IgnoreFields(exchange.PresentationSubmission{}, "DescriptorMap"), - cmpopts.SortSlices(func(l, r presentation.Submission) bool { + cmpopts.SortSlices(func(l, r model.Submission) bool { return l.PresentationSubmission.ID < r.PresentationSubmission.ID }), ) @@ -362,8 +411,7 @@ func TestPresentationAPI(t *testing.T) { }) t.Run("List submissions filter returns empty when status does not match", func(t *testing.T) { - s, err := storage.NewBoltDB() - assert.NoError(t, err) + s := setupTestDB(t) pRouter := setupPresentationRouter(t, s) holderSigner, holderDID := getSigner(t) @@ -407,11 +455,6 @@ func setupPresentationRouter(t *testing.T, s storage.ServiceStorage) *router.Pre pRouter, err := router.NewPresentationRouter(service) assert.NoError(t, err) - - t.Cleanup(func() { - _ = s.Close() - _ = os.Remove(storage.DBFile) - }) return pRouter } diff --git a/pkg/service/operation/model.go b/pkg/service/operation/model.go index 959734cd1..71310845c 100644 --- a/pkg/service/operation/model.go +++ b/pkg/service/operation/model.go @@ -1,14 +1,16 @@ package operation import ( + "fmt" "github.com/TBD54566975/ssi-sdk/util" + "github.com/tbd54566975/ssi-service/pkg/service/operation/storage" "go.einride.tech/aip/filtering" "strings" ) type Result struct { - Error string `json:"error,omitempty"` - Response interface{} `json:"response,omitempty"` + Error string `json:"error,omitempty"` + Response any `json:"response,omitempty"` } type Operation struct { @@ -39,3 +41,17 @@ func (r GetOperationsRequest) Validate() error { type GetOperationsResponse struct { Operations []Operation } + +type GetOperationRequest struct { + ID string `json:"id" validate:"required"` +} + +// Validate does struct validation and returns an error when invalid. +func (r GetOperationRequest) Validate() error { + return util.NewValidator().Struct(r) +} + +// IDFromSubmissionID returns a submission operation ID from the submission ID. +func IDFromSubmissionID(id string) string { + return fmt.Sprintf("%s/%s", storage.SubmissionParentResource, id) +} diff --git a/pkg/service/operation/service.go b/pkg/service/operation/service.go index 01cef0728..7ca01c970 100644 --- a/pkg/service/operation/service.go +++ b/pkg/service/operation/service.go @@ -3,11 +3,16 @@ package operation import ( "fmt" sdkutil "github.com/TBD54566975/ssi-sdk/util" + "github.com/goccy/go-json" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/tbd54566975/ssi-service/internal/util" "github.com/tbd54566975/ssi-service/pkg/service/framework" opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/model" + prestorage "github.com/tbd54566975/ssi-service/pkg/service/presentation/storage" "github.com/tbd54566975/ssi-service/pkg/storage" + "strings" ) type Service struct { @@ -47,19 +52,55 @@ func (s Service) GetOperations(request GetOperationsRequest) (*GetOperationsResp } for i, op := range ops { op := op - newOp := Operation{ - ID: op.ID, - Done: op.Done, - Result: Result{ - Error: op.Error, - Response: op.Response, - }, + newOp, err := serviceModel(op) + if err != nil { + logrus.WithError(err).WithField("operation_id", op.ID).Error("converting to storage operations to model") + continue } resp.Operations[i] = newOp } return resp, nil } +type ServiceModelFunc func(any) any + +func serviceModel(op opstorage.StoredOperation) (Operation, error) { + newOp := Operation{ + ID: op.ID, + Done: op.Done, + Result: Result{ + Error: op.Error, + }, + } + + if len(op.Response) > 0 { + switch { + case strings.HasPrefix(op.ID, opstorage.SubmissionParentResource): + var s prestorage.StoredSubmission + if err := json.Unmarshal(op.Response, &s); err != nil { + return Operation{}, err + } + newOp.Result.Response = model.ServiceModel(&s) + default: + return newOp, errors.New("unknown response type") + } + } + + return newOp, nil +} + +func (s Service) GetOperation(request GetOperationRequest) (Operation, error) { + if err := request.Validate(); err != nil { + return Operation{}, errors.Wrap(err, "invalid request") + } + + storedOp, err := s.storage.GetOperation(request.ID) + if err != nil { + return Operation{}, errors.Wrap(err, "fetching from storage") + } + return serviceModel(storedOp) +} + func NewOperationService(s storage.ServiceStorage) (*Service, error) { opStorage, err := opstorage.NewOperationStorage(s) if err != nil { diff --git a/pkg/service/operation/storage/bolt.go b/pkg/service/operation/storage/bolt.go index d28eb5ec8..e52cb1440 100644 --- a/pkg/service/operation/storage/bolt.go +++ b/pkg/service/operation/storage/bolt.go @@ -1,7 +1,6 @@ package storage import ( - "fmt" "github.com/goccy/go-json" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -12,13 +11,14 @@ import ( ) const ( - namespace = "operation" - submission = "submission" + namespace = "operation_submission" ) const SubmissionParentResource = "/presentations/submissions" -func namespaceFromID(id string) string { +// NamespaceFromID returns a namespace from a given operation ID. An empty string is returned when the namespace cannot +// be determined. +func NamespaceFromID(id string) string { i := strings.LastIndex(id, "/") if i == -1 { return "" @@ -29,7 +29,7 @@ func namespaceFromID(id string) string { func namespaceFromParent(parent string) string { switch parent { case SubmissionParentResource: - return fmt.Sprintf("%s_%s", namespace, submission) + return namespace default: return "" } @@ -48,22 +48,22 @@ func (b BoltOperationStorage) StoreOperation(op StoredOperation) error { if err != nil { return util.LoggingErrorMsgf(err, "marshalling operation with id: %s", id) } - return b.db.Write(namespaceFromID(id), id, jsonBytes) + return b.db.Write(NamespaceFromID(id), id, jsonBytes) } -func (b BoltOperationStorage) GetOperation(id string) (*StoredOperation, error) { - jsonBytes, err := b.db.Read(namespaceFromID(id), id) +func (b BoltOperationStorage) GetOperation(id string) (StoredOperation, error) { + var stored StoredOperation + jsonBytes, err := b.db.Read(NamespaceFromID(id), id) if err != nil { - return nil, util.LoggingErrorMsgf(err, "reading operation with id: %s", id) + return stored, util.LoggingErrorMsgf(err, "reading operation with id: %s", id) } if len(jsonBytes) == 0 { - return nil, util.LoggingNewErrorf("operation not found with id: %s", id) + return stored, util.LoggingNewErrorf("operation not found with id: %s", id) } - var stored StoredOperation if err := json.Unmarshal(jsonBytes, &stored); err != nil { - return nil, util.LoggingErrorMsgf(err, "unmarshalling stored operation: %s", id) + return stored, util.LoggingErrorMsgf(err, "unmarshalling stored operation: %s", id) } - return &stored, nil + return stored, nil } func (b BoltOperationStorage) GetOperations(parent string, filter filtering.Filter) ([]StoredOperation, error) { @@ -96,7 +96,7 @@ func (b BoltOperationStorage) GetOperations(parent string, filter filtering.Filt } func (b BoltOperationStorage) DeleteOperation(id string) error { - if err := b.db.Delete(namespaceFromID(id), id); err != nil { + if err := b.db.Delete(NamespaceFromID(id), id); err != nil { return util.LoggingErrorMsgf(err, "deleting operation: %s", id) } return nil diff --git a/pkg/service/operation/storage/storage.go b/pkg/service/operation/storage/storage.go index dae9f9b08..1ac3b4c20 100644 --- a/pkg/service/operation/storage/storage.go +++ b/pkg/service/operation/storage/storage.go @@ -16,7 +16,7 @@ type StoredOperation struct { Error string `json:"errorResult,omitempty"` // Populated only when Done == true and Error == "" - Response interface{} `json:"result,omitempty"` + Response []byte `json:"response,omitempty"` } func (s StoredOperation) FilterVariablesMap() map[string]interface{} { @@ -32,7 +32,7 @@ func (s StoredOperation) FilterVariablesMap() map[string]interface{} { type Storage interface { StoreOperation(op StoredOperation) error - GetOperation(id string) (*StoredOperation, error) + GetOperation(id string) (StoredOperation, error) GetOperations(parent string, filter filtering.Filter) ([]StoredOperation, error) DeleteOperation(id string) error } diff --git a/pkg/service/presentation/model.go b/pkg/service/presentation/model/model.go similarity index 71% rename from pkg/service/presentation/model.go rename to pkg/service/presentation/model/model.go index 3ab4d6c90..47cada2ef 100644 --- a/pkg/service/presentation/model.go +++ b/pkg/service/presentation/model/model.go @@ -1,4 +1,4 @@ -package presentation +package model import ( credsdk "github.com/TBD54566975/ssi-sdk/credential" @@ -6,6 +6,7 @@ import ( "github.com/TBD54566975/ssi-sdk/util" "github.com/tbd54566975/ssi-service/internal/credential" "github.com/tbd54566975/ssi-service/internal/keyaccess" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/storage" "go.einride.tech/aip/filtering" ) @@ -66,11 +67,33 @@ type ListSubmissionRequest struct { } type Submission struct { - // One of {`pending`, `done`}. + // One of {`pending`, `approved`, `denied`}. Status string `json:"status" validate:"required"` + // The reason why the submission was approved or denied. + Reason string `json:"reason"` *exchange.PresentationSubmission } type ListSubmissionResponse struct { Submissions []Submission `json:"submissions"` } + +type ReviewSubmissionRequest struct { + ID string `json:"id" validate:"required"` + Approved bool `json:"approved"` + Reason string `json:"reason"` +} + +// Validate runs validation on the request struct and returns errors when it's invalid. +func (r ReviewSubmissionRequest) Validate() error { + return util.NewValidator().Struct(r) +} + +// ServiceModel creates a Submission from a given StoredSubmission. +func ServiceModel(storedSubmission *storage.StoredSubmission) Submission { + return Submission{ + Status: storedSubmission.Status.String(), + Reason: storedSubmission.Reason, + PresentationSubmission: &storedSubmission.Submission, + } +} diff --git a/pkg/service/presentation/service.go b/pkg/service/presentation/service.go index 7e6bf39f3..1061d8b0d 100644 --- a/pkg/service/presentation/service.go +++ b/pkg/service/presentation/service.go @@ -15,6 +15,7 @@ import ( "github.com/tbd54566975/ssi-service/pkg/service/framework" "github.com/tbd54566975/ssi-service/pkg/service/operation" opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage" + "github.com/tbd54566975/ssi-service/pkg/service/presentation/model" presentationstorage "github.com/tbd54566975/ssi-service/pkg/service/presentation/storage" "github.com/tbd54566975/ssi-service/pkg/service/schema" "github.com/tbd54566975/ssi-service/pkg/storage" @@ -80,7 +81,7 @@ func NewPresentationService(config config.PresentationServiceConfig, s storage.S // CreatePresentationDefinition houses the main service logic for presentation definition creation. It validates the input, and // produces a presentation definition value that conforms with the PresentationDefinition specification. -func (s Service) CreatePresentationDefinition(request CreatePresentationDefinitionRequest) (*CreatePresentationDefinitionResponse, error) { +func (s Service) CreatePresentationDefinition(request model.CreatePresentationDefinitionRequest) (*model.CreatePresentationDefinitionResponse, error) { logrus.Debugf("creating presentation definition: %+v", request) if !request.IsValid() { @@ -97,12 +98,12 @@ func (s Service) CreatePresentationDefinition(request CreatePresentationDefiniti return nil, util.LoggingErrorMsg(err, "could not store presentation") } - return &CreatePresentationDefinitionResponse{ + return &model.CreatePresentationDefinitionResponse{ PresentationDefinition: storedPresentation.PresentationDefinition, }, nil } -func (s Service) GetPresentationDefinition(request GetPresentationDefinitionRequest) (*GetPresentationDefinitionResponse, error) { +func (s Service) GetPresentationDefinition(request model.GetPresentationDefinitionRequest) (*model.GetPresentationDefinitionResponse, error) { logrus.Debugf("getting presentation definition: %s", request.ID) storedPresentation, err := s.storage.GetPresentation(request.ID) @@ -112,10 +113,10 @@ func (s Service) GetPresentationDefinition(request GetPresentationDefinitionRequ if storedPresentation == nil { return nil, util.LoggingNewErrorf("presentation definition with id<%s> could not be found", request.ID) } - return &GetPresentationDefinitionResponse{ID: storedPresentation.ID, PresentationDefinition: storedPresentation.PresentationDefinition}, nil + return &model.GetPresentationDefinitionResponse{ID: storedPresentation.ID, PresentationDefinition: storedPresentation.PresentationDefinition}, nil } -func (s Service) DeletePresentationDefinition(request DeletePresentationDefinitionRequest) error { +func (s Service) DeletePresentationDefinition(request model.DeletePresentationDefinitionRequest) error { logrus.Debugf("deleting presentation definition: %s", request.ID) if err := s.storage.DeletePresentation(request.ID); err != nil { @@ -127,7 +128,7 @@ func (s Service) DeletePresentationDefinition(request DeletePresentationDefiniti // CreateSubmission houses the main service logic for presentation submission creation. It validates the input, and // produces a presentation submission value that conforms with the Submission specification. -func (s Service) CreateSubmission(request CreateSubmissionRequest) (*operation.Operation, error) { +func (s Service) CreateSubmission(request model.CreateSubmissionRequest) (*operation.Operation, error) { if !request.IsValid() { return nil, errors.Errorf("invalid create presentation submission request: %+v", request) } @@ -184,7 +185,7 @@ func (s Service) CreateSubmission(request CreateSubmissionRequest) (*operation.O return nil, errors.Wrap(err, "could not store presentation") } - opID := fmt.Sprintf("%s/%s", opstorage.SubmissionParentResource, storedSubmission.Submission.ID) + opID := operation.IDFromSubmissionID(storedSubmission.Submission.ID) storedOp := opstorage.StoredOperation{ ID: opID, Done: false, @@ -199,22 +200,19 @@ func (s Service) CreateSubmission(request CreateSubmissionRequest) (*operation.O }, nil } -func (s Service) GetSubmission(request GetSubmissionRequest) (*GetSubmissionResponse, error) { +func (s Service) GetSubmission(request model.GetSubmissionRequest) (*model.GetSubmissionResponse, error) { logrus.Debugf("getting presentation submission: %s", request.ID) storedSubmission, err := s.storage.GetSubmission(request.ID) if err != nil { return nil, errors.Wrap(err, "fetching from storage") } - return &GetSubmissionResponse{ - Submission: Submission{ - Status: storedSubmission.Status.String(), - PresentationSubmission: &storedSubmission.Submission, - }, + return &model.GetSubmissionResponse{ + Submission: model.ServiceModel(storedSubmission), }, nil } -func (s Service) ListSubmissions(request ListSubmissionRequest) (*ListSubmissionResponse, error) { +func (s Service) ListSubmissions(request model.ListSubmissionRequest) (*model.ListSubmissionResponse, error) { logrus.Debug("listing presentation submissions") subs, err := s.storage.ListSubmissions(request.Filter) @@ -222,14 +220,25 @@ func (s Service) ListSubmissions(request ListSubmissionRequest) (*ListSubmission return nil, errors.Wrap(err, "fetching submissions from storage") } - resp := &ListSubmissionResponse{Submissions: make([]Submission, 0, len(subs))} + resp := &model.ListSubmissionResponse{Submissions: make([]model.Submission, 0, len(subs))} for _, sub := range subs { sub := sub // What's this?? see https://github.com/golang/go/wiki/CommonMistakes#using-reference-to-loop-iterator-variable - resp.Submissions = append(resp.Submissions, Submission{ - Status: sub.Status.String(), - PresentationSubmission: &sub.Submission, - }) + resp.Submissions = append(resp.Submissions, model.ServiceModel(&sub)) } return resp, nil } + +func (s Service) ReviewSubmission(request model.ReviewSubmissionRequest) (*model.Submission, error) { + if err := request.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid request") + } + + updatedSubmission, _, err := s.storage.UpdateSubmission(request.ID, request.Approved, request.Reason, operation.IDFromSubmissionID(request.ID)) + if err != nil { + return nil, errors.Wrap(err, "updating submission") + } + + m := model.ServiceModel(&updatedSubmission) + return &m, nil +} diff --git a/pkg/service/presentation/storage/bolt.go b/pkg/service/presentation/storage/bolt.go index e91d1c4df..6f092cb11 100644 --- a/pkg/service/presentation/storage/bolt.go +++ b/pkg/service/presentation/storage/bolt.go @@ -6,9 +6,9 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tbd54566975/ssi-service/internal/util" - "go.einride.tech/aip/filtering" - + opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage" "github.com/tbd54566975/ssi-service/pkg/storage" + "go.einride.tech/aip/filtering" ) const ( @@ -20,6 +20,63 @@ type BoltPresentationStorage struct { db *storage.BoltDB } +type opUpdater struct { + storage.UpdaterWithMap +} + +func (u opUpdater) SetUpdatedResponse(response []byte) { + u.UpdaterWithMap.Values["response"] = response +} + +func (u opUpdater) Validate(v []byte) error { + var op opstorage.StoredOperation + if err := json.Unmarshal(v, &op); err != nil { + return errors.Wrap(err, "unmarshalling operation") + } + + if op.Done { + return errors.New("operation already marked as done") + } + + return nil +} + +var _ storage.ResponseSettingUpdater = (*opUpdater)(nil) + +func (b BoltPresentationStorage) UpdateSubmission(id string, approved bool, reason string, opID string) (StoredSubmission, opstorage.StoredOperation, error) { + m := map[string]any{ + "status": StatusDenied, + "reason": reason, + } + if approved { + m["status"] = StatusApproved + } + submissionData, operationData, err := b.db.UpdateValueAndOperation( + submissionNamespace, + id, + storage.NewUpdater(m), + opstorage.NamespaceFromID(opID), + opID, + opUpdater{ + storage.NewUpdater(map[string]any{ + "done": true, + }), + }) + if err != nil { + return StoredSubmission{}, opstorage.StoredOperation{}, errors.Wrap(err, "updating value and operation") + } + + var s StoredSubmission + if err = json.Unmarshal(submissionData, &s); err != nil { + return StoredSubmission{}, opstorage.StoredOperation{}, errors.Wrap(err, "unmarshalling written submission") + } + var op opstorage.StoredOperation + if err = json.Unmarshal(operationData, &op); err != nil { + return StoredSubmission{}, opstorage.StoredOperation{}, errors.Wrap(err, "unmarshalling written operation") + } + return s, op, nil +} + func (b BoltPresentationStorage) ListSubmissions(filter filtering.Filter) ([]StoredSubmission, error) { allData, err := b.db.ReadAll(submissionNamespace) if err != nil { diff --git a/pkg/service/presentation/storage/storage.go b/pkg/service/presentation/storage/storage.go index 35099e58b..41b248878 100644 --- a/pkg/service/presentation/storage/storage.go +++ b/pkg/service/presentation/storage/storage.go @@ -4,6 +4,7 @@ import ( "github.com/TBD54566975/ssi-sdk/credential/exchange" "github.com/pkg/errors" "github.com/tbd54566975/ssi-service/internal/util" + opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage" "github.com/tbd54566975/ssi-service/pkg/storage" "go.einride.tech/aip/filtering" ) @@ -47,10 +48,12 @@ type Status uint8 func (s Status) String() string { switch s { - case StatusDone: - return "done" case StatusPending: return "pending" + case StatusDenied: + return "denied" + case StatusApproved: + return "approved" default: return "unknown" } @@ -59,12 +62,14 @@ func (s Status) String() string { const ( StatusUnknown Status = iota StatusPending - StatusDone + StatusDenied + StatusApproved ) type StoredSubmission struct { Status Status `json:"status"` Submission exchange.PresentationSubmission `json:"submission"` + Reason string `json:"reason"` } func (s StoredSubmission) FilterVariablesMap() map[string]interface{} { @@ -77,6 +82,7 @@ type SubmissionStorage interface { StoreSubmission(schema StoredSubmission) error GetSubmission(id string) (*StoredSubmission, error) ListSubmissions(filtering.Filter) ([]StoredSubmission, error) + UpdateSubmission(id string, approved bool, reason string, submissionID string) (StoredSubmission, opstorage.StoredOperation, error) } var ErrSubmissionNotFound = errors.New("submission not found") diff --git a/pkg/storage/bolt.go b/pkg/storage/bolt.go index ab81513b8..827b8faa3 100644 --- a/pkg/storage/bolt.go +++ b/pkg/storage/bolt.go @@ -3,6 +3,7 @@ package storage import ( "bytes" "fmt" + "github.com/goccy/go-json" "strings" "time" @@ -141,6 +142,108 @@ func (b *BoltDB) DeleteNamespace(namespace string) error { }) } +// UpdaterWithMap is a json map based Updater implementation. The key/values from the map are used to update the +// unmarshalled JSON representation of the stored data. +type UpdaterWithMap struct { + Values map[string]any +} + +// Validate is a default implementation for UpdaterWithMap which does no validation. Users can pass embed UpdaterWithMap +// into a custom struct and redefine this method in order to have custom logic. +func (u UpdaterWithMap) Validate(v []byte) error { + return nil +} + +// NewUpdater creates a new UpdaterWithMap with the given map. +func NewUpdater(values map[string]any) UpdaterWithMap { + return UpdaterWithMap{ + Values: values, + } +} + +func (u UpdaterWithMap) Update(v []byte) ([]byte, error) { + var model map[string]interface{} + if err := json.Unmarshal(v, &model); err != nil { + return nil, errors.Wrap(err, "unmarshalling json") + } + for k, val := range u.Values { + model[k] = val + } + data, err := json.Marshal(model) + if err != nil { + return nil, errors.Wrap(err, "marshalling updated struct") + } + return data, nil +} + +// Updater encapsulates the Update method, which take a slice of bytes, and updates it before it's stored in the DB. +type Updater interface { + Update(v []byte) ([]byte, error) + // Validate runs after the data has been loaded from disk, but before the write is actually performed. + Validate(v []byte) error +} + +type ResponseSettingUpdater interface { + Updater + // SetUpdatedResponse sets the response that the Update method will later use to modify the data. + SetUpdatedResponse([]byte) +} + +// UpdateValueAndOperation updates the value stored in (namespace,key) with the new values specified in the map. +// The updated value is then stored inside the (opNamespace, opKey), and the "done" value is set to true. +func (b *BoltDB) UpdateValueAndOperation(namespace, key string, updater Updater, opNamespace, opKey string, opUpdater ResponseSettingUpdater) (first, op []byte, err error) { + err = b.db.Update(func(tx *bolt.Tx) error { + if err := updateTxFn(namespace, key, updater, &first)(tx); err != nil { + return err + } + opUpdater.SetUpdatedResponse(first) + if err = updateTxFn(opNamespace, opKey, opUpdater, &op)(tx); err != nil { + return err + } + return nil + }) + return first, op, err +} + +func (b *BoltDB) Update(namespace string, key string, values map[string]any) ([]byte, error) { + var updatedData []byte + err := b.db.Update(updateTxFn(namespace, key, NewUpdater(values), &updatedData)) + return updatedData, err +} + +func updateTxFn(namespace string, key string, updater Updater, updatedData *[]byte) func(tx *bolt.Tx) error { + return func(tx *bolt.Tx) error { + data, err := updateTx(tx, namespace, key, updater) + if err != nil { + return err + } + *updatedData = data + return nil + } +} + +func updateTx(tx *bolt.Tx, namespace string, key string, updater Updater) ([]byte, error) { + bucket := tx.Bucket([]byte(namespace)) + if bucket == nil { + return nil, util.LoggingNewErrorf("namespace<%s> does not exist", namespace) + } + v := bucket.Get([]byte(key)) + if v == nil { + return nil, util.LoggingNewErrorf("key not found %s", key) + } + if err := updater.Validate(v); err != nil { + return nil, util.LoggingErrorMsg(err, "validating update") + } + data, err := updater.Update(v) + if err != nil { + return nil, err + } + if err = bucket.Put([]byte(key), data); err != nil { + return nil, errors.Wrap(err, "writing to db") + } + return data, nil +} + // MakeNamespace takes a set of possible namespace values and combines them as a convention func MakeNamespace(ns ...string) string { return strings.Join(ns, "-") diff --git a/pkg/storage/bolt_test.go b/pkg/storage/bolt_test.go index e46a48b04..c816eaa0a 100644 --- a/pkg/storage/bolt_test.go +++ b/pkg/storage/bolt_test.go @@ -1,21 +1,16 @@ package storage import ( + "fmt" "github.com/goccy/go-json" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "os" "testing" ) func TestBoltDB(t *testing.T) { - db, err := NewBoltDBWithFile("test.db") - assert.NoError(t, err) - assert.NotEmpty(t, db) - - t.Cleanup(func() { - _ = db.Close() - os.Remove("test.db") - }) + db := setupBoltDB(t) // create a name space and a message in it namespace := "F1" @@ -94,21 +89,14 @@ func TestBoltDB(t *testing.T) { } func TestBoltDBPrefixAndKeys(t *testing.T) { - db, err := NewBoltDBWithFile("test.db") - assert.NoError(t, err) - assert.NotEmpty(t, db) - - t.Cleanup(func() { - _ = db.Close() - os.Remove("test.db") - }) + db := setupBoltDB(t) namespace := "blockchains" // set up prefix read test dummyData := []byte("dummy") - err = db.Write(namespace, "bitcoin-testnet", dummyData) + err := db.Write(namespace, "bitcoin-testnet", dummyData) assert.NoError(t, err) err = db.Write(namespace, "bitcoin-mainnet", dummyData) @@ -140,3 +128,211 @@ func TestBoltDBPrefixAndKeys(t *testing.T) { assert.Contains(t, allKeys, "bitcoin-mainnet") assert.Contains(t, allKeys, "tezos-mainnet") } + +func setupBoltDB(t *testing.T) *BoltDB { + db, err := NewBoltDBWithFile("test.db") + assert.NoError(t, err) + assert.NotEmpty(t, db) + + t.Cleanup(func() { + _ = db.Close() + os.Remove("test.db") + }) + return db +} + +type testStruct struct { + Status int `json:"status"` + Reason string `json:"reason"` +} + +type operation struct { + Done bool `json:"done"` + Response []byte `json:"response"` +} + +func TestBoltDB_Update(t *testing.T) { + db := setupBoltDB(t) + namespace := "simple" + + data, err := json.Marshal(testStruct{ + Status: 0, + Reason: "", + }) + require.NoError(t, err) + require.NoError(t, db.Write(namespace, "123", data)) + + type args struct { + key string + values map[string]any + } + tests := []struct { + name string + args args + expectedData testStruct + expectedError assert.ErrorAssertionFunc + }{ + { + name: "simple update", + args: args{ + key: "123", + values: map[string]any{ + "status": 1, + "reason": "something here", + }, + }, + expectedData: testStruct{ + Status: 1, + Reason: "something here", + }, + expectedError: assert.NoError, + }, + { + name: "other key returns error", + args: args{ + key: "456", + values: map[string]any{ + "status": 1, + "reason": "something here", + }, + }, + expectedData: testStruct{}, + expectedError: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err = db.Update(namespace, tt.args.key, tt.args.values) + if !tt.expectedError(t, err) { + return + } + var s testStruct + if tt.expectedData != s { + assert.NoError(t, json.Unmarshal(data, &s)) + assert.Equal(t, tt.expectedData, s) + } + }) + } +} + +type testOpUpdater struct { + UpdaterWithMap +} + +func (f testOpUpdater) SetUpdatedResponse(bytes []byte) { + f.UpdaterWithMap.Values["response"] = bytes +} + +func TestBoltDB_UpdatedSubmissionAndOperationTxFn(t *testing.T) { + db := setupBoltDB(t) + namespace := "simple" + opNamespace := "operation" + + data, err := json.Marshal(testStruct{ + Status: 0, + Reason: "", + }) + require.NoError(t, err) + require.NoError(t, db.Write(namespace, "123", data)) + + data, err = json.Marshal(operation{ + Done: false, + Response: nil, + }) + require.NoError(t, err) + require.NoError(t, db.Write(opNamespace, "op123", data)) + + type args struct { + namespace string + key string + updater Updater + opNamespace string + opKey string + } + tests := []struct { + name string + args args + wantFirst *testStruct + wantOpDone bool + wantOpResponse *testStruct + wantErr assert.ErrorAssertionFunc + }{ + { + name: "first and second get updated", + args: args{ + namespace: namespace, + key: "123", + updater: NewUpdater(map[string]any{ + "status": 1, + "reason": "hello", + }), + opNamespace: opNamespace, + opKey: "op123", + }, + wantFirst: &testStruct{ + Status: 1, + Reason: "hello", + }, + wantOpDone: true, + wantOpResponse: &testStruct{ + Status: 1, + Reason: "hello", + }, + wantErr: assert.NoError, + }, + { + name: "non-existent op key returns error", + args: args{ + namespace: namespace, + key: "123", + updater: NewUpdater(map[string]any{ + "status": 1, + "reason": "hello", + }), + opNamespace: opNamespace, + opKey: "crazy key", + }, + wantErr: assert.Error, + }, + { + name: "non-existent key returns error", + args: args{ + namespace: namespace, + key: "crazy key", + updater: NewUpdater(map[string]any{ + "status": 1, + "reason": "hello", + }), + opNamespace: opNamespace, + opKey: "op123", + }, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFirstData, gotOpData, err := db.UpdateValueAndOperation(tt.args.namespace, tt.args.key, tt.args.updater, tt.args.opNamespace, tt.args.opKey, testOpUpdater{ + NewUpdater(map[string]any{ + "done": true, + }), + }) + if !tt.wantErr(t, err, fmt.Sprintf("UpdateValueAndOperation(%v, %v, %v, %v, %v)", tt.args.namespace, tt.args.key, tt.args.updater, tt.args.opNamespace, tt.args.opKey)) { + return + } + if tt.wantFirst == nil { + return + } + var gotFirst testStruct + assert.NoError(t, json.Unmarshal(gotFirstData, &gotFirst)) + assert.Equal(t, *tt.wantFirst, gotFirst) + + var gotOp operation + assert.NoError(t, json.Unmarshal(gotOpData, &gotOp)) + assert.Equal(t, tt.wantOpDone, gotOp.Done) + + var gotOpResponse testStruct + assert.NoError(t, json.Unmarshal(gotOp.Response, &gotOpResponse)) + assert.Equal(t, *tt.wantOpResponse, gotOpResponse) + }) + } +} diff --git a/sip/sips/sip6/README.md b/sip/sips/sip6/README.md index 7432f50c5..22155252f 100644 --- a/sip/sips/sip6/README.md +++ b/sip/sips/sip6/README.md @@ -246,7 +246,7 @@ Upon a submission, the following steps will be performed: 9. Check that the paths are resolvable to fields in the claims. This should follow the spec as described in [https://identity.foundation/presentation-exchange/#processing-of-submission-entries](https://identity.foundation/presentation-exchange/#processing-of-submission-entries) 10. A `Submission` is stored in the DB. 11. An operation ID is generated, and an operation is stored in a KV database. -12. A response of type `Operation` is sent back to the client with `id := "presentations/submissions/{submission_id}"` +12. A response of type `Operation` is sent back to the client with `id := "/presentations/submissions/{submission_id}"` 13. Bask in the glory of a successful submission. ### GET @@ -280,7 +280,7 @@ The URL is `/v1/presentations/submissions/:id/review`. This endpoint enables adm } ``` -Ideally, only the `TBD Admin` should have authorization to perform this. After this method is called, the operation with `id==presentations/submissions/{submission_id}` will be updated with the result of this invocation (and the `done` field will be set to true). The `submission` object’s review state will be updated as well. +Ideally, only the `TBD Admin` should have authorization to perform this. After this method is called, the operation with `id==/presentations/submissions/{submission_id}` will be updated with the result of this invocation (and the `done` field will be set to true). The `submission` object’s review state will be updated as well. The response of this endpoint will contain a `Submission` object with `status != 'pending'`. @@ -292,7 +292,7 @@ An object of type `Operation` and will look as follows ```json { - "id": "{namespace}/{unique_id}", // example: "presentations/submissions/a30e3b91-fb77-4d22-95fa-871689c322e2" + "id": "/{namespace}/{unique_id}", // example: "/presentations/submissions/a30e3b91-fb77-4d22-95fa-871689c322e2" "done": true, // when "false", then "result" will be empty as it's still being calculated. "result": { // only present when "done" == true. When present only one of ["error", "response"] will be populated. "error": "some string with the error",