diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cfbb3aae..92c46a93 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,10 +20,3 @@ Describe how reviewers can test this change to be sure that it works correctly. - [ ] I have added new test fixtures as needed to support added tests - [ ] I have added or updated the documentation - [ ] Check this box if a reviewer can merge this pull request after approval (leave it unchecked if you want to do it yourself) - -### Reviewer(s) checklist - -- [ ] Any new user-facing content that has been added for this PR has been QA'ed to ensure correct grammar, spelling, and understandability. -- [ ] To the best of my ability, I believe that this PR represents a good solution to the specified problem and that it should be merged into the main code base. - - diff --git a/pkg/node/node.go b/pkg/node/node.go index 67717247..ef5f0d04 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -120,7 +120,7 @@ func New(conf config.Config) (node *Node, err error) { audit.UseKeyChain(kc) // Create the admin web ui server if it is enabled - if node.admin, err = web.New(conf, node.store, node.network); err != nil { + if node.admin, err = web.New(conf, node.store, node.network, node.webhook); err != nil { return nil, err } diff --git a/pkg/postman/sunrise.go b/pkg/postman/sunrise.go index 2cc3f2e9..8de3b629 100644 --- a/pkg/postman/sunrise.go +++ b/pkg/postman/sunrise.go @@ -12,6 +12,7 @@ import ( "github.com/trisacrypto/envoy/pkg/emails" "github.com/trisacrypto/envoy/pkg/enum" "github.com/trisacrypto/envoy/pkg/store/models" + "github.com/trisacrypto/envoy/pkg/webhook" "github.com/trisacrypto/trisa/pkg/ivms101" trisa "github.com/trisacrypto/trisa/pkg/trisa/api/v1beta1" generic "github.com/trisacrypto/trisa/pkg/trisa/data/generic/v1beta1" @@ -123,6 +124,29 @@ func ReceiveSunriseReject(envelopeID uuid.UUID, reject *trisa.Error) (packet *Su return packet, nil } +// WebhookRequest creates a notification for the result of a Sunrise review. +// Unlike a network reply, the webhook should receive the state of the outgoing +// response because the incoming envelope is synthetic. +func (s *SunrisePacket) WebhookRequest() (request *webhook.Request, err error) { + request = s.In.WebhookRequest() + request.TransferState = s.Out.Envelope.TransferState().String() + + if s.In.Envelope.IsError() { + return request, nil + } + + var payload *trisa.Payload + if payload, err = s.In.Envelope.Payload(); err != nil { + return nil, fmt.Errorf("could not retrieve sunrise webhook payload: %w", err) + } + + if err = request.AddPayload(payload); err != nil { + return nil, fmt.Errorf("could not add sunrise webhook payload: %w", err) + } + + return request, nil +} + // Returns the email contacts of the compliance officers associated with the counterparty. func (s *SunrisePacket) Contacts() (contacts []*models.Contact, err error) { if contacts, err = s.Counterparty.Contacts(); err != nil { diff --git a/pkg/postman/sunrise_test.go b/pkg/postman/sunrise_test.go new file mode 100644 index 00000000..738ff216 --- /dev/null +++ b/pkg/postman/sunrise_test.go @@ -0,0 +1,77 @@ +package postman_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/trisacrypto/envoy/pkg/postman" + "github.com/trisacrypto/envoy/pkg/store/models" + "github.com/trisacrypto/envoy/pkg/webhook" + trisa "github.com/trisacrypto/trisa/pkg/trisa/api/v1beta1" +) + +func TestSunriseWebhookRequest(t *testing.T) { + // Use the same payload shape that a Sunrise recipient submits during review. + payload, err := loadPayloadFixture("testdata/identity.pb.json", "testdata/transaction.pb.json") + require.NoError(t, err) + + // An accepted Sunrise review becomes an accepted incoming webhook message + // containing the identity and transaction payload. + t.Run("Accept", func(t *testing.T) { + packet, err := postman.ReceiveSunriseAccept(uuid.New(), payload) + require.NoError(t, err) + + packet.Counterparty = &models.Counterparty{Name: "Example VASP"} + request, err := packet.WebhookRequest() + require.NoError(t, err) + + var received *webhook.Request + mock := webhook.NewMock() + mock.OnCallback = func(_ context.Context, request *webhook.Request) (*webhook.Reply, error) { + received = request + return &webhook.Reply{TransactionID: request.TransactionID}, nil + } + + _, err = mock.Callback(context.Background(), request) + require.NoError(t, err) + require.Equal(t, 1, mock.Callbacks) + require.Same(t, request, received) + require.Equal(t, trisa.TransferAccepted.String(), received.TransferState) + require.NotNil(t, received.Payload) + require.NotNil(t, received.Payload.Identity) + require.NotNil(t, received.Payload.Transaction) + }) + + // A rejected review is delivered as an error-only webhook message, with + // the transfer state determined by the rejection's retry flag. + t.Run("Reject", func(t *testing.T) { + reject := &trisa.Error{ + Code: trisa.ComplianceCheckFail, + Message: "transaction rejected", + Retry: false, + } + packet, err := postman.ReceiveSunriseReject(uuid.New(), reject) + require.NoError(t, err) + + packet.Counterparty = &models.Counterparty{Name: "Example VASP"} + request, err := packet.WebhookRequest() + require.NoError(t, err) + + mock := webhook.NewMock() + var received *webhook.Request + mock.OnCallback = func(_ context.Context, request *webhook.Request) (*webhook.Reply, error) { + received = request + return &webhook.Reply{TransactionID: request.TransactionID}, nil + } + + _, err = mock.Callback(context.Background(), request) + require.NoError(t, err) + require.Equal(t, 1, mock.Callbacks) + require.Same(t, request, received) + require.Equal(t, trisa.TransferRejected.String(), received.TransferState) + require.Equal(t, reject, received.Error) + require.Nil(t, received.Payload) + }) +} diff --git a/pkg/web/server.go b/pkg/web/server.go index eff4bcbc..80cfac36 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -16,6 +16,7 @@ import ( "github.com/trisacrypto/envoy/pkg/store/models" "github.com/trisacrypto/envoy/pkg/trisa/network" "github.com/trisacrypto/envoy/pkg/web/auth" + "github.com/trisacrypto/envoy/pkg/webhook" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" @@ -33,6 +34,7 @@ type Server struct { url *url.URL vasp *models.Counterparty trisa network.Network + webhook webhook.Handler started time.Time healthy bool ready bool diff --git a/pkg/web/sunrise.go b/pkg/web/sunrise.go index 0f13facc..10f7c579 100644 --- a/pkg/web/sunrise.go +++ b/pkg/web/sunrise.go @@ -22,6 +22,7 @@ import ( "github.com/trisacrypto/envoy/pkg/web/auth" "github.com/trisacrypto/envoy/pkg/web/htmx" "github.com/trisacrypto/envoy/pkg/web/scene" + "github.com/trisacrypto/envoy/pkg/webhook" trisa "github.com/trisacrypto/trisa/pkg/trisa/api/v1beta1" "github.com/trisacrypto/trisa/pkg/trisa/envelope" "github.com/trisacrypto/trisa/pkg/trisa/keys" @@ -71,6 +72,19 @@ func (s *Server) SendSunrise(ctx context.Context, packet *postman.SunrisePacket) return nil } +// Sends a webhook request to the configured webhook handler for the result of +// a sunrise review. +func (s *Server) notifySunriseWebhook(ctx context.Context, request *webhook.Request) { + if s.webhook == nil { + return + } + + if _, err := s.webhook.Callback(ctx, request); err != nil { + log := logger.Tracing(ctx) + log.Error().Err(err).Msg("could not notify webhook of sunrise message") + } +} + //=========================================================================== // Sunrise Pages //=========================================================================== @@ -312,6 +326,7 @@ func (s *Server) SunriseMessageReject(c *gin.Context) { sunriseID ulid.ULID sunriseMsg *models.Sunrise packet *postman.SunrisePacket + hookReq *webhook.Request ) in = &api.Rejection{} @@ -377,6 +392,13 @@ func (s *Server) SunriseMessageReject(c *gin.Context) { return } + // Build the webhook request before Save seals the incoming envelope. + if hookReq, err = packet.WebhookRequest(); err != nil { + c.Error(err) + c.JSON(http.StatusInternalServerError, api.Error("could not complete request")) + return + } + // Create a prepared transaction to create secure envelopes if packet.DB, err = s.store.PrepareTransaction(ctx, packet.Transaction.ID, &models.ComplianceAuditLog{ ChangeNotes: sql.NullString{Valid: true, String: "Server.SunriseMessageReject()"}, @@ -409,6 +431,9 @@ func (s *Server) SunriseMessageReject(c *gin.Context) { return } + // Notify the webhook only after the review has been committed. + s.notifySunriseWebhook(ctx, hookReq) + // If successful, then redirect to the sunrise message complete page. htmx.Redirect(c, http.StatusTemporaryRedirect, "/sunrise/complete") } @@ -422,6 +447,7 @@ func (s *Server) SunriseMessageAccept(c *gin.Context) { sunriseMsg *models.Sunrise payload *trisa.Payload packet *postman.SunrisePacket + hookReq *webhook.Request ) in = &api.Envelope{} @@ -500,6 +526,13 @@ func (s *Server) SunriseMessageAccept(c *gin.Context) { return } + // Build the webhook request before Save seals the incoming envelope. + if hookReq, err = packet.WebhookRequest(); err != nil { + c.Error(err) + c.JSON(http.StatusInternalServerError, api.Error("could not complete request")) + return + } + // Create a prepared transaction to create secure envelopes if packet.DB, err = s.store.PrepareTransaction(ctx, packet.Transaction.ID, &models.ComplianceAuditLog{ ChangeNotes: sql.NullString{Valid: true, String: "Server.SunriseMessageAccept()"}, @@ -538,6 +571,9 @@ func (s *Server) SunriseMessageAccept(c *gin.Context) { return } + // Notify the webhook only after the review has been committed. + s.notifySunriseWebhook(ctx, hookReq) + // If successful, then redirect to the sunrise message complete page. htmx.Redirect(c, http.StatusTemporaryRedirect, "/sunrise/complete") } diff --git a/pkg/web/web.go b/pkg/web/web.go index 6095305b..da978499 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -9,21 +9,23 @@ import ( "github.com/trisacrypto/envoy/pkg/trisa/network" "github.com/trisacrypto/envoy/pkg/web/auth" "github.com/trisacrypto/envoy/pkg/web/scene" + "github.com/trisacrypto/envoy/pkg/webhook" "github.com/gin-gonic/gin" "go.rtnl.ai/ulid" ) // Create a new web server that serves the compliance and admin web user interface. -func New(conf config.Config, store store.Store, network network.Network) (s *Server, err error) { +func New(conf config.Config, store store.Store, network network.Network, callback webhook.Handler) (s *Server, err error) { if err = conf.Web.Validate(); err != nil { return nil, err } s = &Server{ - conf: conf, - store: store, - trisa: network, + conf: conf, + store: store, + trisa: network, + webhook: callback, } // If not enabled, return just the server stub @@ -70,7 +72,7 @@ func New(conf config.Config, store store.Store, network network.Network) (s *Ser // Debug returns a server that uses the specified http server instead of creating one. // This function is primarily used to create test servers easily. func Debug(conf config.Config, store store.Store, network network.Network, srv *http.Server) (s *Server, err error) { - if s, err = New(conf, store, network); err != nil { + if s, err = New(conf, store, network, nil); err != nil { return nil, err } diff --git a/pkg/web/web_test.go b/pkg/web/web_test.go index d9ded3ba..73c0bfe2 100644 --- a/pkg/web/web_test.go +++ b/pkg/web/web_test.go @@ -30,7 +30,7 @@ func TestServerEnabled(t *testing.T) { UIEnabled: false, }} - _, err := web.New(conf, nil, nil) + _, err := web.New(conf, nil, nil, nil) require.EqualError(t, err, "invalid configuration: if enabled, either the api, ui, or both need to be enabled") }) @@ -73,7 +73,7 @@ func TestServerEnabled(t *testing.T) { Origin: "http://locahost:57132", }} - srv, err := web.New(conf, store, network) + srv, err := web.New(conf, store, network, nil) require.NoError(t, err, "could not start web server") err = srv.Serve(nil) @@ -166,7 +166,7 @@ func TestServerEnabled(t *testing.T) { Origin: "http://locahost:57132", }} - srv, err := web.New(conf, store, network) + srv, err := web.New(conf, store, network, nil) require.NoError(t, err, "could not start web server") err = srv.Serve(nil)