From b279c36cb59b63fb70ba386435a27276c47c04ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Tue, 28 Nov 2023 14:53:26 -0300 Subject: [PATCH 01/39] [SDP-960] serve/httphandler: API endpoint to fetch the verification types (#113) What API endpoint to get the verification types GET receiver/verification-types Why Add dropdown for choosing Verification Type when creating new disbursements on FE --- internal/data/disbursements.go | 9 ++++++++ .../serve/httphandler/receiver_handler.go | 5 +++++ .../httphandler/receiver_handler_test.go | 21 +++++++++++++++++++ internal/serve/serve.go | 3 +++ internal/serve/serve_test.go | 1 + 5 files changed, 39 insertions(+) diff --git a/internal/data/disbursements.go b/internal/data/disbursements.go index cda54077b..5491128e2 100644 --- a/internal/data/disbursements.go +++ b/internal/data/disbursements.go @@ -58,6 +58,15 @@ const ( VerificationFieldNationalID VerificationField = "NATIONAL_ID_NUMBER" ) +// GetAllVerificationFields returns all verification fields +func GetAllVerificationFields() []VerificationField { + return []VerificationField{ + VerificationFieldDateOfBirth, + VerificationFieldPin, + VerificationFieldNationalID, + } +} + type DisbursementStatusHistoryEntry struct { UserID string `json:"user_id"` Status DisbursementStatus `json:"status"` diff --git a/internal/serve/httphandler/receiver_handler.go b/internal/serve/httphandler/receiver_handler.go index 3f6c3d896..db87e4719 100644 --- a/internal/serve/httphandler/receiver_handler.go +++ b/internal/serve/httphandler/receiver_handler.go @@ -133,3 +133,8 @@ func (rh ReceiverHandler) GetReceivers(w http.ResponseWriter, r *http.Request) { httpjson.RenderStatus(w, http.StatusOK, httpResponse, httpjson.JSON) } + +// GetReceiverVerification returns a list of verification types +func (rh ReceiverHandler) GetReceiverVerificationTypes(w http.ResponseWriter, r *http.Request) { + httpjson.Render(w, data.GetAllVerificationFields(), httpjson.JSON) +} diff --git a/internal/serve/httphandler/receiver_handler_test.go b/internal/serve/httphandler/receiver_handler_test.go index a846f2dc4..d75f6b08b 100644 --- a/internal/serve/httphandler/receiver_handler_test.go +++ b/internal/serve/httphandler/receiver_handler_test.go @@ -1616,3 +1616,24 @@ func Test_ReceiverHandler_BuildReceiversResponse(t *testing.T) { err = dbTx.Commit() require.NoError(t, err) } + +func Test_ReceiverHandler_GetReceiverVerificatioTypes(t *testing.T) { + handler := &ReceiverHandler{} + + rr := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/receivers/verification-types", nil) + require.NoError(t, err) + http.HandlerFunc(handler.GetReceiverVerificationTypes).ServeHTTP(rr, req) + + resp := rr.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + expectedBody := `[ + "DATE_OF_BIRTH", + "PIN", + "NATIONAL_ID_NUMBER" + ]` + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, expectedBody, string(respBody)) +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index afe12ccae..e97c1384f 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -262,6 +262,9 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). Get("/{id}", receiversHandler.GetReceiver) + r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)). + Get("/verification-types", receiversHandler.GetReceiverVerificationTypes) + updateReceiverHandler := httphandler.UpdateReceiverHandler{Models: o.Models, DBConnectionPool: o.dbConnectionPool} r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). Patch("/{id}", updateReceiverHandler.UpdateReceiver) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index f4638e81a..7ef065e57 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -294,6 +294,7 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { {http.MethodGet, "/receivers/1234"}, {http.MethodPatch, "/receivers/1234"}, {http.MethodPatch, "/receivers/wallets/1234"}, + {http.MethodGet, "/receivers/verification-types"}, // Countries {http.MethodGet, "/countries"}, // Assets From a665505a87761dc58da6796a19cd4b5f93bcefe5 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Tue, 28 Nov 2023 14:25:22 -0800 Subject: [PATCH 02/39] [SDP-961] Change `POST /disbursements` to accept Verification Type (#103) --- internal/data/disbursements.go | 8 +- internal/data/disbursements_test.go | 8 +- internal/data/fixtures.go | 8 ++ .../integrationtests/integration_tests.go | 9 +- .../serve/httphandler/disbursement_handler.go | 27 ++-- .../httphandler/disbursement_handler_test.go | 79 +++++++---- .../disbursement_instructions_validator.go | 19 ++- ...isbursement_instructions_validator_test.go | 126 ++++++++++++++---- .../disbursement_request_validator.go | 26 ++++ .../disbursement_request_validator_test.go | 32 +++++ 10 files changed, 272 insertions(+), 70 deletions(-) create mode 100644 internal/serve/validators/disbursement_request_validator.go create mode 100644 internal/serve/validators/disbursement_request_validator_test.go diff --git a/internal/data/disbursements.go b/internal/data/disbursements.go index 5491128e2..2e8bd413d 100644 --- a/internal/data/disbursements.go +++ b/internal/data/disbursements.go @@ -86,9 +86,9 @@ var ( func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disbursement) (string, error) { const q = ` INSERT INTO - disbursements (name, status, status_history, wallet_id, asset_id, country_code) + disbursements (name, status, status_history, wallet_id, asset_id, country_code, verification_field) VALUES - ($1, $2, $3, $4, $5, $6) + ($1, $2, $3, $4, $5, $6, $7) RETURNING id ` var newId string @@ -99,6 +99,7 @@ func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disburseme disbursement.Wallet.ID, disbursement.Asset.ID, disbursement.Country.Code, + disbursement.VerificationField, ) if err != nil { // check if the error is a duplicate key error @@ -139,6 +140,7 @@ func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id d.file_content, d.created_at, d.updated_at, + d.verification_field, w.id as "wallet.id", w.name as "wallet.name", w.homepage as "wallet.homepage", @@ -189,6 +191,7 @@ func (d *DisbursementModel) GetByName(ctx context.Context, sqlExec db.SQLExecute d.file_content, d.created_at, d.updated_at, + d.verification_field, w.id as "wallet.id", w.name as "wallet.name", w.homepage as "wallet.homepage", @@ -328,6 +331,7 @@ func (d *DisbursementModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, d.verification_field, d.created_at, d.updated_at, + d.verification_field, COALESCE(d.file_name, '') as file_name, w.id as "wallet.id", w.name as "wallet.name", diff --git a/internal/data/disbursements_test.go b/internal/data/disbursements_test.go index 91c933da2..ec3d06e3a 100644 --- a/internal/data/disbursements_test.go +++ b/internal/data/disbursements_test.go @@ -37,9 +37,10 @@ func Test_DisbursementModelInsert(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, + Asset: asset, + Country: country, + Wallet: wallet, + VerificationField: VerificationFieldDateOfBirth, } t.Run("returns error when disbursement already exists is not found", func(t *testing.T) { @@ -67,6 +68,7 @@ func Test_DisbursementModelInsert(t *testing.T) { assert.Equal(t, 1, len(actual.StatusHistory)) assert.Equal(t, DraftDisbursementStatus, actual.StatusHistory[0].Status) assert.Equal(t, "user1", actual.StatusHistory[0].UserID) + assert.Equal(t, VerificationFieldDateOfBirth, actual.VerificationField) }) } diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index cff9c5e4b..b62a9e43d 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -514,6 +514,10 @@ func CreateDisbursementFixture(t *testing.T, ctx context.Context, sqlExec db.SQL if d.Country == nil { d.Country = GetCountryFixture(t, ctx, sqlExec, FixtureCountryUKR) } + if d.VerificationField == "" { + d.VerificationField = VerificationFieldDateOfBirth + } + // insert disbursement if d.StatusHistory == nil { d.StatusHistory = []DisbursementStatusHistoryEntry{{ @@ -583,6 +587,10 @@ func CreateDraftDisbursementFixture(t *testing.T, ctx context.Context, sqlExec d insert.Status = DraftDisbursementStatus } + if insert.VerificationField == "" { + insert.VerificationField = VerificationFieldDateOfBirth + } + id, err := model.Insert(ctx, &insert) require.NoError(t, err) diff --git a/internal/integrationtests/integration_tests.go b/internal/integrationtests/integration_tests.go index 6c886aa56..17134a8c8 100644 --- a/internal/integrationtests/integration_tests.go +++ b/internal/integrationtests/integration_tests.go @@ -121,10 +121,11 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Creating disbursement using server API") disbursement, err := it.serverAPI.CreateDisbursement(ctx, authToken, &httphandler.PostDisbursementRequest{ - Name: opts.DisbursementName, - CountryCode: "USA", - WalletID: wallet.ID, - AssetID: asset.ID, + Name: opts.DisbursementName, + CountryCode: "USA", + WalletID: wallet.ID, + AssetID: asset.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) if err != nil { return fmt.Errorf("error creating disbursement: %w", err) diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index 1b9af8a78..780b8568b 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -33,10 +33,11 @@ type DisbursementHandler struct { } type PostDisbursementRequest struct { - Name string `json:"name"` - CountryCode string `json:"country_code"` - WalletID string `json:"wallet_id"` - AssetID string `json:"asset_id"` + Name string `json:"name"` + CountryCode string `json:"country_code"` + WalletID string `json:"wallet_id"` + AssetID string `json:"asset_id"` + VerificationField data.VerificationField `json:"verification_field"` } type PatchDisbursementStatusRequest struct { @@ -52,9 +53,7 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req return } - // validate request - v := validators.NewValidator() - + v := validators.NewDisbursementRequestValidator(disbursementRequest.VerificationField) v.Check(disbursementRequest.Name != "", "name", "name is required") v.Check(disbursementRequest.CountryCode != "", "country_code", "country_code is required") v.Check(disbursementRequest.WalletID != "", "wallet_id", "wallet_id is required") @@ -65,6 +64,13 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req return } + verificationField := v.ValidateAndGetVerificationType() + + if v.HasErrors() { + httperror.BadRequest("Verification field invalid", err, v.Errors).Render(w) + return + } + ctx := r.Context() wallet, err := d.Models.Wallets.Get(ctx, disbursementRequest.WalletID) if err != nil { @@ -107,9 +113,10 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req Status: data.DraftDisbursementStatus, UserID: user.ID, }}, - Wallet: wallet, - Asset: asset, - Country: country, + Wallet: wallet, + Asset: asset, + Country: country, + VerificationField: verificationField, } newId, err := d.Models.Disbursements.Insert(ctx, &disbursement) diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index 23d88159a..b3c42b004 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -97,7 +97,8 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { { "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", - "country_code": "UKR" + "country_code": "UKR", + "verification_field": "date_of_birth" }` want := ` @@ -116,7 +117,8 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { { "name": "My New Disbursement name 5", "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", - "country_code": "UKR" + "country_code": "UKR", + "verification_field": "date_of_birth" }` want := `{"error":"Request invalid", "extras": {"wallet_id": "wallet_id is required"}}` @@ -129,7 +131,8 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { { "name": "My New Disbursement name 5", "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - "country_code": "UKR" + "country_code": "UKR", + "verification_field": "date_of_birth" }` want := `{"error":"Request invalid", "extras": {"asset_id": "asset_id is required"}}` @@ -142,7 +145,8 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { { "name": "My New Disbursement name 5", "wallet_id": "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - "asset_id": "61dbfa89-943a-413c-b862-a2177384d321" + "asset_id": "61dbfa89-943a-413c-b862-a2177384d321", + "verification_field": "date_of_birth" }` want := `{"error":"Request invalid", "extras": {"country_code": "country_code is required"}}` @@ -150,12 +154,27 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { assertPOSTResponse(t, ctx, handler, method, url, requestBody, want, http.StatusBadRequest) }) - t.Run("returns error when wallet_id is not valid", func(t *testing.T) { + t.Run("returns error when no verification field is provided", func(t *testing.T) { requestBody, err := json.Marshal(PostDisbursementRequest{ Name: "disbursement 1", CountryCode: country.Code, AssetID: asset.ID, - WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + WalletID: enabledWallet.ID, + }) + require.NoError(t, err) + + want := `{"error":"Verification field invalid", "extras": {"verification_field": "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER"}}` + + assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) + }) + + t.Run("returns error when wallet_id is not valid", func(t *testing.T) { + requestBody, err := json.Marshal(PostDisbursementRequest{ + Name: "disbursement 1", + CountryCode: country.Code, + AssetID: asset.ID, + WalletID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -167,10 +186,11 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { t.Run("returns error when wallet is not enabled", func(t *testing.T) { data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, disabledWallet.ID) requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: disabledWallet.ID, + Name: "disbursement 1", + CountryCode: country.Code, + AssetID: asset.ID, + WalletID: disabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -181,10 +201,11 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { t.Run("returns error when asset_id is not valid", func(t *testing.T) { requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", - WalletID: enabledWallet.ID, + Name: "disbursement 1", + CountryCode: country.Code, + AssetID: "aab4a4a9-2493-4f37-9741-01d5bd31d68b", + WalletID: enabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -195,10 +216,11 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { t.Run("returns error when country_code is not valid", func(t *testing.T) { requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: "AAA", - AssetID: asset.ID, - WalletID: enabledWallet.ID, + Name: "disbursement 1", + CountryCode: "AAA", + AssetID: asset.ID, + WalletID: enabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -217,10 +239,11 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { mMonitorService.On("MonitorCounters", monitor.DisbursementsCounterTag, labels.ToMap()).Return(nil).Once() requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: "disbursement 1", - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, + Name: "disbursement 1", + CountryCode: country.Code, + AssetID: asset.ID, + WalletID: enabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -238,10 +261,11 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { expectedName := "disbursement 2" requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: expectedName, - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, + Name: expectedName, + CountryCode: country.Code, + AssetID: asset.ID, + WalletID: enabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, }) require.NoError(t, err) @@ -1055,6 +1079,9 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { r := chi.NewRouter() r.Patch("/disbursements/{id}/status", handler.PatchDisbursementStatus) + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{}) + require.NotNil(t, disbursement) + readyStatusHistory := []data.DisbursementStatusHistoryEntry{ { Status: data.DraftDisbursementStatus, diff --git a/internal/serve/validators/disbursement_instructions_validator.go b/internal/serve/validators/disbursement_instructions_validator.go index e3d2e6149..badcc0ea3 100644 --- a/internal/serve/validators/disbursement_instructions_validator.go +++ b/internal/serve/validators/disbursement_instructions_validator.go @@ -5,9 +5,16 @@ import ( "strings" "time" + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +const ( + VERIFICATION_FIELD_PIN_MIN_LENGTH = 4 + VERIFICATION_FIELD_PIN_MAX_LENGTH = 8 + + VERIFICATION_FIELD_MAX_ID_LENGTH = 50 ) type DisbursementInstructionsValidator struct { @@ -46,5 +53,15 @@ func (iv *DisbursementInstructionsValidator) ValidateInstruction(instruction *da // check if date of birth is in the past iv.Check(dob.Before(time.Now()), fmt.Sprintf("line %d - birthday", lineNumber), "date of birth cannot be in the future") + } else if iv.verificationField == data.VerificationFieldPin { + if len(verification) < VERIFICATION_FIELD_PIN_MIN_LENGTH || len(verification) > VERIFICATION_FIELD_PIN_MAX_LENGTH { + iv.addError(fmt.Sprintf("line %d - pin", lineNumber), "invalid pin. Cannot have less than 4 or more than 8 characters in pin") + } + } else if iv.verificationField == data.VerificationFieldNationalID { + if len(verification) > VERIFICATION_FIELD_MAX_ID_LENGTH { + iv.addError(fmt.Sprintf("line %d - national id", lineNumber), "invalid national id. Cannot have more than 50 characters in national id") + } + } else { + log.Warnf("Verification field %v is not being validated for ValidateReceiver", iv) } } diff --git a/internal/serve/validators/disbursement_instructions_validator_test.go b/internal/serve/validators/disbursement_instructions_validator_test.go index ebb282a13..a98f6694c 100644 --- a/internal/serve/validators/disbursement_instructions_validator_test.go +++ b/internal/serve/validators/disbursement_instructions_validator_test.go @@ -9,11 +9,12 @@ import ( func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing.T) { tests := []struct { - name string - actual *data.DisbursementInstruction - lineNumber int - hasErrors bool - expectedErrors map[string]interface{} + name string + actual *data.DisbursementInstruction + lineNumber int + verificationField data.VerificationField + hasErrors bool + expectedErrors map[string]interface{} }{ { name: "valid record", @@ -23,8 +24,9 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5", VerificationValue: "1990-01-01", }, - lineNumber: 1, - hasErrors: false, + lineNumber: 1, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: false, }, { name: "empty phone number", @@ -33,17 +35,19 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5", VerificationValue: "1990-01-01", }, - lineNumber: 2, - hasErrors: true, + lineNumber: 2, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 2 - phone": "phone cannot be empty", }, }, { - name: "empty phone, id, amount and birthday", - actual: &data.DisbursementInstruction{}, - lineNumber: 2, - hasErrors: true, + name: "empty phone, id, amount and birthday", + actual: &data.DisbursementInstruction{}, + lineNumber: 2, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 2 - amount": "invalid amount. Amount must be a positive number", "line 2 - birthday": "invalid date of birth format. Correct format: 1990-01-01", @@ -59,8 +63,9 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5", VerificationValue: "1990-01-01", }, - lineNumber: 2, - hasErrors: true, + lineNumber: 2, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 2 - phone": "invalid phone format. Correct format: +380445555555", }, @@ -73,8 +78,9 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5USDC", VerificationValue: "1990-01-01", }, - lineNumber: 3, - hasErrors: true, + lineNumber: 3, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 3 - amount": "invalid amount. Amount must be a positive number", }, @@ -87,8 +93,9 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "-100.5", VerificationValue: "1990-01-01", }, - lineNumber: 3, - hasErrors: true, + lineNumber: 3, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 3 - amount": "invalid amount. Amount must be a positive number", }, @@ -101,8 +108,9 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5", VerificationValue: "1990/01/01", }, - lineNumber: 3, - hasErrors: true, + lineNumber: 3, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 3 - birthday": "invalid date of birth format. Correct format: 1990-01-01", }, @@ -115,17 +123,87 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing Amount: "100.5", VerificationValue: "2090-01-01", }, - lineNumber: 3, - hasErrors: true, + lineNumber: 3, + verificationField: data.VerificationFieldDateOfBirth, + hasErrors: true, expectedErrors: map[string]interface{}{ "line 3 - birthday": "date of birth cannot be in the future", }, }, + { + name: "valid pin", + actual: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "1234", + }, + lineNumber: 3, + verificationField: data.VerificationFieldPin, + hasErrors: false, + }, + { + name: "invalid pin - less than 4 characters", + actual: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "123", + }, + lineNumber: 3, + verificationField: data.VerificationFieldPin, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - pin": "invalid pin. Cannot have less than 4 or more than 8 characters in pin", + }, + }, + { + name: "invalid pin - more than 8 characters", + actual: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "123456789", + }, + lineNumber: 3, + verificationField: data.VerificationFieldPin, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - pin": "invalid pin. Cannot have less than 4 or more than 8 characters in pin", + }, + }, + { + name: "valid national id", + actual: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "ABCD123", + }, + lineNumber: 3, + verificationField: data.VerificationFieldNationalID, + hasErrors: false, + }, + { + name: "invalid national - more than 50 characters", + actual: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78", + }, + lineNumber: 3, + verificationField: data.VerificationFieldNationalID, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - national id": "invalid national id. Cannot have more than 50 characters in national id", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - iv := NewDisbursementInstructionsValidator(data.VerificationFieldDateOfBirth) + iv := NewDisbursementInstructionsValidator(tt.verificationField) iv.ValidateInstruction(tt.actual, tt.lineNumber) if tt.hasErrors { diff --git a/internal/serve/validators/disbursement_request_validator.go b/internal/serve/validators/disbursement_request_validator.go new file mode 100644 index 000000000..7bbcc1c6d --- /dev/null +++ b/internal/serve/validators/disbursement_request_validator.go @@ -0,0 +1,26 @@ +package validators + +import "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + +type DisbursementRequestValidator struct { + verificationField data.VerificationField + *Validator +} + +func NewDisbursementRequestValidator(verificationField data.VerificationField) *DisbursementRequestValidator { + return &DisbursementRequestValidator{ + verificationField: verificationField, + Validator: NewValidator(), + } +} + +// ValidateAndGetVerificationType validates if the verification type field is a valid value. +func (dv *DisbursementRequestValidator) ValidateAndGetVerificationType() data.VerificationField { + switch dv.verificationField { + case data.VerificationFieldDateOfBirth, data.VerificationFieldPin, data.VerificationFieldNationalID: + return dv.verificationField + default: + dv.Check(false, "verification_field", "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER") + return "" + } +} diff --git a/internal/serve/validators/disbursement_request_validator_test.go b/internal/serve/validators/disbursement_request_validator_test.go new file mode 100644 index 000000000..b2e326c47 --- /dev/null +++ b/internal/serve/validators/disbursement_request_validator_test.go @@ -0,0 +1,32 @@ +package validators + +import ( + "testing" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stretchr/testify/assert" +) + +func Test_DisbursementRequestValidator_ValidateAndGetVerificationType(t *testing.T) { + t.Run("Valid verification type", func(t *testing.T) { + validField := []data.VerificationField{ + data.VerificationFieldDateOfBirth, + data.VerificationFieldPin, + data.VerificationFieldNationalID, + } + for _, field := range validField { + validator := NewDisbursementRequestValidator(field) + assert.Equal(t, field, validator.ValidateAndGetVerificationType()) + } + }) + + t.Run("Invalid verification type", func(t *testing.T) { + field := data.VerificationField("field") + validator := NewDisbursementRequestValidator(field) + + actual := validator.ValidateAndGetVerificationType() + assert.Empty(t, actual) + assert.Equal(t, 1, len(validator.Errors)) + assert.Equal(t, "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER", validator.Errors["verification_field"]) + }) +} From 7df3634f7e354f8719ec2ff9d1407a9745be57a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Tue, 28 Nov 2023 22:45:56 -0300 Subject: [PATCH 03/39] [SDP-974] data/httphandler/validators: Adding sorting to GET /users endpoint. (#104) What Add sort and direction parameters to sort GET /users endpoint. Why Sort users according to query parameters. --- CHANGELOG.md | 2 +- internal/data/query_params.go | 2 + internal/serve/httphandler/user_handler.go | 37 ++- .../serve/httphandler/user_handler_test.go | 280 +++++++++++++++++- .../serve/validators/user_query_validator.go | 27 ++ 5 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 internal/serve/validators/user_query_validator.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d285f99e..b0b28eccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -> Place unreleased changes here. +- Add sorting to `GET /users` endpoint [#104](https://github.com/stellar/stellar-disbursement-platform-backend/pull/104) ## [1.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0-rc2...1.0.0) diff --git a/internal/data/query_params.go b/internal/data/query_params.go index c24cd4811..bb91a9403 100644 --- a/internal/data/query_params.go +++ b/internal/data/query_params.go @@ -20,6 +20,8 @@ type SortField string const ( SortFieldName SortField = "name" + SortFieldEmail SortField = "email" + SortFieldIsActive SortField = "is_active" SortFieldCreatedAt SortField = "created_at" SortFieldUpdatedAt SortField = "updated_at" ) diff --git a/internal/serve/httphandler/user_handler.go b/internal/serve/httphandler/user_handler.go index c940f1eee..36bc14b79 100644 --- a/internal/serve/httphandler/user_handler.go +++ b/internal/serve/httphandler/user_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "sort" "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" @@ -32,6 +33,18 @@ type UserActivationRequest struct { IsActive *bool `json:"is_active"` } +type UserSorterByEmail []auth.User + +func (a UserSorterByEmail) Len() int { return len(a) } +func (a UserSorterByEmail) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a UserSorterByEmail) Less(i, j int) bool { return a[i].Email < a[j].Email } + +type UserSorterByIsActive []auth.User + +func (a UserSorterByIsActive) Len() int { return len(a) } +func (a UserSorterByIsActive) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a UserSorterByIsActive) Less(i, j int) bool { return a[i].IsActive } + func (uar UserActivationRequest) validate() *httperror.HTTPError { validator := validators.NewValidator() @@ -275,6 +288,13 @@ func (h UserHandler) UpdateUserRoles(rw http.ResponseWriter, req *http.Request) } func (h UserHandler) GetAllUsers(rw http.ResponseWriter, req *http.Request) { + validator := validators.NewUserQueryValidator() + queryParams := validator.ParseParametersFromRequest(req) + if validator.HasErrors() { + httperror.BadRequest("request invalid", nil, validator.Errors).Render(rw) + return + } + ctx := req.Context() token, ok := ctx.Value(middleware.TokenContextKey).(string) @@ -290,10 +310,25 @@ func (h UserHandler) GetAllUsers(rw http.ResponseWriter, req *http.Request) { httperror.Unauthorized("", err, nil).Render(rw) return } - httperror.InternalError(ctx, "Cannot get all users", err, nil).Render(rw) return } + // Order users + switch queryParams.SortBy { + case data.SortFieldEmail: + if queryParams.SortOrder == data.SortOrderDESC { + sort.Sort(sort.Reverse(UserSorterByEmail(users))) + } else { + sort.Sort(UserSorterByEmail(users)) + } + case data.SortFieldIsActive: + if queryParams.SortOrder == data.SortOrderDESC { + sort.Sort(sort.Reverse(UserSorterByIsActive(users))) + } else { + sort.Sort(UserSorterByIsActive(users)) + } + } + httpjson.RenderStatus(rw, http.StatusOK, users, httpjson.JSON) } diff --git a/internal/serve/httphandler/user_handler_test.go b/internal/serve/httphandler/user_handler_test.go index ede35bd76..34201069d 100644 --- a/internal/serve/httphandler/user_handler_test.go +++ b/internal/serve/httphandler/user_handler_test.go @@ -1232,12 +1232,111 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { assert.Contains(t, buf.String(), "Cannot get all users") }) - t.Run("returns all users successfully", func(t *testing.T) { + const orderByEmailAscURL = "/users?sort=email&direction=ASC" + const orderByEmailDescURL = "/users?sort=email&direction=DESC" + const orderByIsActiveAscURL = "/users?sort=is_active&direction=ASC" + const orderByIsActiveDescURL = "/users?sort=is_active&direction=DESC" + + t.Run("returns all users ordered by email ASC", func(t *testing.T) { token := "mytoken" ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByEmailAscURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userC@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + { + ID: "user3-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + }, + { + "id": "user3-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userC@email.com", + "is_active": true, + "roles": [ + "owner" + ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by email DESC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByEmailDescURL, nil) require.NoError(t, err) jwtManagerMock. @@ -1252,7 +1351,7 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { ID: "user1-ID", FirstName: "First", LastName: "Last", - Email: "user1@email.com", + Email: "userA@email.com", IsOwner: false, IsActive: false, Roles: []string{data.BusinessUserRole.String()}, @@ -1261,7 +1360,16 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { ID: "user2-ID", FirstName: "First", LastName: "Last", - Email: "user2@email.com", + Email: "userC@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + { + ID: "user3-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", IsOwner: true, IsActive: true, Roles: []string{data.OwnerUserRole.String()}, @@ -1280,25 +1388,185 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { wantsBody := ` [ + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userC@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, + { + "id": "user3-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, { "id": "user1-ID", "first_name": "First", "last_name": "Last", - "email": "user1@email.com", + "email": "userA@email.com", "is_active": false, "roles": [ "business" ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by is_active ASC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByIsActiveAscURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ { "id": "user2-ID", "first_name": "First", "last_name": "Last", - "email": "user2@email.com", + "email": "userB@email.com", "is_active": true, "roles": [ "owner" ] + }, + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by is_active DESC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByIsActiveDescURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + }, + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] } ] ` diff --git a/internal/serve/validators/user_query_validator.go b/internal/serve/validators/user_query_validator.go new file mode 100644 index 000000000..5425596a9 --- /dev/null +++ b/internal/serve/validators/user_query_validator.go @@ -0,0 +1,27 @@ +package validators + +import ( + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +type UserQueryValidator struct { + QueryValidator +} + +var ( + DefaultUserSortField = data.SortFieldEmail + DefaultUserSortOrder = data.SortOrderASC + AllowedUserSorts = []data.SortField{data.SortFieldEmail, data.SortFieldIsActive} +) + +// NewUserQueryValidator creates a new UserQueryValidator with the provided configuration. +func NewUserQueryValidator() *UserQueryValidator { + return &UserQueryValidator{ + QueryValidator: QueryValidator{ + Validator: NewValidator(), + DefaultSortField: DefaultUserSortField, + DefaultSortOrder: DefaultUserSortOrder, + AllowedSortFields: AllowedUserSorts, + }, + } +} From 36ed4b5f9b88968ca12bf5dbd1ab16883541aff0 Mon Sep 17 00:00:00 2001 From: Steven Tomlinson <8976999+steven-tomlinson@users.noreply.github.com> Date: Thu, 7 Dec 2023 14:33:27 -0800 Subject: [PATCH 04/39] docker-compose.yml refactor (#114) * Update docker-compose-sdp-anchor.yml Had to remove depends_on entries from services in order to install. This error typically occurs when a service defined in a Docker Compose file attempts to extend another service that has depends_on attributes. According to Docker Compose's documentation, a service that uses depends_on cannot be extended. * Update docker-compose-frontend.yml Removed depends_on references. This error typically occurs when a service defined in a Docker Compose file attempts to extend another service that has depends_on attributes. According to Docker Compose's documentation, a service that uses depends_on cannot be extended * Update docker-compose-tss.yml Removed depends_on references This error typically occurs when a service defined in a Docker Compose file attempts to extend another service that has depends_on attributes. According to Docker Compose's documentation, a service that uses depends_on cannot be extended * Update docker-compose-frontend.yml Moves depends_on to main docker.compose. * Update docker-compose-sdp-anchor.yml Moves depends_on to main docker.compose. * Update docker-compose-tss.yml Moves depends_on to main docker.compose. * Update docker-compose.yml Moves 'depends_on' sections from service definitions to top-level docker-compose.yml https://docs.docker.com/compose/compose-file/05-services/#restrictions * Update docker-compose.yml Corrects typo in version number. --- dev/docker-compose-frontend.yml | 3 --- dev/docker-compose-sdp-anchor.yml | 4 ---- dev/docker-compose-tss.yml | 3 --- dev/docker-compose.yml | 12 ++++++++++++ 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/dev/docker-compose-frontend.yml b/dev/docker-compose-frontend.yml index 27d2d4f1e..88c8cd046 100644 --- a/dev/docker-compose-frontend.yml +++ b/dev/docker-compose-frontend.yml @@ -7,6 +7,3 @@ services: - "3000:80" volumes: - ./env-config.js:/usr/share/nginx/html/settings/env-config.js - depends_on: - - db - - sdp-api \ No newline at end of file diff --git a/dev/docker-compose-sdp-anchor.yml b/dev/docker-compose-sdp-anchor.yml index 2c66bfb53..20c08c07f 100644 --- a/dev/docker-compose-sdp-anchor.yml +++ b/dev/docker-compose-sdp-anchor.yml @@ -65,8 +65,6 @@ services: ./stellar-disbursement-platform db auth migrate up ./stellar-disbursement-platform db setup-for-network ./stellar-disbursement-platform serve - depends_on: - - db db-anchor-platform: container_name: anchor-platform-postgres-db @@ -89,8 +87,6 @@ services: - "8080:8080" # sep-server - "8085:8085" # platform-server - "8082:8082" # metrics - depends_on: - - db-anchor-platform environment: HOST_URL: http://localhost:8080 SEP_SERVER_PORT: 8080 diff --git a/dev/docker-compose-tss.yml b/dev/docker-compose-tss.yml index a46807533..d7159f889 100644 --- a/dev/docker-compose-tss.yml +++ b/dev/docker-compose-tss.yml @@ -17,9 +17,6 @@ services: TSS_METRICS_PORT: "9002" TSS_METRICS_TYPE: "TSS_PROMETHEUS" DISTRIBUTION_SEED: ${DISTRIBUTION_SEED} - depends_on: - - db - - sdp-api entrypoint: "" command: - sh diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 2b20d794c..5402257c3 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -10,24 +10,36 @@ services: extends: file: docker-compose-sdp-anchor.yml service: sdp-api + depends_on: + - db db-anchor-platform: extends: file: docker-compose-sdp-anchor.yml service: db-anchor-platform + depends_on: + - sdp-api volumes: - postgres-ap-db:/data/postgres anchor-platform: extends: file: docker-compose-sdp-anchor.yml service: anchor-platform + depends_on: + - db-anchor-platform sdp-tss: extends: file: docker-compose-tss.yml service: sdp-tss + depends_on: + - db + - sdp-api sdp-frontend: extends: file: docker-compose-frontend.yml service: sdp-frontend + depends_on: + - db + - sdp-api volumes: postgres-db: driver: local From 9419210ede382ed4e6049e2d402dc07f3cf851ee Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Tue, 12 Dec 2023 11:31:54 -0800 Subject: [PATCH 05/39] [SDP-1008] Add missing space when building the query (#121) ### What Add missing space when building the query ### Why The previous code was creating the string `%sGROUP BY...` instead of `%s GROUP BY`. --- internal/data/wallets.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/data/wallets.go b/internal/data/wallets.go index 588b9837f..3b00ee119 100644 --- a/internal/data/wallets.go +++ b/internal/data/wallets.go @@ -110,11 +110,11 @@ func (wm *WalletModel) FindWallets(ctx context.Context, enabledFilter *bool) ([] var args []interface{} if enabledFilter != nil { - whereClause = "WHERE w.enabled = $1" + whereClause = "WHERE w.enabled = $1 " args = append(args, *enabledFilter) } - query := fmt.Sprintf(getQuery, whereClause+`GROUP BY w.id ORDER BY w.name`) + query := fmt.Sprintf(getQuery, whereClause+" GROUP BY w.id ORDER BY w.name") err := wm.dbConnectionPool.SelectContext(ctx, &wallets, query, args...) if err != nil { From 9424bf744629bd4a996fb152f36b4b97ba2230cf Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Tue, 12 Dec 2023 13:17:20 -0800 Subject: [PATCH 06/39] [SDP-996] Make `POST /assets` idempotent (#122) ### What Make `POST /assets` idempotent. ### Why The previous implementation was causing a hassle with a partner that had seeded their database before using the frontend application. --- .github/pull_request_template.md | 4 ++-- internal/data/assets.go | 24 +++++++++++-------- internal/data/assets_test.go | 16 ++++++++----- .../serve/httphandler/assets_handler_test.go | 21 +++++----------- .../send_receiver_wallets_invite_service.go | 16 +++++++++---- ...nd_receiver_wallets_invite_service_test.go | 10 ++++---- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5b0ddf53a..5033c136e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,9 +14,9 @@ #### PR Structure -* [ ] This PR has reasonably narrow scope (if not, break it down into smaller PRs). +* [ ] This PR has a reasonably narrow scope (if not, break it down into smaller PRs). +* [ ] This PR title and description are clear enough for anyone to review it. * [ ] This PR does not mix refactoring changes with feature changes (split into two PRs otherwise). -* [ ] This PR's title starts with the name of the package, area, or subject affected by the change. #### Thoroughness diff --git a/internal/data/assets.go b/internal/data/assets.go index 9f3af6217..51ed8400b 100644 --- a/internal/data/assets.go +++ b/internal/data/assets.go @@ -123,18 +123,22 @@ func (a *AssetModel) GetAll(ctx context.Context) ([]Asset, error) { return assets, nil } +// Insert is idempotent and returns a new asset if it doesn't exist or the existing one if it does, clearing the +// deleted_at field if it was marked as deleted. func (a *AssetModel) Insert(ctx context.Context, sqlExec db.SQLExecuter, code string, issuer string) (*Asset, error) { const query = ` - INSERT INTO assets - (code, issuer) - VALUES - ($1, $2) - ON CONFLICT (code, issuer) DO - UPDATE SET - deleted_at = NULL - WHERE - assets.deleted_at IS NOT NULL - RETURNING * + WITH upsert_asset AS ( + INSERT INTO assets + (code, issuer) + VALUES + ($1, $2) + ON CONFLICT (code, issuer) DO UPDATE + SET deleted_at = NULL WHERE assets.deleted_at IS NOT NULL + RETURNING * + ) + SELECT * FROM upsert_asset + UNION ALL -- // The UNION statement is applied to prevent the updated_at field from being autoupdated when the asset already exists. + SELECT * FROM assets WHERE code = $1 AND issuer = $2 AND NOT EXISTS (SELECT 1 FROM upsert_asset); ` var asset Asset diff --git a/internal/data/assets_test.go b/internal/data/assets_test.go index 962ceedcb..723982865 100644 --- a/internal/data/assets_test.go +++ b/internal/data/assets_test.go @@ -132,7 +132,7 @@ func Test_AssetModelGetByWalletID(t *testing.T) { }) } -func Test_AssetModelInsert(t *testing.T) { +func Test_AssetModel_Ensure(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -203,7 +203,7 @@ func Test_AssetModelInsert(t *testing.T) { assert.NotNil(t, usdcDB.DeletedAt) }) - t.Run("does not insert the same asset again", func(t *testing.T) { + t.Run("asset insertion is idempotent", func(t *testing.T) { DeleteAllAssetFixtures(t, ctx, dbConnectionPool.SqlxDB()) code := "USDT" issuer := "GBVHJTRLQRMIHRYTXZQOPVYCVVH7IRJN3DOFT7VC6U75CBWWBVDTWURG" @@ -212,9 +212,13 @@ func Test_AssetModelInsert(t *testing.T) { require.NoError(t, err) assert.NotNil(t, asset) - duplicatedAsset, err := assetModel.Insert(ctx, dbConnectionPool, code, issuer) - assert.EqualError(t, err, "error inserting asset: sql: no rows in result set") - assert.Nil(t, duplicatedAsset) + idempotentAsset, err := assetModel.Insert(ctx, dbConnectionPool, code, issuer) + require.NoError(t, err) + assert.NotNil(t, idempotentAsset) + assert.Equal(t, asset.Code, idempotentAsset.Code) + assert.Equal(t, asset.Issuer, idempotentAsset.Issuer) + assert.Equal(t, asset.DeletedAt, idempotentAsset.DeletedAt) + assert.Empty(t, asset.DeletedAt) }) t.Run("creates the stellar native asset successfully", func(t *testing.T) { @@ -228,7 +232,7 @@ func Test_AssetModelInsert(t *testing.T) { assert.Empty(t, asset.Issuer) }) - t.Run("does not create an asset with empty issuer", func(t *testing.T) { + t.Run("does not create an asset with empty issuer (unless it's XLM)", func(t *testing.T) { DeleteAllAssetFixtures(t, ctx, dbConnectionPool.SqlxDB()) asset, err := assetModel.Insert(ctx, dbConnectionPool, "USDC", "") diff --git a/internal/serve/httphandler/assets_handler_test.go b/internal/serve/httphandler/assets_handler_test.go index d39241e89..62330d0a8 100644 --- a/internal/serve/httphandler/assets_handler_test.go +++ b/internal/serve/httphandler/assets_handler_test.go @@ -310,7 +310,7 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) - t.Run("failed creating asset, duplicated asset", func(t *testing.T) { + t.Run("asset creation is idempotent", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) tx, err := txnbuild.NewTransaction( @@ -344,7 +344,7 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { signatureService. On("SignStellarTransaction", mock.Anything, tx, distributionKP.Address()). Return(signedTx, nil). - Once() + Twice() horizonClientMock. On("AccountDetail", horizonclient.AccountRequest{ @@ -355,43 +355,34 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { Sequence: 123, Balances: []horizon.Balance{}, }, nil). - Once(). + Twice(). On("SubmitTransactionWithOptions", signedTx, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). - Once() + Twice() // Creating the asset requestBody, err := json.Marshal(AssetRequest{Code: code, Issuer: issuer}) require.NoError(t, err) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/assets", bytes.NewReader(requestBody)) require.NoError(t, err) - rr := httptest.NewRecorder() http.HandlerFunc(handler.CreateAsset).ServeHTTP(rr, req) resp := rr.Result() - + defer resp.Body.Close() assert.Equal(t, http.StatusCreated, resp.StatusCode) // Duplicating the asset requestBody, err = json.Marshal(AssetRequest{Code: code, Issuer: issuer}) require.NoError(t, err) - req, err = http.NewRequestWithContext(ctx, http.MethodPost, "/assets", bytes.NewReader(requestBody)) require.NoError(t, err) - rr = httptest.NewRecorder() http.HandlerFunc(handler.CreateAsset).ServeHTTP(rr, req) resp = rr.Result() defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusConflict, resp.StatusCode) - assert.JSONEq(t, `{"error": "asset already exists"}`, string(respBody)) + assert.Equal(t, http.StatusCreated, resp.StatusCode) }) t.Run("failed creating asset, error adding asset trustline", func(t *testing.T) { diff --git a/internal/services/send_receiver_wallets_invite_service.go b/internal/services/send_receiver_wallets_invite_service.go index e8422b6aa..ea4a96281 100644 --- a/internal/services/send_receiver_wallets_invite_service.go +++ b/internal/services/send_receiver_wallets_invite_service.go @@ -179,7 +179,13 @@ func (s SendReceiverWalletInviteService) SendInvite(ctx context.Context) error { }) } +// shouldSendInvitationSMS returns true if we should send the invitation SMS to the receiver. It will be used to either +// send the invitation for the first time, or to resend it automatically according with the organization's SMS Resend +// Interval and the maximum number of SMS resend attempts. + func (s SendReceiverWalletInviteService) shouldSendInvitationSMS(ctx context.Context, organization *data.Organization, rwa *data.ReceiverWalletAsset) bool { + truncatedPhoneNumber := utils.TruncateString(rwa.ReceiverWallet.Receiver.PhoneNumber, 3) + // We've never sent a Invitation SMS if rwa.ReceiverWallet.InvitationSentAt == nil { return true @@ -188,8 +194,8 @@ func (s SendReceiverWalletInviteService) shouldSendInvitationSMS(ctx context.Con // If organization's SMS Resend Interval is nil and we've sent the invitation message to the receiver, we won't resend it. if organization.SMSResendInterval == nil && rwa.ReceiverWallet.InvitationSentAt != nil { log.Ctx(ctx).Debugf( - "the invitation message was not resent to the receiver %s because the organization's SMS Resend Interval is nil", - rwa.ReceiverWallet.Receiver.ID) + "the invitation message was not automatically resent to the receiver %s with phone number %s because the organization's SMS Resend Interval is nil", + rwa.ReceiverWallet.Receiver.ID, truncatedPhoneNumber) return false } @@ -198,7 +204,8 @@ func (s SendReceiverWalletInviteService) shouldSendInvitationSMS(ctx context.Con // Check if the receiver wallet reached the maximum number of SMS resend attempts. if rwa.ReceiverWallet.ReceiverWalletStats.TotalInvitationSMSResentAttempts >= s.maxInvitationSMSResendAttempts { log.Ctx(ctx).Debugf( - "the invitation message was not resent to the receiver because the maximum number of SMS resend attempts has been reached: Receiver ID %s - Wallet ID %s - Total Invitation SMS resent %d - Maximum attempts %d", + "the invitation message was not resent to the receiver because the maximum number of SMS resend attempts has been reached: Phone Number: %s - Receiver ID %s - Wallet ID %s - Total Invitation SMS resent %d - Maximum attempts %d", + truncatedPhoneNumber, rwa.ReceiverWallet.Receiver.ID, rwa.WalletID, rwa.ReceiverWallet.ReceiverWalletStats.TotalInvitationSMSResentAttempts, @@ -212,7 +219,8 @@ func (s SendReceiverWalletInviteService) shouldSendInvitationSMS(ctx context.Con AddDate(0, 0, -int(*organization.SMSResendInterval*(rwa.ReceiverWallet.ReceiverWalletStats.TotalInvitationSMSResentAttempts+1))) if !rwa.ReceiverWallet.InvitationSentAt.Before(resendPeriod) { log.Ctx(ctx).Debugf( - "the invitation message was not resent to the receiver because the receiver is not in the resend period: Receiver ID %s - Wallet ID %s - Last Invitation Sent At %s - SMS Resend Interval %d day(s)", + "the invitation message was not automatically resent to the receiver because the receiver is not in the resend period: Phone Number: %s - Receiver ID %s - Wallet ID %s - Last Invitation Sent At %s - SMS Resend Interval %d day(s)", + truncatedPhoneNumber, rwa.ReceiverWallet.Receiver.ID, rwa.WalletID, rwa.ReceiverWallet.InvitationSentAt.Format(time.RFC1123), diff --git a/internal/services/send_receiver_wallets_invite_service_test.go b/internal/services/send_receiver_wallets_invite_service_test.go index 719577a94..08eb13c86 100644 --- a/internal/services/send_receiver_wallets_invite_service_test.go +++ b/internal/services/send_receiver_wallets_invite_service_test.go @@ -760,7 +760,8 @@ func Test_SendReceiverWalletInviteService_shouldSendInvitationSMS(t *testing.T) ReceiverWallet: data.ReceiverWallet{ InvitationSentAt: &invitationSentAt, Receiver: data.Receiver{ - ID: "receiver-ID", + ID: "receiver-ID", + PhoneNumber: "+123456789", }, ReceiverWalletStats: data.ReceiverWalletStats{ TotalInvitationSMSResentAttempts: maxInvitationSMSResendAttempts, @@ -778,7 +779,7 @@ func Test_SendReceiverWalletInviteService_shouldSendInvitationSMS(t *testing.T) require.Len(t, entries, 1) assert.Equal( t, - "the invitation message was not resent to the receiver because the maximum number of SMS resend attempts has been reached: Receiver ID receiver-ID - Wallet ID wallet-ID - Total Invitation SMS resent 3 - Maximum attempts 3", + "the invitation message was not resent to the receiver because the maximum number of SMS resend attempts has been reached: Phone Number: +12...789 - Receiver ID receiver-ID - Wallet ID wallet-ID - Total Invitation SMS resent 3 - Maximum attempts 3", entries[0].Message, ) }) @@ -791,7 +792,8 @@ func Test_SendReceiverWalletInviteService_shouldSendInvitationSMS(t *testing.T) ReceiverWallet: data.ReceiverWallet{ InvitationSentAt: &invitationSentAt, Receiver: data.Receiver{ - ID: "receiver-ID", + ID: "receiver-ID", + PhoneNumber: "+123456789", }, ReceiverWalletStats: data.ReceiverWalletStats{ TotalInvitationSMSResentAttempts: 1, @@ -810,7 +812,7 @@ func Test_SendReceiverWalletInviteService_shouldSendInvitationSMS(t *testing.T) assert.Equal( t, fmt.Sprintf( - "the invitation message was not resent to the receiver because the receiver is not in the resend period: Receiver ID receiver-ID - Wallet ID wallet-ID - Last Invitation Sent At %s - SMS Resend Interval 2 day(s)", + "the invitation message was not automatically resent to the receiver because the receiver is not in the resend period: Phone Number: +12...789 - Receiver ID receiver-ID - Wallet ID wallet-ID - Last Invitation Sent At %s - SMS Resend Interval 2 day(s)", invitationSentAt.Format(time.RFC1123), ), entries[0].Message, From d208a08b9c0509f5337cb95e7fa920d4ce93ac95 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Wed, 13 Dec 2023 14:46:14 -0800 Subject: [PATCH 07/39] Hotfix/improve error logs for debugging (#125) ### What Add the client_domain when logging the message where the user with the {phone_number, client_domain} pair could not be found. Also, updated a log from error to warn. ### Why Better debuggability. --- internal/data/wallets.go | 2 +- internal/serve/httphandler/receiver_send_otp_handler.go | 7 ++++--- internal/transactionsubmission/utils/errors.go | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/data/wallets.go b/internal/data/wallets.go index 588b9837f..0c6cab51c 100644 --- a/internal/data/wallets.go +++ b/internal/data/wallets.go @@ -110,7 +110,7 @@ func (wm *WalletModel) FindWallets(ctx context.Context, enabledFilter *bool) ([] var args []interface{} if enabledFilter != nil { - whereClause = "WHERE w.enabled = $1" + whereClause = "WHERE w.enabled = $1 " args = append(args, *enabledFilter) } diff --git a/internal/serve/httphandler/receiver_send_otp_handler.go b/internal/serve/httphandler/receiver_send_otp_handler.go index cc76b74e7..ffd9a9321 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler.go +++ b/internal/serve/httphandler/receiver_send_otp_handler.go @@ -62,10 +62,11 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } + truncatedPhoneNumber := utils.TruncateString(receiverSendOTPRequest.PhoneNumber, 3) if phoneValidateErr := utils.ValidatePhoneNumber(receiverSendOTPRequest.PhoneNumber); phoneValidateErr != nil { extras := map[string]interface{}{"phone_number": "phone_number is required"} if !errors.Is(phoneValidateErr, utils.ErrEmptyPhoneNumber) { - phoneValidateErr = fmt.Errorf("validating phone number %s: %w", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, len(receiverSendOTPRequest.PhoneNumber)/4), phoneValidateErr) + phoneValidateErr = fmt.Errorf("validating phone number %s: %w", truncatedPhoneNumber, phoneValidateErr) log.Ctx(ctx).Error(phoneValidateErr) extras["phone_number"] = "invalid phone number provided" } @@ -110,7 +111,7 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } if numberOfUpdatedRows < 1 { - log.Ctx(ctx).Warnf("updated no rows in receiver send OTP handler for phone number: %s", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, len(receiverSendOTPRequest.PhoneNumber)/4)) + log.Ctx(ctx).Warnf("updated no rows in ReceiverSendOTPHandler, please verify if the provided phone number (%s) and client_domain (%s) are both valid", truncatedPhoneNumber, sep24Claims.ClientDomainClaim) } else { sendOTPData := ReceiverSendOTPData{ OTP: newOTP, @@ -140,7 +141,7 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request Message: builder.String(), } - log.Ctx(ctx).Infof("sending OTP message to phone number: %s", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, 3)) + log.Ctx(ctx).Infof("sending OTP message to phone number: %s", truncatedPhoneNumber) err = h.SMSMessengerClient.SendMessage(smsMessage) if err != nil { httperror.InternalError(ctx, "Cannot send OTP message", err, nil).Render(w) diff --git a/internal/transactionsubmission/utils/errors.go b/internal/transactionsubmission/utils/errors.go index 1a85d29c2..68a14fa21 100644 --- a/internal/transactionsubmission/utils/errors.go +++ b/internal/transactionsubmission/utils/errors.go @@ -67,7 +67,7 @@ func NewHorizonErrorWrapper(err error) *HorizonErrorWrapper { resultCodes, resCodeErr := hError.ResultCodes() if resCodeErr != nil { - log.Errorf("parsing result_codes: %v", resCodeErr) + log.Warnf("parsing result_codes: %v", resCodeErr) } return &HorizonErrorWrapper{ From 67c4af63f52b2e27cfc07b10b8551a54eee5bd1b Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Thu, 14 Dec 2023 21:19:26 -0800 Subject: [PATCH 08/39] [SDP-1022] Hotfix: update Vibrant Assist's `client_domain` (#126) ### What Update client_domain on Vibrant Assist from api.vibrantapp.com to vibrantapp.com. ### Why It was incorrect. --- CHANGELOG.md | 10 ++++++++++ cmd/db_test.go | 4 ++-- helmchart/sdp/Chart.yaml | 2 +- .../setup_wallets_for_network_service_test.go | 12 ++++++------ internal/services/wallets/wallets_pubnet.go | 2 +- main.go | 2 +- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d285f99e..a242ad92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). > Place unreleased changes here. +## [1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0...1.0.1) + +### Changed + +- Update log message for better debugging. [#125](https://github.com/stellar/stellar-disbursement-platform-backend/pull/125) + +### Fixed + +- Fix client_domain from the Viobrant Assist wallet. [#126](https://github.com/stellar/stellar-disbursement-platform-backend/pull/126) + ## [1.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0-rc2...1.0.0) ### Added diff --git a/cmd/db_test.go b/cmd/db_test.go index 24d084491..9ef081896 100644 --- a/cmd/db_test.go +++ b/cmd/db_test.go @@ -217,7 +217,7 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { // assert.Equal(t, "https://www.beansapp.com/disbursements/registration?redirect=true", wallets[0].DeepLinkSchema) assert.Equal(t, "Vibrant Assist", wallets[0].Name) assert.Equal(t, "https://vibrantapp.com/vibrant-assist", wallets[0].Homepage) - assert.Equal(t, "api.vibrantapp.com", wallets[0].SEP10ClientDomain) + assert.Equal(t, "vibrantapp.com", wallets[0].SEP10ClientDomain) assert.Equal(t, "https://vibrantapp.com/sdp", wallets[0].DeepLinkSchema) assert.Equal(t, "Vibrant Assist RC", wallets[1].Name) @@ -235,7 +235,7 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://vibrantapp.com/sdp", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 30eed34ec..4fb5d9ffe 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) version: 0.9.3 -appVersion: "1.0.0" +appVersion: "1.0.1" type: application maintainers: - name: Stellar Development Foundation diff --git a/internal/services/setup_wallets_for_network_service_test.go b/internal/services/setup_wallets_for_network_service_test.go index 708a51916..f8a7c441a 100644 --- a/internal/services/setup_wallets_for_network_service_test.go +++ b/internal/services/setup_wallets_for_network_service_test.go @@ -60,7 +60,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://vibrantapp.com/sdp", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() @@ -89,7 +89,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://aidpubnet.netlify.app", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", }, { Name: "BOSS Money", @@ -118,7 +118,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { assert.Equal(t, "Vibrant Assist", wallets[1].Name) assert.Equal(t, "https://vibrantapp.com/vibrant-assist", wallets[1].Homepage) - assert.Equal(t, "api.vibrantapp.com", wallets[1].SEP10ClientDomain) + assert.Equal(t, "vibrantapp.com", wallets[1].SEP10ClientDomain) assert.Equal(t, "https://aidpubnet.netlify.app", wallets[1].DeepLinkSchema) expectedLogs := []string{ @@ -130,7 +130,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://aidpubnet.netlify.app", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() @@ -152,7 +152,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://aidpubnet.netlify.app", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", Assets: []data.Asset{ { Code: "USDC", @@ -241,7 +241,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://aidpubnet.netlify.app", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", "Assets:", "* USDC - GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", } diff --git a/internal/services/wallets/wallets_pubnet.go b/internal/services/wallets/wallets_pubnet.go index eda88ffce..4967eb873 100644 --- a/internal/services/wallets/wallets_pubnet.go +++ b/internal/services/wallets/wallets_pubnet.go @@ -7,7 +7,7 @@ var PubnetWallets = []data.Wallet{ Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://vibrantapp.com/sdp", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", Assets: []data.Asset{ { Code: "USDC", diff --git a/main.go b/main.go index 4521fd22e..32771ad60 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.0.0" +const Version = "1.0.1" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" From a37fba06540f3a7cdf4eb67498c0f16a549a7e39 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Fri, 15 Dec 2023 16:16:06 -0800 Subject: [PATCH 09/39] [SDP-962] Change SEP-24 Flow to display different verifications based on Disbursement's verification type (#116) Modify the SEP-24 flow to perform verification for an entered phone number based on the latest verification type. The current SEP-24 flow is hardcoded to only accept date of birth but we will have disbursement files that will include pin and national id, and the front-end will need to change to be able to parse those values. --- internal/data/models.go | 2 +- internal/data/receiver_verification.go | 44 +++++++++++--- internal/data/receiver_verification_test.go | 58 +++++++++++++++++++ .../htmltemplate/tmpl/receiver_register.tmpl | 7 +-- .../httphandler/receiver_send_otp_handler.go | 12 +++- .../receiver_send_otp_handler_test.go | 53 +++++++++++++++-- .../publicfiles/js/receiver_registration.js | 26 +++++++-- internal/serve/serve.go | 5 +- .../receiver_registration_validator.go | 13 ++++- .../receiver_registration_validator_test.go | 46 +++++++++++++++ 10 files changed, 241 insertions(+), 25 deletions(-) diff --git a/internal/data/models.go b/internal/data/models.go index e8946829c..83d83be11 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -42,7 +42,7 @@ func NewModels(dbConnectionPool db.DBConnectionPool) (*Models, error) { Payment: &PaymentModel{dbConnectionPool: dbConnectionPool}, Receiver: &ReceiverModel{}, DisbursementInstructions: NewDisbursementInstructionModel(dbConnectionPool), - ReceiverVerification: &ReceiverVerificationModel{}, + ReceiverVerification: &ReceiverVerificationModel{dbConnectionPool: dbConnectionPool}, ReceiverWallet: &ReceiverWalletModel{dbConnectionPool: dbConnectionPool}, DisbursementReceivers: &DisbursementReceiverModel{dbConnectionPool: dbConnectionPool}, Message: &MessageModel{dbConnectionPool: dbConnectionPool}, diff --git a/internal/data/receiver_verification.go b/internal/data/receiver_verification.go index d85a25502..f660cca32 100644 --- a/internal/data/receiver_verification.go +++ b/internal/data/receiver_verification.go @@ -2,6 +2,8 @@ package data import ( "context" + "database/sql" + "errors" "fmt" "strings" "time" @@ -24,7 +26,9 @@ type ReceiverVerification struct { FailedAt *time.Time `db:"failed_at"` } -type ReceiverVerificationModel struct{} +type ReceiverVerificationModel struct { + dbConnectionPool db.DBConnectionPool +} type ReceiverVerificationInsert struct { ReceiverID string `db:"receiver_id"` @@ -48,7 +52,7 @@ func (rvi *ReceiverVerificationInsert) Validate() error { } // GetByReceiverIDsAndVerificationField returns receiver verifications by receiver IDs and verification type. -func (m ReceiverVerificationModel) GetByReceiverIDsAndVerificationField(ctx context.Context, sqlExec db.SQLExecuter, receiverIds []string, verificationField VerificationField) ([]*ReceiverVerification, error) { +func (m *ReceiverVerificationModel) GetByReceiverIDsAndVerificationField(ctx context.Context, sqlExec db.SQLExecuter, receiverIds []string, verificationField VerificationField) ([]*ReceiverVerification, error) { receiverVerifications := []*ReceiverVerification{} query := ` SELECT @@ -74,7 +78,7 @@ func (m ReceiverVerificationModel) GetByReceiverIDsAndVerificationField(ctx cont } // GetAllByReceiverId returns all receiver verifications by receiver id. -func (m ReceiverVerificationModel) GetAllByReceiverId(ctx context.Context, sqlExec db.SQLExecuter, receiverId string) ([]ReceiverVerification, error) { +func (m *ReceiverVerificationModel) GetAllByReceiverId(ctx context.Context, sqlExec db.SQLExecuter, receiverId string) ([]ReceiverVerification, error) { receiverVerifications := []ReceiverVerification{} query := ` SELECT @@ -91,8 +95,35 @@ func (m ReceiverVerificationModel) GetAllByReceiverId(ctx context.Context, sqlEx return receiverVerifications, nil } +// GetLatestByPhoneNumber returns the latest updated receiver verification for some receiver that is associated with a phone number. +func (m *ReceiverVerificationModel) GetLatestByPhoneNumber(ctx context.Context, phoneNumber string) (*ReceiverVerification, error) { + receiverVerification := ReceiverVerification{} + query := ` + SELECT + rv.* + FROM + receiver_verifications rv + JOIN receivers r ON rv.receiver_id = r.id + WHERE + r.phone_number = $1 + ORDER BY + rv.updated_at DESC + LIMIT 1 + ` + + err := m.dbConnectionPool.GetContext(ctx, &receiverVerification, query, phoneNumber) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrRecordNotFound + } + return nil, fmt.Errorf("fetching receiver verifications for phone number %s: %w", phoneNumber, err) + } + + return &receiverVerification, nil +} + // Insert inserts a new receiver verification -func (m ReceiverVerificationModel) Insert(ctx context.Context, sqlExec db.SQLExecuter, verificationInsert ReceiverVerificationInsert) (string, error) { +func (m *ReceiverVerificationModel) Insert(ctx context.Context, sqlExec db.SQLExecuter, verificationInsert ReceiverVerificationInsert) (string, error) { err := verificationInsert.Validate() if err != nil { return "", fmt.Errorf("error validating receiver verification insert: %w", err) @@ -111,7 +142,6 @@ func (m ReceiverVerificationModel) Insert(ctx context.Context, sqlExec db.SQLExe ` _, err = sqlExec.ExecContext(ctx, query, verificationInsert.ReceiverID, verificationInsert.VerificationField, hashedValue) - if err != nil { return "", fmt.Errorf("error inserting receiver verification: %w", err) } @@ -120,7 +150,7 @@ func (m ReceiverVerificationModel) Insert(ctx context.Context, sqlExec db.SQLExe } // UpdateVerificationValue updates the hashed value of a receiver verification. -func (m ReceiverVerificationModel) UpdateVerificationValue(ctx context.Context, +func (m *ReceiverVerificationModel) UpdateVerificationValue(ctx context.Context, sqlExec db.SQLExecuter, receiverID string, verificationField VerificationField, @@ -148,7 +178,7 @@ func (m ReceiverVerificationModel) UpdateVerificationValue(ctx context.Context, } // UpdateVerificationValue updates the hashed value of a receiver verification. -func (m ReceiverVerificationModel) UpdateReceiverVerification(ctx context.Context, receiverVerification ReceiverVerification, sqlExec db.SQLExecuter) error { +func (m *ReceiverVerificationModel) UpdateReceiverVerification(ctx context.Context, receiverVerification ReceiverVerification, sqlExec db.SQLExecuter) error { query := ` UPDATE receiver_verifications diff --git a/internal/data/receiver_verification_test.go b/internal/data/receiver_verification_test.go index 9e1238c51..c68b8a636 100644 --- a/internal/data/receiver_verification_test.go +++ b/internal/data/receiver_verification_test.go @@ -2,6 +2,7 @@ package data import ( "context" + "fmt" "testing" "time" @@ -125,6 +126,63 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { }) } +func Test_ReceiverVerificationModel_GetReceiverVerificationByReceiverId(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{ + PhoneNumber: "+13334445555", + }) + + t.Run("returns error when the receiver has no verifications registered", func(t *testing.T) { + receiverVerificationModel := ReceiverVerificationModel{dbConnectionPool: dbConnectionPool} + _, err := receiverVerificationModel.GetLatestByPhoneNumber(ctx, receiver.PhoneNumber) + require.Error(t, err, fmt.Errorf("cannot query any receiver verifications for phone number %s", receiver.PhoneNumber)) + }) + + t.Run("returns the latest receiver verification for a list of receiver verifications", func(t *testing.T) { + earlierTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + verification1 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldDateOfBirth, + VerificationValue: "1990-01-01", + }) + verification1.UpdatedAt = earlierTime + + verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldPin, + VerificationValue: "1234", + }) + verification2.UpdatedAt = earlierTime + + verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldNationalID, + VerificationValue: "5678", + }) + + receiverVerificationModel := ReceiverVerificationModel{dbConnectionPool: dbConnectionPool} + actualVerification, err := receiverVerificationModel.GetLatestByPhoneNumber(ctx, receiver.PhoneNumber) + require.NoError(t, err) + + assert.Equal(t, + ReceiverVerification{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldNationalID, + HashedValue: verification3.HashedValue, + CreatedAt: verification3.CreatedAt, + UpdatedAt: verification3.UpdatedAt, + }, *actualVerification) + }) +} + func Test_ReceiverVerificationModel_Insert(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() diff --git a/internal/htmltemplate/tmpl/receiver_register.tmpl b/internal/htmltemplate/tmpl/receiver_register.tmpl index d74b02781..f06ae3772 100644 --- a/internal/htmltemplate/tmpl/receiver_register.tmpl +++ b/internal/htmltemplate/tmpl/receiver_register.tmpl @@ -87,7 +87,7 @@ - +

Enter passcode

@@ -119,14 +119,11 @@ />
- + -
diff --git a/internal/serve/httphandler/receiver_send_otp_handler.go b/internal/serve/httphandler/receiver_send_otp_handler.go index cc76b74e7..f4315cb63 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler.go +++ b/internal/serve/httphandler/receiver_send_otp_handler.go @@ -35,7 +35,8 @@ type ReceiverSendOTPRequest struct { } type ReceiverSendOTPResponseBody struct { - Message string `json:"message"` + Message string `json:"message"` + VerificationField data.VerificationField `json:"verification_field"` } func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -90,6 +91,12 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } + receiverVerification, err := h.Models.ReceiverVerification.GetLatestByPhoneNumber(ctx, receiverSendOTPRequest.PhoneNumber) + if err != nil { + httperror.InternalError(ctx, "Cannot find latest receiver verification for receiver", err, nil).Render(w) + return + } + // Generate a new 6 digits OTP newOTP, err := utils.RandomString(6, utils.NumberBytes) if err != nil { @@ -149,7 +156,8 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } response := ReceiverSendOTPResponseBody{ - Message: "if your phone number is registered, you'll receive an OTP", + Message: "if your phone number is registered, you'll receive an OTP", + VerificationField: receiverVerification.VerificationField, } httpjson.RenderStatus(w, http.StatusOK, response, httpjson.JSON) } diff --git a/internal/serve/httphandler/receiver_send_otp_handler_test.go b/internal/serve/httphandler/receiver_send_otp_handler_test.go index 6be199eee..edd44d468 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler_test.go +++ b/internal/serve/httphandler/receiver_send_otp_handler_test.go @@ -53,9 +53,14 @@ func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { ctx := context.Background() - receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{PhoneNumber: "+380443973607"}) + phoneNumber := "+380443973607" + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{PhoneNumber: phoneNumber}) receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) wallet1 := data.CreateWalletFixture(t, ctx, dbConnectionPool, "testWallet", "https://home.page", "home.page", "wallet123://") + data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ + ReceiverID: receiver1.ID, + VerificationField: data.VerificationFieldDateOfBirth, + }) _ = data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, data.RegisteredReceiversWalletStatus) _ = data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet1.ID, data.RegisteredReceiversWalletStatus) @@ -157,7 +162,7 @@ func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { assert.JSONEq(t, `{"error": "request invalid", "extras": {"phone_number": "invalid phone number provided"}}`, string(respBody)) }) - t.Run("returns 200 - Ok if the token is in the request context and body it's valid", func(t *testing.T) { + t.Run("returns 200 - Ok if the token is in the request context and body is valid", func(t *testing.T) { reCAPTCHAValidator. On("IsTokenValid", mock.Anything, "XyZ"). Return(true, nil). @@ -193,7 +198,7 @@ func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Type"), "/json; charset=utf-8") - assert.JSONEq(t, string(respBody), `{"message":"if your phone number is registered, you'll receive an OTP"}`) + assert.JSONEq(t, string(respBody), `{"message":"if your phone number is registered, you'll receive an OTP", "verification_field":"DATE_OF_BIRTH"}`) }) t.Run("returns 200 - parses a custom OTP message template successfully", func(t *testing.T) { @@ -237,7 +242,7 @@ func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Type"), "/json; charset=utf-8") - assert.JSONEq(t, string(respBody), `{"message":"if your phone number is registered, you'll receive an OTP"}`) + assert.JSONEq(t, string(respBody), `{"message":"if your phone number is registered, you'll receive an OTP", "verification_field":"DATE_OF_BIRTH"}`) }) t.Run("returns 500 - InternalServerError when something goes wrong when sending the SMS", func(t *testing.T) { @@ -309,6 +314,46 @@ func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { assert.JSONEq(t, wantsBody, string(respBody)) }) + t.Run("returns 500 - InternalServerError if phone number is not associated with receiver verification", func(t *testing.T) { + requestSendOTP := ReceiverSendOTPRequest{ + PhoneNumber: "+14152223333", + ReCAPTCHAToken: "XyZ", + } + reqBody, _ = json.Marshal(requestSendOTP) + + reCAPTCHAValidator. + On("IsTokenValid", mock.Anything, "XyZ"). + Return(true, nil). + Once() + req, err := http.NewRequest(http.MethodPost, "/wallet-registration/otp", strings.NewReader(string(reqBody))) + require.NoError(t, err) + + validClaims := &anchorplatform.SEP24JWTClaims{ + ClientDomainClaim: wallet1.SEP10ClientDomain, + RegisteredClaims: jwt.RegisteredClaims{ + ID: "test-transaction-id", + Subject: "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), + }, + } + req = req.WithContext(context.WithValue(req.Context(), anchorplatform.SEP24ClaimsContextKey, validClaims)) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + resp := rr.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + { + "error": "Cannot find latest receiver verification for receiver" + } + ` + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + t.Run("returns 400 - BadRequest when recaptcha token is invalid", func(t *testing.T) { reCAPTCHAValidator. On("IsTokenValid", mock.Anything, "XyZ"). diff --git a/internal/serve/publicfiles/js/receiver_registration.js b/internal/serve/publicfiles/js/receiver_registration.js index 2065d3572..2ff2ae57c 100644 --- a/internal/serve/publicfiles/js/receiver_registration.js +++ b/internal/serve/publicfiles/js/receiver_registration.js @@ -51,9 +51,9 @@ async function sendSms(phoneNumber, reCAPTCHAToken, onSuccess, onError) { recaptcha_token: reCAPTCHAToken, }), }); - await request.json(); + const resp = await request.json(); - onSuccess(); + onSuccess(resp.verification_field); } catch (error) { onError(error); } @@ -96,6 +96,8 @@ async function submitPhoneNumber(event) { "#g-recaptcha-response" ); const buttonEls = phoneNumberSectionEl.querySelectorAll("[data-button]"); + const verificationFieldTitle = document.querySelector("label[for='verification']"); + const verificationFieldInput = document.querySelector("#verification"); if (!reCAPTCHATokenEl || !reCAPTCHATokenEl.value) { toggleErrorNotification( @@ -133,7 +135,22 @@ async function submitPhoneNumber(event) { return; } - function showNextPage() { + function showNextPage(verificationField) { + verificationFieldInput.type = "text"; + if(verificationField === "DATE_OF_BIRTH") { + verificationFieldTitle.textContent = "Date of birth"; + verificationFieldInput.name = "date_of_birth"; + verificationFieldInput.type = "date"; + } + else if(verificationField === "NATIONAL_ID_NUMBER") { + verificationFieldTitle.textContent = "National ID number"; + verificationFieldInput.name = "national_id_number"; + } + else if(verificationField === "PIN") { + verificationFieldTitle.textContent = "Pin"; + verificationFieldInput.name = "pin"; + } + phoneNumberSectionEl.style.display = "none"; reCAPTCHATokenEl.style.display = "none"; passcodeSectionEl.style.display = "flex"; @@ -169,6 +186,7 @@ async function submitOtp(event) { ); const otpEl = document.getElementById("otp"); const verificationEl = document.getElementById("verification"); + const verificationField = verificationEl.getAttribute("name"); const buttonEls = passcodeSectionEl.querySelectorAll("[data-button]"); @@ -213,7 +231,7 @@ async function submitOtp(event) { phone_number: phoneNumber, otp: otp, verification: verification, - verification_type: "date_of_birth", + verification_type: verificationField, recaptcha_token: reCAPTCHATokenEl.value, }), }); diff --git a/internal/serve/serve.go b/internal/serve/serve.go index e97c1384f..9d58dcd07 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -380,7 +380,10 @@ func handleHTTP(o ServeOptions) *chi.Mux { mux.Route("/wallet-registration", func(r chi.Router) { sep24QueryTokenAuthenticationMiddleware := anchorplatform.SEP24QueryTokenAuthenticateMiddleware(o.sep24JWTManager, o.NetworkPassphrase) - r.With(sep24QueryTokenAuthenticationMiddleware).Get("/start", httphandler.ReceiverRegistrationHandler{ReceiverWalletModel: o.Models.ReceiverWallet, ReCAPTCHASiteKey: o.ReCAPTCHASiteKey}.ServeHTTP) // This loads the SEP-24 PII registration webpage. + r.With(sep24QueryTokenAuthenticationMiddleware).Get("/start", httphandler.ReceiverRegistrationHandler{ + ReceiverWalletModel: o.Models.ReceiverWallet, + ReCAPTCHASiteKey: o.ReCAPTCHASiteKey, + }.ServeHTTP) // This loads the SEP-24 PII registration webpage. sep24HeaderTokenAuthenticationMiddleware := anchorplatform.SEP24HeaderTokenAuthenticateMiddleware(o.sep24JWTManager, o.NetworkPassphrase) r.With(sep24HeaderTokenAuthenticationMiddleware).Post("/otp", httphandler.ReceiverSendOTPHandler{Models: o.Models, SMSMessengerClient: o.SMSMessengerClient, ReCAPTCHAValidator: reCAPTCHAValidator}.ServeHTTP) diff --git a/internal/serve/validators/receiver_registration_validator.go b/internal/serve/validators/receiver_registration_validator.go index b7b2b5e94..e0a57904c 100644 --- a/internal/serve/validators/receiver_registration_validator.go +++ b/internal/serve/validators/receiver_registration_validator.go @@ -42,8 +42,19 @@ func (rv *ReceiverRegistrationValidator) ValidateReceiver(receiverInfo *data.Rec // validate verification field // date of birth with format 2006-01-02 if vt == data.VerificationFieldDateOfBirth { - _, err := time.Parse("2006-01-02", verification) + dob, err := time.Parse("2006-01-02", verification) rv.CheckError(err, "verification", "invalid date of birth format. Correct format: 1990-01-01") + + // check if date of birth is in the past + rv.Check(dob.Before(time.Now()), "verification", "date of birth cannot be in the future") + } else if vt == data.VerificationFieldPin { + if len(verification) < VERIFICATION_FIELD_PIN_MIN_LENGTH || len(verification) > VERIFICATION_FIELD_PIN_MAX_LENGTH { + rv.addError("verification", "invalid pin. Cannot have less than 4 or more than 8 characters in pin") + } + } else if vt == data.VerificationFieldNationalID { + if len(verification) > VERIFICATION_FIELD_MAX_ID_LENGTH { + rv.addError("verification", "invalid national id. Cannot have more than 50 characters in national id") + } } else { // TODO: validate other VerificationField types. log.Warnf("Verification type %v is not being validated for ValidateReceiver", vt) diff --git a/internal/serve/validators/receiver_registration_validator_test.go b/internal/serve/validators/receiver_registration_validator_test.go index 2cb6257d3..cebce9c71 100644 --- a/internal/serve/validators/receiver_registration_validator_test.go +++ b/internal/serve/validators/receiver_registration_validator_test.go @@ -83,6 +83,36 @@ func Test_ReceiverRegistrationValidator_ValidateReceiver(t *testing.T) { assert.Equal(t, "invalid date of birth format. Correct format: 1990-01-01", validator.Errors["verification"]) }) + t.Run("Invalid pin", func(t *testing.T) { + validator := NewReceiverRegistrationValidator() + + receiverInfo := data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "ABCDE1234", + VerificationType: "PIN", + } + validator.ValidateReceiver(&receiverInfo) + + assert.Equal(t, 1, len(validator.Errors)) + assert.Equal(t, "invalid pin. Cannot have less than 4 or more than 8 characters in pin", validator.Errors["verification"]) + }) + + t.Run("Invalid national ID number", func(t *testing.T) { + validator := NewReceiverRegistrationValidator() + + receiverInfo := data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78XXXXX", + VerificationType: "NATIONAL_ID_NUMBER", + } + validator.ValidateReceiver(&receiverInfo) + + assert.Equal(t, 1, len(validator.Errors)) + assert.Equal(t, "invalid national id. Cannot have more than 50 characters in national id", validator.Errors["verification"]) + }) + t.Run("Valid receiver values", func(t *testing.T) { validator := NewReceiverRegistrationValidator() @@ -99,6 +129,22 @@ func Test_ReceiverRegistrationValidator_ValidateReceiver(t *testing.T) { assert.Equal(t, "123456", receiverInfo.OTP) assert.Equal(t, "1990-01-01", receiverInfo.VerificationValue) assert.Equal(t, data.VerificationField("DATE_OF_BIRTH"), receiverInfo.VerificationType) + + receiverInfo.VerificationValue = "1234" + receiverInfo.VerificationType = "pin" + validator.ValidateReceiver(&receiverInfo) + + assert.Equal(t, 0, len(validator.Errors)) + assert.Equal(t, "1234", receiverInfo.VerificationValue) + assert.Equal(t, data.VerificationField("PIN"), receiverInfo.VerificationType) + + receiverInfo.VerificationValue = "NATIONALIDNUMBER123" + receiverInfo.VerificationType = "national_id_number" + validator.ValidateReceiver(&receiverInfo) + + assert.Equal(t, 0, len(validator.Errors)) + assert.Equal(t, "NATIONALIDNUMBER123", receiverInfo.VerificationValue) + assert.Equal(t, data.VerificationField("NATIONAL_ID_NUMBER"), receiverInfo.VerificationType) }) } From c26c2f5f6efd5c56111221ce7db7f45e0d712818 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Fri, 15 Dec 2023 17:36:01 -0800 Subject: [PATCH 10/39] Release 1.0.1 to develop (#129) ### What [Release 1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/issues/127) to develop ### Why To sync the `main` branch hotfixes: - #125 - #126 --- CHANGELOG.md | 10 ++++++++++ cmd/db_test.go | 4 ++-- helmchart/sdp/Chart.yaml | 2 +- .../serve/httphandler/receiver_send_otp_handler.go | 7 ++++--- .../setup_wallets_for_network_service_test.go | 12 ++++++------ internal/services/wallets/wallets_pubnet.go | 2 +- internal/transactionsubmission/utils/errors.go | 2 +- main.go | 2 +- 8 files changed, 26 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b28eccd..b50266df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add sorting to `GET /users` endpoint [#104](https://github.com/stellar/stellar-disbursement-platform-backend/pull/104) +## [1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0...1.0.1) + +### Changed + +- Update log message for better debugging. [#125](https://github.com/stellar/stellar-disbursement-platform-backend/pull/125) + +### Fixed + +- Fix client_domain from the Viobrant Assist wallet. [#126](https://github.com/stellar/stellar-disbursement-platform-backend/pull/126) + ## [1.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0-rc2...1.0.0) ### Added diff --git a/cmd/db_test.go b/cmd/db_test.go index 4a4855660..4d0195e6d 100644 --- a/cmd/db_test.go +++ b/cmd/db_test.go @@ -226,7 +226,7 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { // Test the two wallets assert.Equal(t, "Vibrant Assist", vibrantAssist.Name) assert.Equal(t, "https://vibrantapp.com/vibrant-assist", vibrantAssist.Homepage) - assert.Equal(t, "api.vibrantapp.com", vibrantAssist.SEP10ClientDomain) + assert.Equal(t, "vibrantapp.com", vibrantAssist.SEP10ClientDomain) assert.Equal(t, "https://vibrantapp.com/sdp", vibrantAssist.DeepLinkSchema) assert.Equal(t, "Vibrant Assist RC", vibrantAssistRC.Name) @@ -244,7 +244,7 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://vibrantapp.com/sdp", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 30eed34ec..4fb5d9ffe 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) version: 0.9.3 -appVersion: "1.0.0" +appVersion: "1.0.1" type: application maintainers: - name: Stellar Development Foundation diff --git a/internal/serve/httphandler/receiver_send_otp_handler.go b/internal/serve/httphandler/receiver_send_otp_handler.go index f4315cb63..120136412 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler.go +++ b/internal/serve/httphandler/receiver_send_otp_handler.go @@ -63,10 +63,11 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } + truncatedPhoneNumber := utils.TruncateString(receiverSendOTPRequest.PhoneNumber, 3) if phoneValidateErr := utils.ValidatePhoneNumber(receiverSendOTPRequest.PhoneNumber); phoneValidateErr != nil { extras := map[string]interface{}{"phone_number": "phone_number is required"} if !errors.Is(phoneValidateErr, utils.ErrEmptyPhoneNumber) { - phoneValidateErr = fmt.Errorf("validating phone number %s: %w", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, len(receiverSendOTPRequest.PhoneNumber)/4), phoneValidateErr) + phoneValidateErr = fmt.Errorf("validating phone number %s: %w", truncatedPhoneNumber, phoneValidateErr) log.Ctx(ctx).Error(phoneValidateErr) extras["phone_number"] = "invalid phone number provided" } @@ -117,7 +118,7 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } if numberOfUpdatedRows < 1 { - log.Ctx(ctx).Warnf("updated no rows in receiver send OTP handler for phone number: %s", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, len(receiverSendOTPRequest.PhoneNumber)/4)) + log.Ctx(ctx).Warnf("updated no rows in ReceiverSendOTPHandler, please verify if the provided phone number (%s) and client_domain (%s) are both valid", truncatedPhoneNumber, sep24Claims.ClientDomainClaim) } else { sendOTPData := ReceiverSendOTPData{ OTP: newOTP, @@ -147,7 +148,7 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request Message: builder.String(), } - log.Ctx(ctx).Infof("sending OTP message to phone number: %s", utils.TruncateString(receiverSendOTPRequest.PhoneNumber, 3)) + log.Ctx(ctx).Infof("sending OTP message to phone number: %s", truncatedPhoneNumber) err = h.SMSMessengerClient.SendMessage(smsMessage) if err != nil { httperror.InternalError(ctx, "Cannot send OTP message", err, nil).Render(w) diff --git a/internal/services/setup_wallets_for_network_service_test.go b/internal/services/setup_wallets_for_network_service_test.go index 6a6200b69..f476bfbd4 100644 --- a/internal/services/setup_wallets_for_network_service_test.go +++ b/internal/services/setup_wallets_for_network_service_test.go @@ -72,7 +72,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://vibrantapp.com/sdp", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() @@ -101,7 +101,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://aidpubnet.netlify.app", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", }, { Name: "BOSS Money", @@ -130,7 +130,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { assert.Equal(t, "Vibrant Assist", wallets[1].Name) assert.Equal(t, "https://vibrantapp.com/vibrant-assist", wallets[1].Homepage) - assert.Equal(t, "api.vibrantapp.com", wallets[1].SEP10ClientDomain) + assert.Equal(t, "vibrantapp.com", wallets[1].SEP10ClientDomain) assert.Equal(t, "https://aidpubnet.netlify.app", wallets[1].DeepLinkSchema) expectedLogs := []string{ @@ -142,7 +142,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://aidpubnet.netlify.app", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", } logs := buf.String() @@ -164,7 +164,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://aidpubnet.netlify.app", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", Assets: []data.Asset{ { Code: "USDC", @@ -253,7 +253,7 @@ func Test_SetupWalletsForProperNetwork(t *testing.T) { "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", "Deep Link Schema: https://aidpubnet.netlify.app", - "SEP-10 Client Domain: api.vibrantapp.com", + "SEP-10 Client Domain: vibrantapp.com", "Assets:", "* USDC - GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", } diff --git a/internal/services/wallets/wallets_pubnet.go b/internal/services/wallets/wallets_pubnet.go index 772c8b6e8..22e31c28f 100644 --- a/internal/services/wallets/wallets_pubnet.go +++ b/internal/services/wallets/wallets_pubnet.go @@ -10,7 +10,7 @@ var PubnetWallets = []data.Wallet{ Name: "Vibrant Assist", Homepage: "https://vibrantapp.com/vibrant-assist", DeepLinkSchema: "https://vibrantapp.com/sdp", - SEP10ClientDomain: "api.vibrantapp.com", + SEP10ClientDomain: "vibrantapp.com", Assets: []data.Asset{ assets.USDCAssetPubnet, }, diff --git a/internal/transactionsubmission/utils/errors.go b/internal/transactionsubmission/utils/errors.go index 1a85d29c2..68a14fa21 100644 --- a/internal/transactionsubmission/utils/errors.go +++ b/internal/transactionsubmission/utils/errors.go @@ -67,7 +67,7 @@ func NewHorizonErrorWrapper(err error) *HorizonErrorWrapper { resultCodes, resCodeErr := hError.ResultCodes() if resCodeErr != nil { - log.Errorf("parsing result_codes: %v", resCodeErr) + log.Warnf("parsing result_codes: %v", resCodeErr) } return &HorizonErrorWrapper{ diff --git a/main.go b/main.go index 4521fd22e..32771ad60 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.0.0" +const Version = "1.0.1" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" From e308c7734dd38a0736bee05975a87ad42de50335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 22 Dec 2023 13:33:34 -0300 Subject: [PATCH 11/39] [SDP-1025]: New endpoint to cancel individual payment in ready status (#130) What Implement a new endpoint PATCH /payments/{id}/status that allows the user to change the status of an individual payment Why UNHCR asked for the ability to cancel individual payments. --- internal/data/payments_state_machine.go | 10 ++ internal/data/payments_state_machine_test.go | 153 ++++++++++++++++++ .../serve/httphandler/payments_handler.go | 57 +++++++ .../httphandler/payments_handler_test.go | 148 +++++++++++++++++ internal/serve/serve.go | 2 + internal/serve/serve_test.go | 3 +- .../services/payment_management_service.go | 61 +++++++ .../payment_management_service_test.go | 110 +++++++++++++ 8 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 internal/services/payment_management_service.go create mode 100644 internal/services/payment_management_service_test.go diff --git a/internal/data/payments_state_machine.go b/internal/data/payments_state_machine.go index f0230ef04..23487a7d8 100644 --- a/internal/data/payments_state_machine.go +++ b/internal/data/payments_state_machine.go @@ -66,6 +66,16 @@ func (status PaymentStatus) SourceStatuses() []PaymentStatus { return fromStates } +// ToPaymentStatus converts a string to a PaymentStatus +func ToPaymentStatus(s string) (PaymentStatus, error) { + err := PaymentStatus(s).Validate() + if err != nil { + return "", err + } + + return PaymentStatus(strings.ToUpper(s)), nil +} + func (status PaymentStatus) State() State { return State(status) } diff --git a/internal/data/payments_state_machine_test.go b/internal/data/payments_state_machine_test.go index aeec4d7c3..f74abe7de 100644 --- a/internal/data/payments_state_machine_test.go +++ b/internal/data/payments_state_machine_test.go @@ -1,6 +1,7 @@ package data import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -59,3 +60,155 @@ func Test_PaymentStatus_PaymentStatuses(t *testing.T) { expectedStatuses := []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus} require.Equal(t, expectedStatuses, PaymentStatuses()) } + +func Test_PaymentStatus_ToPaymentStatus(t *testing.T) { + tests := []struct { + name string + actual string + want PaymentStatus + err error + }{ + { + name: "valid entry", + actual: "CANCELED", + want: CanceledPaymentStatus, + err: nil, + }, + { + name: "valid lower case", + actual: "canceled", + want: CanceledPaymentStatus, + err: nil, + }, + { + name: "valid weird case", + actual: "CancEled", + want: CanceledPaymentStatus, + err: nil, + }, + { + name: "invalid entry", + actual: "NOT_VALID", + want: CanceledPaymentStatus, + err: fmt.Errorf("invalid payment status: NOT_VALID"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToPaymentStatus(tt.actual) + + if tt.err != nil { + require.EqualError(t, err, tt.err.Error()) + return + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} + +func Test_PaymentStatus_TransitionTo(t *testing.T) { + tests := []struct { + name string + actual PaymentStatus + target PaymentStatus + err error + }{ + { + name: "disbursement started transition - success", + actual: DraftPaymentStatus, + target: ReadyPaymentStatus, + err: nil, + }, + { + name: "disbursement started transition - success", + actual: DraftPaymentStatus, + target: ReadyPaymentStatus, + err: nil, + }, + { + name: "payment gets submitted if user is ready", + actual: ReadyPaymentStatus, + target: PendingPaymentStatus, + err: nil, + }, + { + name: "user pauses payment transition", + actual: ReadyPaymentStatus, + target: PausedPaymentStatus, + err: nil, + }, + { + name: "user cancels payment transition", + actual: ReadyPaymentStatus, + target: CanceledPaymentStatus, + err: nil, + }, + { + name: "user resumes payment transition", + actual: PausedPaymentStatus, + target: ReadyPaymentStatus, + err: nil, + }, + { + name: "payment fails transition", + actual: PendingPaymentStatus, + target: FailedPaymentStatus, + err: nil, + }, + { + name: "payment is retried transition", + actual: FailedPaymentStatus, + target: PendingPaymentStatus, + err: nil, + }, + { + name: "payment succeeds transition", + actual: PendingPaymentStatus, + target: SuccessPaymentStatus, + err: nil, + }, + { + name: "invalid cancellation 1", + actual: DraftPaymentStatus, + target: CanceledPaymentStatus, + err: fmt.Errorf("cannot transition from DRAFT to CANCELED"), + }, + { + name: "invalid cancellation 2", + actual: PendingPaymentStatus, + target: CanceledPaymentStatus, + err: fmt.Errorf("cannot transition from PENDING to CANCELED"), + }, + { + name: "invalid cancellation 3", + actual: PausedPaymentStatus, + target: CanceledPaymentStatus, + err: fmt.Errorf("cannot transition from PAUSED to CANCELED"), + }, + { + name: "invalid cancellation 4", + actual: FailedPaymentStatus, + target: CanceledPaymentStatus, + err: fmt.Errorf("cannot transition from FAILED to CANCELED"), + }, + { + name: "invalid cancellation 5", + actual: SuccessPaymentStatus, + target: CanceledPaymentStatus, + err: fmt.Errorf("cannot transition from SUCCESS to CANCELED"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.actual.TransitionTo(tt.target) + if tt.err != nil { + require.EqualError(t, err, tt.err.Error()) + return + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index d3263ecfe..93f9a2127 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -2,6 +2,7 @@ package httphandler import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -16,6 +17,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) @@ -153,3 +155,58 @@ func (p PaymentsHandler) getPaymentsWithCount(ctx context.Context, queryParams * return utils.NewResultWithTotal(totalPayments, payments), nil }) } + +type PatchPaymentStatusRequest struct { + Status string `json:"status"` +} + +type UpdatePaymentStatusResponseBody struct { + Message string `json:"message"` +} + +func (p PaymentsHandler) PatchPaymentStatus(w http.ResponseWriter, r *http.Request) { + var patchRequest PatchPaymentStatusRequest + err := json.NewDecoder(r.Body).Decode(&patchRequest) + if err != nil { + httperror.BadRequest("invalid request body", err, nil).Render(w) + return + } + + // validate request + toStatus, err := data.ToPaymentStatus(patchRequest.Status) + if err != nil { + httperror.BadRequest("invalid status", err, nil).Render(w) + return + } + + paymentManagementService := services.NewPaymentManagementService(p.Models, p.DBConnectionPool) + response := UpdatePaymentStatusResponseBody{} + + ctx := r.Context() + paymentID := chi.URLParam(r, "id") + + switch toStatus { + case data.CanceledPaymentStatus: + err = paymentManagementService.CancelPayment(ctx, paymentID) + response.Message = "Payment canceled" + default: + err = services.ErrPaymentStatusCantBeChanged + } + + if err != nil { + switch { + case errors.Is(err, services.ErrPaymentNotFound): + httperror.NotFound(services.ErrPaymentNotFound.Error(), err, nil).Render(w) + case errors.Is(err, services.ErrPaymentNotReadyToCancel): + httperror.BadRequest(services.ErrPaymentNotReadyToCancel.Error(), err, nil).Render(w) + case errors.Is(err, services.ErrPaymentStatusCantBeChanged): + httperror.BadRequest(services.ErrPaymentStatusCantBeChanged.Error(), err, nil).Render(w) + default: + msg := fmt.Sprintf("Cannot update payment ID %s with status: %s", paymentID, toStatus) + httperror.InternalError(ctx, msg, err, nil).Render(w) + } + return + } + + httpjson.RenderStatus(w, http.StatusOK, response, httpjson.JSON) +} diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index 617733fe5..5a29c564d 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -1,6 +1,7 @@ package httphandler import ( + "bytes" "context" "encoding/json" "errors" @@ -18,6 +19,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stretchr/testify/assert" @@ -1010,3 +1012,149 @@ func Test_PaymentsHandler_getPaymentsWithCount(t *testing.T) { assert.Equal(t, response.Result, []data.Payment{*payment2, *payment}) }) } + +func Test_PaymentsHandler_PatchPaymentStatus(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + authManagerMock := &auth.AuthManagerMock{} + + handler := &PaymentsHandler{ + Models: models, + DBConnectionPool: models.DBConnectionPool, + AuthManager: authManagerMock, + } + + ctx := context.Background() + + r := chi.NewRouter() + r.Patch("/payments/{id}/status", handler.PatchPaymentStatus) + + // create fixtures + wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) + asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) + country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUSA) + + // create disbursements + startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "ready disbursement", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + }) + + // create disbursement receivers + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + rw1 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + rw2 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + readyPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw1, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + draftPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw2, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "200", + Status: data.DraftPaymentStatus, + }) + + reqBody := bytes.NewBuffer(nil) + t.Run("invalid body", func(t *testing.T) { + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("/payments/%s/status", readyPayment.ID), reqBody) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "invalid request body") + }) + + t.Run("invalid status", func(t *testing.T) { + err := json.NewEncoder(reqBody).Encode(PatchPaymentStatusRequest{Status: "INVALID"}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("/payments/%s/status", readyPayment.ID), reqBody) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "invalid status") + }) + + t.Run("payment not found", func(t *testing.T) { + err := json.NewEncoder(reqBody).Encode(PatchPaymentStatusRequest{Status: "CANCELED"}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("/payments/%s/status", "invalid-id"), reqBody) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNotFound, rr.Code) + require.Contains(t, rr.Body.String(), "payment not found") + }) + + t.Run("payment can't be canceled", func(t *testing.T) { + err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "CANCELED"}) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("/payments/%s/status", draftPayment.ID), reqBody) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), services.ErrPaymentNotReadyToCancel.Error()) + }) + + t.Run("payment status can't be changed", func(t *testing.T) { + err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "READY"}) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("/payments/%s/status", readyPayment.ID), reqBody) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), services.ErrPaymentStatusCantBeChanged.Error()) + }) + + t.Run("payment canceled successfully", func(t *testing.T) { + err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "Canceled"}) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("/payments/%s/status", readyPayment.ID), reqBody) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "Payment canceled") + + payment, err := handler.Models.Payment.Get(context.Background(), readyPayment.ID, models.DBConnectionPool) + require.NoError(t, err) + require.Equal(t, data.CanceledPaymentStatus, payment.Status) + }) + + authManagerMock.AssertExpectations(t) +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 9d58dcd07..0d0345b1b 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -253,6 +253,8 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.Get("/", paymentsHandler.GetPayments) r.Get("/{id}", paymentsHandler.GetPayment) r.Patch("/retry", paymentsHandler.RetryPayments) + r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). + Patch("/{id}/status", paymentsHandler.PatchPaymentStatus) }) r.Route("/receivers", func(r chi.Router) { diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 7ef065e57..0d228698c 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -284,11 +284,12 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { {http.MethodGet, "/disbursements"}, {http.MethodGet, "/disbursements/1234"}, {http.MethodGet, "/disbursements/1234/receivers"}, - {http.MethodGet, "/disbursements/1234/status"}, + {http.MethodPatch, "/disbursements/1234/status"}, // Payments {http.MethodGet, "/payments"}, {http.MethodGet, "/payments/1234"}, {http.MethodPatch, "/payments/retry"}, + {http.MethodPatch, "/payments/1234/status"}, // Receivers {http.MethodGet, "/receivers"}, {http.MethodGet, "/receivers/1234"}, diff --git a/internal/services/payment_management_service.go b/internal/services/payment_management_service.go new file mode 100644 index 000000000..b6de2bfe9 --- /dev/null +++ b/internal/services/payment_management_service.go @@ -0,0 +1,61 @@ +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db" +) + +// PaymentManagementService is a service for managing disbursements. +type PaymentManagementService struct { + models *data.Models + dbConnectionPool db.DBConnectionPool +} + +var ( + ErrPaymentNotFound = errors.New("payment not found") + ErrPaymentNotReadyToCancel = errors.New("payment is not ready to be canceled") + ErrPaymentStatusCantBeChanged = errors.New("payment status can't be changed to the requested status") +) + +// NewPaymentManagementService is a factory function for creating a new PaymentManagementService. +func NewPaymentManagementService(models *data.Models, dbConnectionPool db.DBConnectionPool) *PaymentManagementService { + return &PaymentManagementService{ + models: models, + dbConnectionPool: dbConnectionPool, + } +} + +// CancelPayment update payment to status 'canceled' +func (s *PaymentManagementService) CancelPayment(ctx context.Context, paymentID string) error { + return db.RunInTransaction(ctx, s.dbConnectionPool, nil, func(dbTx db.DBTransaction) error { + payment, err := s.models.Payment.Get(ctx, paymentID, dbTx) + if err != nil { + if errors.Is(err, data.ErrRecordNotFound) { + return ErrPaymentNotFound + } + return fmt.Errorf("error getting payment with id %s: %w", paymentID, err) + } + + // 1. Verify Transition is Possible + err = payment.Status.TransitionTo(data.PaymentStatus(data.CanceledPaymentStatus)) + if err != nil { + return ErrPaymentNotReadyToCancel + } + + // 2. Update payment status to `canceled` + numRowsAffected, err := s.models.Payment.UpdateStatuses(ctx, dbTx, []*data.Payment{payment}, data.CanceledPaymentStatus) + if err != nil { + return fmt.Errorf("error updating payment status with id %s to 'canceled': %w", paymentID, err) + } + + if numRowsAffected == 0 { + return ErrPaymentStatusCantBeChanged + } + + return nil + }) +} diff --git a/internal/services/payment_management_service_test.go b/internal/services/payment_management_service_test.go new file mode 100644 index 000000000..1bd5c9f1a --- /dev/null +++ b/internal/services/payment_management_service_test.go @@ -0,0 +1,110 @@ +package services + +import ( + "context" + "testing" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stretchr/testify/require" +) + +func Test_PaymentManagementService_CancelPayment(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + models, outerErr := data.NewModels(dbConnectionPool) + require.NoError(t, outerErr) + + token := "token" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + service := NewPaymentManagementService(models, models.DBConnectionPool) + + // create fixtures + wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) + asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) + country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUSA) + + // create disbursements + startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "ready disbursement", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + }) + + // create disbursement receivers + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiver3 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiver4 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + rw1 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + rw2 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + rw3 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver3.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver4.ID, wallet.ID, data.ReadyReceiversWalletStatus) + + readyPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw1, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + draftPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw2, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "200", + Status: data.DraftPaymentStatus, + }) + pausedPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw3, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "300", + Status: data.PausedPaymentStatus, + }) + pendingPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: startedDisbursement, + Asset: *asset, + Amount: "400", + Status: data.PendingPaymentStatus, + }) + + t.Run("payment doesn't exist", func(t *testing.T) { + id := "5e1f1c7f5b6c9c0001c1b1b1" + + err := service.CancelPayment(ctx, id) + require.ErrorIs(t, err, ErrPaymentNotFound) + }) + + t.Run("payment not ready to cancel", func(t *testing.T) { + err := service.CancelPayment(ctx, draftPayment.ID) + require.ErrorIs(t, err, ErrPaymentNotReadyToCancel) + + err = service.CancelPayment(ctx, pausedPayment.ID) + require.ErrorIs(t, err, ErrPaymentNotReadyToCancel) + + err = service.CancelPayment(ctx, pendingPayment.ID) + require.ErrorIs(t, err, ErrPaymentNotReadyToCancel) + }) + + t.Run("payment canceled", func(t *testing.T) { + err := service.CancelPayment(ctx, readyPayment.ID) + require.NoError(t, err) + + payment, err := models.Payment.Get(ctx, readyPayment.ID, models.DBConnectionPool) + require.NoError(t, err) + require.Equal(t, data.CanceledPaymentStatus, payment.Status) + }) +} From f67740d4b445bf0cd0df84aae892de299038bf40 Mon Sep 17 00:00:00 2001 From: Marwen Abid Date: Fri, 22 Dec 2023 13:41:50 -0500 Subject: [PATCH 12/39] Change to Circle USDC. (#132) --- internal/integrationtests/docker-compose-e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/integrationtests/docker-compose-e2e-tests.yml b/internal/integrationtests/docker-compose-e2e-tests.yml index 7b419f466..42a2d0266 100644 --- a/internal/integrationtests/docker-compose-e2e-tests.yml +++ b/internal/integrationtests/docker-compose-e2e-tests.yml @@ -45,7 +45,7 @@ services: USER_EMAIL: ${USER_EMAIL} USER_PASSWORD: ${USER_PASSWORD} DISBURSED_ASSET_CODE: USDC - DISBURSED_ASSET_ISSUER: GDKLFXO3FL25I7ST632KMMBP5D72QGTDV55TOWUB2XG2O67NNQDKYMLG + DISBURSED_ASSET_ISSUER: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 RECEIVER_ACCOUNT_PUBLIC_KEY: GCDYFAJSZPH3RCXL6NWMMOY54CXNUBYFTDCBW7GGG6VPBW3WSDKSB2NU RECEIVER_ACCOUNT_PRIVATE_KEY: SDSAVUWVNOFG2JEHKIWEUHAYIA6PLGEHLMHX2TMVKEQGZKOFQ7XXKDFE DISBURSEMENT_CSV_FILE_PATH: files From 1d0b6fa1adec95d833aa2cf3dc74418d1cd8c73a Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Fri, 22 Dec 2023 11:29:51 -0800 Subject: [PATCH 13/39] [SDP-971] Protocol 20 Horizon SDK upgrade (#107) --- go.list | 132 ++++++++++++++++++++++++++++++-------------------------- go.mod | 39 +++++++++-------- go.sum | 91 ++++++++++++++++++++------------------ 3 files changed, 140 insertions(+), 122 deletions(-) diff --git a/go.list b/go.list index 104adb63c..c3a89dfa0 100644 --- a/go.list +++ b/go.list @@ -1,15 +1,17 @@ github.com/stellar/stellar-disbursement-platform-backend -cloud.google.com/go v0.110.0 +cloud.google.com/go v0.110.7 cloud.google.com/go/bigquery v1.8.0 -cloud.google.com/go/compute v1.19.0 +cloud.google.com/go/compute v1.23.0 cloud.google.com/go/compute/metadata v0.2.3 cloud.google.com/go/datastore v1.1.0 -cloud.google.com/go/firestore v1.9.0 -cloud.google.com/go/longrunning v0.4.1 +cloud.google.com/go/firestore v1.13.0 +cloud.google.com/go/iam v1.1.1 +cloud.google.com/go/longrunning v0.5.1 cloud.google.com/go/pubsub v1.3.1 -cloud.google.com/go/storage v1.14.0 +cloud.google.com/go/storage v1.30.1 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 firebase.google.com/go v3.12.0+incompatible +github.com/2opremio/pretty v0.2.2-0.20230601220618-e1d5758b2a95 github.com/BurntSushi/toml v1.3.2 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 @@ -18,24 +20,25 @@ github.com/Joker/jade v1.1.3 github.com/Masterminds/goutils v1.1.1 github.com/Masterminds/semver/v3 v3.2.0 github.com/Masterminds/sprig/v3 v3.2.3 -github.com/Masterminds/squirrel v1.5.0 -github.com/Microsoft/go-winio v0.4.14 +github.com/Masterminds/squirrel v1.5.4 +github.com/Microsoft/go-winio v0.6.1 github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 github.com/adjust/goautoneg v0.0.0-20150426214442-d788f35a0315 github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f github.com/alecthomas/kingpin/v2 v2.3.2 github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 github.com/andybalholm/brotli v1.0.5 -github.com/armon/go-metrics v0.4.0 +github.com/armon/go-metrics v0.4.1 github.com/armon/go-radix v1.0.0 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 -github.com/aws/aws-sdk-go v1.44.321 +github.com/aws/aws-sdk-go v1.45.26 github.com/aymerick/douceur v0.2.0 github.com/beevik/etree v1.1.0 github.com/beorn7/perks v1.0.1 github.com/bgentry/speakeasy v0.1.0 github.com/buger/goreplay v1.3.2 github.com/census-instrumentation/opencensus-proto v0.2.1 +github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d github.com/cespare/xxhash/v2 v2.2.0 github.com/chzyer/logex v1.2.1 github.com/chzyer/readline v1.5.1 @@ -46,26 +49,28 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 github.com/coreos/go-semver v0.3.0 github.com/coreos/go-systemd/v22 v22.3.2 github.com/cpuguy83/go-md2man/v2 v2.0.2 +github.com/creachadair/jrpc2 v1.1.0 +github.com/creachadair/mds v0.0.1 github.com/creack/pty v1.1.9 -github.com/davecgh/go-spew v1.1.1 +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/denisenkom/go-mssqldb v0.9.0 github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 -github.com/elazarl/go-bindata-assetfs v1.0.0 +github.com/elazarl/go-bindata-assetfs v1.0.1 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad github.com/envoyproxy/protoc-gen-validate v0.1.0 -github.com/fatih/color v1.13.0 +github.com/fatih/color v1.14.1 github.com/fatih/structs v1.1.0 github.com/flosch/pongo2/v4 v4.0.2 github.com/frankban/quicktest v1.14.4 github.com/fsnotify/fsnotify v1.6.0 github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 -github.com/getsentry/raven-go v0.0.0-20160805001729-c9d3cc542ad1 +github.com/getsentry/raven-go v0.2.0 github.com/getsentry/sentry-go v0.23.0 github.com/gin-contrib/sse v0.1.0 github.com/gin-gonic/gin v1.8.1 github.com/go-chi/chi v4.1.2+incompatible github.com/go-chi/chi/v5 v5.0.10 -github.com/go-errors/errors v1.4.2 +github.com/go-errors/errors v1.5.1 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 github.com/go-gorp/gorp/v3 v3.1.0 @@ -77,8 +82,7 @@ github.com/go-playground/universal-translator v0.18.0 github.com/go-playground/validator/v10 v10.11.1 github.com/go-sql-driver/mysql v1.6.0 github.com/gobuffalo/logger v1.0.6 -github.com/gobuffalo/packd v1.0.1 -github.com/gobuffalo/packr v1.12.1 +github.com/gobuffalo/packd v1.0.2 github.com/gobuffalo/packr/v2 v2.8.3 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/goccy/go-json v0.9.11 @@ -97,30 +101,29 @@ github.com/google/go-cmp v0.5.9 github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 github.com/google/martian v2.1.0+incompatible github.com/google/martian/v3 v3.1.0 -github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2 +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 github.com/google/renameio v0.1.0 -github.com/google/s2a-go v0.1.3 -github.com/google/uuid v1.3.0 -github.com/googleapis/enterprise-certificate-proxy v0.2.3 -github.com/googleapis/gax-go/v2 v2.8.0 +github.com/google/s2a-go v0.1.7 +github.com/google/uuid v1.3.1 +github.com/googleapis/enterprise-certificate-proxy v0.3.1 +github.com/googleapis/gax-go/v2 v2.12.0 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 github.com/gorilla/css v1.0.0 github.com/gorilla/schema v1.2.0 github.com/graph-gophers/graphql-go v1.3.0 -github.com/guregu/null v2.1.3-0.20151024101046-79c5bd36b615+incompatible -github.com/hashicorp/consul/api v1.20.0 +github.com/guregu/null v4.0.0+incompatible +github.com/hashicorp/consul/api v1.25.1 github.com/hashicorp/errwrap v1.1.0 github.com/hashicorp/go-cleanhttp v0.5.2 -github.com/hashicorp/go-hclog v1.2.0 +github.com/hashicorp/go-hclog v1.5.0 github.com/hashicorp/go-immutable-radix v1.3.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-rootcerts v1.0.2 -github.com/hashicorp/golang-lru v0.5.4 +github.com/hashicorp/golang-lru v1.0.2 github.com/hashicorp/hcl v1.0.0 github.com/hashicorp/serf v0.10.1 -github.com/holiman/uint256 v1.2.0 +github.com/holiman/uint256 v1.2.3 github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c -github.com/hpcloud/tail v1.0.0 github.com/huandu/xstrings v1.4.0 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 github.com/imdario/mergo v0.3.13 @@ -144,8 +147,7 @@ github.com/kataras/pio v0.0.11 github.com/kataras/sitemap v0.0.6 github.com/kataras/tunnel v0.0.4 github.com/kisielk/gotool v1.0.0 -github.com/klauspost/compress v1.16.0 -github.com/konsorten/go-windows-terminal-sequences v1.0.1 +github.com/klauspost/compress v1.17.0 github.com/kr/fs v0.1.0 github.com/kr/pretty v0.3.1 github.com/kr/pty v1.1.1 @@ -172,6 +174,7 @@ github.com/mattn/go-runewidth v0.0.9 github.com/mattn/go-sqlite3 v1.14.15 github.com/matttproud/golang_protobuf_extensions v1.0.4 github.com/microcosm-cc/bluemonday v1.0.23 +github.com/minio/highwayhash v1.0.2 github.com/mitchellh/cli v1.1.5 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-homedir v1.1.0 @@ -181,49 +184,55 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd github.com/modern-go/reflect2 v1.0.2 github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f +github.com/nats-io/jwt/v2 v2.4.1 +github.com/nats-io/nats.go v1.30.2 +github.com/nats-io/nkeys v0.4.5 +github.com/nats-io/nuid v1.0.1 github.com/nelsam/hel/v2 v2.3.3 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e +github.com/nxadm/tail v1.4.8 github.com/nyaruka/phonenumbers v1.1.8 github.com/olekukonko/tablewriter v0.0.5 -github.com/onsi/ginkgo v1.7.0 -github.com/onsi/gomega v1.4.3 +github.com/onsi/ginkgo v1.16.5 +github.com/onsi/gomega v1.27.10 github.com/opentracing/opentracing-go v1.1.0 -github.com/pelletier/go-toml v1.9.0 -github.com/pelletier/go-toml/v2 v2.0.9 +github.com/pelletier/go-toml v1.9.5 +github.com/pelletier/go-toml/v2 v2.1.0 github.com/pingcap/errors v0.11.4 github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.1 -github.com/pmezard/go-difflib v1.0.0 +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/posener/complete v1.2.3 github.com/poy/onpar v1.1.2 -github.com/prometheus/client_golang v1.16.0 -github.com/prometheus/client_model v0.4.0 +github.com/prometheus/client_golang v1.17.0 +github.com/prometheus/client_model v0.5.0 github.com/prometheus/common v0.44.0 -github.com/prometheus/procfs v0.11.1 -github.com/rogpeppe/go-internal v1.10.0 -github.com/rs/cors v1.9.0 -github.com/rs/xhandler v0.0.0-20160618193221-ed27b6fd6521 +github.com/prometheus/procfs v0.12.0 +github.com/rogpeppe/go-internal v1.11.0 +github.com/rs/cors v1.10.1 github.com/rubenv/sql-migrate v1.5.2 github.com/russross/blackfriday/v2 v2.1.0 -github.com/sagikazarmark/crypt v0.10.0 +github.com/sagikazarmark/crypt v0.15.0 +github.com/sagikazarmark/locafero v0.3.0 +github.com/sagikazarmark/slog-shim v0.1.0 github.com/schollz/closestmatch v2.1.0+incompatible github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 github.com/sergi/go-diff v0.0.0-20161205080420-83532ca1c1ca github.com/shopspring/decimal v1.3.1 -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 +github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c github.com/sirupsen/logrus v1.9.3 -github.com/spf13/afero v1.9.5 +github.com/sourcegraph/conc v0.3.0 +github.com/spf13/afero v1.10.0 github.com/spf13/cast v1.5.1 github.com/spf13/cobra v1.7.0 -github.com/spf13/jwalterweatherman v1.1.0 github.com/spf13/pflag v1.0.5 -github.com/spf13/viper v1.16.0 -github.com/stellar/go v0.0.0-20230810175703-9c94bc588b15 -github.com/stellar/go-xdr v0.0.0-20211103144802-8017fc4bdfee +github.com/spf13/viper v1.17.0 +github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 github.com/stellar/throttled v2.2.3-0.20190823235211-89d75816f59d+incompatible github.com/stretchr/objx v0.5.1 github.com/stretchr/testify v1.8.4 -github.com/subosito/gotenv v1.4.2 +github.com/subosito/gotenv v1.6.0 github.com/tdewolff/minify/v2 v2.12.4 github.com/tdewolff/parse/v2 v2.6.4 github.com/twilio/twilio-go v1.11.0 @@ -246,40 +255,39 @@ github.com/yudai/gojsondiff v0.0.0-20170107030110-7b1b7adf999d github.com/yudai/golcs v0.0.0-20150405163532-d1c525dea8ce github.com/yudai/pp v2.0.1+incompatible github.com/yuin/goldmark v1.4.13 -github.com/ziutek/mymysql v1.5.4 go.etcd.io/etcd/api/v3 v3.5.9 go.etcd.io/etcd/client/pkg/v3 v3.5.9 -go.etcd.io/etcd/client/v2 v2.305.7 +go.etcd.io/etcd/client/v2 v2.305.9 go.etcd.io/etcd/client/v3 v3.5.9 go.opencensus.io v0.24.0 go.uber.org/atomic v1.9.0 -go.uber.org/multierr v1.8.0 +go.uber.org/multierr v1.11.0 go.uber.org/zap v1.21.0 golang.org/x/crypto v0.15.0 -golang.org/x/exp v0.0.0-20230810033253-352e893a4cad +golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/image v0.0.0-20190802002840-cff245a6509b -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 -golang.org/x/mod v0.11.0 +golang.org/x/mod v0.13.0 golang.org/x/net v0.18.0 -golang.org/x/oauth2 v0.8.0 -golang.org/x/sync v0.3.0 +golang.org/x/oauth2 v0.12.0 +golang.org/x/sync v0.4.0 golang.org/x/sys v0.14.0 golang.org/x/term v0.14.0 golang.org/x/text v0.14.0 golang.org/x/time v0.3.0 -golang.org/x/tools v0.6.0 +golang.org/x/tools v0.14.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 -google.golang.org/api v0.122.0 +google.golang.org/api v0.143.0 google.golang.org/appengine v1.6.7 -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 -google.golang.org/grpc v1.55.0 +google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb +google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 +google.golang.org/grpc v1.58.3 google.golang.org/protobuf v1.31.0 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/errgo.v2 v2.1.0 -gopkg.in/fsnotify.v1 v1.4.7 gopkg.in/gavv/httpexpect.v1 v1.0.0-20170111145843-40724cf1e4a0 -gopkg.in/gorp.v1 v1.7.1 gopkg.in/ini.v1 v1.67.0 gopkg.in/square/go-jose.v2 v2.4.1 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 diff --git a/go.mod b/go.mod index 4d7e3b6a5..50c189701 100644 --- a/go.mod +++ b/go.mod @@ -4,40 +4,40 @@ go 1.19 require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/aws/aws-sdk-go v1.44.321 + github.com/aws/aws-sdk-go v1.45.26 github.com/getsentry/sentry-go v0.23.0 github.com/go-chi/chi v4.1.2+incompatible github.com/go-chi/chi/v5 v5.0.10 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/golang-jwt/jwt/v4 v4.5.0 - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.3.1 github.com/gorilla/schema v1.2.0 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/manifoldco/promptui v0.9.0 github.com/nyaruka/phonenumbers v1.1.8 - github.com/prometheus/client_golang v1.16.0 - github.com/rs/cors v1.9.0 + github.com/prometheus/client_golang v1.17.0 + github.com/rs/cors v1.10.1 github.com/rubenv/sql-migrate v1.5.2 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 - github.com/spf13/viper v1.16.0 - github.com/stellar/go v0.0.0-20230810175703-9c94bc588b15 + github.com/spf13/viper v1.17.0 + github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 github.com/stretchr/testify v1.8.4 github.com/twilio/twilio-go v1.11.0 golang.org/x/crypto v0.15.0 - golang.org/x/exp v0.0.0-20230810033253-352e893a4cad + golang.org/x/exp v0.0.0-20231006140011-7918f672742d ) require ( github.com/BurntSushi/toml v1.3.2 // indirect - github.com/Masterminds/squirrel v1.5.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chzyer/readline v1.5.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/go-errors/errors v1.4.2 // indirect + github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -50,20 +50,23 @@ require ( github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.4.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.44.0 // indirect - github.com/prometheus/procfs v0.11.1 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/sagikazarmark/locafero v0.3.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect - github.com/spf13/afero v1.9.5 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.1 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stellar/go-xdr v0.0.0-20211103144802-8017fc4bdfee // indirect + github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect github.com/stretchr/objx v0.5.1 // indirect - github.com/subosito/gotenv v1.4.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.18.0 // indirect golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index bca470715..33915b2c5 100644 --- a/go.sum +++ b/go.sum @@ -40,14 +40,14 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8= -github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f h1:zvClvFQwU++UpIUBGC8YmDlfhUrweEy1R1Fj1gu5iIM= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.44.321 h1:iXwFLxWjZPjYqjPq0EcCs46xX7oDLEELte1+BzgpKk8= -github.com/aws/aws-sdk-go v1.44.321/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.45.26 h1:PJ2NJNY5N/yeobLYe1Y+xLdavBi67ZI8gvph6ftwVCg= +github.com/aws/aws-sdk-go v1.45.26/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -70,8 +70,9 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -89,8 +90,8 @@ github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyN github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -99,8 +100,7 @@ github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpj github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= -github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0= -github.com/gobuffalo/packr v1.12.1 h1:+5u3rqgdhswdYXhrX6DHaO7BM4P8oxrbvgZm9H1cRI4= +github.com/gobuffalo/packd v1.0.2 h1:Yg523YqnOxGIWCp69W12yYBKsoChwI7mtu6ceM9Bwfw= github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d h1:KbPOUXFUDJxwZ04vbmDOc3yuruGvVO+LOa7cVER3yWw= github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= @@ -166,18 +166,18 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= @@ -194,7 +194,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -228,56 +228,62 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db h1:eZgFHVkk9uOTaOQLC6tgjkzdp7Ays8eEVecBcfHZlJQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nyaruka/phonenumbers v1.1.8 h1:mjFu85FeoH2Wy18aOMUvxqi1GgAqiQSJsa/cCC5yu2s= github.com/nyaruka/phonenumbers v1.1.8/go.mod h1:DC7jZd321FqUe+qWSNcHi10tyIyGNXGcNbfkPvdp1Vs= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= -github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= -github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= -github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= -github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= -github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= +github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 h1:S4OC0+OBKz6mJnzuHioeEat74PuQ4Sgvbf8eus695sc= github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2/go.mod h1:8zLRYR5npGjaOXgPSKat5+oOh+UHd8OdbS18iqX9F6Y= github.com/sergi/go-diff v0.0.0-20161205080420-83532ca1c1ca h1:oR/RycYTFTVXzND5r4FdsvbnBn0HJXSVeNAnwaTXRwk= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= -github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= -github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= -github.com/stellar/go v0.0.0-20230810175703-9c94bc588b15 h1:snRtfXX7WGO3frwMk6KtAJzLCRX9t48xDx0PX6tUbXg= -github.com/stellar/go v0.0.0-20230810175703-9c94bc588b15/go.mod h1:iTkyf5zUHlaIjZjyxaLLXLv+YHqg3etsqn8AOQ+DvG8= -github.com/stellar/go-xdr v0.0.0-20211103144802-8017fc4bdfee h1:fbVs0xmXpBvVS4GBeiRmAE3Le70ofAqFMch1GTiq/e8= -github.com/stellar/go-xdr v0.0.0-20211103144802-8017fc4bdfee/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= +github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= +github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= +github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 h1:LcQ01nwgxVoCmzAthjGSbxun9z/mxuqDy4uYETw4jEQ= +github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6/go.mod h1:PAWie4LYyDzJXqDVG4Qcj1Nt+uNk7sjzgSCXndQYsBA= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -292,8 +298,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= -github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twilio/twilio-go v1.11.0 h1:ixO2DfAV4c0Yza0Tom5F5ZZB8WUbigiFc9wD84vbYnc= github.com/twilio/twilio-go v1.11.0/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -317,6 +323,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -337,8 +345,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230810033253-352e893a4cad h1:g0bG7Z4uG+OgH2QDODnjp6ggkk1bJDsINcuWmJN1iJU= -golang.org/x/exp v0.0.0-20230810033253-352e893a4cad/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -636,7 +644,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/gavv/httpexpect.v1 v1.0.0-20170111145843-40724cf1e4a0 h1:r5ptJ1tBxVAeqw4CrYWhXIMr0SybY3CDHuIbCg5CFVw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= From 84349b2306ec87811da26c07fdbaf0e819d358dd Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Tue, 2 Jan 2024 10:34:38 -0800 Subject: [PATCH 14/39] [SDP-1023] Add unique payment ID to disbursement instructions file as an optional field (#131) - allow insert of optional field external_payment_id when performing a disbursement upload via /disbursement/{id}/instructions - allow retrieval of external_payment_id if it exists on a payment, otherwise omit field via /payments and /payments/{id} --- internal/data/disbursement_instructions.go | 20 +++++++------ .../data/disbursement_instructions_test.go | 29 +++++++++++++++++++ internal/data/disbursements_test.go | 6 ++-- internal/data/fixtures.go | 5 ++-- internal/data/fixtures_test.go | 10 +++---- internal/data/payments.go | 22 +++++++++----- ...payments-table-add-external-payment-id.sql | 9 ++++++ .../httphandler/payments_handler_test.go | 12 ++++---- 8 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 internal/db/migrations/2023-12-18.0-alter-payments-table-add-external-payment-id.sql diff --git a/internal/data/disbursement_instructions.go b/internal/data/disbursement_instructions.go index 1fd794772..775c9e09b 100644 --- a/internal/data/disbursement_instructions.go +++ b/internal/data/disbursement_instructions.go @@ -9,10 +9,11 @@ import ( ) type DisbursementInstruction struct { - Phone string `csv:"phone"` - ID string `csv:"id"` - Amount string `csv:"amount"` - VerificationValue string `csv:"verification"` + Phone string `csv:"phone"` + ID string `csv:"id"` + Amount string `csv:"amount"` + VerificationValue string `csv:"verification"` + ExternalPaymentId *string `csv:"paymentID"` } type DisbursementInstructionModel struct { @@ -194,11 +195,12 @@ func (di DisbursementInstructionModel) ProcessAll(ctx context.Context, userID st for _, instruction := range instructions { receiver := receiverMap[instruction.Phone] payment := PaymentInsert{ - ReceiverID: receiver.ID, - DisbursementID: disbursement.ID, - Amount: instruction.Amount, - AssetID: disbursement.Asset.ID, - ReceiverWalletID: receiverWalletsMap[receiver.ID], + ReceiverID: receiver.ID, + DisbursementID: disbursement.ID, + Amount: instruction.Amount, + AssetID: disbursement.Asset.ID, + ReceiverWalletID: receiverWalletsMap[receiver.ID], + ExternalPaymentID: instruction.ExternalPaymentId, } payments = append(payments, payment) } diff --git a/internal/data/disbursement_instructions_test.go b/internal/data/disbursement_instructions_test.go index 21ab1d2e8..fa04b253b 100644 --- a/internal/data/disbursement_instructions_test.go +++ b/internal/data/disbursement_instructions_test.go @@ -2,6 +2,7 @@ package data import ( "context" + "database/sql" "testing" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" @@ -47,16 +48,19 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { VerificationValue: "1990-01-02", } + externalPaymentID := "abc123" instruction3 := DisbursementInstruction{ Phone: "+380-12-345-673", Amount: "100.03", ID: "123456783", VerificationValue: "1990-01-03", + ExternalPaymentId: &externalPaymentID, } instructions := []*DisbursementInstruction{&instruction1, &instruction2, &instruction3} expectedPhoneNumbers := []string{instruction1.Phone, instruction2.Phone, instruction3.Phone} expectedExternalIDs := []string{instruction1.ID, instruction2.ID, instruction3.ID} expectedPayments := []string{instruction1.Amount, instruction2.Amount, instruction3.Amount} + expectedExternalPaymentIDs := []string{*instruction3.ExternalPaymentId} disbursementUpdate := &DisbursementUpdate{ ID: disbursement.ID, @@ -91,6 +95,9 @@ func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { actualPayments := GetPaymentsByDisbursementID(t, ctx, dbConnectionPool, disbursement.ID) assert.Equal(t, expectedPayments, actualPayments) + actualExternalPaymentIDs := GetExternalPaymentIDsByDisbursementID(t, ctx, dbConnectionPool, disbursement.ID) + assert.Equal(t, expectedExternalPaymentIDs, actualExternalPaymentIDs) + // Verify Disbursement actualDisbursement, err := di.disbursementModel.Get(ctx, dbConnectionPool, disbursement.ID) require.NoError(t, err) @@ -304,3 +311,25 @@ func GetPaymentsByDisbursementID(t *testing.T, ctx context.Context, dbConnection require.NoError(t, err) return payments } + +func GetExternalPaymentIDsByDisbursementID(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, disbursementID string) []string { + query := ` + SELECT + p.external_payment_id + FROM + payments p + WHERE p.disbursement_id = $1 + ` + var externalPaymentIDRefs []sql.NullString + err := dbConnectionPool.SelectContext(ctx, &externalPaymentIDRefs, query, disbursementID) + require.NoError(t, err) + + var externalPaymentIDs []string + for _, v := range externalPaymentIDRefs { + if v.String != "" { + externalPaymentIDs = append(externalPaymentIDs, v.String) + } + } + + return externalPaymentIDs +} diff --git a/internal/data/disbursements_test.go b/internal/data/disbursements_test.go index ec3d06e3a..fdb2cb47f 100644 --- a/internal/data/disbursements_test.go +++ b/internal/data/disbursements_test.go @@ -437,9 +437,9 @@ func Test_DisbursementModel_Update(t *testing.T) { }) disbursementFileContent := CreateInstructionsFixture(t, []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20"}, - {"0987654321", "2", "321", "1974-07-19"}, - {"0987654321", "3", "321", "1974-07-19"}, + {"1234567890", "1", "123.12", "1995-02-20", nil}, + {"0987654321", "2", "321", "1974-07-19", nil}, + {"0987654321", "3", "321", "1974-07-19", nil}, }) t.Run("update instructions", func(t *testing.T) { diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index b62a9e43d..e6f36c98a 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -459,9 +459,9 @@ func CreatePaymentFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecu const query = ` INSERT INTO payments (receiver_id, disbursement_id, receiver_wallet_id, asset_id, amount, status, status_history, - stellar_transaction_id, stellar_operation_id, created_at, updated_at) + stellar_transaction_id, stellar_operation_id, created_at, updated_at, external_payment_id) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id ` @@ -478,6 +478,7 @@ func CreatePaymentFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecu p.StellarOperationID, p.CreatedAt, p.UpdatedAt, + p.ExternalPaymentID, ) require.NoError(t, err) diff --git a/internal/data/fixtures_test.go b/internal/data/fixtures_test.go index f48136f00..e06e952a4 100644 --- a/internal/data/fixtures_test.go +++ b/internal/data/fixtures_test.go @@ -89,8 +89,8 @@ func Test_Fixtures_CreateInstructionsFixture(t *testing.T) { t.Run("writes records correctly", func(t *testing.T) { instructions := []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20"}, - {"0987654321", "2", "321", "1974-07-19"}, + {"1234567890", "1", "123.12", "1995-02-20", nil}, + {"0987654321", "2", "321", "1974-07-19", nil}, } buf := CreateInstructionsFixture(t, instructions) lines := strings.Split(string(buf), "\n") @@ -116,9 +116,9 @@ func Test_Fixtures_UpdateDisbursementInstructionsFixture(t *testing.T) { }) instructions := []*DisbursementInstruction{ - {"1234567890", "1", "123.12", "1995-02-20"}, - {"0987654321", "2", "321", "1974-07-19"}, - {"0987654321", "3", "321", "1974-07-19"}, + {"1234567890", "1", "123.12", "1995-02-20", nil}, + {"0987654321", "2", "321", "1974-07-19", nil}, + {"0987654321", "3", "321", "1974-07-19", nil}, } t.Run("update instructions", func(t *testing.T) { diff --git a/internal/data/payments.go b/internal/data/payments.go index d4468ccfc..32f001f00 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -30,6 +30,7 @@ type Payment struct { ReceiverWallet *ReceiverWallet `json:"receiver_wallet,omitempty" db:"receiver_wallet"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ExternalPaymentID string `json:"external_payment_id,omitempty" db:"external_payment_id"` } type PaymentStatusHistoryEntry struct { @@ -50,11 +51,12 @@ var ( ) type PaymentInsert struct { - ReceiverID string `db:"receiver_id"` - DisbursementID string `db:"disbursement_id"` - Amount string `db:"amount"` - AssetID string `db:"asset_id"` - ReceiverWalletID string `db:"receiver_wallet_id"` + ReceiverID string `db:"receiver_id"` + DisbursementID string `db:"disbursement_id"` + Amount string `db:"amount"` + AssetID string `db:"asset_id"` + ReceiverWalletID string `db:"receiver_wallet_id"` + ExternalPaymentID *string `db:"external_payment_id"` } type PaymentUpdate struct { @@ -150,6 +152,7 @@ func (p *PaymentModel) Get(ctx context.Context, id string, sqlExec db.SQLExecute p.status_history, p.created_at, p.updated_at, + COALESCE(p.external_payment_id, '') as external_payment_id, d.id as "disbursement.id", d.name as "disbursement.name", d.status as "disbursement.status", @@ -227,6 +230,7 @@ func (p *PaymentModel) GetAll(ctx context.Context, queryParams *QueryParams, sql p.status_history, p.created_at, p.updated_at, + COALESCE(p.external_payment_id, '') as external_payment_id, d.id as "disbursement.id", d.name as "disbursement.name", d.status as "disbursement.status", @@ -341,18 +345,20 @@ func (p *PaymentModel) InsertAll(ctx context.Context, sqlExec db.SQLExecuter, in asset_id, receiver_id, disbursement_id, - receiver_wallet_id + receiver_wallet_id, + external_payment_id ) VALUES ( $1, $2, $3, $4, - $5 + $5, + $6 ) ` for _, payment := range inserts { - _, err := sqlExec.ExecContext(ctx, query, payment.Amount, payment.AssetID, payment.ReceiverID, payment.DisbursementID, payment.ReceiverWalletID) + _, err := sqlExec.ExecContext(ctx, query, payment.Amount, payment.AssetID, payment.ReceiverID, payment.DisbursementID, payment.ReceiverWalletID, payment.ExternalPaymentID) if err != nil { return fmt.Errorf("error inserting payment: %w", err) } diff --git a/internal/db/migrations/2023-12-18.0-alter-payments-table-add-external-payment-id.sql b/internal/db/migrations/2023-12-18.0-alter-payments-table-add-external-payment-id.sql new file mode 100644 index 000000000..a480d7c1d --- /dev/null +++ b/internal/db/migrations/2023-12-18.0-alter-payments-table-add-external-payment-id.sql @@ -0,0 +1,9 @@ +-- +migrate Up + +ALTER TABLE public.payments + ADD COLUMN external_payment_id VARCHAR(64) NULL; + +-- +migrate Down + +ALTER TABLE public.payments + DROP COLUMN external_payment_id; diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index 5a29c564d..e5e6670fb 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -80,9 +80,10 @@ func Test_PaymentsHandlerGet(t *testing.T) { Timestamp: time.Now(), }, }, - Disbursement: disbursement, - Asset: *asset, - ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + ReceiverWallet: receiverWallet, + ExternalPaymentID: "mockID", }) t.Run("successfully returns payment details for given ID", func(t *testing.T) { @@ -141,12 +142,13 @@ func Test_PaymentsHandlerGet(t *testing.T) { "invitation_sent_at": null }, "created_at": %q, - "updated_at": %q + "updated_at": %q, + "external_payment_id": %q }`, payment.ID, payment.StellarTransactionID, payment.StellarOperationID, payment.StatusHistory[0].Timestamp.Format(time.RFC3339Nano), disbursement.ID, disbursement.CreatedAt.Format(time.RFC3339Nano), disbursement.UpdatedAt.Format(time.RFC3339Nano), asset.ID, receiverWallet.ID, receiver.ID, wallet.ID, receiverWallet.StellarAddress, receiverWallet.StellarMemo, receiverWallet.StellarMemoType, receiverWallet.CreatedAt.Format(time.RFC3339Nano), receiverWallet.UpdatedAt.Format(time.RFC3339Nano), - payment.CreatedAt.Format(time.RFC3339Nano), payment.UpdatedAt.Format(time.RFC3339Nano)) + payment.CreatedAt.Format(time.RFC3339Nano), payment.UpdatedAt.Format(time.RFC3339Nano), payment.ExternalPaymentID) assert.JSONEq(t, wantJson, rr.Body.String()) }) From 68032ad890a486350b24c7ff397580cb1201a875 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Fri, 5 Jan 2024 11:12:57 -0800 Subject: [PATCH 15/39] [SDP-1031] Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage (#138) Add a disclaimer to the SMS message warning users about the risk of sharing their wallet registration OTP with a third party --- internal/serve/httphandler/receiver_send_otp_handler.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/serve/httphandler/receiver_send_otp_handler.go b/internal/serve/httphandler/receiver_send_otp_handler.go index 120136412..28723c543 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler.go +++ b/internal/serve/httphandler/receiver_send_otp_handler.go @@ -18,6 +18,10 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) +// OTPMessageDisclaimer contains disclaimer text that needs to be added as part of the OTP message to remind the +// receiver how sensitive the data is. +const OTPMessageDisclaimer = " If you did not request this code, please ignore. Do not share your code with anyone." + type ReceiverSendOTPHandler struct { Models *data.Models SMSMessengerClient message.MessengerClient @@ -125,7 +129,7 @@ func (h ReceiverSendOTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request OrganizationName: organization.Name, } - otpMessageTemplate := organization.OTPMessageTemplate + otpMessageTemplate := organization.OTPMessageTemplate + OTPMessageDisclaimer if !strings.Contains(organization.OTPMessageTemplate, "{{.OTP}}") { // Adding the OTP code to the template otpMessageTemplate = fmt.Sprintf(`{{.OTP}} %s`, strings.TrimSpace(otpMessageTemplate)) From bf66812e227ddc7b233a219c34eb78ffea568c2f Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Mon, 8 Jan 2024 14:33:19 -0300 Subject: [PATCH 16/39] cmd/httphandler: log user activity when updating user info (#139) What Log the user activity when updating users' info (updating roles, creating users through CLI). Why Security review. --- internal/serve/httphandler/user_handler.go | 30 +++- .../serve/httphandler/user_handler_test.go | 145 ++++++++++++++---- stellar-auth/pkg/cli/add_user.go | 3 +- 3 files changed, 144 insertions(+), 34 deletions(-) diff --git a/internal/serve/httphandler/user_handler.go b/internal/serve/httphandler/user_handler.go index 36bc14b79..5cb61624b 100644 --- a/internal/serve/httphandler/user_handler.go +++ b/internal/serve/httphandler/user_handler.go @@ -149,10 +149,10 @@ func (h UserHandler) UserActivation(rw http.ResponseWriter, req *http.Request) { var activationErr error if *reqBody.IsActive { - log.Ctx(ctx).Infof("[ActivateUserAccount] - Activating user with account ID %s", reqBody.UserID) + log.Ctx(ctx).Infof("[ActivateUserAccount] - User ID %s activating user with account ID %s", userID, reqBody.UserID) activationErr = h.AuthManager.ActivateUser(ctx, token, reqBody.UserID) } else { - log.Ctx(ctx).Infof("[DeactivateUserAccount] - Deactivating user with account ID %s", reqBody.UserID) + log.Ctx(ctx).Infof("[DeactivateUserAccount] - User ID %s deactivating user with account ID %s", userID, reqBody.UserID) activationErr = h.AuthManager.DeactivateUser(ctx, token, reqBody.UserID) } @@ -173,6 +173,13 @@ func (h UserHandler) UserActivation(rw http.ResponseWriter, req *http.Request) { func (h UserHandler) CreateUser(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() + token, ok := ctx.Value(middleware.TokenContextKey).(string) + if !ok { + log.Ctx(ctx).Warn("token not found when updating user activation") + httperror.Unauthorized("", nil, nil).Render(rw) + return + } + var reqBody CreateUserRequest if err := httpdecode.DecodeJSON(req, &reqBody); err != nil { err = fmt.Errorf("decoding the request body: %w", err) @@ -186,6 +193,14 @@ func (h UserHandler) CreateUser(rw http.ResponseWriter, req *http.Request) { return } + authenticatedUserID, err := h.AuthManager.GetUserID(ctx, token) + if err != nil { + err = fmt.Errorf("getting request authenticated user ID: %w", err) + log.Ctx(ctx).Error(err) + httperror.Unauthorized("", err, nil).Render(rw) + return + } + newUser := &auth.User{ FirstName: reqBody.FirstName, LastName: reqBody.LastName, @@ -241,7 +256,7 @@ func (h UserHandler) CreateUser(rw http.ResponseWriter, req *http.Request) { return } - log.Ctx(ctx).Infof("[CreateUserAccount] - Created user with account ID %s", u.ID) + log.Ctx(ctx).Infof("[CreateUserAccount] - User ID %s created user with account ID %s", authenticatedUserID, u.ID) httpjson.RenderStatus(rw, http.StatusCreated, u, httpjson.JSON) } @@ -268,6 +283,14 @@ func (h UserHandler) UpdateUserRoles(rw http.ResponseWriter, req *http.Request) return } + authenticatedUserID, err := h.AuthManager.GetUserID(ctx, token) + if err != nil { + err = fmt.Errorf("getting request authenticated user ID: %w", err) + log.Ctx(ctx).Error(err) + httperror.Unauthorized("", err, nil).Render(rw) + return + } + updateUserRolesErr := h.AuthManager.UpdateUserRoles(ctx, token, reqBody.UserID, data.FromUserRoleArrayToStringArray(reqBody.Roles)) if updateUserRolesErr != nil { if errors.Is(updateUserRolesErr, auth.ErrInvalidToken) { @@ -284,6 +307,7 @@ func (h UserHandler) UpdateUserRoles(rw http.ResponseWriter, req *http.Request) return } + log.Ctx(ctx).Infof("[UpdateUserRoles] - User ID %s updated user with account ID %s roles to %v", authenticatedUserID, reqBody.UserID, reqBody.Roles) httpjson.RenderStatus(rw, http.StatusOK, map[string]string{"message": "user roles were updated successfully"}, httpjson.JSON) } diff --git a/internal/serve/httphandler/user_handler_test.go b/internal/serve/httphandler/user_handler_test.go index 34201069d..94cf11aa5 100644 --- a/internal/serve/httphandler/user_handler_test.go +++ b/internal/serve/httphandler/user_handler_test.go @@ -292,7 +292,7 @@ func Test_UserHandler_UserActivation(t *testing.T) { Return(true, nil). Times(4). On("GetUserFromToken", mock.Anything, token). - Return(&auth.User{}, nil). + Return(&auth.User{ID: "authenticated-user-id"}, nil). Twice() defer mocks.JWTManagerMock.AssertExpectations(t) @@ -332,7 +332,7 @@ func Test_UserHandler_UserActivation(t *testing.T) { assert.JSONEq(t, `{"message": "user activation was updated successfully"}`, string(respBody)) // validate logs - require.Contains(t, getTestEntries()[0].Message, "[ActivateUserAccount] - Activating user with account ID user-id") + require.Contains(t, getTestEntries()[0].Message, "[ActivateUserAccount] - User ID authenticated-user-id activating user with account ID user-id") }) } @@ -394,13 +394,12 @@ func Test_UserHandler_CreateUser(t *testing.T) { r := chi.NewRouter() - authenticatorMock := &auth.AuthenticatorMock{} - authManager := auth.NewAuthManager(auth.WithCustomAuthenticatorOption(authenticatorMock)) + authManagerMock := &auth.AuthManagerMock{} messengerClientMock := &message.MessengerClientMock{} uiBaseURL := "https://sdp.com" handler := &UserHandler{ - AuthManager: authManager, + AuthManager: authManagerMock, MessengerClient: messengerClientMock, UIBaseURL: uiBaseURL, Models: models, @@ -410,9 +409,26 @@ func Test_UserHandler_CreateUser(t *testing.T) { r.Post(url, handler.CreateUser) - t.Run("returns error when request body is invalid", func(t *testing.T) { + t.Run("returns Unauthorized when token is invalid", func(t *testing.T) { req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(`{}`)) require.NoError(t, err) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + resp := w.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.JSONEq(t, `{"error":"Not authorized."}`, string(respBody)) + }) + + t.Run("returns error when request body is invalid", func(t *testing.T) { + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(`{}`)) + require.NoError(t, err) w := httptest.NewRecorder() @@ -446,7 +462,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["role1", "role2"] } ` - req, err = http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w = httptest.NewRecorder() @@ -478,7 +494,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["role1"] } ` - req, err = http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w = httptest.NewRecorder() @@ -505,7 +521,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { buf := new(strings.Builder) log.DefaultLogger.SetOutput(buf) - req, err = http.NewRequest(http.MethodPost, url, strings.NewReader(`"invalid"`)) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(`"invalid"`)) require.NoError(t, err) w = httptest.NewRecorder() @@ -528,16 +544,12 @@ func Test_UserHandler_CreateUser(t *testing.T) { }) t.Run("returns error when Auth Manager fails", func(t *testing.T) { - u := &auth.User{ - FirstName: "First", - LastName: "Last", - Email: "email@email.com", - Roles: []string{data.DeveloperUserRole.String()}, - } + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) - authenticatorMock. - On("CreateUser", mock.Anything, u, ""). - Return(nil, errors.New("unexpected error")). + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("", errors.New("unexpected error")). Once() body := ` @@ -548,7 +560,8 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["developer"] } ` - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w := httptest.NewRecorder() @@ -561,6 +574,42 @@ func Test_UserHandler_CreateUser(t *testing.T) { require.NoError(t, err) wantsBody := ` + { + "error": "Not authorized." + } + ` + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + + u := &auth.User{ + FirstName: "First", + LastName: "Last", + Email: "email@email.com", + Roles: []string{data.DeveloperUserRole.String()}, + } + + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("authenticated-user-id", nil). + Once(). + On("CreateUser", mock.Anything, u, ""). + Return(nil, errors.New("unexpected error")). + Once() + + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + require.NoError(t, err) + + w = httptest.NewRecorder() + + r.ServeHTTP(w, req) + + resp = w.Result() + + respBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody = ` { "error": "Cannot create user" } @@ -571,6 +620,9 @@ func Test_UserHandler_CreateUser(t *testing.T) { }) t.Run("returns Bad Request when user is duplicated", func(t *testing.T) { + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + u := &auth.User{ FirstName: "First", LastName: "Last", @@ -578,7 +630,10 @@ func Test_UserHandler_CreateUser(t *testing.T) { Roles: []string{data.DeveloperUserRole.String()}, } - authenticatorMock. + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("authenticated-user-id", nil). + Once(). On("CreateUser", mock.Anything, u, ""). Return(nil, auth.ErrUserEmailAlreadyExists). Once() @@ -591,7 +646,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["developer"] } ` - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w := httptest.NewRecorder() @@ -608,6 +663,9 @@ func Test_UserHandler_CreateUser(t *testing.T) { }) t.Run("returns error when sending email fails", func(t *testing.T) { + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + u := &auth.User{ FirstName: "First", LastName: "Last", @@ -623,7 +681,10 @@ func Test_UserHandler_CreateUser(t *testing.T) { Roles: u.Roles, } - authenticatorMock. + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("authenticated-user-id", nil). + Once(). On("CreateUser", mock.Anything, u, ""). Return(expectedUser, nil). Once() @@ -657,7 +718,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["developer"] } ` - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w := httptest.NewRecorder() @@ -680,6 +741,9 @@ func Test_UserHandler_CreateUser(t *testing.T) { }) t.Run("returns error when joining the forgot password link", func(t *testing.T) { + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + u := &auth.User{ FirstName: "First", LastName: "Last", @@ -695,7 +759,10 @@ func Test_UserHandler_CreateUser(t *testing.T) { Roles: u.Roles, } - authenticatorMock. + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("authenticated-user-id", nil). + Once(). On("CreateUser", mock.Anything, u, ""). Return(expectedUser, nil). Once() @@ -708,13 +775,13 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["developer"] } ` - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w := httptest.NewRecorder() http.HandlerFunc(UserHandler{ - AuthManager: authManager, + AuthManager: authManagerMock, MessengerClient: messengerClientMock, UIBaseURL: "%invalid%", Models: models, @@ -736,6 +803,9 @@ func Test_UserHandler_CreateUser(t *testing.T) { }) t.Run("creates user successfully", func(t *testing.T) { + token := "mytoken" + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + buf := new(strings.Builder) log.DefaultLogger.SetOutput(buf) log.SetLevel(log.InfoLevel) @@ -756,7 +826,10 @@ func Test_UserHandler_CreateUser(t *testing.T) { IsActive: true, } - authenticatorMock. + authManagerMock. + On("GetUserID", mock.Anything, token). + Return("authenticated-user-id", nil). + Once(). On("CreateUser", mock.Anything, u, ""). Return(expectedUser, nil). Once() @@ -790,7 +863,7 @@ func Test_UserHandler_CreateUser(t *testing.T) { "roles": ["developer"] } ` - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) require.NoError(t, err) w := httptest.NewRecorder() @@ -817,10 +890,10 @@ func Test_UserHandler_CreateUser(t *testing.T) { assert.JSONEq(t, wantsBody, string(respBody)) // validate logs - require.Contains(t, buf.String(), "[CreateUserAccount] - Created user with account ID user-id") + require.Contains(t, buf.String(), "[CreateUserAccount] - User ID authenticated-user-id created user with account ID user-id") }) - authenticatorMock.AssertExpectations(t) + authManagerMock.AssertExpectations(t) messengerClientMock.AssertExpectations(t) } @@ -1043,6 +1116,9 @@ func Test_UserHandler_UpdateUserRoles(t *testing.T) { jwtManagerMock. On("ValidateToken", mock.Anything, token). Return(true, nil). + Twice(). + On("GetUserFromToken", mock.Anything, token). + Return(&auth.User{ID: "authenticated-user-id"}, nil). Once() roleManagerMock. @@ -1078,6 +1154,12 @@ func Test_UserHandler_UpdateUserRoles(t *testing.T) { token := "mytoken" jwtManagerMock. + On("ValidateToken", mock.Anything, token). + Return(true, nil). + Once(). + On("GetUserFromToken", mock.Anything, token). + Return(&auth.User{ID: "authenticated-user-id"}, nil). + Once(). On("ValidateToken", mock.Anything, token). Return(false, errors.New("unexpected error")). Once() @@ -1116,6 +1198,9 @@ func Test_UserHandler_UpdateUserRoles(t *testing.T) { jwtManagerMock. On("ValidateToken", mock.Anything, token). Return(true, nil). + Twice(). + On("GetUserFromToken", mock.Anything, token). + Return(&auth.User{ID: "authenticated-user-id"}, nil). Once() roleManagerMock. diff --git a/stellar-auth/pkg/cli/add_user.go b/stellar-auth/pkg/cli/add_user.go index 24716f56a..761caa35d 100644 --- a/stellar-auth/pkg/cli/add_user.go +++ b/stellar-auth/pkg/cli/add_user.go @@ -158,11 +158,12 @@ func execAddUser(ctx context.Context, dbUrl string, email, firstName, lastName, Roles: roles, } - _, err = authManager.CreateUser(ctx, newUser, password) + u, err := authManager.CreateUser(ctx, newUser, password) if err != nil { return fmt.Errorf("error creating user: %w", err) } + log.Ctx(ctx).Infof("[CLI - CreateUserAccount] - Created user with account ID %s through CLI with roles %v", u.ID, roles) return nil } From 97b86e0de0d7c8ebcc927ecd0c2f8065fc6a3ef7 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Wed, 10 Jan 2024 13:13:13 -0800 Subject: [PATCH 17/39] [SDP-1033] Coinspect SDP-006 Weak password policy (#143) - All password handlers already use the same pw validation utility function. Increased min pw length to 12 as we agreed. - Added as part of the same pw validation utility function the ability to determine whether the pw is too common resulting in a validation error. common_passwords.txt.gz contains a list of strings that meet our set of requirements out of this master list. --- internal/serve/httphandler/profile_handler.go | 3 +- .../serve/httphandler/profile_handler_test.go | 13 +++- .../httphandler/reset_password_handler.go | 5 +- .../reset_password_handler_test.go | 10 +-- internal/serve/serve.go | 10 ++- stellar-auth/pkg/cli/add_user.go | 6 +- .../pkg/utils/common_passwords.txt.gz | Bin 0 -> 18573 bytes stellar-auth/pkg/utils/password_validation.go | 68 ++++++++++++++++-- .../pkg/utils/password_validation_test.go | 13 +++- 9 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 stellar-auth/pkg/utils/common_passwords.txt.gz diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index 827de445d..868327e4e 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -42,6 +42,7 @@ type ProfileHandler struct { BaseURL string PublicFilesFS fs.FS DistributionPublicKey string + PasswordValidator *authUtils.PasswordValidator } type PatchOrganizationProfileRequest struct { @@ -248,7 +249,7 @@ func (h ProfileHandler) PatchUserPassword(rw http.ResponseWriter, req *http.Requ // validate if the password format attends the requirements badRequestExtras := map[string]interface{}{} - err := authUtils.ValidatePassword(reqBody.NewPassword) + err := h.PasswordValidator.ValidatePassword(reqBody.NewPassword) if err != nil { var validatePasswordError *authUtils.ValidatePasswordError if errors.As(err, &validatePasswordError) { diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index 7d74d4313..c234dd8cc 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -19,14 +19,16 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/publicfiles" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) func createOrganizationProfileMultipartRequest(t *testing.T, url, fieldName, filename, body string, fileContent io.Reader) (*http.Request, error) { @@ -931,7 +933,12 @@ func Test_ProfileHandler_PatchUserPassword(t *testing.T) { auth.WithCustomAuthenticatorOption(authenticatorMock), auth.WithCustomJWTManagerOption(jwtManagerMock), ) - handler := &ProfileHandler{AuthManager: authManager} + pwValidator, _ := utils.GetPasswordValidatorInstance() + + handler := &ProfileHandler{ + AuthManager: authManager, + PasswordValidator: pwValidator, + } url := "/profile/reset-password" ctx := context.Background() diff --git a/internal/serve/httphandler/reset_password_handler.go b/internal/serve/httphandler/reset_password_handler.go index 658bd2c89..2d5e73e9c 100644 --- a/internal/serve/httphandler/reset_password_handler.go +++ b/internal/serve/httphandler/reset_password_handler.go @@ -17,7 +17,8 @@ import ( // ResetPasswordHandler resets the user password by receiving a valid reset token // and the new password. type ResetPasswordHandler struct { - AuthManager auth.AuthManager + AuthManager auth.AuthManager + PasswordValidator *authUtils.PasswordValidator } type ResetPasswordRequest struct { @@ -38,7 +39,7 @@ func (h ResetPasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // validate password badRequestExtras := map[string]interface{}{} - err = authUtils.ValidatePassword(resetPasswordRequest.Password) + err = h.PasswordValidator.ValidatePassword(resetPasswordRequest.Password) if err != nil { var validatePasswordError *authUtils.ValidatePasswordError if errors.As(err, &validatePasswordError) { diff --git a/internal/serve/httphandler/reset_password_handler_test.go b/internal/serve/httphandler/reset_password_handler_test.go index 985c6cc73..0c8725661 100644 --- a/internal/serve/httphandler/reset_password_handler_test.go +++ b/internal/serve/httphandler/reset_password_handler_test.go @@ -8,10 +8,11 @@ import ( "testing" "github.com/stellar/go/support/log" - - "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) func Test_ResetPasswordHandlerPost(t *testing.T) { @@ -22,9 +23,10 @@ func Test_ResetPasswordHandlerPost(t *testing.T) { authManager := auth.NewAuthManager( auth.WithCustomAuthenticatorOption(authenticatorMock), ) - + pwValidator, _ := utils.GetPasswordValidatorInstance() handler := &ResetPasswordHandler{ - AuthManager: authManager, + AuthManager: authManager, + PasswordValidator: pwValidator, } t.Run("Should return http status 200 on a valid request", func(t *testing.T) { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 0d0345b1b..0862c1f65 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -31,6 +31,7 @@ import ( txnsubmitterutils "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" + authUtils "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) const ServiceID = "serve" @@ -82,6 +83,7 @@ type ServeOptions struct { ReCAPTCHASiteSecretKey string EnableMFA bool EnableReCAPTCHA bool + PasswordValidator *authUtils.PasswordValidator } // SetupDependencies uses the serve options to setup the dependencies for the server. @@ -140,6 +142,11 @@ func (opts *ServeOptions) SetupDependencies() error { return fmt.Errorf("error creating signature service: %w", err) } + opts.PasswordValidator, err = authUtils.GetPasswordValidatorInstance() + if err != nil { + return fmt.Errorf("error initializing password validator: %w", err) + } + return nil } @@ -315,6 +322,7 @@ func handleHTTP(o ServeOptions) *chi.Mux { MaxMemoryAllocation: httphandler.DefaultMaxMemoryAllocation, BaseURL: o.BaseURL, DistributionPublicKey: o.DistributionPublicKey, + PasswordValidator: o.PasswordValidator, } r.Route("/profile", func(r chi.Router) { r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)). @@ -369,7 +377,7 @@ func handleHTTP(o ServeOptions) *chi.Mux { ReCAPTCHAValidator: reCAPTCHAValidator, ReCAPTCHAEnabled: o.EnableReCAPTCHA, }.ServeHTTP) - mux.Post("/reset-password", httphandler.ResetPasswordHandler{AuthManager: authManager}.ServeHTTP) + mux.Post("/reset-password", httphandler.ResetPasswordHandler{AuthManager: authManager, PasswordValidator: o.PasswordValidator}.ServeHTTP) // START SEP-24 endpoints mux.Get("/.well-known/stellar.toml", httphandler.StellarTomlHandler{ diff --git a/stellar-auth/pkg/cli/add_user.go b/stellar-auth/pkg/cli/add_user.go index 761caa35d..bb3300ce6 100644 --- a/stellar-auth/pkg/cli/add_user.go +++ b/stellar-auth/pkg/cli/add_user.go @@ -105,7 +105,11 @@ func AddUserCmd(databaseURLFlagName string, passwordPrompt PasswordPromptInterfa log.Fatalf("add-user error prompting password: %s", err) } - err = utils.ValidatePassword(result) + pwValidator, err := utils.GetPasswordValidatorInstance() + if err != nil { + log.Fatalf("cannot initialize password validator: %s", err) + } + err = pwValidator.ValidatePassword(result) if err != nil { log.Fatalf("password is not valid: %v", err) } diff --git a/stellar-auth/pkg/utils/common_passwords.txt.gz b/stellar-auth/pkg/utils/common_passwords.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..2c2a932c014a4d30d460eb52356fc533daa0292d GIT binary patch literal 18573 zcmV(tK17mM(ZEtR0aA9+EcW-iJb1rmvbO3Dq*^;X|&@Bqz^A-+! zuGmp0An2Qm70VBDV)F|SeP@9HadDpgA1Pe+{PsC>R+W`05Nfg+DI|HeJv(yj`N93k z7@NFvT+cTJ9}+J#j$>w?m%S`jjLfKPyO`PIvD>0B+IkwFD|2nfF~)v+rM5%=jXEvG z#FxD8Bco{K*z|15Wc*{Wk+0L1dT!u5h8^0mlf-WQEN;famYSuGoua5=T3)hQr>El? zCzng)go)X+qABoe%=SF6s>%1_$C$=mO(XZsK^^-f`oid@eo9m4{b5v-D5jqCI4}p6 zyHnpc2A8jR-fA(<%TlPJIbX;BWpKe2Uwju&5{iwElOzUbRnHyAvCU;|hFKY`zjupw z>z39&4r3K;;~f{&{L~Pv87nSKKk*VjF=nScn3>#Y@D4}oCpCAccqo49Aj7{|2 zv@A_#v=D`{p3t|+(`fsbWb10SuIomj(XzbrytLm%(i=4&E0PB{MZK$r{qJQuOgF)J zvIsi%gk8VGaN7EB>{*86I&R?l zwrKJ?HBQ+>8{x@T zn+-LxmhF(VhYXc(s6_q2@Ope!hkQW_CfkFoTd(?bL0O*Hi#3W4r0rGo7SEbjz>QY--~|JK@fiUPMkoz=9Cv@Et}eNjmN>6n1&^m^RBXWF%AZ~PkWX@ z_HAR|JvC%;`6z2)>=P?chPkm}Zk9SS_NLFLC~-_ODnS^<#$Gp7)r^^AE|HgrdIgIK zh#DK0W*Sptzq#DY?(=dMNqMl%*zF->YawLyFt%5fEp-uO!>~VkIU8G>iy}KxA40h` z2Ok$Jp9aC%%mpNEn1r3g)r7p2^-#GK!$MciX{i52#+L6+BFD*_ZYVBc`8w{eNj;RY zh4+12vcBMpdyCG>kcTYz9~sFP7YgJwu({?NXT>A~#~dbEuqGFV9MmbFY*vw4)yt4i z-YZvy@{ZelV^!UtYDiS-8tcsGMdnlAFc=Hy-3kNT^Wy)z5o^sW=#yEAfNgO`bisKG zYr+pV3_&BcFj>iEoxI3)Bbc-wW93|A6MDR4+R8Y_%JxyYPS!LggZwWGI4>EfOa=U| zG6r&NSjokRYVswGBXBz2u~SB7$6yZ{qML5gd^Cocl!$%TH5R$582VSJn%@}6rQu;< zaw%Xt1TkP_VZ57>-(c95m6r|Nh2ZLQys=is(p1ek z$gwaX$xAI@=kT~%q2D2k;IP8pe;oS<59bFBN)#>Q*zsjVYsp|Q|K9m}vwde^-8%6T z*NH}1ZbJHREJZQr_4+IQaCBsxNA`?tN~4oslEVp2rh|*wSoIATo^vzAR8bWy2d30B zU{yM}(T-CNEQ|xDS@0}txFO2XYDB5~ORy1j%6iFDJ2GHPyU4Os)Nspq?ZX(|pVc%K z2K?)W(#L9AS9E7H_)9YyKd*FI;yAQzKZW^!l_wA8qu7}C9qzsyEy zan2`P$&3zLeA5@f8mdTwlW~}1Cyg9qMw0~hBggsAm}`lFnFZr8r?7~tXy7>3$)Nii zNW!@Oi#uR&7MOxKz7ikbM4k+u%U|{)4fEIza*q|UF)cY0X*(^PTxA3r@@P3;5GB?Z zCGbNF`qVERa0MLzbI(BX%Ieq*jcLX-35+SP*xI>f-^1)u${6L4aq(-wlH2WOjMGv# ztLJ*|&G=9M8}VOXVvM{gxE(r9zk8X5VTl0<&~OKKV#PHq z0o^sOpM>VvRQ0+;1p6MkUyzB8jGn_-Cr}Z^3>pe$>c24AApgK)1~93c+$gtDMMFdG z8di6H1`2R+f_ztv=*=q$8$#GHdqUDggdVg#qw8fhHXXYGx*1&qb;WSAGz>rhA_26p zZjw!um?MNa6dPUdw31LAs=HUJ402D=L53>an`9ob{iBr)t3h;>yd zL|#m&3#5BxmFGIo8xe2!1ULjyo@)<=uNjMlDI;y$wkV3;O;X@)S=D?o*&-%bLkA4e z+p&w$q(p!^0h&(*)T`jI_mEvMMkk!5naxKW+eTY1%=bOtiGDQnLT8z*5m4El8_hTr z^qaz!*p;Wxb$OT3fkFEH5 z9eLiBMydLmEOQ*kMk(`7O%kGcGfF{VzfftMz*?03gdalqKStStvjtT==Utw7&6Ex=kWWAfMASkmIpCk7NQX78>y13G-3ALB3RGa5#40x{bT+u3Ur9 zYy}WNo7b~b1|ORO+FX$q_hoGGp64q(-xElLHzU-f8J`;F@x1aA7->`g1%&`<9aJs{ zh;&Or1uy0Rd}!B*noT!!!J**wW1gHZ-n<z*ht=c1-GHuu@37P<*^OCkX9oO;>+_ESo-w9sw+?P6vJz z4-bPNI?RcQI*y|PhGc~gbJm-2jCcS7#?ow>It*#*&=f+E!k3xV)EATP9L6jwVP(F) zj&3R8XIPZybJq{?o*FFRr>ZbOzPj_?NZMPJuc_D9a_Wcs%Se1U&>)U~dWKxrv4bc? zFQ?ODAP&ed*y%}%&U%nW^e+an?e5u>xUuvbBf51?l2`q)q=Ab(BZ9NCm)m{vk9e$5 z`6!B1nU7YU9ijwbgedHVC~9Gm^pp{(mTJa(1jle(f$%-sOak9E;o6PhK1DbCRc?FH z_+e2Ruu`^fC+0w&7EUp6a=X3dVjIB--JOH#sxEAzJ-M%4R7h@roZPm=ikufb!~WkH3qk&8L~Pvyd<vmzr zj|=gRlWuNf-+)PQ(Ix4~(19AG=T=i{6Yjw4c>^?ArG}H|&Uv21#}BNMlazwDatT9N zpQ@P}78>O4Ja<@3kM2&QFh$^3v!*v3#Fi~3^&l~^q;ouxMswXKPG`RzLR8laYtcB9Ao!_4K*KTMycOLnc zq1KM$IvyU;O#=}KFiP|f;1tDChJCAC-;27qxR2`z23kn-MTB%*5F6?ldKLiIc?!Qi zB=7d)0b;HjL?tN%05%(X0ZzqHpGUC?5Yviij$kSJ_dGL+Fr@?H4(tFVYst<*HejXC zr+Ti7Ym-Ge)nZLqd+rlV%Bi67B>-JuBmI(xI%Um6{y-g7*Nj#t&w-)89ndYLJ&95$ zOzGm%bu3tU!FaHQ$8wW5*5YpJm~lWOfBs58DV>J4?}t?7Bew!rX*Yp4I1dZq zv#ZVexy#O1X;iJeVxZQJTlvuOqgju2lkM0RokdYpfNJertYsdI{us#++E0uGu(9+3 zF3?MxVinXXzh*1XtrU5-QJUhVbK_?*Cbe#`-v9@c_)s3N6*WLyvrCc2nZ03lq*b9rJg*KA-WI1 z{GUco<5H!i$QZDYMFT6xYHS9NVj?0qRoC?>&Z6l(h4Bt)4x5OwQOp_{qVbKv?_8`4 z%X=5WBF~I|)2SQSN%~9CNTiWZ6j!>3NO)vd3lu>1m8e=Y1wgr(*k1yUp3*FpXQn5$ zE{dQnBIk>OTxD#WP%Y|q;*d^TE+R$OI>E_EcRCZM+c?)F_SRs91_D~=0)4QkB<^L} zytX3h+y;!b$k@*|hOZJ`jWC;c{1T-F1y;?x2+}lho}t(Av}*F)UT~%0bvd>*eBdc^ z$=x!4%m-@pdWy%q$5~yiApH0-CjEXakmZ-_Qz#@>WtFJOtV(Ok>Q?1rS!Y$-wLsSZ znb1kpd|u);4(2wU{d$CG&JVUpHdJ*|z7XAk5xfR2f0Bhk1SX6YT;Q}pi9^(ATBpq_ znqlf-)*R|$;2=HGOp~{h5L4nfzU@AVXz~u-hhf2Er)hvEA+$zm&#nFwh^_^1{wE1) zL?@@iu+dE;4-f=Tv&LvdGe~kupCAT>M?j1)^dXU1a~y~1zSI1gy*#I0EhH&6@q0;W z|GDowQMxskCD8;7Ug%wEjXL8u!AQ9fpD+tbG-V_sm4=uW9PK$;R@dfznIi;3>&7Z| zd@@5MsKcXkJ=WCqkN;|U4hB35z1@I8Km=}q&V9wt05_6Ri$g@KVlvKNRTDyrEp1Rr zzfMO{v}u-YTklHrdOtPk)&MqYC!4N#+i*iX;F_?~On-!de&%mySM8dRIfU zbK`&y{UQsj;#ui{km@obkbxgeV<*w&vNcU?EAhKiQw@;*=OT;j%f(Ec_^Jm4&8LYe zqz5g+acEJchUSVvJo+V?c@CLf5{jl{WA}VJ1jI1QBzw1g(i5wbRZy#n0d_!K8VzN=Qsm2(;`o!`Ut z6GK2%GTvEwyfVf&ZT4$PF9my=^hA1cyVSWCGZR%=5d$IwfT>4uep8x>=;mF8rqBXuCzM9RFE>N-S68wh8Kel6vudld7#`YLkwt#8 z=4;oS6(iJP7}-H9%Xyj;^!-geAdYq3JrB!O}O9 zssU-0;8gweq9iEYBQ&|Cv~~nnHAgt%hUtNz_X-wS5-GHDCBMABdF%c~!a6XTi*v=!OWCru#xP55XArQRfUxvo0mCu**(3mZtX0oAV-_OOj<9MOLwJ2`8y z4>U0W#zXn8D+@&e zMSOc!m116+2chd`uwhkUCSov!=*(?F5V=SlPDVQ{-e_*=S)DxLCu{Ph}32_JBS=RlH{qcm=(B0!!fe7 zVyAr(6#eN5b4~G;X4a;~_XpFzSTJu0a82`~=u%*Ni$+}E?!W_8BN5R3lC^FCbI*se z(Xv1SgHU?ZrZ4^ymVW!FDH41#mKKP*6$DNl9cA&}@@R?1xfLL2vJbHW2@P-7lGTEb zCOJ`FHQm_o)ubGH<5xT#l1-!$Cmi<1?La^z5NcOs`6s$AudQX{E9?#RrC8IofNP;Zv}p6H&Q!LQ6iW~S9zQQnY!lESEjUFo&;?}Y zYt?Sb3Q+BP;f)*L?fN#!l&k=d>`_`(>&7;ok$1d7k)kVepulr!d3yaNd&!hPjWGNOCIP0{vE;>8rW*UO zg9$_o2vnnA04)ED|nz`HAkya;MHA{Vkj z*XyMfSWA4V^pj&DDyEk$I#&E#&{f)%T?D;I)7`1B2O_5E-1*R10SnMKojR5Ey)3rL zq+Mxpz*Z7`YlE1P!bK2x5@7Asgc9Rpk`e)<4Cq*{pQQ6L3E{t-8SM2*MaH@Swn|!C zkiK&;GWd|89>&+c98nl0SydyPyuCU4^{`1qD}#AMT_VJ|ryveT{HO}<%R>_BJaK(q za1w)bW<@`x-8o)jG5O=fQy~kO5jIM~x4y2#IAwqc1>!{_Dt;2w))_P*?RplC-Js}I zl&J+{Pz({86;OWzbE5!2Wl9tJSY^+p#tWB^Vgp)9v(e{Bm;<`p?6iPwhMM7eR z0iDrUk^APr2R3Mr##~T|E^Shg()%xD*O$s5RCJvWZIMZ2G!5T$0BnhCN91N{ZCv(7 z`3$<4B8c<#SQ62rBV0FrQ;Dm=Af;n3PMuIkGelZ2Wv&IG?kNaCB>29Az@Md)zl_M# za@^2$Q)eX}Aw8y-fv|iXx@G5lb)uBUxNa_Y?M3<;)bu|m3S6y1gYhE;-RsQOBC zxkOGv!+$v6Rj~`svWgX#j#8^DAhOto>-X$vH0~!WbAWg#oY6VCS@7>r-aUu6D;RQBt&31K2>1(7B9#hJ}uTb>D{X7p<-@GaV1S` z&*<%^>qcUV?Lpj;?h!WrO@Ik7YVBxiku<$DZSk73mfQRiYB)+u1fR zTb4Fm9acTZAlb=bGhi2gskX2;EHEMpc>^~vV0!N|ld}vFg~TBDDa%urdmEt923kh) z&Rtm5j19&eA*Qs)+nFE^cY^}?ZBxJ8H0h?v_6=o`=Y;$?x@h)CE-HhJhsp!#jYXFK zB>ST>lu{`W9Id=0+^n#h0Gi)C>}Vlg~4l>rFj~tnZJ= zaf=CsZK{%yDp(*Fc?XGDYEXZmnK1ybdTz3pom;q@q`U|Xe8IfT9K~#}**<_=io?s$ zjIC|l<*eNG&uKgr!Xh~NN^R`UkeA&;R_8hQ%WBO~qYVz}$$LCCRLeyctmdeKMD;g6 zj*dM@VpQlUchvEwR-{}*J>3 z0Bo}uKSY)A0nv}4Zc<RBmTHbZX(E+#2JxaGJNLsw``hV)RR=v?=?VUa?o>ye&Byf8{Xv;+HgP23C{X@V-bCrojl+0 z(SD1IW^;IUbzPhMmYNp8Me(OqnR-u`#h?0ST<~{s-wM)Vw4yh~+~l`QS+k`sMV^|VwPOSUu2Di4r?iNSi;INR zTY?tNU!)tjHiEJsa?@%Z4VB8Y2eQ&YCQ9E0+95Doayw>@<<@>DhCzclT#=cwPb+Gyp<#?_Lg$_3fDMGY{?z$T~}Ec7DPQ5~KbRDcd$s)!XYwX}gZ{%k!M zuriBm*@8Nw4hvh@T?I0Ncowv{ANm*}`v#YcTpPm@#O@6OBh*6yLrWjfemY7zE=xV1tOY#4m@AYhbxRX77;AzDIVs8MY}8&yC3-Vy=4fFbJTQyxU#Fcz%@-X}jlLU>U_z?nflAnoIh zaB$>4ilw($u5Y;n8?3RMi?rV@ z5EQQWe`I;K#pM4?`5om!3;7Mur~te}b%(LZ*;X~@*wB=)>7W7we*sqcGJ|o$73_Gb zWLQb;;eqvAQnc6h?C`RU19Mpjt5QpkogfizChrYTs^9kdk@wTn1yUvx-z|j$cLt)9 zKa0PXa-|&DXWOUs?}%Mh&uiI2?(b}8=&Szyx(n{nQoR?q>wWOU<2-LbqPK4Kd*v+q zxFNS=$=R+&f#^>RqdVk_?ElTlAS(WY;_J|CA@5wAod?` zX<6;=FdUUUn9mN@(yl8aTi8vXhqSeaNQ8JQ;15Z`lp?!$C{m7A{vp!kD}NyHUMT>m z_yeq!#VX=N6>>)$^^gT(sk^E&FAbw}h)Wg-81(WeyFry_nl_^C$~1KxG_yox&{=t= zT7+}x$a1viOt_w-)1``{sI$g%E>BH&w1-#OFvg0=$>kfe;wh1C~j3XQ(qGX!+*Dnk7n!;vP z=!(#H$;4@fDC<$#zVCnNKt~F10st$x1o)RhQIt+!%j-UAJN`}o^|Q1kfABx-4opVg=tX469uROuofMlF@`N7( z+JmLO6S)E!0WY$j7*C~Dow&DHA97oA8BrSxuIJr$K31t4lYkWWTb5UQIHygQ z^b;5ia0VB~OlxRCz){`#%tpc>1w$-hb)wO;#Hxtl?6#k#+m3dDS-0|Qbzq>U;0=|} zmeVvro)sBoLbVb*zJ@cyFof`YclbCMDvB#;o=wlWp$kqgx{)6+0~v+woyVdM*?+8_lK3$fh#ojH@R`Z(VL6|G;iO_)~92 zMYZ9e&vfAiflJ!H3-xsiPaM$(2M_r5Vd$??wONFB_EbbJHS6 z4xFc>ls5;xf;cv`Ka55+a!Fm!{?uUMwBtOjR5E@q(_81$Vc*qyKXGN*iG!XxJ}nXetR>$Nz9JfN9>Wh^#*aSAT-Cn5ycRrky+; z!jbrwk=LSGFsSCY7cg=>C@ zDBkwhW?S8Gh8Sqfnd4oV`i-W2r5!!LkX(h>5iN&#D@;0!c4hl=a8g)ogP`NUpR$&# z4&o8-KtP26*)*&HL+)|2y1OEK@eM%2iSoq zht~E0Oy60>4qni}o$Mpv+8_fG`$7&(&SIh^c3P?#lPc$f5)%+4q*@E&z(g%}Mhy{lNx<1KlWZdk&CBK%_yAln6T z9m1m+xME6H@9^TjL}$l90xu<4i-MtUG3@FHZ+`;+K8zdamhTd_5$J$&@B9w%|KO1V zh`pyVa~E%vbJZ3HWGRwxI4Y2WKn5^M#4|Z_#JaToLq%|A6~^W+6NHO5&@Y5}q4W?= zAvczB(j@j|n+C$;0=QwIXoDX_x^hFzP)pkAqCz>dm!65AK|TP0234UAc(?=^D1_N; zl!8CW`37jhhzAMfGKfk5h;|}UNZ5)x1u-J5(OtU&edF(Dm{sN$tY%#SpZL=)fj8yH zHg`otvyXnG#^n}oXdMEL3`Mx?4gg3O=T11+x4@1OWclpGI4|;vfaE0d70n_X>|)$Y zvUHxJ)i)deqgDnm<1Jq9GJEtN&|gPo$Qj+InwPb9vt4)N_(-c{DUJr|k43ppRrjKH9&T*e zE=5hK#6bXxUe{Aa2q>rNRqHnq60xhJhdz&yqWkyN3cJ4AUYz=O7Z%HprRW{%QEQJ* zCv-#hnkdg+UUnR3hkouh^iI{)kd9UkYt+2whSkJSl_g32H)F6Nx>BiNfqk*i-o)l4aaf$$z*$VZI<=L$J zSvb1TxY~%pk4h1(I7)w;3Zbd0YG&UG=ZVB#@NR3M z4>Hkt37ev0&hlMTLFsi%eF!~B!a2F6=ByXg*{@R-{-lnnq`a5aC9j~Kf`aNYuOhL= zxf}G5&Gq40-uz+(T{&0WOT20cd`*?!9GzCz)>Ic5^R;T$dC-bHS;P6NF;;?FJ2jab z(}aFRy)@;?Y>5}_QLe9yyO%oKZJ{9>TfdnUFIO!FW2jziwogaba}4s+Th7Jmi5kxR zSj18$m8K9Sv{uV@he{b*E3VZRh@HlE$VIj`%gf8?%6_B0bX#RH8ryzaWQwBdtN4et)-w{ z);WP`${Dq`QC3i&LM1I-+3aP|K|X4mj8QXo@U1}>YIA2b%s$sD^AzRP%PIPKbfl!b zdZ;sB_@}GKz3w`Hn&pw}=hf{#SydiYF+Hzt&VCN#Bh)fc-^w*l0)I;vuO$OhbDq`K zu^a&jUhTDEwO9bWG-y?CO;fykWB$?aHpKmm_nhh%M6jvI=>ehqQ}{N-4cPOU5>+NYuER?IWK{uA9%N&8{YCbuNhv zA1u3=`nqwbQ9%3cupcdx)QM1xB4ZI~s#!62%>oU9S0SpZO562UsZ~C2S20-7Pn+yW zwq~W;U@sZ9JrkX^xO>*=dS>2aO^y(INJ5f1?Oev&bXhod28p|l-dt0e>{#>JTDR^+ zZD5PfJ?Raleja-be6xo-u&AC4kvq3eH;brdteX)h!R{~mJ}%0#t2U?A_a%NeS$-96 zq^wpVv+^9~>7{L9GEd{Qyuy~oD6zWNYkLKXQyY@19~V2H=9o%J;u=w}<@X)cI#x5n zwIk0hvhML%TgyvD=SHQ(gH;A&S8p}dEH12Sn5}r#{yD~o&lrZNperMp)n3kIt?LQ% zg<6fH80iU-)rr;bs(GinM8T_DmX00TN{7}_()JWs5TdrS5U~}y+VqAmX#xJQ#iQ1S z!*u3FnT-}nUD&zH+RTO2q8%n3;;s##O;f&_qAIUWi+V8Va7`0T-iz87RQ?NYL*^CL zI>+0Z6IH-Jw?x2EZ537A7b1^5 zPmbDQ`mFCVWy!vz;q|Oj$*k)K-6(Hg_ZkCixl{pf?FVMbwbC}gYdbk+x!3DX^s?QR zmbVoP3A)Fu5}`>WJQ|80&#{5Ye#m>dXD-XRi}^%%xd&iE;uM9hiRET$S9O_a@8uMS zD6P4#4(d?*v=FBIyF5|L{WJ&GnW_uSf-uD{k+b01n|Nl+f-)sEj+gE=49e4k)vG6f4-*ogB z-}}_f55H~q{qMN@ZuF_AZztd9+TS*M@wZGb`pU2ue#`fQZ(n&qn0(Xjr*A#q{qzIP zW_;4lxBi~<*K_UI{-(cU?Iz!Q-Q-)o`Z5DN zU;baRr+0gN)8Athlkan#!n7X z6Oz148O=&P-=A&}p{OaL9hK`8PVkM>EsWxBEc$fI?hLh@{PV_C7_5N4;swh*TB8N1 z<&-7-)0q7)|4g%4iE00n{f9YK>%D`huCV4;v~Ew{Tkdpk9j5F20EU3lJ&{{Lq}V7Y)`yWW;0sa zzV`D+thw9HAlx_&Jr&#$^V~*84A(Zd2KL@K8lbYSxBHcm=WNy(Cfavh{CMR+^LIxx zjaA!>N~W=c!9hyzREnN899}~QuIe=mKSa<{emeqU9^mHdv98+qr|<{3$|{vvQDwP) zQE*hEmb`iFU4S93cvJD4-lPPuEXx0l(V&QZ*F2%u0mK*w|Dymx<%qi?^ee}Imbm80Yu@r052U%gH6F8B z;a)zjv%fWG^-~kk@GFL~i^L!@^I#~DwhbR_Yq&^T z*(*mA1(yrG8bI3hp7b(FNFwwb#S1eqL=4Qz>q%5o&z5>?`bMv$Lg(_aVrRoQOTXMZ zm}piY@|{_uUc(B`2yjtwVYYnQ`T6pQWyQ3!5)dPBJu^ki7_H#-9qSKr2j25(q_smx z7i`e`N4xX)xYp^gF$}cd;YmjWk=#63ok#Kq4H%J=*8r?ac1YOJ#UrV2tm4t)K^GJ5 z71m4H*|9_rkT?q5Jc)}kaRSFrjU#GT$@6aQAJC(yoaVu=_98yJ^oEL~BFB*qtZbl1 z!NSGXYYkhP8LDTYsC=sxXfKoLmHJ4^s&> zbrRabmADU1SGu=K#zWhrv*=MnJ8}n>2Ktoc{k*9&c9*V5@6y>KFl7C*W-b#}8oL@# zK-$x+vhx-8!*1+%HTG_Ec9UPsD&25N-W|IP9##7?c71RQ;7}aEr+H6f-}hCnnS$8` z-bjcZ+b%mSA)!9IMgJS8awkvtJfOV~GNy}s*Zv6;ItT&jYiW(gF6$;^N9!}X%Tp`6%RxbPL9PoOL$N!w-w@R7%Rwq` zp+j%`YTm}u7Q=JB2>m;wo$uKj%%lvSs9nvOFYLkFA$@lmC^0`ccAqIBt?m%}I`qOy2= z=Epd6#{O!%#H;q9ew2p!?>=Y$n(w!T4tjBBb*(%pRbVa2 zv|loHuEI6p*jGkMr{9fDN_Wv29|C~#rqfHqQ&>bw7fqT?OXP)q2&YJlnwD(NY(!@j zDb;Yw8`@3R^OK&T4-YFuGp6)Tmjrd{^~tp;(k7zUhdj6*rOPFwgVtkT6KPMUs0P7B zIVSz3BHL*noBZt9)kO_^~M?kfO0qX)!_&IdmA_KGvsmdrhpL<0DE@(P2?b8qgjE6g#w4=|+0z5m8n% zLIODr$&jZ@&y+rE7F6okei#R)*ZENxy`-`CFQnk;scZY{_25OZxGM}!U-GvB)8zV!GwOgwa2jt1kE1=C5uFk~ z!;R3UW@e;&Gah*_v&i>SUuhlrSFsY$)?`WPlwZ=U}mg}!^SrQOrSN;J`9h_>H&9LZ%LP0OeHOC3pBGs z8ENS1bi)Ux&n0?OD2R3${boARdkTU zn^={w$Mj3q{90{7rz!q2X}QTtQ1h4W<(aYXO$)t=#}h(34y{am*mQoJ^}UKV`oGh# zmPVyi^q;Iq0?$U zbe@~&gK12XYvbxWqn9$Lb?R@jg`$J?C7Yohy~J1FZ0QvW5)~n3X)xa|3Vm=VcWq88%8w7;dh zX6w$YY5k8Bt=13b3MH@5us zghB5I2Qc}I13cE`dYAZLynIS);e9Xb1iZw_`L`NfGgc||RcKW7%XCHn3_1$(m(xxs z>rNLu}`(ag5NHV=FMyd zH2rI=VA$b6l^3L6R!{FPXlCrgVYwvpqBkA>GU>3GRIeue}*aruYncqbe*gf zee2-6fakP-cCVq6$y1-9w@|T_UY+PSM9Fm80~N`{Th_m@DLzGA`+ z%w;0LLnfH73unDcamhTPTv`K4k?zlrL1&CAiixz0!chlepLHr-38^!mO zJ}+0rd&2yPx{Jl{RY_}X-aPtdno6I1_h!fiHX_xVRo@(-PiufzK>M!hUR?U>mvp8j z{oMK7y=)z>9XE8UHac&v-a1H|^i#59Z)VNvlcihXufc~0$%T>hB3c*uWddCeDepbN zLiOR2ya62q@t0!_gEp|gbc6UT`8<%$tVXmKhG9B)A12p_dg~RQzbw}DJ3x{?2rT{- z|JwD@qfv6nqvnlNs2!)Yq4mRKXU8>qyl6UKaGk7gAN6!NzewE=jqnd4=K<{g>9M}) z=?g~c^m`k$BK^BlJE3N>$jRr5cB;p8ZL+MV5dHyr2!;2T**WQ_OIkY64bg7;*G(`0 zf&D49o>n^Je4DG~bJu$X^G6^mCKMgO_a;jOSkstIxQb2)eRFyLlDISH_#h5I!ZfN$ z@V|tQcNu+&k>h{L;sx!!OrujC<1Y^|a~jMf`O36?x z0>Qqi(>B^EZX__x+QTa$Dz#|F;&0L*Wu^Cd`1gE*%9g1&*TD^GFE2>+>mNw(gC%4R zGuO5%+K54E2N-*OO4tcCsi| zM)^-Bjeq{xi(_Y*T=M9j?B`YRBMq~kVEd29n}IwpR9ls$_5^LZ72fEHC?>>J*sr^L zd(GjQ)c+p1G+!cEKr_-&TlV66(5DW&M?-%grpyNYr6DFhE=-W|k(T4rw;^fNmc{bbDxJpzX_?pXN2cds z(AoMQ0y{X$n@0p;kRDHUh|tptv`1`b%XRcJ)r&2QoC%h@`q0*|5tTw%c&vxRS*(8bfZy5o6j6E{#rK~SAB2uO3~2wzp&)?G(1@_SfKUwA zV-(c&HO$groc9Alge zI8oE}^*mI^J*hm?8DRpzis)MBrq_Lt01!lV#;=llX>tvV*nwq46@*8c!T<2%__`S1 z5wTBXDntG3t7d4CNq^2r_W{g;G-ec7>as4XDk?9hx2L1a);H%usJRW^r?)^E-99Jk zk>|G{_^#a;85C_Ryv_>))`xgDekK>U$1~|+lMLJSd3)+d1btlQKvtFddX}E>a$pSs z_uvdiuX{A%QDmp{+g(B!W!bzjWLz#wik>8v9y+G$Uz6H}BWQw?K1M&9y!^gbQxeTS z6p{CChO-1B){Z6hz{nDQ5W$XV)G%2-fazL`#IN;-I9FD#|?&8n_;b9 zW$a92SwNz{3MdFXhMw#}@#xlm)8!c4PB(271`*c`ZalYfJN;GI@6u=^Q}0J%L*$tt zO42W31MBT>+8xJ2dM{~@*PrxTzXQhRbb};uo+oX8P!KN8J~#LvA^NO#7@M-IW`W$J zk8f(-*f?6B?S~P=>g_$|)oq_1gMSnq%C$M`up6Tv=>ma=ZvU89p74l(-maJ-Nb(Vz z^9C_hFLhCe8iB1&dNs#bV@7bAg9zf&Nrj2Lg15DcF1lNO8gl|GQgHzQ4w2TrvCY)7iZi?MxKNzfizyDFe{f^hsVgJ?6?h!5u=yf z`5--y>9RA~@W)p_fy|)GX5A<)`CSOTJextW6W)StL9apQfGQ#I)~SR$2g;z z^=7|0e$k`u_e3ns)SdL$IPJ1V;&3OQl1RFKA2j*QpShN{WUJ~e zdoJIztV7V7O}H}P?F)KYV+o}pU{)oIIF}c!;Kms6agqb*LVQulJrX5pakOL!Y=~F< zW9>}2nTI3*cp2b4d1w^ajAzN!Tu;I;qMU8(6CP5pV9YxCQgk3x%+j{xk|p;ev(>U- zu5j#yX{#3drTsfAq8&1_{>1mDT32K14XUcMoO_MpA-&JsVAblL{=UUh-LPmt2j&5D zAV<5QK-Nkqc_z$vEqk=j+Gu5=9KP`KeT&4kaz+!avAc)^KJg& z@3S6xv91TU0|(c@h`W^yeIRZ9g!NeO<@MO#=)qjv(C?_aN9Fbx{{vv!_`MD0ZNp_^O5CImT~ZTMsxT?P9Y6RL z^!?`w`h@oVd0W1I^2qyT8*UKTt|c_r!ktF$ICL<>t^Uz)vUQQZ=9H$ z!P}Z!_bPVurKUQTI_h7y6jOOq% 0 { failedValidations["invalid character"] = fmt.Sprintf("password cannot contain any invalid characters ('%s')", strings.Join(invalidCharacteres, "', '")) } + if !hasLength { + failedValidations["length"] = fmt.Sprintf("password length must be between %d and %d characters", passwordMinLength, passwordMaxLength) + } if len(failedValidations) == 0 { - return nil + if pv.determineIfCommonPassword(input) { + failedValidations["common password"] = "password is determined to be too common" + } else { + return nil + } } return &ValidatePasswordError{FailedValidationsMap: failedValidations} } + +func (pv *PasswordValidator) determineIfCommonPassword(input string) bool { + _, found := pv.commonPasswordsList[strings.ToLower(input)] + return found +} diff --git a/stellar-auth/pkg/utils/password_validation_test.go b/stellar-auth/pkg/utils/password_validation_test.go index 7df220878..0e51331b4 100644 --- a/stellar-auth/pkg/utils/password_validation_test.go +++ b/stellar-auth/pkg/utils/password_validation_test.go @@ -9,7 +9,12 @@ import ( ) func Test_ValidatePassword(t *testing.T) { + pwValidator := PasswordValidator{ + commonPasswordsList: map[string]bool{"password!1234": true}, + } + const ( + commonPasswordErrMsg = "password is determined to be too common" defaultErrMsg = "password validation failed for the following reason(s)" invalidLengthErrMsg = "length: password length must be between 12 and 36 characters" invalidCharsErrMsg = "invalid character: password cannot contain any invalid characters (" @@ -20,6 +25,7 @@ func Test_ValidatePassword(t *testing.T) { ) allErrMessages := []string{ + commonPasswordErrMsg, defaultErrMsg, invalidLengthErrMsg, invalidCharsErrMsg, @@ -119,6 +125,11 @@ func Test_ValidatePassword(t *testing.T) { input: "11Az22By33Cx", errContains: []string{defaultErrMsg, missingSpecialCharErrMsg}, }, + { + name: "only one criteria is missing: [common password]", + input: "pAssWord!1234", + errContains: []string{defaultErrMsg, commonPasswordErrMsg}, + }, { name: "🎉 All criteria was met!", input: "!1Az?2By.3Cx", @@ -133,7 +144,7 @@ func Test_ValidatePassword(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := ValidatePassword(tc.input) + err := pwValidator.ValidatePassword(tc.input) if tc.errContains == nil { require.NoError(t, err) } else { From fe3e14877cebcf67281465fc1a4d56d4638ae6b1 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Wed, 10 Jan 2024 13:21:50 -0800 Subject: [PATCH 18/39] [SDP-1011] add user permission for the business role for receiver details (#144) --- internal/serve/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 0862c1f65..38c973242 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -268,7 +268,7 @@ func handleHTTP(o ServeOptions) *chi.Mux { receiversHandler := httphandler.ReceiverHandler{Models: o.Models, DBConnectionPool: o.dbConnectionPool} r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole, data.BusinessUserRole)). Get("/", receiversHandler.GetReceivers) - r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). + r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole, data.BusinessUserRole)). Get("/{id}", receiversHandler.GetReceiver) r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)). From ce0d6a8fd8f7387987a74bae7e86094356d97d63 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Fri, 12 Jan 2024 12:07:30 -0800 Subject: [PATCH 19/39] [SDP-1032] Add logs for any changes in user profile or the organization profile (#145) ### What Start logging important changes on user or organization profiles, for traceability. Here are the functions that are now being logged: - Changes made through `PatchOrganizationProfile` - Log message: `log.Ctx(ctx).Warnf("[PatchOrganizationProfile] - userID %s will update the organization fields %v", user.ID, nonEmptyKeys)` - Changes made through `PatchUserProfile` - Log message: `log.Ctx(ctx).Warnf("[PatchUserProfile] - Will update email for userID %s to %s", user.ID, utils.TruncateString(reqBody.Email, 3))` - Changes made through `PatchUserPassword` - Log message: `log.Ctx(ctx).Warnf("[UpdateUserPassword] - Will update password for user account ID %s", user.ID)` Also, refactored some tests. ### Why So we can better track changes made in user profiles or Organization profiles, for accountability. --- internal/data/organizations.go | 16 +- internal/serve/httphandler/profile_handler.go | 118 +- .../serve/httphandler/profile_handler_test.go | 1711 +++++++---------- 3 files changed, 777 insertions(+), 1068 deletions(-) diff --git a/internal/data/organizations.go b/internal/data/organizations.go index d0796fd4f..aa37e2743 100644 --- a/internal/data/organizations.go +++ b/internal/data/organizations.go @@ -49,16 +49,16 @@ type Organization struct { } type OrganizationUpdate struct { - Name string - Logo []byte - TimezoneUTCOffset string - IsApprovalRequired *bool - SMSResendInterval *int64 - PaymentCancellationPeriodDays *int64 + Name string `json:",omitempty"` + Logo []byte `json:",omitempty"` + TimezoneUTCOffset string `json:",omitempty"` + IsApprovalRequired *bool `json:",omitempty"` + SMSResendInterval *int64 `json:",omitempty"` + PaymentCancellationPeriodDays *int64 `json:",omitempty"` // Using pointers to accept empty strings - SMSRegistrationMessageTemplate *string - OTPMessageTemplate *string + SMSRegistrationMessageTemplate *string `json:",omitempty"` + OTPMessageTemplate *string `json:",omitempty"` } type LogoType string diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index 868327e4e..4db152731 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -2,10 +2,12 @@ package httphandler import ( "bytes" + "context" "encoding/json" "errors" "fmt" "image" + "sort" // Don't remove the `image/jpeg` and `image/png` packages import unless // the `image` package is no longer necessary. @@ -69,7 +71,6 @@ type PatchUserProfileRequest struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` - Password string `json:"password"` } type GetProfileResponse struct { @@ -89,9 +90,9 @@ type PatchUserPasswordRequest struct { func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() - _, ok := ctx.Value(middleware.TokenContextKey).(string) - if !ok { - httperror.Unauthorized("", nil, nil).Render(rw) + _, user, httpErr := getTokenAndUser(ctx, h.AuthManager) + if httpErr != nil { + httpErr.Render(rw) return } @@ -154,7 +155,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht return } - err = h.Models.Organizations.Update(ctx, &data.OrganizationUpdate{ + organizationUpdate := data.OrganizationUpdate{ Name: reqBody.OrganizationName, Logo: fileContentBytes, TimezoneUTCOffset: reqBody.TimezoneUTCOffset, @@ -163,7 +164,26 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht OTPMessageTemplate: reqBody.OTPMessageTemplate, SMSResendInterval: reqBody.SMSResendInterval, PaymentCancellationPeriodDays: reqBody.PaymentCancellationPeriodDays, - }) + } + requestDict, err := utils.ConvertType[data.OrganizationUpdate, map[string]interface{}](organizationUpdate) + if err != nil { + httperror.InternalError(ctx, "Cannot convert organization update to map", err, nil).Render(rw) + return + } + var nonEmptyChanges []string + for k, v := range requestDict { + if !utils.IsEmpty(v) { + value := v + if k == "Logo" { + value = "..." + } + nonEmptyChanges = append(nonEmptyChanges, fmt.Sprintf("%s='%v'", k, value)) + } + } + sort.Strings(nonEmptyChanges) + + log.Ctx(ctx).Warnf("[PatchOrganizationProfile] - userID %s will update the organization fields [%s]", user.ID, strings.Join(nonEmptyChanges, ", ")) + err = h.Models.Organizations.Update(ctx, &organizationUpdate) if err != nil { httperror.InternalError(ctx, "Cannot update organization", err, nil).Render(rw) return @@ -175,9 +195,9 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht func (h ProfileHandler) PatchUserProfile(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() - token, ok := ctx.Value(middleware.TokenContextKey).(string) - if !ok { - httperror.Unauthorized("", nil, nil).Render(rw) + token, user, httpErr := getTokenAndUser(ctx, h.AuthManager) + if httpErr != nil { + httpErr.Render(rw) return } @@ -189,13 +209,6 @@ func (h ProfileHandler) PatchUserProfile(rw http.ResponseWriter, req *http.Reque return } - if reqBody.Password != "" && len(reqBody.Password) < 8 { - httperror.BadRequest("", nil, map[string]interface{}{ - "password": "password should have at least 8 characters", - }).Render(rw) - return - } - if reqBody.Email != "" { if err := utils.ValidateEmail(reqBody.Email); err != nil { httperror.BadRequest("", nil, map[string]interface{}{ @@ -203,16 +216,17 @@ func (h ProfileHandler) PatchUserProfile(rw http.ResponseWriter, req *http.Reque }).Render(rw) return } + log.Ctx(ctx).Warnf("[PatchUserProfile] - Will update email for userID %s to %s", user.ID, utils.TruncateString(reqBody.Email, 3)) } - if reqBody.FirstName == "" && reqBody.LastName == "" && reqBody.Email == "" && reqBody.Password == "" { + if utils.IsEmpty(reqBody) { httperror.BadRequest("", nil, map[string]interface{}{ - "details": "provide at least first_name, last_name, email or password.", + "details": "provide at least first_name, last_name or email.", }).Render(rw) return } - err := h.AuthManager.UpdateUser(ctx, token, reqBody.FirstName, reqBody.LastName, reqBody.Email, reqBody.Password) + err := h.AuthManager.UpdateUser(ctx, token, reqBody.FirstName, reqBody.LastName, reqBody.Email, "") if err != nil { httperror.InternalError(ctx, "Cannot update user profiles", err, nil).Render(rw) return @@ -224,9 +238,9 @@ func (h ProfileHandler) PatchUserProfile(rw http.ResponseWriter, req *http.Reque func (h ProfileHandler) PatchUserPassword(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() - token, ok := ctx.Value(middleware.TokenContextKey).(string) - if !ok { - httperror.Unauthorized("", nil, nil).Render(rw) + token, user, httpErr := getTokenAndUser(ctx, h.AuthManager) + if httpErr != nil { + httpErr.Render(rw) return } @@ -267,48 +281,22 @@ func (h ProfileHandler) PatchUserPassword(rw http.ResponseWriter, req *http.Requ return } + log.Ctx(ctx).Warnf("[PatchUserPassword] - Will update password for user account ID %s", user.ID) err = h.AuthManager.UpdatePassword(ctx, token, reqBody.CurrentPassword, reqBody.NewPassword) if err != nil { httperror.InternalError(ctx, "Cannot update user password", err, nil).Render(rw) return } - userID, err := h.AuthManager.GetUserID(ctx, token) - if err != nil { - httperror.InternalError(ctx, "Cannot get user ID", err, nil).Render(rw) - return - } - log.Ctx(ctx).Infof("[UpdateUserPassword] - Updated password for user with account ID %s", userID) - httpjson.RenderStatus(rw, http.StatusOK, map[string]string{"message": "user password updated successfully"}, httpjson.JSON) } func (h ProfileHandler) GetProfile(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() - token, ok := ctx.Value(middleware.TokenContextKey).(string) - if !ok { - httperror.Unauthorized("", nil, nil).Render(rw) - return - } - - user, err := h.AuthManager.GetUser(ctx, token) - if err != nil { - if errors.Is(err, auth.ErrInvalidToken) { - err = fmt.Errorf("getting user profile: %w", err) - log.Ctx(ctx).Error(err) - httperror.Unauthorized("", err, nil).Render(rw) - return - } - - if errors.Is(err, auth.ErrUserNotFound) { - err = fmt.Errorf("user from token %s not found: %w", token, err) - log.Ctx(ctx).Error(err) - httperror.BadRequest("", err, nil).Render(rw) - return - } - - httperror.InternalError(ctx, "Cannot get user", err, nil).Render(rw) + _, user, httpErr := getTokenAndUser(ctx, h.AuthManager) + if httpErr != nil { + httpErr.Render(rw) return } @@ -425,3 +413,29 @@ func (h ProfileHandler) GetOrganizationLogo(rw http.ResponseWriter, req *http.Re httperror.InternalError(ctx, "Cannot write organization logo to response", err, nil).Render(rw) } } + +func getTokenAndUser(ctx context.Context, authManager auth.AuthManager) (token string, user *auth.User, httpErr *httperror.HTTPError) { + token, ok := ctx.Value(middleware.TokenContextKey).(string) + if !ok { + return "", nil, httperror.Unauthorized("", nil, nil) + } + + user, err := authManager.GetUser(ctx, token) + if err != nil { + if errors.Is(err, auth.ErrInvalidToken) { + err = fmt.Errorf("getting user profile: %w", err) + log.Ctx(ctx).Error(err) + return "", nil, httperror.Unauthorized("", err, nil) + } + + if errors.Is(err, auth.ErrUserNotFound) { + err = fmt.Errorf("user from token %s not found: %w", token, err) + log.Ctx(ctx).Error(err) + return "", nil, httperror.BadRequest("", err, nil) + } + + return "", nil, httperror.InternalError(ctx, "Cannot get user", err, nil) + } + + return token, user, nil +} diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index c234dd8cc..71b2900a9 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -13,6 +13,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "testing/fstest" @@ -20,6 +21,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/support/log" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" @@ -31,7 +33,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) -func createOrganizationProfileMultipartRequest(t *testing.T, url, fieldName, filename, body string, fileContent io.Reader) (*http.Request, error) { +func createOrganizationProfileMultipartRequest(t *testing.T, ctx context.Context, url, fieldName, filename, body string, fileContent io.Reader) *http.Request { buf := new(bytes.Buffer) writer := multipart.NewWriter(buf) defer writer.Close() @@ -40,20 +42,25 @@ func createOrganizationProfileMultipartRequest(t *testing.T, url, fieldName, fil fieldName = "logo" } + if fileContent == nil { + fileContent = new(bytes.Buffer) + } + + // Insert file into the Multipart form part, err := writer.CreateFormFile(fieldName, filename) require.NoError(t, err) - _, err = io.Copy(part, fileContent) require.NoError(t, err) - // adding the data + // Insert JSON body into the Multipart form err = writer.WriteField("data", body) require.NoError(t, err) + // Create the request req, err := http.NewRequest(http.MethodPatch, url, buf) require.NoError(t, err) req.Header.Set("Content-Type", writer.FormDataContentType()) - return req, nil + return req.WithContext(ctx) } func resetOrganizationInfo(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool) { @@ -93,1044 +100,732 @@ func Test_PatchOrganizationProfileRequest_AreAllFieldsEmpty(t *testing.T) { assert.False(t, res) } -func Test_ProfileHandler_PatchOrganizationProfile(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() +func Test_ProfileHandler_PatchOrganizationProfile_Failures(t *testing.T) { + // PNG file + pngImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) + pngImgBuf := new(bytes.Buffer) + err := png.Encode(pngImgBuf, pngImg) + require.NoError(t, err) - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + // CSV file + csvBuf := new(bytes.Buffer) + csvWriter := csv.NewWriter(csvBuf) + err = csvWriter.WriteAll([][]string{ + {"name", "age"}, + {"foo", "99"}, + {"bar", "99"}, + }) require.NoError(t, err) - defer dbConnectionPool.Close() - models, err := data.NewModels(dbConnectionPool) + // JPEG too big + imgTooBig := data.CreateMockImage(t, 3840, 2160, data.ImageSizeMedium) + imgTooBigBuf := new(bytes.Buffer) + err = jpeg.Encode(imgTooBigBuf, imgTooBig, &jpeg.Options{Quality: jpeg.DefaultQuality}) require.NoError(t, err) - handler := &ProfileHandler{Models: models, MaxMemoryAllocation: DefaultMaxMemoryAllocation} url := "/profile/organization" - - ctx := context.Background() - - t.Run("returns Unauthorized error when no token is found", func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, nil) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - assert.JSONEq(t, `{"error": "Not authorized."}`, string(respBody)) - }) - - t.Run("returns BadRequest error when the request is invalid", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - // Invalid JSON data - img := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) - imgBuf := new(bytes.Buffer) - err := png.Encode(imgBuf, img) - require.NoError(t, err) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "logo", "logo.png", `invalid`, imgBuf) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "The request was invalid in some way."}`, string(respBody)) - - // Invalid file format - csvBuf := new(bytes.Buffer) - csvWriter := csv.NewWriter(csvBuf) - err = csvWriter.WriteAll([][]string{ - {"name", "age"}, - {"foo", "99"}, - {"bar", "99"}, - }) - require.NoError(t, err) - - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "logo", "logo.csv", `{}`, csvBuf) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - wantsBody := ` - { + user := &auth.User{ID: "user-id"} + testCases := []struct { + name string + token string + getRequestFn func(t *testing.T, ctx context.Context) *http.Request + mockAuthManagerFn func(authManagerMock *auth.AuthManagerMock) + wantStatusCode int + wantRespBody string + }{ + { + name: "returns Unauthorized when no token is found", + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return httptest.NewRequest(http.MethodPatch, url, nil).WithContext(ctx) + }, + wantStatusCode: http.StatusUnauthorized, + wantRespBody: `{"error": "Not authorized."}`, + }, + { + name: "returns BadRequest when the request is not valid (invalid JSON)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.png", `invalid`, pngImgBuf) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{"error": "The request was invalid in some way."}`, + }, + { + name: "returns BadRequest when the request is not valid (invalid file format)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.csv", `{}`, csvBuf) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ "error": "The request was invalid in some way.", "extras": { "logo": "invalid file type provided. Expected png or jpeg." } - } - ` - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, wantsBody, string(respBody)) - - // Neither logo and organization_name isn't present. - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "wrong", "logo.png", `{}`, new(bytes.Buffer)) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "request is invalid", "extras": {"details": "data or logo is required"}}`, string(respBody)) - }) - - t.Run("returns BadRequest error when the request size is too large", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - img := data.CreateMockImage(t, 3840, 2160, data.ImageSizeMedium) - imgBuf := new(bytes.Buffer) - err := jpeg.Encode(imgBuf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) - require.NoError(t, err) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "logo", "logo.jpeg", `{}`, imgBuf) - require.NoError(t, err) - - req = req.WithContext(ctx) - - getEntries := log.DefaultLogger.StartTest(log.ErrorLevel) - - profileHandler := &ProfileHandler{Models: models, MaxMemoryAllocation: 1024 * 1024} - http.HandlerFunc(profileHandler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "could not parse multipart form data", "extras": {"details": "request too large. Max size 2MB."}}`, string(respBody)) - - entries := getEntries() - assert.Equal(t, "error parsing multipart form: http: request body too large", entries[0].Message) - }) - - t.Run("updates the organization's name successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Equal(t, "MyCustomAid", org.Name) - assert.Nil(t, org.Logo) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "My Org Name"}`, new(bytes.Buffer)) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Equal(t, "My Org Name", org.Name) - assert.Nil(t, org.Logo) - }) - - t.Run("updates the organization's timezone UTC offset successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Equal(t, "+00:00", org.TimezoneUTCOffset) - assert.Equal(t, "MyCustomAid", org.Name) - assert.Nil(t, org.Logo) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"timezone_utc_offset": "-03:00"}`, new(bytes.Buffer)) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Equal(t, "-03:00", org.TimezoneUTCOffset) - assert.Equal(t, "MyCustomAid", org.Name) - assert.Nil(t, org.Logo) - }) - - t.Run("updates the organization's IsApprovalRequired successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - assert.False(t, org.IsApprovalRequired) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"is_approval_required": true}`, new(bytes.Buffer)) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - require.True(t, org.IsApprovalRequired) - }) - - t.Run("updates the organization's logo successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - // PNG logo - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Nil(t, org.Logo) - assert.Equal(t, "MyCustomAid", org.Name) - - img := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) - imgBuf := new(bytes.Buffer) - err = png.Encode(imgBuf, img) - require.NoError(t, err) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "logo", "logo.png", `{}`, imgBuf) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + }`, + }, + { + name: "returns BadRequest when the request is not valid (both file and data are empty)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "invalidParameterName", "logo.csv", `{}`, pngImgBuf) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "request is invalid", + "extras": { + "details": "data or logo is required" + } + }`, + }, + { + name: "returns BadRequest error when the request size is too large", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.png", `{}`, imgTooBigBuf) + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "could not parse multipart form data", + "extras": { + "details": "request too large. Max size 2MB." + } + }`, + }, + } - resp := w.Result() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Inject authenticated token into context: + ctx := context.Background() + if tc.token != "" { + ctx = context.WithValue(ctx, middleware.TokenContextKey, tc.token) + } - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) + // Setup password validator + pwValidator, err := utils.GetPasswordValidatorInstance() + require.NoError(t, err) + + // Setup handler with mocked dependencies + handler := &ProfileHandler{MaxMemoryAllocation: 1024 * 1024, PasswordValidator: pwValidator} + if tc.mockAuthManagerFn != nil { + authManagerMock := &auth.AuthManagerMock{} + tc.mockAuthManagerFn(authManagerMock) + handler.AuthManager = authManagerMock + defer authManagerMock.AssertExpectations(t) + } - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + // Execute the request + req := tc.getRequestFn(t, ctx) + w := httptest.NewRecorder() + http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + + // Assert response + resp := w.Result() + assert.Equal(t, tc.wantStatusCode, resp.StatusCode) + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + assert.JSONEq(t, tc.wantRespBody, string(respBody)) + }) + } +} - org, err = models.Organizations.Get(ctx) +func Test_ProfileHandler_PatchOrganizationProfile_Successful(t *testing.T) { + // PNG file + newPNGImgBuf := func() *bytes.Buffer { + pngImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) + pngImgBuf := new(bytes.Buffer) + err := png.Encode(pngImgBuf, pngImg) require.NoError(t, err) + return pngImgBuf + } - // renew buffer - imgBuf = new(bytes.Buffer) - err = png.Encode(imgBuf, img) - require.NoError(t, err) + var nilInt64 *int64 - assert.Equal(t, imgBuf.Bytes(), org.Logo) - assert.Equal(t, "MyCustomAid", org.Name) + // JPEG file + jpegImg := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) + jpegImgBuf := new(bytes.Buffer) + err := jpeg.Encode(jpegImgBuf, jpegImg, &jpeg.Options{Quality: jpeg.DefaultQuality}) + require.NoError(t, err) - // JPEG logo - resetOrganizationInfo(t, ctx, dbConnectionPool) + url := "/profile/organization" + user := &auth.User{ID: "user-id"} + testCases := []struct { + name string + token string + updateOrgInitialValuesFn func(t *testing.T, ctx context.Context, models *data.Models) + getRequestFn func(t *testing.T, ctx context.Context) *http.Request + mockAuthManagerFn func(authManagerMock *auth.AuthManagerMock) + resultingFieldsToCompare map[string]interface{} + wantLogEntries []string + }{ + { + name: "🎉 successfully updates the organization's logo (PNG)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.png", `{}`, newPNGImgBuf()) + }, + resultingFieldsToCompare: map[string]interface{}{ + "Logo": newPNGImgBuf().Bytes(), + }, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [Logo='...']"}, + }, + { + name: "🎉 successfully updates the organization's logo (JPEG)", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.jpeg", `{}`, jpegImgBuf) + }, + resultingFieldsToCompare: map[string]interface{}{ + "Logo": jpegImgBuf.Bytes(), + }, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [Logo='...']"}, + }, + { + name: "🎉 successfully updates ALL the organization fields", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "is_approval_required": true, + "organization_name": "My Org Name", + "otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋", + "payment_cancellation_period_days": 2, + "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", + "sms_resend_interval": 2, + "timezone_utc_offset": "-03:00" + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "logo", "logo.png", reqBody, newPNGImgBuf()) + }, + resultingFieldsToCompare: map[string]interface{}{ + "IsApprovalRequired": true, + "Name": "My Org Name", + "Logo": newPNGImgBuf().Bytes(), + "OTPMessageTemplate": "Here's your OTP Code to complete your registration. MyOrg 👋", + "PaymentCancellationPeriodDays": int64(2), + "SMSRegistrationMessageTemplate": "My custom receiver wallet registration invite. MyOrg 👋", + "SMSResendInterval": int64(2), + "TimezoneUTCOffset": "-03:00", + }, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [IsApprovalRequired='true', Logo='...', Name='My Org Name', OTPMessageTemplate='Here's your OTP Code to complete your registration. MyOrg 👋', PaymentCancellationPeriodDays='2', SMSRegistrationMessageTemplate='My custom receiver wallet registration invite. MyOrg 👋', SMSResendInterval='2', TimezoneUTCOffset='-03:00']"}, + }, + { + name: "🎉 successfully updates organization back to its default values", + token: "token", + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + updateOrgInitialValuesFn: func(t *testing.T, ctx context.Context, models *data.Models) { + otpMessageTemplate := "custom OTPMessageTemplate" + smsRegistrationMessageTemplate := "custom SMSRegistrationMessageTemplate" + smsResendInterval := int64(123) + paymentCancellationPeriodDays := int64(456) + err := models.Organizations.Update(ctx, &data.OrganizationUpdate{ + SMSRegistrationMessageTemplate: &smsRegistrationMessageTemplate, + OTPMessageTemplate: &otpMessageTemplate, + SMSResendInterval: &smsResendInterval, + PaymentCancellationPeriodDays: &paymentCancellationPeriodDays, + }) + require.NoError(t, err) + }, + getRequestFn: func(t *testing.T, ctx context.Context) *http.Request { + reqBody := `{ + "sms_registration_message_template": "", + "otp_message_template": "", + "sms_resend_interval": 0, + "payment_cancellation_period_days": 0 + }` + return createOrganizationProfileMultipartRequest(t, ctx, url, "", "", reqBody, new(bytes.Buffer)) + }, + resultingFieldsToCompare: map[string]interface{}{ + "SMSRegistrationMessageTemplate": "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.", + "OTPMessageTemplate": "{{.OTP}} is your {{.OrganizationName}} phone verification code.", + "SMSResendInterval": nilInt64, + "PaymentCancellationPeriodDays": nilInt64, + }, + wantLogEntries: []string{"[PatchOrganizationProfile] - userID user-id will update the organization fields [OTPMessageTemplate='', PaymentCancellationPeriodDays='0', SMSRegistrationMessageTemplate='', SMSResendInterval='0']"}, + }, + } - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + log.SetLevel(log.InfoLevel) + + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + // Inject authenticated token into context: + ctx := context.Background() + if tc.token != "" { + ctx = context.WithValue(ctx, middleware.TokenContextKey, tc.token) + } - assert.Nil(t, org.Logo) - assert.Equal(t, "MyCustomAid", org.Name) + // Assert DB before + if tc.updateOrgInitialValuesFn != nil { + tc.updateOrgInitialValuesFn(t, ctx, models) + } + org, err := models.Organizations.Get(ctx) + require.NoError(t, err) + for k, expectedValue := range tc.resultingFieldsToCompare { + fieldValue := reflect.ValueOf(org).Elem().FieldByName(k) + if fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() { + fieldValue = fieldValue.Elem() + } + assert.NotEqual(t, expectedValue, fieldValue.Interface()) + } - img = data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) - imgBuf = new(bytes.Buffer) - err = jpeg.Encode(imgBuf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) - require.NoError(t, err) + // Setup password validator + pwValidator, err := utils.GetPasswordValidatorInstance() + require.NoError(t, err) + + // Setup handler with mocked dependencies + handler := &ProfileHandler{Models: models, MaxMemoryAllocation: 1024 * 1024, PasswordValidator: pwValidator} + if tc.mockAuthManagerFn != nil { + authManagerMock := &auth.AuthManagerMock{} + tc.mockAuthManagerFn(authManagerMock) + handler.AuthManager = authManagerMock + defer authManagerMock.AssertExpectations(t) + } - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "logo", "logo.jpeg", `{}`, imgBuf) - require.NoError(t, err) + // Execute the request + req := tc.getRequestFn(t, ctx) + w := httptest.NewRecorder() + http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + + // Assert response + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + + // Assert DB after + org, err = models.Organizations.Get(ctx) + require.NoError(t, err) + for k, expectedValue := range tc.resultingFieldsToCompare { + fieldValue := reflect.ValueOf(org).Elem().FieldByName(k) + if fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() { + fieldValue = fieldValue.Elem() + } + assert.Equal(t, expectedValue, fieldValue.Interface()) + } - req = req.WithContext(ctx) + // Assert logs + for _, logEntry := range tc.wantLogEntries { + require.Contains(t, buf.String(), logEntry) + } + }) + } +} - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) +func Test_ProfileHandler_PatchUserProfile(t *testing.T) { + user := &auth.User{ID: "user-id"} + testCases := []struct { + name string + token string + reqBody string + mockAuthManagerFn func(authManagerMock *auth.AuthManagerMock) + wantStatusCode int + wantRespBody string + wantLogEntries []string + }{ + { + name: "returns Unauthorized when no token is found", + wantStatusCode: http.StatusUnauthorized, + wantRespBody: `{"error": "Not authorized."}`, + }, + { + name: "returns BadRequest when the request has an invalid JSON body", + token: "token", + reqBody: `invalid`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{"error": "The request was invalid in some way."}`, + }, + { + name: "returns BadRequest when the request has an invalid email", + token: "token", + reqBody: `{"email": "invalid"}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "email": "invalid email provided" + } + }`, + }, + { + name: "returns BadRequest if none of the fields are provided", + token: "token", + reqBody: `{}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "details":"provide at least first_name, last_name or email." + } + }`, + }, + { + name: "returns InternalServerError when AuthManager fails", + token: "token", + reqBody: `{ + "first_name": "First", + "last_name": "Last", + "email": "email@email.com" + }`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once(). + On("UpdateUser", mock.Anything, "token", "First", "Last", "email@email.com", ""). + Return(errors.New("unexpected error")). + Once() + }, + wantStatusCode: http.StatusInternalServerError, + wantRespBody: `{"error":"Cannot update user profiles"}`, + }, + { + name: "🎉 successfully updates user profile", + token: "token", + reqBody: `{ + "first_name": "First", + "last_name": "Last", + "email": "email@email.com" + }`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once(). + On("UpdateUser", mock.Anything, "token", "First", "Last", "email@email.com", ""). + Return(nil). + Once() + }, + wantStatusCode: http.StatusOK, + wantRespBody: `{"message": "user profile updated successfully"}`, + wantLogEntries: []string{ + "[PatchUserProfile] - Will update email for userID user-id to ema...com", + }, + }, + } - resp = w.Result() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + log.SetLevel(log.InfoLevel) + + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Inject authenticated token into context: + ctx := context.Background() + if tc.token != "" { + ctx = context.WithValue(ctx, middleware.TokenContextKey, tc.token) + } - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) + // Setup password validator + pwValidator, err := utils.GetPasswordValidatorInstance() + require.NoError(t, err) + + // Setup handler with mocked dependencies + handler := &ProfileHandler{PasswordValidator: pwValidator} + if tc.mockAuthManagerFn != nil { + authManagerMock := &auth.AuthManagerMock{} + tc.mockAuthManagerFn(authManagerMock) + handler.AuthManager = authManagerMock + defer authManagerMock.AssertExpectations(t) + } - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + // Execute the request + var body io.Reader + if tc.reqBody != "" { + body = strings.NewReader(tc.reqBody) + } + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, "/profile/user", body) + require.NoError(t, err) + http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) + + // Assert response + resp := w.Result() + assert.Equal(t, tc.wantStatusCode, resp.StatusCode) + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + assert.JSONEq(t, tc.wantRespBody, string(respBody)) + + // Validate logs + for _, logEntry := range tc.wantLogEntries { + assert.Contains(t, buf.String(), logEntry) + } + }) + } +} - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) +func Test_ProfileHandler_PatchUserPassword(t *testing.T) { + user := &auth.User{ID: "user-id"} + testCases := []struct { + name string + token string + reqBody string + mockAuthManagerFn func(authManagerMock *auth.AuthManagerMock) + wantStatusCode int + wantRespBody string + wantLogEntries []string + }{ + { + name: "returns Unauthorized error when no token is found", + token: "", + wantStatusCode: http.StatusUnauthorized, + wantRespBody: `{"error": "Not authorized."}`, + }, + { + name: "returns BadRequest error when JSON decoding fails", + token: "token", + reqBody: `invalid`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{"error": "The request was invalid in some way."}`, + }, + { + name: "returns BadRequest error when current_password and new_password are not provided", + token: "token", + reqBody: `{}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "current_password":"current_password is required", + "new_password":"new_password should be different from current_password" + } + }`, + }, + { + name: "returns BadRequest error when current_password and new_password are equal", + token: "token", + reqBody: `{"current_password": "currentpassword", "new_password": "currentpassword"}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "new_password":"new_password should be different from current_password" + } + }`, + }, + { + name: "returns BadRequest error when password does not match all the criteria", + token: "token", + reqBody: `{"current_password": "currentpassword", "new_password": "1Az2By3Cx"}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once() + }, + wantStatusCode: http.StatusBadRequest, + wantRespBody: `{ + "error": "The request was invalid in some way.", + "extras": { + "length":"password length must be between 12 and 36 characters", + "special character":"password must contain at least one special character" + } + }`, + }, + { + name: "returns InternalServerError when AuthManager fails", + token: "token", + reqBody: `{"current_password": "currentpassword", "new_password": "!1Az?2By.3Cx"}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once(). + On("UpdatePassword", mock.Anything, "token", "currentpassword", "!1Az?2By.3Cx"). + Return(errors.New("unexpected error")). + Once() + }, + wantStatusCode: http.StatusInternalServerError, + wantRespBody: `{"error":"Cannot update user password"}`, + }, + { + name: "🎉 successfully updates the user password", + token: "token", + reqBody: `{"current_password": "currentpassword", "new_password": "!1Az?2By.3Cx"}`, + mockAuthManagerFn: func(authManagerMock *auth.AuthManagerMock) { + authManagerMock. + On("GetUser", mock.Anything, "token"). + Return(user, nil). + Once(). + On("UpdatePassword", mock.Anything, "token", "currentpassword", "!1Az?2By.3Cx"). + Return(nil). + Once() + }, + wantStatusCode: http.StatusOK, + wantRespBody: `{"message": "user password updated successfully"}`, + wantLogEntries: []string{ + "[PatchUserPassword] - Will update password for user account ID user-id", + }, + }, + } - // renew buffer - imgBuf = new(bytes.Buffer) - err = jpeg.Encode(imgBuf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) - require.NoError(t, err) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + log.SetLevel(log.InfoLevel) + + // Setup DB + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Inject authenticated token into context: + ctx := context.Background() + if tc.token != "" { + ctx = context.WithValue(ctx, middleware.TokenContextKey, tc.token) + } - assert.Equal(t, imgBuf.Bytes(), org.Logo) - assert.Equal(t, "MyCustomAid", org.Name) - }) + // Setup password validator + pwValidator, err := utils.GetPasswordValidatorInstance() + require.NoError(t, err) + + // Setup handler with mocked dependencies + handler := &ProfileHandler{PasswordValidator: pwValidator} + if tc.mockAuthManagerFn != nil { + authManagerMock := &auth.AuthManagerMock{} + tc.mockAuthManagerFn(authManagerMock) + handler.AuthManager = authManagerMock + defer authManagerMock.AssertExpectations(t) + } - t.Run("updates organization name, timezone UTC offset and logo successfully", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - - assert.Equal(t, "MyCustomAid", org.Name) - assert.Equal(t, "+00:00", org.TimezoneUTCOffset) - assert.Nil(t, org.Logo) - - img := data.CreateMockImage(t, 300, 300, data.ImageSizeSmall) - imgBuf := new(bytes.Buffer) - err = png.Encode(imgBuf, img) - require.NoError(t, err) - - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "logo", "logo.png", `{"organization_name": "My Org Name", "timezone_utc_offset": "-03:00"}`, imgBuf) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - - // renew buffer - imgBuf = new(bytes.Buffer) - err = png.Encode(imgBuf, img) - require.NoError(t, err) - - assert.Equal(t, "My Org Name", org.Name) - assert.Equal(t, "-03:00", org.TimezoneUTCOffset) - assert.Equal(t, imgBuf.Bytes(), org.Logo) - }) - - t.Run("updates organization's SMS Registration Message Template", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - defaultMessage := "You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register." - assert.Equal(t, defaultMessage, org.SMSRegistrationMessageTemplate) - - // Custom message - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, "My custom receiver wallet registration invite. MyOrg 👋", org.SMSRegistrationMessageTemplate) - - // Don't update the message - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "MyOrg"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, "My custom receiver wallet registration invite. MyOrg 👋", org.SMSRegistrationMessageTemplate) - - // Back to default message - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"sms_registration_message_template": ""}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, defaultMessage, org.SMSRegistrationMessageTemplate) - }) - - t.Run("updates organization's OTP Message Template", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - - defaultMessage := "{{.OTP}} is your {{.OrganizationName}} phone verification code." - assert.Equal(t, defaultMessage, org.OTPMessageTemplate) - - // Custom message - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, "Here's your OTP Code to complete your registration. MyOrg 👋", org.OTPMessageTemplate) - - // Don't update the message - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "MyOrg"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, "Here's your OTP Code to complete your registration. MyOrg 👋", org.OTPMessageTemplate) - - // Back to default message - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"otp_message_template": ""}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, defaultMessage, org.OTPMessageTemplate) - }) - - t.Run("updates organization's SMS Resend Interval", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Nil(t, org.SMSResendInterval) - - // Custom interval - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"sms_resend_interval": 2}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, int64(2), *org.SMSResendInterval) - - // Don't update the interval - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "MyOrg"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, int64(2), *org.SMSResendInterval) - - // Back to default interval - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"sms_resend_interval": 0}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Nil(t, org.SMSResendInterval) - }) - - t.Run("updates organization's Payment Cancellation Period", func(t *testing.T) { - resetOrganizationInfo(t, ctx, dbConnectionPool) - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - org, err := models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Nil(t, org.PaymentCancellationPeriodDays) - - // Custom period - w := httptest.NewRecorder() - req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period_days": 2}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp := w.Result() - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, int64(2), *org.PaymentCancellationPeriodDays) - - // Don't update the period - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "MyOrg"}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Equal(t, int64(2), *org.PaymentCancellationPeriodDays) - - // Back to default period - w = httptest.NewRecorder() - req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period_days": 0}`, new(bytes.Buffer)) - require.NoError(t, err) - req = req.WithContext(ctx) - http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) - - resp = w.Result() - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) - - org, err = models.Organizations.Get(ctx) - require.NoError(t, err) - assert.Nil(t, org.PaymentCancellationPeriodDays) - }) -} - -func Test_ProfileHandler_PatchUserProfile(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - authenticatorMock := &auth.AuthenticatorMock{} - jwtManagerMock := &auth.JWTManagerMock{} - authManager := auth.NewAuthManager( - auth.WithCustomAuthenticatorOption(authenticatorMock), - auth.WithCustomJWTManagerOption(jwtManagerMock), - ) - - handler := &ProfileHandler{AuthManager: authManager} - url := "/profile/user" - - ctx := context.Background() - - t.Run("returns Unauthorized error when no token is found", func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, nil) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - assert.JSONEq(t, `{"error": "Not authorized."}`, string(respBody)) - }) - - t.Run("returns BadRequest error when the request is invalid", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - // Invalid JSON - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`invalid`)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "The request was invalid in some way."}`, string(respBody)) - - // Invalid email - w = httptest.NewRecorder() - req, err = http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`{"email": "invalid"}`)) - require.NoError(t, err) - - req = req.WithContext(ctx) - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp = w.Result() - - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "The request was invalid in some way.", "extras": {"email": "invalid email provided"}}`, string(respBody)) - - // Password too short - w = httptest.NewRecorder() - req, err = http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`{"password": "short"}`)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp = w.Result() - - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "The request was invalid in some way.", "extras": {"password": "password should have at least 8 characters"}}`, string(respBody)) - - // None of values provided - w = httptest.NewRecorder() - req, err = http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`{}`)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp = w.Result() - - respBody, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.JSONEq(t, `{"error": "The request was invalid in some way.", "extras": {"details":"provide at least first_name, last_name, email or password."}}`, string(respBody)) - }) - - t.Run("returns InternalServerError when AuthManager fails", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - reqBody := ` - { - "first_name": "First", - "last_name": "Last", - "email": "email@email.com", - "password": "mypassword" + // Execute the request + var body io.Reader + if tc.reqBody != "" { + body = strings.NewReader(tc.reqBody) } - ` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - jwtManagerMock. - On("ValidateToken", req.Context(), "token"). - Return(true, nil). - Once(). - On("GetUserFromToken", req.Context(), "token"). - Return(&auth.User{ID: "user-id"}, nil). - Once() - - authenticatorMock. - On("UpdateUser", req.Context(), "user-id", "First", "Last", "email@email.com", "mypassword"). - Return(errors.New("unexpected error")). - Once() - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - assert.JSONEq(t, `{"error":"Cannot update user profiles"}`, string(respBody)) - }) - - t.Run("updates the user profile successfully", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - reqBody := ` - { - "first_name": "First", - "last_name": "Last", - "email": "email@email.com", - "password": "mypassword" + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, "/profile/reset-password", body) + require.NoError(t, err) + http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) + + // Assert response + resp := w.Result() + assert.Equal(t, tc.wantStatusCode, resp.StatusCode) + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + assert.JSONEq(t, tc.wantRespBody, string(respBody)) + + // Validate logs + for _, logEntry := range tc.wantLogEntries { + assert.Contains(t, buf.String(), logEntry) } - ` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - jwtManagerMock. - On("ValidateToken", req.Context(), "token"). - Return(true, nil). - Once(). - On("GetUserFromToken", req.Context(), "token"). - Return(&auth.User{ID: "user-id"}, nil). - Once() - - authenticatorMock. - On("UpdateUser", req.Context(), "user-id", "First", "Last", "email@email.com", "mypassword"). - Return(nil). - Once() - - http.HandlerFunc(handler.PatchUserProfile).ServeHTTP(w, req) - - resp := w.Result() - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, `{"message": "user profile updated successfully"}`, string(respBody)) - }) - - authenticatorMock.AssertExpectations(t) - jwtManagerMock.AssertExpectations(t) -} - -func Test_ProfileHandler_PatchUserPassword(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() - - authenticatorMock := &auth.AuthenticatorMock{} - jwtManagerMock := &auth.JWTManagerMock{} - authManager := auth.NewAuthManager( - auth.WithCustomAuthenticatorOption(authenticatorMock), - auth.WithCustomJWTManagerOption(jwtManagerMock), - ) - pwValidator, _ := utils.GetPasswordValidatorInstance() - - handler := &ProfileHandler{ - AuthManager: authManager, - PasswordValidator: pwValidator, - } - - url := "/profile/reset-password" - ctx := context.Background() - - user := &auth.User{ - ID: "user-id", - FirstName: "First", - LastName: "Last", - Email: "email@email.com", + }) } - - t.Run("returns Unauthorized error when no token is found", func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, nil) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - assert.JSONEq(t, `{"error": "Not authorized."}`, string(respBody)) - }) - - t.Run("returns BadRequest error when JSON decoding fails", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`invalid`)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - assert.JSONEq(t, `{"error": "The request was invalid in some way."}`, string(respBody)) - }) - - t.Run("returns BadRequest error when current_password and new_password are not provided", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(`{}`)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - wantBody := `{ - "error": "The request was invalid in some way.", - "extras": { - "current_password":"current_password is required", - "new_password":"new_password should be different from current_password" - } - }` - assert.JSONEq(t, wantBody, string(respBody)) - }) - - t.Run("returns BadRequest error when current_password and new_password are equal", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - reqBody := `{"current_password": "currentpassword", "new_password": "currentpassword"}` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - wantBody := `{ - "error": "The request was invalid in some way.", - "extras": { - "new_password":"new_password should be different from current_password" - } - }` - assert.JSONEq(t, wantBody, string(respBody)) - }) - - t.Run("returns BadRequest error when password does not match all the criteria", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - reqBody := `{"current_password": "currentpassword", "new_password": "1Az2By3Cx"}` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - defer resp.Body.Close() - wantBody := `{ - "error": "The request was invalid in some way.", - "extras": { - "length":"password length must be between 12 and 36 characters", - "special character":"password must contain at least one special character" - } - }` - assert.JSONEq(t, wantBody, string(respBody)) - }) - - t.Run("returns InternalServerError when AuthManager fails", func(t *testing.T) { - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - reqBody := `{"current_password": "currentpassword", "new_password": "!1Az?2By.3Cx"}` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - jwtManagerMock. - On("ValidateToken", req.Context(), "token"). - Return(true, nil). - Once(). - On("GetUserFromToken", req.Context(), "token"). - Return(user, nil). - Once() - - authenticatorMock. - On("UpdatePassword", req.Context(), user, "currentpassword", "!1Az?2By.3Cx"). - Return(errors.New("unexpected error")). - Once() - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.JSONEq(t, `{"error":"Cannot update user password"}`, string(respBody)) - }) - - t.Run("updates the user password successfully", func(t *testing.T) { - buf := new(strings.Builder) - log.DefaultLogger.SetOutput(buf) - log.SetLevel(log.InfoLevel) - - ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") - reqBody := `{"current_password": "currentpassword", "new_password": "!1Az?2By.3Cx"}` - - w := httptest.NewRecorder() - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(reqBody)) - require.NoError(t, err) - - jwtManagerMock. - On("ValidateToken", req.Context(), "token"). - Return(true, nil). - Twice(). - On("GetUserFromToken", req.Context(), "token"). - Return(user, nil). - Twice() - - authenticatorMock. - On("UpdatePassword", req.Context(), user, "currentpassword", "!1Az?2By.3Cx"). - Return(nil). - Once() - - http.HandlerFunc(handler.PatchUserPassword).ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(t, http.StatusOK, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.JSONEq(t, `{"message": "user password updated successfully"}`, string(respBody)) - - // validate logs - require.Contains(t, buf.String(), "[UpdateUserPassword] - Updated password for user with account ID user-id") - }) - - authenticatorMock.AssertExpectations(t) - jwtManagerMock.AssertExpectations(t) } func Test_ProfileHandler_GetProfile(t *testing.T) { From fbcbbcec00367bf3715bcf68d11038b34e21da25 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Fri, 12 Jan 2024 15:53:58 -0800 Subject: [PATCH 20/39] [Fix] make password length generation + encryption be consistent with validation reqs (#147) --- stellar-auth/pkg/auth/authenticator.go | 4 ++-- stellar-auth/pkg/auth/authenticator_test.go | 5 ++-- stellar-auth/pkg/auth/password_encrypter.go | 16 +++++++++---- .../pkg/auth/password_encrypter_test.go | 15 +++++++++--- stellar-auth/pkg/cli/add_user.go | 4 ++-- stellar-auth/pkg/cli/add_user_test.go | 23 ++++++++++--------- 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/stellar-auth/pkg/auth/authenticator.go b/stellar-auth/pkg/auth/authenticator.go index 25ddce24b..e98c02a14 100644 --- a/stellar-auth/pkg/auth/authenticator.go +++ b/stellar-auth/pkg/auth/authenticator.go @@ -105,12 +105,12 @@ func (a *defaultAuthenticator) CreateUser(ctx context.Context, user *User, passw // In case no password is passed we generate a random OTP (One Time Password) if password == "" { // Random length pasword - randomNumber, err := rand.Int(rand.Reader, big.NewInt(maxPasswordLength-minPasswordLength+1)) + randomNumber, err := rand.Int(rand.Reader, big.NewInt(MaxPasswordLength-MinPasswordLength+1)) if err != nil { return nil, fmt.Errorf("error generating random number in create user: %w", err) } - passwordLength := int(randomNumber.Int64() + minPasswordLength) + passwordLength := int(randomNumber.Int64() + MinPasswordLength) password, err = utils.StringWithCharset(passwordLength, utils.PasswordCharset) if err != nil { return nil, fmt.Errorf("error generating random password string in create user: %w", err) diff --git a/stellar-auth/pkg/auth/authenticator_test.go b/stellar-auth/pkg/auth/authenticator_test.go index 582a5aac2..89012302e 100644 --- a/stellar-auth/pkg/auth/authenticator_test.go +++ b/stellar-auth/pkg/auth/authenticator_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "testing" "time" @@ -201,7 +202,7 @@ func Test_DefaultAuthenticator_CreateUser(t *testing.T) { u, err := authenticator.CreateUser(ctx, user, password) assert.Nil(t, u) - assert.EqualError(t, err, "error encrypting password: password should have at least 8 characters") + assert.EqualError(t, err, fmt.Sprintf("error encrypting password: password should have at least %d characters", MinPasswordLength)) passwordEncrypterMock. On("Encrypt", ctx, password). @@ -696,7 +697,7 @@ func Test_DefaultAuthenticator_UpdateUser(t *testing.T) { Once() err := authenticator.UpdateUser(ctx, "user-id", "", "", "", "short") - assert.EqualError(t, err, "password should have at least 8 characters") + assert.EqualError(t, err, fmt.Sprintf("password should have at least %d characters", MinPasswordLength)) }) t.Run("returns error when PasswordEncrypter fails", func(t *testing.T) { diff --git a/stellar-auth/pkg/auth/password_encrypter.go b/stellar-auth/pkg/auth/password_encrypter.go index de9359dda..18c4d9d3c 100644 --- a/stellar-auth/pkg/auth/password_encrypter.go +++ b/stellar-auth/pkg/auth/password_encrypter.go @@ -9,11 +9,14 @@ import ( ) const ( - minPasswordLength = 8 - maxPasswordLength = 16 + MinPasswordLength = 12 + MaxPasswordLength = 36 ) -var ErrPasswordTooShort = errors.New("password should have at least 8 characters") +var ( + ErrPasswordTooShort = fmt.Errorf("password should have at least %d characters", MinPasswordLength) + ErrPasswordTooLong = fmt.Errorf("password should have at most %d characters", MaxPasswordLength) +) // PasswordEncrypter is a interface that defines the methods to encrypt passwords and compare a password with its stored hash. // This interface is used by `DefaultAuthenticator` as the type of `passwordEncrypter` attribute. @@ -30,11 +33,14 @@ type PasswordEncrypter interface { type DefaultPasswordEncrypter struct{} func (e *DefaultPasswordEncrypter) Encrypt(ctx context.Context, password string) (string, error) { - // Assumes that a password can't have less than 8 characters. - if len(password) < minPasswordLength { + if len(password) < MinPasswordLength { return "", ErrPasswordTooShort } + if len(password) > MaxPasswordLength { + return "", ErrPasswordTooLong + } + encryptedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("encrypting password: %w", err) diff --git a/stellar-auth/pkg/auth/password_encrypter_test.go b/stellar-auth/pkg/auth/password_encrypter_test.go index 1fd215a6f..43d4c4fef 100644 --- a/stellar-auth/pkg/auth/password_encrypter_test.go +++ b/stellar-auth/pkg/auth/password_encrypter_test.go @@ -29,8 +29,17 @@ func Test_DefaultPasswordEncrypter_Encrypt(t *testing.T) { assert.Empty(t, encryptedPassword) }) + t.Run("returns err when password is too long", func(t *testing.T) { + password := "G635a3LBOtS!vh6hyuvZFlgG@wLuE6IRd3k#rk" + + encryptedPassword, err := passwordEncrypter.Encrypt(ctx, password) + + assert.EqualError(t, err, ErrPasswordTooLong.Error()) + assert.Empty(t, encryptedPassword) + }) + t.Run("encrypts the password correctly", func(t *testing.T) { - password := "mysecret" + password := "mysecret1234" encryptedPassword, err := passwordEncrypter.Encrypt(ctx, password) require.NoError(t, err) @@ -56,7 +65,7 @@ func Test_DefaultPasswordEncrypter_ComparePassword(t *testing.T) { ctx := context.Background() t.Run("returns false when the password is wrong", func(t *testing.T) { - password := "mysecret" + password := "mysecret1234" encryptedPassword, err := passwordEncrypter.Encrypt(ctx, password) require.NoError(t, err) @@ -68,7 +77,7 @@ func Test_DefaultPasswordEncrypter_ComparePassword(t *testing.T) { }) t.Run("returns true when the password is correct", func(t *testing.T) { - password := "mysecret" + password := "mysecret1234BxYqMmd7Nhwvw" encryptedPassword, err := passwordEncrypter.Encrypt(ctx, password) require.NoError(t, err) diff --git a/stellar-auth/pkg/cli/add_user.go b/stellar-auth/pkg/cli/add_user.go index bb3300ce6..398f298fc 100644 --- a/stellar-auth/pkg/cli/add_user.go +++ b/stellar-auth/pkg/cli/add_user.go @@ -40,7 +40,7 @@ func AddUserCmd(databaseURLFlagName string, passwordPrompt PasswordPromptInterfa }, { Name: "password", - Usage: "Sets the user password, it should be at least 8 characters long, if omitted, the command will generate a random one.", + Usage: fmt.Sprintf("Sets the user password, it should be at least %d characters long, if omitted, the command will generate a random one.", auth.MinPasswordLength), OptType: types.Bool, ConfigKey: &passwordFlag, FlagDefault: false, @@ -64,7 +64,7 @@ func AddUserCmd(databaseURLFlagName string, passwordPrompt PasswordPromptInterfa addUser := &cobra.Command{ Use: "add-user [--owner] [--roles] [--password]", Short: "Add user to the system", - Long: "Add a user to the system. Email should be unique and password must be at least 8 characters long.", + Long: fmt.Sprintf("Add a user to the system. Email should be unique and password must be at least %d characters long.", auth.MinPasswordLength), Args: cobra.ExactArgs(3), PersistentPreRun: func(cmd *cobra.Command, args []string) { ctx := cmd.Context() diff --git a/stellar-auth/pkg/cli/add_user_test.go b/stellar-auth/pkg/cli/add_user_test.go index efa252762..b328f597f 100644 --- a/stellar-auth/pkg/cli/add_user_test.go +++ b/stellar-auth/pkg/cli/add_user_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "strings" "testing" @@ -122,7 +123,7 @@ func Test_authAddUserCommand(t *testing.T) { err := testCmd.Execute() require.NoError(t, err) - expectedUsage := `Add a user to the system. Email should be unique and password must be at least 8 characters long. + expectedUsage := fmt.Sprintf(`Add a user to the system. Email should be unique and password must be at least %d characters long. Usage: test add-user [--owner] [--roles] [--password] [flags] @@ -130,8 +131,8 @@ Usage: Flags: -h, --help help for add-user --owner Set the user as Owner (superuser). Defaults to "false". (OWNER) - --password Sets the user password, it should be at least 8 characters long, if omitted, the command will generate a random one. (PASSWORD) -` + --password Sets the user password, it should be at least %d characters long, if omitted, the command will generate a random one. (PASSWORD) +`, auth.MinPasswordLength, auth.MinPasswordLength) assert.Equal(t, expectedUsage, buf.String()) addUserCmd = AddUserCmd("database-url", &mockPrompt, []string{"role1", "role2", "role3", "role4"}) @@ -145,7 +146,7 @@ Flags: err = testCmd.Execute() require.NoError(t, err) - expectedUsage = `Add a user to the system. Email should be unique and password must be at least 8 characters long. + expectedUsage = fmt.Sprintf(`Add a user to the system. Email should be unique and password must be at least %d characters long. Usage: test add-user [--owner] [--roles] [--password] [flags] @@ -153,9 +154,9 @@ Usage: Flags: -h, --help help for add-user --owner Set the user as Owner (superuser). Defaults to "false". (OWNER) - --password Sets the user password, it should be at least 8 characters long, if omitted, the command will generate a random one. (PASSWORD) + --password Sets the user password, it should be at least %d characters long, if omitted, the command will generate a random one. (PASSWORD) --roles string Set the user roles. It should be comma separated. Example: role1, role2. Available roles: [role1, role2, role3, role4]. (ROLES) -` +`, auth.MinPasswordLength, auth.MinPasswordLength) assert.Equal(t, expectedUsage, buf.String()) }) @@ -195,7 +196,7 @@ func Test_execAddUserFunc(t *testing.T) { ctx := context.Background() t.Run("User must be valid", func(t *testing.T) { - email, password, firstName, lastName := "test@email.com", "mypassword", "First", "Last" + email, password, firstName, lastName := "test@email.com", "mypassword12", "First", "Last" // Invalid invalid err := execAddUser(ctx, dbt.DSN, "", firstName, lastName, password, false, []string{}) @@ -206,7 +207,7 @@ func Test_execAddUserFunc(t *testing.T) { // Invalid password err = execAddUser(ctx, dbt.DSN, email, firstName, lastName, "pass", false, []string{}) - assert.EqualError(t, err, "error creating user: error creating user: error encrypting password: password should have at least 8 characters") + assert.EqualError(t, err, fmt.Sprintf("error creating user: error creating user: error encrypting password: password should have at least %d characters", auth.MinPasswordLength)) // Invalid first name err = execAddUser(ctx, dbt.DSN, email, "", lastName, "pass", false, []string{}) @@ -222,7 +223,7 @@ func Test_execAddUserFunc(t *testing.T) { }) t.Run("Inserted user must have his password encrypted", func(t *testing.T) { - email, password, firstName, lastName := "test2@email.com", "mypassword", "First", "Last" + email, password, firstName, lastName := "test2@email.com", "mypassword12", "First", "Last" err := execAddUser(ctx, dbt.DSN, email, firstName, lastName, password, false, []string{}) require.NoError(t, err) @@ -240,7 +241,7 @@ func Test_execAddUserFunc(t *testing.T) { }) t.Run("Email should be unique", func(t *testing.T) { - email, password, firstName, lastName := "unique@email.com", "mypassword", "First", "Last" + email, password, firstName, lastName := "unique@email.com", "mypassword12", "First", "Last" err := execAddUser(ctx, dbt.DSN, email, firstName, lastName, password, false, []string{}) require.NoError(t, err) @@ -250,7 +251,7 @@ func Test_execAddUserFunc(t *testing.T) { }) t.Run("set the user roles", func(t *testing.T) { - email, password, firstName, lastName := "testroles@email.com", "mypassword", "First", "Last" + email, password, firstName, lastName := "testroles@email.com", "mypassword12", "First", "Last" err := execAddUser(ctx, dbt.DSN, email, firstName, lastName, password, false, []string{"role1", "role2"}) require.NoError(t, err) From 9f857c7640f073c235f0a069ff4673c42c61134f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 18 Jan 2024 13:03:29 -0300 Subject: [PATCH 21/39] [SDP-1012]: Add SMS preview & editing before sending a new disbursement (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What Adds a new field in disbursement table to save sms_registration_message_template Modies POST /disbursements to accept `sms_registration_message_template Modifies the send_receiver_wallets_sms_invitation_job to choose between disbursement level sms or default to organization sms if one isn’t defined. Changes GET /disbursements/:id to return the sms_registration_message_template For retries, we need to make sure that the template used is consistent. Why This was raised by UNICC. They need the ability to trigger disbursements for multiple organizations (initially two). They want to have a custom SMS per organization. --- internal/data/assets.go | 11 +- internal/data/assets_test.go | 76 +++--- internal/data/disbursements.go | 33 +-- internal/data/disbursements_test.go | 12 +- ...r-disbursements-table-add-sms-template.sql | 9 + .../serve/httphandler/disbursement_handler.go | 20 +- .../httphandler/disbursement_handler_test.go | 14 +- .../httphandler/payments_handler_test.go | 3 +- .../send_receiver_wallets_invite_service.go | 22 +- ...nd_receiver_wallets_invite_service_test.go | 232 ++++++++++++++++++ 10 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 internal/db/migrations/2024-01-12.0-alter-disbursements-table-add-sms-template.sql diff --git a/internal/data/assets.go b/internal/data/assets.go index 51ed8400b..fcc4f8b94 100644 --- a/internal/data/assets.go +++ b/internal/data/assets.go @@ -198,9 +198,10 @@ func (a *AssetModel) SoftDelete(ctx context.Context, sqlExec db.SQLExecuter, id } type ReceiverWalletAsset struct { - WalletID string `db:"wallet_id"` - ReceiverWallet ReceiverWallet `db:"receiver_wallet"` - Asset Asset `db:"asset"` + WalletID string `db:"wallet_id"` + ReceiverWallet ReceiverWallet `db:"receiver_wallet"` + Asset Asset `db:"asset"` + DisbursementSMSTemplate *string `json:"-" db:"sms_registration_message_template"` } // GetAssetsPerReceiverWallet returns the assets associated with a READY payment for each receiver @@ -218,6 +219,7 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal SELECT p.id AS payment_id, d.wallet_id, + COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, p.asset_id FROM payments p @@ -226,7 +228,7 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal WHERE p.status = $1 GROUP BY - p.id, p.asset_id, d.wallet_id + p.id, p.asset_id, d.wallet_id, d.sms_registration_message_template ORDER BY p.updated_at DESC ), messages_resent_since_invitation AS ( @@ -251,6 +253,7 @@ func (a *AssetModel) GetAssetsPerReceiverWallet(ctx context.Context, receiverWal ) SELECT DISTINCT lpw.wallet_id, + lpw.sms_registration_message_template, rw.id AS "receiver_wallet.id", rw.invitation_sent_at AS "receiver_wallet.invitation_sent_at", COALESCE(mrsi.total_invitation_sms_resent_attempts, 0) AS "receiver_wallet.total_invitation_sms_resent_attempts", diff --git a/internal/data/assets_test.go b/internal/data/assets_test.go index 723982865..67c93ec8e 100644 --- a/internal/data/assets_test.go +++ b/internal/data/assets_test.go @@ -345,28 +345,32 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { walletB := CreateWalletFixture(t, ctx, dbConnectionPool, "walletB", "https://www.b.com", "www.b.com", "b://") disbursementA1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletA, - Status: ReadyDisbursementStatus, - Asset: asset1, + Country: country, + Wallet: walletA, + Status: ReadyDisbursementStatus, + Asset: asset1, + SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A1", }) disbursementA2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletA, - Status: ReadyDisbursementStatus, - Asset: asset2, + Country: country, + Wallet: walletA, + Status: ReadyDisbursementStatus, + Asset: asset2, + SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template A2", }) disbursementB1 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletB, - Status: ReadyDisbursementStatus, - Asset: asset1, + Country: country, + Wallet: walletB, + Status: ReadyDisbursementStatus, + Asset: asset1, + SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B1", }) disbursementB2 := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ - Country: country, - Wallet: walletB, - Status: ReadyDisbursementStatus, - Asset: asset2, + Country: country, + Wallet: walletB, + Status: ReadyDisbursementStatus, + Asset: asset2, + SMSRegistrationMessageTemplate: "Disbursement SMS Registration Message Template B2", }) // 2. Create receivers, and receiver wallets: @@ -527,8 +531,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { }, InvitationSentAt: &invitationSentAt, }, - WalletID: walletA.ID, - Asset: *asset1, + WalletID: walletA.ID, + Asset: *asset1, + DisbursementSMSTemplate: &disbursementA1.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -540,8 +545,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { }, InvitationSentAt: &invitationSentAt, }, - WalletID: walletA.ID, - Asset: *asset2, + WalletID: walletA.ID, + Asset: *asset2, + DisbursementSMSTemplate: &disbursementA2.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -552,8 +558,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverX.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset1, + WalletID: walletB.ID, + Asset: *asset1, + DisbursementSMSTemplate: &disbursementB1.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -564,8 +571,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverX.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset2, + WalletID: walletB.ID, + Asset: *asset2, + DisbursementSMSTemplate: &disbursementB2.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -576,8 +584,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletA.ID, - Asset: *asset1, + WalletID: walletA.ID, + Asset: *asset1, + DisbursementSMSTemplate: &disbursementA1.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -588,8 +597,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletA.ID, - Asset: *asset2, + WalletID: walletA.ID, + Asset: *asset2, + DisbursementSMSTemplate: &disbursementA2.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -600,8 +610,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset1, + WalletID: walletB.ID, + Asset: *asset1, + DisbursementSMSTemplate: &disbursementB1.SMSRegistrationMessageTemplate, }, { ReceiverWallet: ReceiverWallet{ @@ -612,8 +623,9 @@ func Test_GetAssetsPerReceiverWallet(t *testing.T) { PhoneNumber: receiverY.PhoneNumber, }, }, - WalletID: walletB.ID, - Asset: *asset2, + WalletID: walletB.ID, + Asset: *asset2, + DisbursementSMSTemplate: &disbursementB2.SMSRegistrationMessageTemplate, }, } diff --git a/internal/data/disbursements.go b/internal/data/disbursements.go index 2e8bd413d..a9e1c75fb 100644 --- a/internal/data/disbursements.go +++ b/internal/data/disbursements.go @@ -16,18 +16,19 @@ import ( ) type Disbursement struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Country *Country `json:"country,omitempty" db:"country"` - Wallet *Wallet `json:"wallet,omitempty" db:"wallet"` - Asset *Asset `json:"asset,omitempty" db:"asset"` - Status DisbursementStatus `json:"status" db:"status"` - VerificationField VerificationField `json:"verification_field,omitempty" db:"verification_field"` - StatusHistory DisbursementStatusHistory `json:"status_history,omitempty" db:"status_history"` - FileName string `json:"file_name,omitempty" db:"file_name"` - FileContent []byte `json:"-" db:"file_content"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Country *Country `json:"country,omitempty" db:"country"` + Wallet *Wallet `json:"wallet,omitempty" db:"wallet"` + Asset *Asset `json:"asset,omitempty" db:"asset"` + Status DisbursementStatus `json:"status" db:"status"` + VerificationField VerificationField `json:"verification_field,omitempty" db:"verification_field"` + StatusHistory DisbursementStatusHistory `json:"status_history,omitempty" db:"status_history"` + SMSRegistrationMessageTemplate string `json:"sms_registration_message_template" db:"sms_registration_message_template"` + FileName string `json:"file_name,omitempty" db:"file_name"` + FileContent []byte `json:"-" db:"file_content"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` *DisbursementStats } @@ -86,9 +87,9 @@ var ( func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disbursement) (string, error) { const q = ` INSERT INTO - disbursements (name, status, status_history, wallet_id, asset_id, country_code, verification_field) + disbursements (name, status, status_history, wallet_id, asset_id, country_code, verification_field, sms_registration_message_template) VALUES - ($1, $2, $3, $4, $5, $6, $7) + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id ` var newId string @@ -100,6 +101,7 @@ func (d *DisbursementModel) Insert(ctx context.Context, disbursement *Disburseme disbursement.Asset.ID, disbursement.Country.Code, disbursement.VerificationField, + disbursement.SMSRegistrationMessageTemplate, ) if err != nil { // check if the error is a duplicate key error @@ -141,6 +143,7 @@ func (d *DisbursementModel) Get(ctx context.Context, sqlExec db.SQLExecuter, id d.created_at, d.updated_at, d.verification_field, + COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, w.id as "wallet.id", w.name as "wallet.name", w.homepage as "wallet.homepage", @@ -192,6 +195,7 @@ func (d *DisbursementModel) GetByName(ctx context.Context, sqlExec db.SQLExecute d.created_at, d.updated_at, d.verification_field, + COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, w.id as "wallet.id", w.name as "wallet.name", w.homepage as "wallet.homepage", @@ -332,6 +336,7 @@ func (d *DisbursementModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, d.created_at, d.updated_at, d.verification_field, + COALESCE(d.sms_registration_message_template, '') as sms_registration_message_template, COALESCE(d.file_name, '') as file_name, w.id as "wallet.id", w.name as "wallet.name", diff --git a/internal/data/disbursements_test.go b/internal/data/disbursements_test.go index fdb2cb47f..2942269f6 100644 --- a/internal/data/disbursements_test.go +++ b/internal/data/disbursements_test.go @@ -28,6 +28,8 @@ func Test_DisbursementModelInsert(t *testing.T) { wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") wallet.Assets = nil + smsTemplate := "You have a new payment waiting for you from org x. Click on the link to register." + disbursement := Disbursement{ Name: "disbursement1", Status: DraftDisbursementStatus, @@ -37,10 +39,11 @@ func Test_DisbursementModelInsert(t *testing.T) { UserID: "user1", }, }, - Asset: asset, - Country: country, - Wallet: wallet, - VerificationField: VerificationFieldDateOfBirth, + Asset: asset, + Country: country, + Wallet: wallet, + VerificationField: VerificationFieldDateOfBirth, + SMSRegistrationMessageTemplate: smsTemplate, } t.Run("returns error when disbursement already exists is not found", func(t *testing.T) { @@ -65,6 +68,7 @@ func Test_DisbursementModelInsert(t *testing.T) { assert.Equal(t, asset, actual.Asset) assert.Equal(t, country, actual.Country) assert.Equal(t, wallet, actual.Wallet) + assert.Equal(t, smsTemplate, actual.SMSRegistrationMessageTemplate) assert.Equal(t, 1, len(actual.StatusHistory)) assert.Equal(t, DraftDisbursementStatus, actual.StatusHistory[0].Status) assert.Equal(t, "user1", actual.StatusHistory[0].UserID) diff --git a/internal/db/migrations/2024-01-12.0-alter-disbursements-table-add-sms-template.sql b/internal/db/migrations/2024-01-12.0-alter-disbursements-table-add-sms-template.sql new file mode 100644 index 000000000..74ab1d79b --- /dev/null +++ b/internal/db/migrations/2024-01-12.0-alter-disbursements-table-add-sms-template.sql @@ -0,0 +1,9 @@ +-- +migrate Up +ALTER TABLE + public.disbursements +ADD + COLUMN sms_registration_message_template TEXT NULL; + +-- +migrate Down +ALTER TABLE + public.disbursements DROP COLUMN sms_registration_message_template; \ No newline at end of file diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index 780b8568b..a952ce39d 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -33,11 +33,12 @@ type DisbursementHandler struct { } type PostDisbursementRequest struct { - Name string `json:"name"` - CountryCode string `json:"country_code"` - WalletID string `json:"wallet_id"` - AssetID string `json:"asset_id"` - VerificationField data.VerificationField `json:"verification_field"` + Name string `json:"name"` + CountryCode string `json:"country_code"` + WalletID string `json:"wallet_id"` + AssetID string `json:"asset_id"` + VerificationField data.VerificationField `json:"verification_field"` + SMSRegistrationMessageTemplate string `json:"sms_registration_message_template"` } type PatchDisbursementStatusRequest struct { @@ -113,10 +114,11 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req Status: data.DraftDisbursementStatus, UserID: user.ID, }}, - Wallet: wallet, - Asset: asset, - Country: country, - VerificationField: verificationField, + Wallet: wallet, + Asset: asset, + Country: country, + VerificationField: verificationField, + SMSRegistrationMessageTemplate: disbursementRequest.SMSRegistrationMessageTemplate, } newId, err := d.Models.Disbursements.Insert(ctx, &disbursement) diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index b3c42b004..5af0fd8b8 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -81,6 +81,8 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) + smsTemplate := "You have a new payment waiting for you from org x. Click on the link to register." + t.Run("returns error when body is invalid", func(t *testing.T) { requestBody := ` { @@ -261,11 +263,12 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { expectedName := "disbursement 2" requestBody, err := json.Marshal(PostDisbursementRequest{ - Name: expectedName, - CountryCode: country.Code, - AssetID: asset.ID, - WalletID: enabledWallet.ID, - VerificationField: data.VerificationFieldDateOfBirth, + Name: expectedName, + CountryCode: country.Code, + AssetID: asset.ID, + WalletID: enabledWallet.ID, + VerificationField: data.VerificationFieldDateOfBirth, + SMSRegistrationMessageTemplate: smsTemplate, }) require.NoError(t, err) @@ -289,6 +292,7 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { assert.Equal(t, 1, len(actualDisbursement.StatusHistory)) assert.Equal(t, data.DraftDisbursementStatus, actualDisbursement.StatusHistory[0].Status) assert.Equal(t, user.ID, actualDisbursement.StatusHistory[0].UserID) + assert.Equal(t, smsTemplate, actualDisbursement.SMSRegistrationMessageTemplate) mMonitorService.AssertExpectations(t) }) diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index e5e6670fb..158ad6008 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -115,7 +115,8 @@ func Test_PaymentsHandlerGet(t *testing.T) { "name": "disbursement 1", "status": "DRAFT", "created_at": %q, - "updated_at": %q + "updated_at": %q, + "sms_registration_message_template":"" }, "asset": { "id": %q, diff --git a/internal/services/send_receiver_wallets_invite_service.go b/internal/services/send_receiver_wallets_invite_service.go index ea4a96281..611d2e3f3 100644 --- a/internal/services/send_receiver_wallets_invite_service.go +++ b/internal/services/send_receiver_wallets_invite_service.go @@ -57,15 +57,15 @@ func (s SendReceiverWalletInviteService) SendInvite(ctx context.Context) error { log.Ctx(ctx).Debug("automatic resend invitation SMS is deactivated. Set a valid value to the organization's sms_resend_interval to activate it.") } - smsRegistrationMessageTemplate := organization.SMSRegistrationMessageTemplate - if !strings.Contains(smsRegistrationMessageTemplate, "{{.RegistrationLink}}") { - smsRegistrationMessageTemplate = fmt.Sprintf("%s {{.RegistrationLink}}", strings.TrimSpace(smsRegistrationMessageTemplate)) + orgSMSRegistrationMessageTemplate := organization.SMSRegistrationMessageTemplate + if !strings.Contains(orgSMSRegistrationMessageTemplate, "{{.RegistrationLink}}") { + orgSMSRegistrationMessageTemplate = fmt.Sprintf("%s {{.RegistrationLink}}", strings.TrimSpace(orgSMSRegistrationMessageTemplate)) } // Execute the template early so we avoid hitting the database to query the other info - msgTemplate, err := template.New("").Parse(smsRegistrationMessageTemplate) + msgTemplate, err := template.New("").Parse(orgSMSRegistrationMessageTemplate) if err != nil { - return fmt.Errorf("error parsing SMS registration message template: %w", err) + return fmt.Errorf("error parsing organization SMS registration message template: %w", err) } wallets, err := s.models.Wallets.GetAll(ctx) @@ -115,6 +115,18 @@ func (s SendReceiverWalletInviteService) SendInvite(ctx context.Context) error { continue } + disbursementSMSRegistrationMessageTemplate := rwa.DisbursementSMSTemplate + if disbursementSMSRegistrationMessageTemplate != nil && *disbursementSMSRegistrationMessageTemplate != "" { + if !strings.Contains(*disbursementSMSRegistrationMessageTemplate, "{{.RegistrationLink}}") { + *disbursementSMSRegistrationMessageTemplate = fmt.Sprintf("%s {{.RegistrationLink}}", strings.TrimSpace(*disbursementSMSRegistrationMessageTemplate)) + } + + msgTemplate, err = template.New("").Parse(*disbursementSMSRegistrationMessageTemplate) + if err != nil { + return fmt.Errorf("error parsing disbursement SMS registration message template: %w", err) + } + } + content := new(strings.Builder) err = msgTemplate.Execute(content, struct { OrganizationName string diff --git a/internal/services/send_receiver_wallets_invite_service_test.go b/internal/services/send_receiver_wallets_invite_service_test.go index 08eb13c86..8acab9f10 100644 --- a/internal/services/send_receiver_wallets_invite_service_test.go +++ b/internal/services/send_receiver_wallets_invite_service_test.go @@ -721,6 +721,238 @@ func Test_SendReceiverWalletInviteService(t *testing.T) { assert.Nil(t, msg.AssetID) }) + t.Run("send disbursement invite successfully", func(t *testing.T) { + disbursement3 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet1, + Status: data.ReadyDisbursementStatus, + Asset: asset1, + SMSRegistrationMessageTemplate: "SMS Registration Message template test disbursement 3:", + }) + + disbursement4 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet2, + Status: data.ReadyDisbursementStatus, + Asset: asset2, + SMSRegistrationMessageTemplate: "SMS Registration Message template test disbursement 4:", + }) + + s, err := NewSendReceiverWalletInviteService(models, messengerClientMock, anchorPlatformBaseSepURL, stellarSecretKey, 3, mockCrashTrackerClient) + require.NoError(t, err) + + data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + data.DeleteAllMessagesFixtures(t, ctx, dbConnectionPool) + data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + + rec1RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, data.ReadyReceiversWalletStatus) + data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet2.ID, data.RegisteredReceiversWalletStatus) + + rec2RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet2.ID, data.ReadyReceiversWalletStatus) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Status: data.ReadyPaymentStatus, + Disbursement: disbursement3, + Asset: *asset1, + ReceiverWallet: rec1RW, + Amount: "1", + }) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Status: data.ReadyPaymentStatus, + Disbursement: disbursement4, + Asset: *asset2, + ReceiverWallet: rec2RW, + Amount: "1", + }) + + walletDeepLink1 := WalletDeepLink{ + DeepLink: wallet1.DeepLinkSchema, + AnchorPlatformBaseSepURL: anchorPlatformBaseSepURL, + OrganizationName: "MyCustomAid", + AssetCode: asset1.Code, + AssetIssuer: asset1.Issuer, + } + deepLink1, err := walletDeepLink1.GetSignedRegistrationLink(stellarSecretKey) + require.NoError(t, err) + contentDisbursement3 := fmt.Sprintf("%s %s", disbursement3.SMSRegistrationMessageTemplate, deepLink1) + + walletDeepLink2 := WalletDeepLink{ + DeepLink: wallet2.DeepLinkSchema, + AnchorPlatformBaseSepURL: anchorPlatformBaseSepURL, + OrganizationName: "MyCustomAid", + AssetCode: asset2.Code, + AssetIssuer: asset2.Issuer, + } + deepLink2, err := walletDeepLink2.GetSignedRegistrationLink(stellarSecretKey) + require.NoError(t, err) + contentDisbursement4 := fmt.Sprintf("%s %s", disbursement4.SMSRegistrationMessageTemplate, deepLink2) + + messengerClientMock. + On("SendMessage", message.Message{ + ToPhoneNumber: receiver1.PhoneNumber, + Message: contentDisbursement3, + }). + Return(nil). + Once(). + On("SendMessage", message.Message{ + ToPhoneNumber: receiver2.PhoneNumber, + Message: contentDisbursement4, + }). + Return(nil). + Once() + + err = s.SendInvite(ctx) + require.NoError(t, err) + + receivers, err := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, dbConnectionPool, []string{receiver1.ID}, wallet1.ID) + require.NoError(t, err) + require.Len(t, receivers, 1) + assert.Equal(t, rec1RW.ID, receivers[0].ID) + assert.NotNil(t, receivers[0].InvitationSentAt) + + receivers, err = models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, dbConnectionPool, []string{receiver2.ID}, wallet2.ID) + require.NoError(t, err) + require.Len(t, receivers, 1) + assert.Equal(t, rec2RW.ID, receivers[0].ID) + assert.NotNil(t, receivers[0].InvitationSentAt) + + q := ` + SELECT + type, status, receiver_id, wallet_id, receiver_wallet_id, + title_encrypted, text_encrypted, status_history + FROM + messages + WHERE + receiver_id = $1 AND wallet_id = $2 AND receiver_wallet_id = $3 + ` + var msg data.Message + err = dbConnectionPool.GetContext(ctx, &msg, q, receiver1.ID, wallet1.ID, rec1RW.ID) + require.NoError(t, err) + + assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) + assert.Equal(t, receiver1.ID, msg.ReceiverID) + assert.Equal(t, wallet1.ID, msg.WalletID) + assert.Equal(t, rec1RW.ID, *msg.ReceiverWalletID) + assert.Equal(t, data.SuccessMessageStatus, msg.Status) + assert.Empty(t, msg.TitleEncrypted) + assert.Equal(t, contentDisbursement3, msg.TextEncrypted) + assert.Len(t, msg.StatusHistory, 2) + assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) + assert.Equal(t, data.SuccessMessageStatus, msg.StatusHistory[1].Status) + assert.Nil(t, msg.AssetID) + + msg = data.Message{} + err = dbConnectionPool.GetContext(ctx, &msg, q, receiver2.ID, wallet2.ID, rec2RW.ID) + require.NoError(t, err) + + assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) + assert.Equal(t, receiver2.ID, msg.ReceiverID) + assert.Equal(t, wallet2.ID, msg.WalletID) + assert.Equal(t, rec2RW.ID, *msg.ReceiverWalletID) + assert.Equal(t, data.SuccessMessageStatus, msg.Status) + assert.Empty(t, msg.TitleEncrypted) + assert.Equal(t, contentDisbursement4, msg.TextEncrypted) + assert.Len(t, msg.StatusHistory, 2) + assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) + assert.Equal(t, data.SuccessMessageStatus, msg.StatusHistory[1].Status) + assert.Nil(t, msg.AssetID) + }) + + t.Run("successfully resend the disbursement invitation SMS", func(t *testing.T) { + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet1, + Status: data.ReadyDisbursementStatus, + Asset: asset1, + SMSRegistrationMessageTemplate: "SMS Registration Message template test disbursement:", + }) + + s, err := NewSendReceiverWalletInviteService(models, messengerClientMock, anchorPlatformBaseSepURL, stellarSecretKey, 3, mockCrashTrackerClient) + require.NoError(t, err) + + data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + data.DeleteAllMessagesFixtures(t, ctx, dbConnectionPool) + data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + + rec1RW := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet1.ID, data.ReadyReceiversWalletStatus) + data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet2.ID, data.RegisteredReceiversWalletStatus) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + Asset: *asset1, + ReceiverWallet: rec1RW, + Amount: "1", + }) + + // Marking as sent + var invitationSentAt time.Time + q := "UPDATE receiver_wallets SET invitation_sent_at = NOW() - interval '2 days' - interval '3 hours' WHERE id = $1 RETURNING invitation_sent_at" + err = dbConnectionPool.GetContext(ctx, &invitationSentAt, q, rec1RW.ID) + require.NoError(t, err) + + // Set the SMS Resend Interval + var smsResendInterval int64 = 2 + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{SMSResendInterval: &smsResendInterval, SMSRegistrationMessageTemplate: new(string)}) + require.NoError(t, err) + + walletDeepLink1 := WalletDeepLink{ + DeepLink: wallet1.DeepLinkSchema, + AnchorPlatformBaseSepURL: anchorPlatformBaseSepURL, + OrganizationName: "MyCustomAid", + AssetCode: asset1.Code, + AssetIssuer: asset1.Issuer, + } + deepLink1, err := walletDeepLink1.GetSignedRegistrationLink(stellarSecretKey) + require.NoError(t, err) + contentDisbursement := fmt.Sprintf("%s %s", disbursement.SMSRegistrationMessageTemplate, deepLink1) + + messengerClientMock. + On("SendMessage", message.Message{ + ToPhoneNumber: receiver1.PhoneNumber, + Message: contentDisbursement, + }). + Return(nil). + Once() + + err = s.SendInvite(ctx) + require.NoError(t, err) + + receivers, err := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, dbConnectionPool, []string{receiver1.ID}, wallet1.ID) + require.NoError(t, err) + require.Len(t, receivers, 1) + assert.Equal(t, rec1RW.ID, receivers[0].ID) + require.NotNil(t, receivers[0].InvitationSentAt) + assert.Equal(t, invitationSentAt, *receivers[0].InvitationSentAt) + + q = ` + SELECT + type, status, receiver_id, wallet_id, receiver_wallet_id, + title_encrypted, text_encrypted, status_history + FROM + messages + WHERE + receiver_id = $1 AND wallet_id = $2 AND + receiver_wallet_id = $3 AND created_at > $4 + ` + var msg data.Message + err = dbConnectionPool.GetContext(ctx, &msg, q, receiver1.ID, wallet1.ID, rec1RW.ID, invitationSentAt) + require.NoError(t, err) + + assert.Equal(t, message.MessengerTypeTwilioSMS, msg.Type) + assert.Equal(t, receiver1.ID, msg.ReceiverID) + assert.Equal(t, wallet1.ID, msg.WalletID) + assert.Equal(t, rec1RW.ID, *msg.ReceiverWalletID) + assert.Equal(t, data.SuccessMessageStatus, msg.Status) + assert.Empty(t, msg.TitleEncrypted) + assert.Equal(t, contentDisbursement, msg.TextEncrypted) + assert.Len(t, msg.StatusHistory, 2) + assert.Equal(t, data.PendingMessageStatus, msg.StatusHistory[0].Status) + assert.Equal(t, data.SuccessMessageStatus, msg.StatusHistory[1].Status) + assert.Nil(t, msg.AssetID) + }) + messengerClientMock.AssertExpectations(t) } From 0236e35da7beaa6ff2372188eb9b4402d370d30f Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Thu, 18 Jan 2024 15:22:00 -0800 Subject: [PATCH 22/39] [SDP-1032] Add "Secure Operation Manual" section and updated the code to enforce MFA and reCAPTCHA (#150) ### What - Flip flag names ENABLE_MFA and ENABLE_RECAPTCHA to DISABLE_MFA and DISABLE_RECAPTCHA, because the default behavior is to leave them enabled. - If the network is set to pubnet and MFA or reCAPTCHA are disabled, return an error - Add a Secure Operation Manual section to the readme, with the following subjects: - MFA and reCAPTCHA - Approval flow - The importance of user management and using the right rolled (financial controller vs owner) ### Why To increase the security of hosts and operators. --- README.md | 42 ++++++++++ cmd/serve.go | 16 ++-- cmd/serve_test.go | 7 +- dev/docker-compose-sdp-anchor.yml | 4 +- helmchart/sdp/Chart.yaml | 4 +- helmchart/sdp/README.md | 4 +- helmchart/sdp/values.yaml | 16 ++-- .../docker-compose-e2e-tests.yml | 4 +- .../httphandler/forgot_password_handler.go | 4 +- .../forgot_password_handler_test.go | 4 +- internal/serve/httphandler/login_handler.go | 8 +- .../serve/httphandler/login_handler_test.go | 13 +-- internal/serve/serve.go | 39 +++++++-- internal/serve/serve_test.go | 81 ++++++++++++++++++- 14 files changed, 196 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index d19d4c9c6..ca717aff0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,48 @@ stellar-disbursement-platform --help To quickly test the SDP using preconfigured values, see the [Quick Start Guide](./dev/README.md). +## Secure Operation Manual + +This manual outlines the security measures implemented in the Stellar Disbursement Platform (SDP) to protect the integrity of the platform and its users. By adhering to these guidelines, you can ensure that your use of the SDP is as secure as possible. + +Security is a critical aspect of the SDP. The measures outlined in this document are designed to mitigate risks and enhance the security of the platform. Users are strongly encouraged to follow these guidelines to protect their accounts and operations. + +### Implementation of reCAPTCHA + +Google's reCAPTCHA has been integrated into the SDP to prevent automated attacks and ensure that interactions are performed by humans, not bots. + +ReCAPTCHA is enabled by default and can be disabled in the development environment by setting the `DISABLE_RECAPTCHA` environment variable to `true`. + +**Note:** Disabling reCAPTCHA is not supported for production environments due to security risks. + +### Enforcement of Multi-Factor Authentication + +Multi-Factor Authentication (MFA) provides an additional layer of security to user accounts. It is enforced by default on the SDP and it relies on OTPs sent to the account's email. + +MFA is enabled by default and can be disabled in the development environment by setting the `DISABLE_MFA` environment variable to `true`. + +**Note:** Disabling MFA is not supported for production environments due to security risks. + +### Best Practices for Wallet Management + +The SDP wallet should be used primarily as a hot wallet with a limited amount of funds to minimize potential losses. + +#### Hot and Cold Wallets + +- A hot wallet is connected to the internet and allows for quick transactions. +- A cold wallet is offline and used for storing funds securely. +- Learn more about these concepts at [Investopedia](https://www.investopedia.com/hot-wallet-vs-cold-wallet-7098461). + +### Distribution of Disbursement Responsibilities + +To enhance security, disbursement responsibilities should be distributed among multiple financial controller users. + +#### Recommended Configuration + +1. **Approval Flow**: Enable the approval flow on the organization page to require two users for the disbursement process. The owner can do that at *Profile > Organization > ... > Edit details > Approval flow > Confirm*. +2. **Financial Controller Role**: Create two users with the *Financial Controller* role on the organization page to enforce separation of duties. The owner can do hat at *Settings > Team Members*. +3. **Owner Account Management**: Use the Owner account solely for user management and organization configuration. Avoid using the Owner account for financial controller tasks to minimize the exposure of that account. + ## Architecture ![high_level_architecture](./docs/images/high_level_architecture.png) diff --git a/cmd/serve.go b/cmd/serve.go index c185437cd..3c72e80a9 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -245,19 +245,19 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ Required: true, }, { - Name: "enable-mfa", - Usage: "Enable MFA using email.", + Name: "disable-mfa", + Usage: "Disables the email Multi-Factor Authentication (MFA).", OptType: types.Bool, - ConfigKey: &serveOpts.EnableMFA, - FlagDefault: true, + ConfigKey: &serveOpts.DisableMFA, + FlagDefault: false, Required: false, }, { - Name: "enable-recaptcha", - Usage: "Enable ReCAPTCHA for login and forgot password.", + Name: "disable-recaptcha", + Usage: "Disables ReCAPTCHA for login and forgot password.", OptType: types.Bool, - ConfigKey: &serveOpts.EnableReCAPTCHA, - FlagDefault: true, + ConfigKey: &serveOpts.DisableReCAPTCHA, + FlagDefault: false, Required: false, }, { diff --git a/cmd/serve_test.go b/cmd/serve_test.go index be9d2872c..905058283 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "fmt" "sync" "testing" @@ -109,8 +110,8 @@ func Test_serve(t *testing.T) { DistributionSeed: "SBHQEYSACD5DOK5I656NKLAMOHC6VT64ATOWWM2VJ3URGDGMVGNPG4ON", ReCAPTCHASiteKey: "reCAPTCHASiteKey", ReCAPTCHASiteSecretKey: "reCAPTCHASiteSecretKey", - EnableMFA: true, - EnableReCAPTCHA: true, + DisableMFA: false, + DisableReCAPTCHA: false, } var err error serveOpts.AnchorPlatformAPIService, err = anchorplatform.NewAnchorPlatformAPIService(httpclient.DefaultClient(), serveOpts.AnchorPlatformBasePlatformURL, serveOpts.AnchorPlatformOutgoingJWTSecret) @@ -186,6 +187,8 @@ func Test_serve(t *testing.T) { t.Setenv("ANCHOR_PLATFORM_OUTGOING_JWT_SECRET", serveOpts.AnchorPlatformOutgoingJWTSecret) t.Setenv("DISTRIBUTION_PUBLIC_KEY", serveOpts.DistributionPublicKey) t.Setenv("DISTRIBUTION_SEED", serveOpts.DistributionSeed) + t.Setenv("DISABLE_MFA", fmt.Sprintf("%t", serveOpts.DisableMFA)) + t.Setenv("DISABLE_RECAPTCHA", fmt.Sprintf("%t", serveOpts.DisableMFA)) t.Setenv("BASE_URL", serveOpts.BaseURL) t.Setenv("RECAPTCHA_SITE_KEY", serveOpts.ReCAPTCHASiteKey) t.Setenv("RECAPTCHA_SITE_SECRET_KEY", serveOpts.ReCAPTCHASiteSecretKey) diff --git a/dev/docker-compose-sdp-anchor.yml b/dev/docker-compose-sdp-anchor.yml index 20c08c07f..2a24a73ba 100644 --- a/dev/docker-compose-sdp-anchor.yml +++ b/dev/docker-compose-sdp-anchor.yml @@ -39,8 +39,8 @@ services: DISTRIBUTION_SEED: ${DISTRIBUTION_SEED} RECAPTCHA_SITE_KEY: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI CORS_ALLOWED_ORIGINS: http://localhost:3000 - ENABLE_MFA: "false" - ENABLE_RECAPTCHA: "false" + DISABLE_MFA: "true" + DISABLE_RECAPTCHA: "true" # secrets: AWS_ACCESS_KEY_ID: MY_AWS_ACCESS_KEY_ID diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 4fb5d9ffe..4f500ec08 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) -version: 0.9.3 -appVersion: "1.0.1" +version: 0.9.4 +appVersion: "1.0.2" type: application maintainers: - name: Stellar Development Foundation diff --git a/helmchart/sdp/README.md b/helmchart/sdp/README.md index ef5f148e0..528008174 100644 --- a/helmchart/sdp/README.md +++ b/helmchart/sdp/README.md @@ -121,8 +121,8 @@ Configuration parameters for the SDP Core Service which is the core backend serv | `sdp.configMap.data.SMS_SENDER_TYPE` | The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". | `DRY_RUN` | | `sdp.configMap.data.RECAPTCHA_SITE_KEY` | Site key for ReCaptcha. Required if using ReCaptcha. | `nil` | | `sdp.configMap.data.CORS_ALLOWED_ORIGINS` | Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. | `*` | -| `sdp.configMap.data.ENABLE_RECAPTCHA` | Determines if ReCaptcha should be enabled for login ("true" or "false"). | `false` | -| `sdp.configMap.data.ENABLE_MFA` | Determines if email-based MFA should be enabled during login ("true" or "false"). | `false` | +| `sdp.configMap.data.DISABLE_RECAPTCHA` | Determines if ReCaptcha should be disabled for login ("true" or "false"). | `false` | +| `sdp.configMap.data.DISABLE_MFA` | Determines if email-based MFA should be disabled during login ("true" or "false"). | `false` | | `sdp.configMap.data.SDP_UI_BASE_URL` | The base URL of the SDP UI/dashboard. | `nil` | | `sdp.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | | `sdp.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `sdp-backend-secret-name` | diff --git a/helmchart/sdp/values.yaml b/helmchart/sdp/values.yaml index 570d008fe..8cf9efdbd 100644 --- a/helmchart/sdp/values.yaml +++ b/helmchart/sdp/values.yaml @@ -126,8 +126,8 @@ sdp: ## @param sdp.configMap.data.SMS_SENDER_TYPE The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". ## @param sdp.configMap.data.RECAPTCHA_SITE_KEY Site key for ReCaptcha. Required if using ReCaptcha. ## @param sdp.configMap.data.CORS_ALLOWED_ORIGINS Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. - ## @param sdp.configMap.data.ENABLE_RECAPTCHA Determines if ReCaptcha should be enabled for login ("true" or "false"). - ## @param sdp.configMap.data.ENABLE_MFA Determines if email-based MFA should be enabled during login ("true" or "false"). + ## @param sdp.configMap.data.DISABLE_RECAPTCHA Determines if ReCaptcha should be disabled for login ("true" or "false"). + ## @param sdp.configMap.data.DISABLE_MFA Determines if email-based MFA should be disabled during login ("true" or "false"). ## @param sdp.configMap.data.SDP_UI_BASE_URL The base URL of the SDP UI/dashboard. configMap: annotations: @@ -144,8 +144,8 @@ sdp: SMS_SENDER_TYPE: DRY_RUN RECAPTCHA_SITE_KEY: #required CORS_ALLOWED_ORIGINS: "*" - ENABLE_RECAPTCHA: "false" - ENABLE_MFA: "false" + DISABLE_RECAPTCHA: "false" + DISABLE_MFA: "false" SDP_UI_BASE_URL: #required ## @extra sdp.kubeSecrets Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. @@ -426,7 +426,7 @@ tss: DATABASE_URL: #required DISTRIBUTION_SEED: #required SENTRY_DSN: #optional - + # =========================== START dashboard =========================== @@ -492,9 +492,9 @@ dashboard: className: "nginx" annotations: {} tls: - - hosts: - - '{{ include "dashboard.domain" . }}' - secretName: dashboard-tls-cert-name # You need to create this secret manually. For more instructions, please refer to helmchart/docs/README.md + - hosts: + - '{{ include "dashboard.domain" . }}' + secretName: dashboard-tls-cert-name # You need to create this secret manually. For more instructions, please refer to helmchart/docs/README.md diff --git a/internal/integrationtests/docker-compose-e2e-tests.yml b/internal/integrationtests/docker-compose-e2e-tests.yml index 42a2d0266..81bc5f5f2 100644 --- a/internal/integrationtests/docker-compose-e2e-tests.yml +++ b/internal/integrationtests/docker-compose-e2e-tests.yml @@ -38,8 +38,8 @@ services: DISTRIBUTION_PUBLIC_KEY: ${DISTRIBUTION_PUBLIC_KEY} RECAPTCHA_SITE_KEY: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI CORS_ALLOWED_ORIGINS: "*" - ENABLE_MFA: "false" - ENABLE_RECAPTCHA: "false" + DISABLE_MFA: "true" + DISABLE_RECAPTCHA: "true" # integration tests vars USER_EMAIL: ${USER_EMAIL} diff --git a/internal/serve/httphandler/forgot_password_handler.go b/internal/serve/httphandler/forgot_password_handler.go index 499ded237..3e62ac981 100644 --- a/internal/serve/httphandler/forgot_password_handler.go +++ b/internal/serve/httphandler/forgot_password_handler.go @@ -29,7 +29,7 @@ type ForgotPasswordHandler struct { UIBaseURL string Models *data.Models ReCAPTCHAValidator validators.ReCAPTCHAValidator - ReCAPTCHAEnabled bool + ReCAPTCHADisabled bool } type ForgotPasswordRequest struct { @@ -53,7 +53,7 @@ func (h ForgotPasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) ctx := r.Context() - if h.ReCAPTCHAEnabled { + if !h.ReCAPTCHADisabled { // validating reCAPTCHA Token isValid, recaptchaErr := h.ReCAPTCHAValidator.IsTokenValid(ctx, forgotPasswordRequest.ReCAPTCHAToken) if recaptchaErr != nil { diff --git a/internal/serve/httphandler/forgot_password_handler_test.go b/internal/serve/httphandler/forgot_password_handler_test.go index 92fc8d934..9cc94e136 100644 --- a/internal/serve/httphandler/forgot_password_handler_test.go +++ b/internal/serve/httphandler/forgot_password_handler_test.go @@ -49,7 +49,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { Models: models, UIBaseURL: uiBaseURL, ReCAPTCHAValidator: reCAPTCHAValidatorMock, - ReCAPTCHAEnabled: true, + ReCAPTCHADisabled: false, } t.Run("Should return http status 200 on a valid request", func(t *testing.T) { @@ -124,7 +124,7 @@ func Test_ForgotPasswordHandler(t *testing.T) { Models: models, UIBaseURL: "%invalid%", ReCAPTCHAValidator: reCAPTCHAValidatorMock, - ReCAPTCHAEnabled: true, + ReCAPTCHADisabled: false, }.ServeHTTP).ServeHTTP(rr, req) resp := rr.Result() diff --git a/internal/serve/httphandler/login_handler.go b/internal/serve/httphandler/login_handler.go index 2e9b045ba..02edd3d91 100644 --- a/internal/serve/httphandler/login_handler.go +++ b/internal/serve/httphandler/login_handler.go @@ -49,8 +49,8 @@ type LoginHandler struct { ReCAPTCHAValidator validators.ReCAPTCHAValidator MessengerClient message.MessengerClient Models *data.Models - ReCAPTCHAEnabled bool - MFAEnabled bool + ReCAPTCHADisabled bool + MFADisabled bool } func (h LoginHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { @@ -69,7 +69,7 @@ func (h LoginHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - if h.ReCAPTCHAEnabled { + if !h.ReCAPTCHADisabled { // validating reCAPTCHA Token isValid, err := h.ReCAPTCHAValidator.IsTokenValid(ctx, reqBody.ReCAPTCHAToken) if err != nil { @@ -102,7 +102,7 @@ func (h LoginHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - if !h.MFAEnabled { + if h.MFADisabled { log.Ctx(ctx).Infof("[UserLogin] - Logged in user with account ID %s", user.ID) httpjson.RenderStatus(rw, http.StatusOK, LoginResponse{Token: token}, httpjson.JSON) return diff --git a/internal/serve/httphandler/login_handler_test.go b/internal/serve/httphandler/login_handler_test.go index caf2217a8..ce075406b 100644 --- a/internal/serve/httphandler/login_handler_test.go +++ b/internal/serve/httphandler/login_handler_test.go @@ -67,7 +67,8 @@ func Test_LoginHandler(t *testing.T) { handler := &LoginHandler{ AuthManager: authManager, ReCAPTCHAValidator: reCAPTCHAValidator, - ReCAPTCHAEnabled: true, + ReCAPTCHADisabled: false, + MFADisabled: true, } const url = "/login" @@ -409,11 +410,11 @@ func Test_LoginHandlerr_ServeHTTP_MFA(t *testing.T) { ) messengerClientMock := &message.MessengerClientMock{} loginHandler := &LoginHandler{ - AuthManager: authManager, - ReCAPTCHAEnabled: false, - MFAEnabled: true, - Models: models, - MessengerClient: messengerClientMock, + AuthManager: authManager, + ReCAPTCHADisabled: true, + MFADisabled: false, + Models: models, + MessengerClient: messengerClientMock, } user := &auth.User{ diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 38c973242..b29070355 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -81,8 +81,8 @@ type ServeOptions struct { DistributionSeed string ReCAPTCHASiteKey string ReCAPTCHASiteSecretKey string - EnableMFA bool - EnableReCAPTCHA bool + DisableMFA bool + DisableReCAPTCHA bool PasswordValidator *authUtils.PasswordValidator } @@ -150,10 +150,33 @@ func (opts *ServeOptions) SetupDependencies() error { return nil } +// ValidateSecurity validates the MFA and ReCAPTCHA security options. +func (opts *ServeOptions) ValidateSecurity() error { + if opts.NetworkPassphrase == network.PublicNetworkPassphrase { + if opts.DisableMFA { + return fmt.Errorf("MFA cannot be disabled in pubnet") + } else if opts.DisableReCAPTCHA { + return fmt.Errorf("reCAPTCHA cannot be disabled in pubnet") + } + } + + if opts.DisableMFA { + log.Warnf("MFA is disabled in network '%s'", opts.NetworkPassphrase) + } + if opts.DisableReCAPTCHA { + log.Warnf("reCAPTCHA is disabled in network '%s'", opts.NetworkPassphrase) + } + + return nil +} + func Serve(opts ServeOptions, httpServer HTTPServerInterface) error { - err := opts.SetupDependencies() - if err != nil { - return fmt.Errorf("error starting dependencies: %w", err) + if err := opts.ValidateSecurity(); err != nil { + return fmt.Errorf("validating security options: %w", err) + } + + if err := opts.SetupDependencies(); err != nil { + return fmt.Errorf("starting dependencies: %w", err) } // Start the server @@ -361,8 +384,8 @@ func handleHTTP(o ServeOptions) *chi.Mux { ReCAPTCHAValidator: reCAPTCHAValidator, MessengerClient: o.EmailMessengerClient, Models: o.Models, - ReCAPTCHAEnabled: o.EnableReCAPTCHA, - MFAEnabled: o.EnableMFA, + ReCAPTCHADisabled: o.DisableReCAPTCHA, + MFADisabled: o.DisableMFA, }.ServeHTTP) mux.Post("/mfa", httphandler.MFAHandler{ AuthManager: authManager, @@ -375,7 +398,7 @@ func handleHTTP(o ServeOptions) *chi.Mux { UIBaseURL: o.UIBaseURL, Models: o.Models, ReCAPTCHAValidator: reCAPTCHAValidator, - ReCAPTCHAEnabled: o.EnableReCAPTCHA, + ReCAPTCHADisabled: o.DisableReCAPTCHA, }.ServeHTTP) mux.Post("/reset-password", httphandler.ResetPasswordHandler{AuthManager: authManager, PasswordValidator: o.PasswordValidator}.ServeHTTP) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 0d228698c..85aa756cf 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/network" supporthttp "github.com/stellar/go/support/http" + "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" @@ -100,6 +102,81 @@ func Test_Serve(t *testing.T) { mockCrashTrackerClient.AssertExpectations(t) } +func Test_Serve_callsValidateSecurity(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + serveOptions := getServeOptionsForTests(t, dbt.DSN) + defer serveOptions.dbConnectionPool.Close() + + mHTTPServer := mockHTTPServer{} + serveOptions.NetworkPassphrase = network.PublicNetworkPassphrase + + // Make sure MFA is enforced in pubnet + serveOptions.DisableMFA = true + err := Serve(serveOptions, &mHTTPServer) + require.EqualError(t, err, "validating security options: MFA cannot be disabled in pubnet") + + // Make sure reCAPTCHA is enforced in pubnet + serveOptions.DisableMFA = false + serveOptions.DisableReCAPTCHA = true + err = Serve(serveOptions, &mHTTPServer) + require.EqualError(t, err, "validating security options: reCAPTCHA cannot be disabled in pubnet") +} + +func Test_ServeOptions_ValidateSecurity(t *testing.T) { + t.Run("Pubnet + DisableMFA: should return error", func(t *testing.T) { + serveOptions := ServeOptions{ + NetworkPassphrase: network.PublicNetworkPassphrase, + DisableMFA: true, + } + + err := serveOptions.ValidateSecurity() + require.EqualError(t, err, "MFA cannot be disabled in pubnet") + }) + + t.Run("Pubnet + DisableReCAPTCHA: should return error", func(t *testing.T) { + // Pubnet + DisableReCAPTCHA: should return error + serveOptions := ServeOptions{ + NetworkPassphrase: network.PublicNetworkPassphrase, + DisableReCAPTCHA: true, + } + + err := serveOptions.ValidateSecurity() + require.EqualError(t, err, "reCAPTCHA cannot be disabled in pubnet") + }) + + t.Run("Testnet + DisableMFA: should not return error", func(t *testing.T) { + // Testnet + DisableMFA: should not return error + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + + serveOptions := ServeOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DisableMFA: true, + } + + err := serveOptions.ValidateSecurity() + require.NoError(t, err) + require.Contains(t, buf.String(), "MFA is disabled in network 'Test SDF Network ; September 2015'") + }) + + t.Run("Testnet + DisableReCAPTCHA: should not return error", func(t *testing.T) { + // Testnet + DisableReCAPTCHA: should not return error + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + + serveOptions := ServeOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DisableReCAPTCHA: true, + } + + err := serveOptions.ValidateSecurity() + require.NoError(t, err) + require.Contains(t, buf.String(), "reCAPTCHA is disabled in network 'Test SDF Network ; September 2015'") + }) +} + func Test_handleHTTP_Health(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -194,11 +271,11 @@ func getServeOptionsForTests(t *testing.T, databaseDSN string) ServeOptions { messengerClientMock := message.MessengerClientMock{} messengerClientMock.On("SendMessage", mock.Anything).Return(nil) - crasTrackerClient, err := crashtracker.NewDryRunClient() + crashTrackerClient, err := crashtracker.NewDryRunClient() require.NoError(t, err) serveOptions := ServeOptions{ - CrashTrackerClient: crasTrackerClient, + CrashTrackerClient: crashTrackerClient, DatabaseDSN: databaseDSN, EC256PrivateKey: privateKeyStr, EC256PublicKey: publicKeyStr, From 753a34e0741a4d82929fe13199309d34ecd179b6 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Mon, 22 Jan 2024 14:07:19 -0800 Subject: [PATCH 23/39] [SDP-1014] Preload reCAPTCHA script in attempt to mitigate component loading issues upon login (#152) --- internal/htmltemplate/tmpl/receiver_register.tmpl | 7 ++++--- internal/serve/httphandler/receiver_registration_test.go | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/htmltemplate/tmpl/receiver_register.tmpl b/internal/htmltemplate/tmpl/receiver_register.tmpl index f06ae3772..f5b54660e 100644 --- a/internal/htmltemplate/tmpl/receiver_register.tmpl +++ b/internal/htmltemplate/tmpl/receiver_register.tmpl @@ -18,6 +18,10 @@ + + + +
@@ -214,9 +218,6 @@ {{.JWTToken}}
- - - diff --git a/internal/serve/httphandler/receiver_registration_test.go b/internal/serve/httphandler/receiver_registration_test.go index d8502e573..7f42e64a7 100644 --- a/internal/serve/httphandler/receiver_registration_test.go +++ b/internal/serve/httphandler/receiver_registration_test.go @@ -89,7 +89,7 @@ func Test_ReceiverRegistrationHandler_ServeHTTP(t *testing.T) { assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) assert.Contains(t, string(respBody), "Wallet Registration") assert.Contains(t, string(respBody), `
`) - assert.Contains(t, string(respBody), ``) + assert.Contains(t, string(respBody), ``) }) ctx := context.Background() @@ -158,6 +158,6 @@ func Test_ReceiverRegistrationHandler_ServeHTTP(t *testing.T) { assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) assert.Contains(t, string(respBody), "Wallet Registration") assert.Contains(t, string(respBody), `
`) - assert.Contains(t, string(respBody), ``) + assert.Contains(t, string(respBody), ``) }) } From 20251d9e99fc11e8af0fcfcb62fbd28110215350 Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Thu, 25 Jan 2024 13:35:34 -0800 Subject: [PATCH 24/39] [SDP-1041] add users that created and started a disbursement in disbursement details (#151) Change `GET /disbursements` and `/disbursements/{id}` to include additional info about user who uploaded/created disbursement and user who initiated the disbursement if applicable. handlers now render response using `DisbursementWithUserMetadata` that contains the user info disbursement management service has additional method `AppendUserMetadata` that appends the user info to an existing `DisbursementWithUserMetadata` reference. --- .../serve/httphandler/disbursement_handler.go | 29 +- .../httphandler/disbursement_handler_test.go | 265 +++++++++++++++--- .../disbursement_management_service.go | 74 +++++ .../disbursement_management_service_test.go | 77 ++++- stellar-auth/pkg/auth/auth.go | 10 + stellar-auth/pkg/auth/auth_test.go | 51 ++++ stellar-auth/pkg/auth/authenticator.go | 42 ++- stellar-auth/pkg/auth/authenticator_test.go | 58 +++- stellar-auth/pkg/auth/mocks.go | 15 +- 9 files changed, 564 insertions(+), 57 deletions(-) diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index a952ce39d..6a54c51b1 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -177,14 +177,16 @@ func (d DisbursementHandler) GetDisbursements(w http.ResponseWriter, r *http.Req } if resultWithTotal.Total == 0 { httpjson.RenderStatus(w, http.StatusOK, httpresponse.NewEmptyPaginatedResponse(), httpjson.JSON) - } else { - response, errGet := httpresponse.NewPaginatedResponse(r, resultWithTotal.Result, queryParams.Page, queryParams.PageLimit, resultWithTotal.Total) - if errGet != nil { - httperror.InternalError(ctx, "Cannot write paginated response for disbursements", errGet, nil).Render(w) - return - } - httpjson.RenderStatus(w, http.StatusOK, response, httpjson.JSON) + return + } + + response, errGet := httpresponse.NewPaginatedResponse(r, resultWithTotal.Result, queryParams.Page, queryParams.PageLimit, resultWithTotal.Total) + if errGet != nil { + httperror.InternalError(ctx, "Cannot write paginated response for disbursements", errGet, nil).Render(w) + return } + + httpjson.RenderStatus(w, http.StatusOK, response, httpjson.JSON) } func (d DisbursementHandler) PostDisbursementInstructions(w http.ResponseWriter, r *http.Request) { @@ -276,7 +278,18 @@ func (d DisbursementHandler) GetDisbursement(w http.ResponseWriter, r *http.Requ return } - httpjson.Render(w, disbursement, httpjson.JSON) + disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager) + response, err := disbursementManagementService.AppendUserMetadata(ctx, []*data.Disbursement{disbursement}) + if err != nil { + httperror.NotFound("disbursement user metadata not found", err, nil).Render(w) + } + if len(response) != 1 { + httperror.InternalError( + ctx, fmt.Sprintf("Size of response is unexpected: %d", len(response)), nil, nil, + ).Render(w) + } + + httpjson.Render(w, response[0], httpjson.JSON) } func (d DisbursementHandler) GetDisbursementReceivers(w http.ResponseWriter, r *http.Request) { diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index 5af0fd8b8..132fcb8d8 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -416,9 +416,11 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + authManagerMock := &auth.AuthManagerMock{} handler := &DisbursementHandler{ Models: models, DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, } ts := httptest.NewServer(http.HandlerFunc(handler.GetDisbursements)) @@ -431,38 +433,96 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) + createdByUser := auth.User{ + ID: "User1", + FirstName: "User", + LastName: "One", + } + startedByUser := auth.User{ + ID: "User2", + FirstName: "User", + LastName: "Two", + } + allUsers := []*auth.User{ + &startedByUser, + &createdByUser, + } + + authManagerMock. + On("GetUsersByID", mock.Anything, []string{createdByUser.ID, startedByUser.ID}). + Return(allUsers, nil) + authManagerMock. + On("GetUsersByID", mock.Anything, []string{startedByUser.ID, createdByUser.ID}). + Return(allUsers, nil) + authManagerMock. + On("GetUsersByID", mock.Anything, []string{createdByUser.ID}). + Return([]*auth.User{&createdByUser}, nil) + + createdByUserRef := services.UserReference{ + ID: createdByUser.ID, + FirstName: createdByUser.FirstName, + LastName: createdByUser.LastName, + } + startedByUserRef := services.UserReference{ + ID: startedByUser.ID, + FirstName: startedByUser.FirstName, + LastName: startedByUser.LastName, + } + + draftStatusHistory := data.DisbursementStatusHistory{ + data.DisbursementStatusHistoryEntry{ + Status: data.DraftDisbursementStatus, + UserID: createdByUser.ID, + }, + } + + startedStatusHistory := data.DisbursementStatusHistory{ + data.DisbursementStatusHistoryEntry{ + Status: data.DraftDisbursementStatus, + UserID: createdByUser.ID, + }, + data.DisbursementStatusHistoryEntry{ + Status: data.StartedDisbursementStatus, + UserID: startedByUser.ID, + }, + } + // create disbursements disbursement1 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), + Name: "disbursement 1", + Status: data.DraftDisbursementStatus, + StatusHistory: draftStatusHistory, + Asset: asset, + Wallet: wallet, + Country: country, + CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) disbursement2 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 2", - Status: data.ReadyDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - CreatedAt: time.Date(2023, 2, 20, 23, 40, 20, 1431, time.UTC), + Name: "disbursement 2", + Status: data.ReadyDisbursementStatus, + StatusHistory: draftStatusHistory, + Asset: asset, + Wallet: wallet, + Country: country, + CreatedAt: time.Date(2023, 2, 20, 23, 40, 20, 1431, time.UTC), }) disbursement3 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 3", - Status: data.StartedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - CreatedAt: time.Date(2023, 3, 19, 23, 40, 20, 1431, time.UTC), + Name: "disbursement 3", + Status: data.StartedDisbursementStatus, + StatusHistory: startedStatusHistory, + Asset: asset, + Wallet: wallet, + Country: country, + CreatedAt: time.Date(2023, 3, 19, 23, 40, 20, 1431, time.UTC), }) disbursement4 := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement 4", - Status: data.DraftDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - CreatedAt: time.Date(2023, 4, 19, 23, 40, 20, 1431, time.UTC), + Name: "disbursement 4", + Status: data.DraftDisbursementStatus, + StatusHistory: draftStatusHistory, + Asset: asset, + Wallet: wallet, + Country: country, + CreatedAt: time.Date(2023, 4, 19, 23, 40, 20, 1431, time.UTC), }) tests := []struct { @@ -470,7 +530,7 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { queryParams map[string]string expectedStatusCode int expectedPagination httpresponse.PaginationInfo - expectedDisbursements []data.Disbursement + expectedDisbursements []services.DisbursementWithUserMetadata }{ { name: "fetch all disbursements without filters", @@ -482,7 +542,25 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 1, Total: 4, }, - expectedDisbursements: []data.Disbursement{*disbursement4, *disbursement3, *disbursement2, *disbursement1}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement4, + CreatedBy: createdByUserRef, + }, + { + Disbursement: *disbursement3, + CreatedBy: createdByUserRef, + StartedBy: startedByUserRef, + }, + { + Disbursement: *disbursement2, + CreatedBy: createdByUserRef, + }, + { + Disbursement: *disbursement1, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch first page of disbursements with limit 1 and sort by name", @@ -499,7 +577,12 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 4, Total: 4, }, - expectedDisbursements: []data.Disbursement{*disbursement1}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement1, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch second page of disbursements with limit 1 and sort by name", @@ -516,7 +599,12 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 4, Total: 4, }, - expectedDisbursements: []data.Disbursement{*disbursement2}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement2, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch last page of disbursements with limit 1 and sort by name", @@ -533,7 +621,12 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 4, Total: 4, }, - expectedDisbursements: []data.Disbursement{*disbursement4}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement4, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch last page of disbursements with limit 1 and sort by name", @@ -550,7 +643,12 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 4, Total: 4, }, - expectedDisbursements: []data.Disbursement{*disbursement4}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement4, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch disbursements with status draft", @@ -564,7 +662,16 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 1, Total: 2, }, - expectedDisbursements: []data.Disbursement{*disbursement4, *disbursement1}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement4, + CreatedBy: createdByUserRef, + }, + { + Disbursement: *disbursement1, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch disbursements with status draft and q=1", @@ -579,7 +686,12 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 1, Total: 1, }, - expectedDisbursements: []data.Disbursement{*disbursement1}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement1, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch disbursements after 2023-01-01", @@ -593,7 +705,21 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 1, Total: 3, }, - expectedDisbursements: []data.Disbursement{*disbursement4, *disbursement3, *disbursement2}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement4, + CreatedBy: createdByUserRef, + }, + { + Disbursement: *disbursement3, + CreatedBy: createdByUserRef, + StartedBy: startedByUserRef, + }, + { + Disbursement: *disbursement2, + CreatedBy: createdByUserRef, + }, + }, }, { name: "fetch disbursements after 2023-01-01 and before 2023-03-20", @@ -608,7 +734,17 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { Pages: 1, Total: 2, }, - expectedDisbursements: []data.Disbursement{*disbursement3, *disbursement2}, + expectedDisbursements: []services.DisbursementWithUserMetadata{ + { + Disbursement: *disbursement3, + CreatedBy: createdByUserRef, + StartedBy: startedByUserRef, + }, + { + Disbursement: *disbursement2, + CreatedBy: createdByUserRef, + }, + }, }, } @@ -629,7 +765,7 @@ func Test_DisbursementHandler_GetDisbursements_Success(t *testing.T) { assert.Equal(t, tc.expectedPagination, actualResponse.Pagination) // Parse the response data - var actualDisbursements []data.Disbursement + var actualDisbursements []services.DisbursementWithUserMetadata err = json.Unmarshal(actualResponse.Data, &actualDisbursements) require.NoError(t, err) @@ -828,9 +964,34 @@ func Test_DisbursementHandler_GetDisbursement(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + authManagerMock := &auth.AuthManagerMock{} + createdByUser := auth.User{ + ID: "User1", + FirstName: "User", + LastName: "One", + } + startedByUser := auth.User{ + ID: "User2", + FirstName: "User", + LastName: "Two", + } + + allUsers := []*auth.User{ + &createdByUser, + &startedByUser, + } + + authManagerMock. + On("GetUsersByID", mock.Anything, []string{createdByUser.ID, startedByUser.ID}). + Return(allUsers, nil) + authManagerMock. + On("GetUsersByID", mock.Anything, []string{startedByUser.ID, createdByUser.ID}). + Return(allUsers, nil) + handler := &DisbursementHandler{ Models: models, DBConnectionPool: models.DBConnectionPool, + AuthManager: authManagerMock, } r := chi.NewRouter() @@ -838,16 +999,40 @@ func Test_DisbursementHandler_GetDisbursement(t *testing.T) { // create disbursements disbursement := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, handler.Models.Disbursements, &data.Disbursement{ - Name: "disbursement 1", - Status: data.DraftDisbursementStatus, + Name: "disbursement 1", + Status: data.StartedDisbursementStatus, + StatusHistory: data.DisbursementStatusHistory{ + data.DisbursementStatusHistoryEntry{ + Status: data.DraftDisbursementStatus, + UserID: createdByUser.ID, + }, + data.DisbursementStatusHistoryEntry{ + Status: data.StartedDisbursementStatus, + UserID: startedByUser.ID, + }, + }, CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), }) + response := services.DisbursementWithUserMetadata{ + Disbursement: *disbursement, + CreatedBy: services.UserReference{ + ID: createdByUser.ID, + FirstName: createdByUser.FirstName, + LastName: createdByUser.LastName, + }, + StartedBy: services.UserReference{ + ID: startedByUser.ID, + FirstName: startedByUser.FirstName, + LastName: startedByUser.LastName, + }, + } + tests := []struct { name string id string expectedStatusCode int - expectedDisbursement data.Disbursement + expectedDisbursement services.DisbursementWithUserMetadata expectedErrorMessage string }{ { @@ -860,7 +1045,7 @@ func Test_DisbursementHandler_GetDisbursement(t *testing.T) { name: "success", id: disbursement.ID, expectedStatusCode: http.StatusOK, - expectedDisbursement: *disbursement, + expectedDisbursement: response, }, } for _, tc := range tests { @@ -871,7 +1056,7 @@ func Test_DisbursementHandler_GetDisbursement(t *testing.T) { r.ServeHTTP(rr, req) if rr.Code == http.StatusOK { - var actualDisbursement data.Disbursement + var actualDisbursement services.DisbursementWithUserMetadata require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &actualDisbursement)) require.Equal(t, tc.expectedDisbursement, actualDisbursement) } else { diff --git a/internal/services/disbursement_management_service.go b/internal/services/disbursement_management_service.go index bfc3d4a42..889af4d6e 100644 --- a/internal/services/disbursement_management_service.go +++ b/internal/services/disbursement_management_service.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" + "golang.org/x/exp/maps" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" @@ -20,6 +22,18 @@ type DisbursementManagementService struct { authManager auth.AuthManager } +type UserReference struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +type DisbursementWithUserMetadata struct { + data.Disbursement + CreatedBy UserReference `json:"created_by"` + StartedBy UserReference `json:"started_by"` +} + var ( ErrDisbursementNotFound = errors.New("disbursement not found") ErrDisbursementNotReadyToStart = errors.New("disbursement is not ready to be started") @@ -39,6 +53,59 @@ func NewDisbursementManagementService(models *data.Models, dbConnectionPool db.D } } +func (s *DisbursementManagementService) AppendUserMetadata(ctx context.Context, disbursements []*data.Disbursement) ([]*DisbursementWithUserMetadata, error) { + users := map[string]*auth.User{} + for _, d := range disbursements { + for _, entry := range d.StatusHistory { + if entry.Status == data.DraftDisbursementStatus || entry.Status == data.StartedDisbursementStatus { + users[entry.UserID] = nil + + if entry.Status == data.StartedDisbursementStatus { + // Disbursements could have multiple "started" entries in its status history log from being paused and resumed, etc. + // The earliest entry will refer to the user who initiated the disbursement, and we will not care about any subsequent + // entries. + break + } + } + } + } + + usersList, err := s.authManager.GetUsersByID(ctx, maps.Keys(users)) + if err != nil { + return nil, fmt.Errorf("error getting user for IDs: %w", err) + } + + for _, u := range usersList { + users[u.ID] = u + } + + response := make([]*DisbursementWithUserMetadata, len(disbursements)) + for i, d := range disbursements { + response[i] = &DisbursementWithUserMetadata{ + Disbursement: *d, + } + + for _, entry := range d.StatusHistory { + userInfo := users[entry.UserID] + userRef := UserReference{ + ID: entry.UserID, + FirstName: userInfo.FirstName, + LastName: userInfo.LastName, + } + + if entry.Status == data.DraftDisbursementStatus { + response[i].CreatedBy = userRef + } + if entry.Status == data.StartedDisbursementStatus { + response[i].StartedBy = userRef + break + } + } + } + + return response, nil +} + func (s *DisbursementManagementService) GetDisbursementsWithCount(ctx context.Context, queryParams *data.QueryParams) (*utils.ResultWithTotal, error) { return db.RunInTransactionWithResult(ctx, s.dbConnectionPool, @@ -55,6 +122,13 @@ func (s *DisbursementManagementService) GetDisbursementsWithCount(ctx context.Co if err != nil { return nil, fmt.Errorf("error retrieving disbursements: %w", err) } + + resp, err := s.AppendUserMetadata(ctx, disbursements) + if err != nil { + return nil, fmt.Errorf("error appending user metadata to disbursement response: %w", err) + } + + return utils.NewResultWithTotal(totalDisbursements, resp), nil } return utils.NewResultWithTotal(totalDisbursements, disbursements), nil diff --git a/internal/services/disbursement_management_service_test.go b/internal/services/disbursement_management_service_test.go index 01fda0940..2265e5996 100644 --- a/internal/services/disbursement_management_service_test.go +++ b/internal/services/disbursement_management_service_test.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -24,7 +25,43 @@ func Test_DisbursementManagementService_GetDisbursementsWithCount(t *testing.T) models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) - service := NewDisbursementManagementService(models, models.DBConnectionPool, nil) + users := []*auth.User{ + { + ID: "john-doe", + Email: "john-doe@email.com", + FirstName: "John", + LastName: "Doe", + }, + { + ID: "jane-doe", + Email: "jane-doe@email.com", + FirstName: "Jane", + LastName: "Doe", + }, + } + + userRef := []UserReference{ + { + ID: users[0].ID, + FirstName: users[0].FirstName, + LastName: users[0].LastName, + }, + { + ID: users[1].ID, + FirstName: users[1].FirstName, + LastName: users[1].LastName, + }, + } + + authManagerMock := &auth.AuthManagerMock{} + authManagerMock. + On("GetUsersByID", mock.Anything, []string{users[0].ID, users[1].ID}). + Return(users, nil) + authManagerMock. + On("GetUsersByID", mock.Anything, []string{users[1].ID, users[0].ID}). + Return(users, nil) + + service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock) ctx := context.Background() t.Run("disbursements list empty", func(t *testing.T) { @@ -38,17 +75,45 @@ func Test_DisbursementManagementService_GetDisbursementsWithCount(t *testing.T) t.Run("get disbursements successfully", func(t *testing.T) { // create disbursements - d1 := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{Name: "d1"}) - d2 := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{Name: "d2"}) + d1 := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, + &data.Disbursement{ + Name: "d1", + StatusHistory: []data.DisbursementStatusHistoryEntry{ + { + Status: data.DraftDisbursementStatus, + UserID: users[0].ID, + }, + { + Status: data.StartedDisbursementStatus, + UserID: users[1].ID, + }, + }, + }, + ) + d2 := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, + &data.Disbursement{ + Name: "d2", + StatusHistory: []data.DisbursementStatusHistoryEntry{ + { + Status: data.DraftDisbursementStatus, + UserID: users[1].ID, + }, + }, + }, + ) resultWithTotal, err := service.GetDisbursementsWithCount(ctx, &data.QueryParams{SortOrder: "asc", SortBy: "name"}) require.NoError(t, err) require.Equal(t, 2, resultWithTotal.Total) - result, ok := resultWithTotal.Result.([]*data.Disbursement) + result, ok := resultWithTotal.Result.([]*DisbursementWithUserMetadata) require.True(t, ok) require.Equal(t, 2, len(result)) - require.Equal(t, d1.ID, result[0].ID) - require.Equal(t, d2.ID, result[1].ID) + require.Equal(t, d1.ID, result[0].Disbursement.ID) + require.Equal(t, d2.ID, result[1].Disbursement.ID) + require.Equal(t, userRef[0], result[0].CreatedBy) + require.Equal(t, userRef[1], result[0].StartedBy) + require.Equal(t, userRef[1], result[1].CreatedBy) + require.Equal(t, UserReference{}, result[1].StartedBy) }) } diff --git a/stellar-auth/pkg/auth/auth.go b/stellar-auth/pkg/auth/auth.go index 361e0a84a..5cace2302 100644 --- a/stellar-auth/pkg/auth/auth.go +++ b/stellar-auth/pkg/auth/auth.go @@ -24,6 +24,7 @@ type AuthManager interface { ResetPassword(ctx context.Context, tokenString, password string) error UpdatePassword(ctx context.Context, token, currentPassword, newPassword string) error GetUser(ctx context.Context, tokenString string) (*User, error) + GetUsersByID(ctx context.Context, userIDs []string) ([]*User, error) GetUserID(ctx context.Context, tokenString string) (string, error) GetAllUsers(ctx context.Context, tokenString string) ([]User, error) UpdateUserRoles(ctx context.Context, tokenString, userID string, roles []string) error @@ -319,6 +320,15 @@ func (am *defaultAuthManager) getUserFromToken(ctx context.Context, tokenString return user, nil } +func (am *defaultAuthManager) GetUsersByID(ctx context.Context, userIDs []string) ([]*User, error) { + users, err := am.authenticator.GetUsers(ctx, userIDs) + if err != nil { + return nil, fmt.Errorf("getting user with IDs: %w", err) + } + + return users, nil +} + func (am *defaultAuthManager) GetUserID(ctx context.Context, tokenString string) (string, error) { tokenUser, err := am.getUserFromToken(ctx, tokenString) if err != nil { diff --git a/stellar-auth/pkg/auth/auth_test.go b/stellar-auth/pkg/auth/auth_test.go index dffafb711..7ca423f4b 100644 --- a/stellar-auth/pkg/auth/auth_test.go +++ b/stellar-auth/pkg/auth/auth_test.go @@ -1360,6 +1360,57 @@ func Test_AuthManager_GetUser(t *testing.T) { roleManagerMock.AssertExpectations(t) } +func Test_AuthManager_GetUsersByID(t *testing.T) { + jwtManagerMock := &JWTManagerMock{} + authenticatorMock := &AuthenticatorMock{} + roleManagerMock := &RoleManagerMock{} + authManager := NewAuthManager( + WithCustomJWTManagerOption(jwtManagerMock), + WithCustomAuthenticatorOption(authenticatorMock), + WithCustomRoleManagerOption(roleManagerMock), + ) + + ctx := context.Background() + + t.Run("returns error when aunthenticator fails", func(t *testing.T) { + userIDs := []string{"invalid-id"} + authenticatorMock. + On("GetUsers", ctx, userIDs). + Return(nil, errUnexpectedError). + Once() + + _, err := authManager.GetUsersByID(ctx, userIDs) + require.Error(t, err) + }) + + t.Run("get users by ID successfully", func(t *testing.T) { + expectedUsers := []*User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + }, + } + + userIDs := []string{expectedUsers[0].ID, expectedUsers[1].ID} + authenticatorMock. + On("GetUsers", ctx, userIDs). + Return(expectedUsers, nil). + Once() + + users, err := authManager.GetUsersByID(ctx, userIDs) + require.NoError(t, err) + assert.Equal(t, expectedUsers, users) + }) + + authenticatorMock.AssertExpectations(t) +} + func Test_AuthManager_GetUserID(t *testing.T) { jwtManagerMock := &JWTManagerMock{} authenticatorMock := &AuthenticatorMock{} diff --git a/stellar-auth/pkg/auth/authenticator.go b/stellar-auth/pkg/auth/authenticator.go index e98c02a14..d3e33990e 100644 --- a/stellar-auth/pkg/auth/authenticator.go +++ b/stellar-auth/pkg/auth/authenticator.go @@ -40,6 +40,7 @@ type Authenticator interface { UpdatePassword(ctx context.Context, user *User, currentPassword, newPassword string) error GetAllUsers(ctx context.Context) ([]User, error) GetUser(ctx context.Context, userID string) (*User, error) + GetUsers(ctx context.Context, userIDs []string) ([]*User, error) } type defaultAuthenticator struct { @@ -354,7 +355,7 @@ func (a *defaultAuthenticator) ResetPassword(ctx context.Context, resetToken, pa func (a *defaultAuthenticator) UpdatePassword(ctx context.Context, user *User, currentPassword, newPassword string) error { if currentPassword == "" || newPassword == "" { - return fmt.Errorf("provide currentPassword and newPassword values.") + return fmt.Errorf("provide currentPassword and newPassword values") } _, err := a.ValidateCredentials(ctx, user.Email, currentPassword) @@ -477,6 +478,45 @@ func (a *defaultAuthenticator) GetUser(ctx context.Context, userID string) (*Use }, nil } +// GetUsers retrieves the respective users from a list of user IDs. +func (a *defaultAuthenticator) GetUsers(ctx context.Context, userIDs []string) ([]*User, error) { + const query = ` + SELECT + id, + first_name, + last_name + FROM + auth_users + WHERE + id = ANY($1::text[]) AND is_active = true + ` + + var dbUsers []authUser + err := a.dbConnectionPool.SelectContext(ctx, &dbUsers, query, pq.Array(userIDs)) + if err != nil { + return nil, fmt.Errorf("error querying user IDs: %w", err) + } + if len(dbUsers) != len(userIDs) { + return nil, + fmt.Errorf( + "error querying user IDs: searching for %d users, found %d users", + len(userIDs), + len(dbUsers), + ) + } + + users := make([]*User, len(dbUsers)) + for i, u := range dbUsers { + users[i] = &User{ + ID: u.ID, + FirstName: u.FirstName, + LastName: u.LastName, + } + } + + return users, nil +} + type defaultAuthenticatorOption func(a *defaultAuthenticator) func newDefaultAuthenticator(options ...defaultAuthenticatorOption) *defaultAuthenticator { diff --git a/stellar-auth/pkg/auth/authenticator_test.go b/stellar-auth/pkg/auth/authenticator_test.go index 89012302e..ce191deb3 100644 --- a/stellar-auth/pkg/auth/authenticator_test.go +++ b/stellar-auth/pkg/auth/authenticator_test.go @@ -643,6 +643,62 @@ func Test_DefaultAuthenticator_GetAllUsers(t *testing.T) { passwordEncrypterMock.AssertExpectations(t) } +func Test_DefaultAuthenticator_GetUsers(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + passwordEncrypterMock := &PasswordEncrypterMock{} + authenticator := newDefaultAuthenticator(withAuthenticatorDatabaseConnectionPool(dbConnectionPool)) + + ctx := context.Background() + + t.Run("returns an error if users for user IDs cannot be found", func(t *testing.T) { + userIDs := []string{"invalid-id"} + _, err := authenticator.GetUsers(ctx, userIDs) + require.EqualError(t, err, "error querying user IDs: searching for 1 users, found 0 users") + }) + + t.Run("gets users for provided IDs successfully", func(t *testing.T) { + passwordEncrypterMock. + On("Encrypt", ctx, mock.AnythingOfType("string")). + Return("encryptedPassword", nil) + + randUser1 := CreateRandomAuthUserFixture(t, ctx, dbConnectionPool, passwordEncrypterMock, false, "role1", "role2") + randUser2 := CreateRandomAuthUserFixture(t, ctx, dbConnectionPool, passwordEncrypterMock, true, "role1", "role2") + randUser3 := CreateRandomAuthUserFixture(t, ctx, dbConnectionPool, passwordEncrypterMock, false, "role3") + + users, err := authenticator.GetUsers( + ctx, []string{randUser1.ID, randUser2.ID, randUser3.ID}, + ) + require.NoError(t, err) + + expectedUsers := []*User{ + { + ID: randUser1.ID, + FirstName: randUser1.FirstName, + LastName: randUser1.LastName, + }, + { + ID: randUser2.ID, + FirstName: randUser2.FirstName, + LastName: randUser2.LastName, + }, + { + ID: randUser3.ID, + FirstName: randUser3.FirstName, + LastName: randUser3.LastName, + }, + } + + assert.Equal(t, expectedUsers, users) + }) + + passwordEncrypterMock.AssertExpectations(t) +} + func Test_DefaultAuthenticator_UpdateUser(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -907,7 +963,7 @@ func Test_DefaultAuthenticator_UpdatePassword(t *testing.T) { Once() randUser := CreateRandomAuthUserFixture(t, ctx, dbConnectionPool, passwordEncrypterMock, false) err := authenticator.UpdatePassword(ctx, randUser.ToUser(), "", "") - assert.EqualError(t, err, "provide currentPassword and newPassword values.") + assert.EqualError(t, err, "provide currentPassword and newPassword values") }) t.Run("returns error when credentials are invalid", func(t *testing.T) { diff --git a/stellar-auth/pkg/auth/mocks.go b/stellar-auth/pkg/auth/mocks.go index eaf22ebcd..b66beda9c 100644 --- a/stellar-auth/pkg/auth/mocks.go +++ b/stellar-auth/pkg/auth/mocks.go @@ -121,6 +121,14 @@ func (am *AuthenticatorMock) GetUser(ctx context.Context, userID string) (*User, return args.Get(0).(*User), args.Error(1) } +func (am *AuthenticatorMock) GetUsers(ctx context.Context, userIDs []string) ([]*User, error) { + args := am.Called(ctx, userIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*User), args.Error(1) +} + var _ Authenticator = (*AuthenticatorMock)(nil) type RoleManagerMock struct { @@ -250,8 +258,13 @@ func (am *AuthManagerMock) GetUser(ctx context.Context, tokenString string) (*Us return args.Get(0).(*User), args.Error(1) } -func (am *AuthManagerMock) GetUserID(ctx context.Context, tokenString string) (string, error) { +func (am *AuthManagerMock) GetUsersByID(ctx context.Context, tokenString []string) ([]*User, error) { args := am.Called(ctx, tokenString) + return args.Get(0).([]*User), args.Error(1) +} + +func (am *AuthManagerMock) GetUserID(ctx context.Context, userID string) (string, error) { + args := am.Called(ctx, userID) return args.Get(0).(string), args.Error(1) } From a50459672d50947e154aeae36b16c17fc2e34578 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Mon, 29 Jan 2024 16:58:55 -0800 Subject: [PATCH 25/39] [SDP-853] Use CI to m sure the helm README is up to date (#164) ### What Use CI to make sure the helm README is up to date. ### Why We sometimes forget to update it. --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9be938430..d37f473d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,34 @@ jobs: - name: Run ./gomod.sh run: ./gomod.sh + check-helm-readme: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install NodeJs + uses: actions/setup-node@v2 + with: + node-version: 14 + + - name: Install Helm Readme Generator (@bitnami/readme-generator-for-helm) + run: npm install -g @bitnami/readme-generator-for-helm + + - name: Generate README.md for comparison + run: readme-generator -v helmchart/sdp/values.yaml -r helmchart/sdp/README.md + + - name: Check if helmchart/sdp/README.md is in sync with helmchart/sdp/values.yaml + run: | + if git diff --exit-code --stat helmchart/sdp/README.md; then + echo "✅ helmchart/sdp/README.md is in sync with helmchart/sdp/values.yaml" + else + echo "🚨 helmchart/sdp/README.md needs to be re-generated!" + echo "Run 'readme-generator -v helmchart/sdp/values.yaml -r helmchart/sdp/README.md' locally and commit the changes." + echo "Refer to https://github.com/bitnami/readme-generator-for-helm for more information." + exit 1 + fi + build: runs-on: ubuntu-latest steps: @@ -104,7 +132,7 @@ jobs: complete: if: always() - needs: [check, build, test] + needs: [check, check-helm-readme, build, test] runs-on: ubuntu-latest steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') From 3c0db9ee63d3a070880bc5c18decad8c4e543e8a Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Tue, 30 Jan 2024 10:58:16 -0800 Subject: [PATCH 26/39] Chore: Update CI `check` to run the exhaustive validator (#163) ### What Add `exhaustive` check to the CI, and fix the missing enum cases surfaced by this check. ### Why Go doesn't;t enforce enums to be exhaustive, so adding such a check guarantees that we're covering all the enum use cases properly. --- .github/workflows/ci.yml | 6 ++++++ internal/data/fixtures.go | 2 ++ .../httphandler/payments_handler_test.go | 2 +- internal/serve/httphandler/user_handler.go | 20 +++++++++---------- .../validators/payment_query_validator.go | 4 ++-- .../payment_query_validator_test.go | 4 ++-- .../transactionsubmission/manager_test.go | 3 +++ 7 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d37f473d8..ebd241ce5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: - name: Run ./gomod.sh run: ./gomod.sh + - name: Install github.com/nishanths/exhaustive + run: go install github.com/nishanths/exhaustive/cmd/exhaustive@latest + + - name: Run exhaustive + run: exhaustive -default-signifies-exhaustive ./... + check-helm-readme: runs-on: ubuntu-latest steps: diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index e6f36c98a..d8f818371 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -738,6 +738,8 @@ func CreateMockImage(t *testing.T, width, height int, size ImageSize) image.Imag switch size { case ImageSizeSmall: c = smallImageColor() + case ImageSizeMedium: + // NO-OP case ImageSizeLarge: c = largeImageColor() } diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index 158ad6008..8deb5b16a 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -234,7 +234,7 @@ func Test_PaymentHandler_GetPayments_Errors(t *testing.T) { "status": "invalid_status", }, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"error":"request invalid", "extras":{"status":"invalid parameter. valid values are: draft, ready, pending, paused, success, failed"}}`, + expectedResponse: `{"error":"request invalid", "extras":{"status":"invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED"}}`, }, { name: "returns error when created_at_after is invalid", diff --git a/internal/serve/httphandler/user_handler.go b/internal/serve/httphandler/user_handler.go index 5cb61624b..e2534f494 100644 --- a/internal/serve/httphandler/user_handler.go +++ b/internal/serve/httphandler/user_handler.go @@ -339,19 +339,19 @@ func (h UserHandler) GetAllUsers(rw http.ResponseWriter, req *http.Request) { } // Order users + sortingFn := sort.Sort + if queryParams.SortOrder == data.SortOrderDESC { + sortingFn = func(data sort.Interface) { + sort.Sort(sort.Reverse(data)) + } + } switch queryParams.SortBy { case data.SortFieldEmail: - if queryParams.SortOrder == data.SortOrderDESC { - sort.Sort(sort.Reverse(UserSorterByEmail(users))) - } else { - sort.Sort(UserSorterByEmail(users)) - } + sortingFn(UserSorterByEmail(users)) case data.SortFieldIsActive: - if queryParams.SortOrder == data.SortOrderDESC { - sort.Sort(sort.Reverse(UserSorterByIsActive(users))) - } else { - sort.Sort(UserSorterByIsActive(users)) - } + sortingFn(UserSorterByIsActive(users)) + default: + log.Ctx(ctx).Warnf("unexpected sort field in GetAllUsers: %s", queryParams.SortBy) } httpjson.RenderStatus(rw, http.StatusOK, users, httpjson.JSON) diff --git a/internal/serve/validators/payment_query_validator.go b/internal/serve/validators/payment_query_validator.go index 85252da2c..d0ad4e227 100644 --- a/internal/serve/validators/payment_query_validator.go +++ b/internal/serve/validators/payment_query_validator.go @@ -57,10 +57,10 @@ func (qv *PaymentQueryValidator) ValidateAndGetPaymentFilters(filters map[data.F func (qv *PaymentQueryValidator) validateAndGetPaymentStatus(status string) data.PaymentStatus { s := data.PaymentStatus(strings.ToUpper(status)) switch s { - case data.DraftPaymentStatus, data.ReadyPaymentStatus, data.PendingPaymentStatus, data.PausedPaymentStatus, data.SuccessPaymentStatus, data.FailedPaymentStatus: + case data.DraftPaymentStatus, data.ReadyPaymentStatus, data.PendingPaymentStatus, data.PausedPaymentStatus, data.SuccessPaymentStatus, data.FailedPaymentStatus, data.CanceledPaymentStatus: return s default: - qv.Check(false, string(data.FilterKeyStatus), "invalid parameter. valid values are: draft, ready, pending, paused, success, failed") + qv.Check(false, string(data.FilterKeyStatus), "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED") return "" } } diff --git a/internal/serve/validators/payment_query_validator_test.go b/internal/serve/validators/payment_query_validator_test.go index e8c3b5da0..7e26e45de 100644 --- a/internal/serve/validators/payment_query_validator_test.go +++ b/internal/serve/validators/payment_query_validator_test.go @@ -35,7 +35,7 @@ func Test_PaymentQueryValidator_ValidateDisbursementFilters(t *testing.T) { validator.ValidateAndGetPaymentFilters(filters) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: draft, ready, pending, paused, success, failed", validator.Errors["status"]) + assert.Equal(t, "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED", validator.Errors["status"]) }) t.Run("Invalid date", func(t *testing.T) { @@ -84,6 +84,6 @@ func Test_PaymentQueryValidator_ValidateAndGetPaymentStatus(t *testing.T) { actual := validator.validateAndGetPaymentStatus(invalidStatus) assert.Empty(t, actual) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: draft, ready, pending, paused, success, failed", validator.Errors["status"]) + assert.Equal(t, "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED", validator.Errors["status"]) }) } diff --git a/internal/transactionsubmission/manager_test.go b/internal/transactionsubmission/manager_test.go index 63a5831f3..d8981caa9 100644 --- a/internal/transactionsubmission/manager_test.go +++ b/internal/transactionsubmission/manager_test.go @@ -498,6 +498,9 @@ func Test_Manager_ProcessTransactions(t *testing.T) { case signalTypeOSSigquit: err = syscall.Kill(syscall.Getpid(), syscall.SIGQUIT) require.NoError(t, err) + + default: + // NO-OP } cancel() From 1145529fa65d8dd8ff8c6a63f43f46934940c57f Mon Sep 17 00:00:00 2001 From: Erica Liu Date: Wed, 31 Jan 2024 14:08:27 -0800 Subject: [PATCH 27/39] [SDP-1049] Validate the Distribution account balance before starting a Disbursement (#161) Add a extra validation step before transitioning disbursement to the `READY` status to check whether distribution account holds enough of target asset balance to fulfill any pending payments along with the payments on the target disbursement. Pending payments from other disbursements are any in the status -`PAUSED` -`READY` -`PENDING` and exclude those in any terminal statuses `SUCCESS`, `CANCELED` and `FAILED` as well as `PAUSED` since those types of payments can remain in that state indefinitely. --- internal/data/payments.go | 8 +- internal/data/payments_state_machine.go | 6 + internal/data/payments_test.go | 15 +- .../serve/httphandler/disbursement_handler.go | 23 ++- .../httphandler/disbursement_handler_test.go | 89 ++++++-- internal/serve/serve.go | 10 +- .../disbursement_management_service.go | 96 ++++++++- .../disbursement_management_service_test.go | 193 +++++++++++++++--- 8 files changed, 372 insertions(+), 68 deletions(-) diff --git a/internal/data/payments.go b/internal/data/payments.go index 32f001f00..968ccc57f 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -628,7 +628,13 @@ func (p *PaymentModel) GetByIDs(ctx context.Context, sqlExec db.SQLExecuter, pay func newPaymentQuery(baseQuery string, queryParams *QueryParams, paginated bool, sqlExec db.SQLExecuter) (string, []interface{}) { qb := NewQueryBuilder(baseQuery) if queryParams.Filters[FilterKeyStatus] != nil { - qb.AddCondition("p.status = ?", queryParams.Filters[FilterKeyStatus]) + if statusSlice, ok := queryParams.Filters[FilterKeyStatus].([]PaymentStatus); ok { + if len(statusSlice) > 0 { + qb.AddCondition("p.status = ANY(?)", pq.Array(statusSlice)) + } + } else { + qb.AddCondition("p.status = ?", queryParams.Filters[FilterKeyStatus]) + } } if queryParams.Filters[FilterKeyReceiverID] != nil { qb.AddCondition("p.receiver_id = ?", queryParams.Filters[FilterKeyReceiverID]) diff --git a/internal/data/payments_state_machine.go b/internal/data/payments_state_machine.go index 23487a7d8..765c21a33 100644 --- a/internal/data/payments_state_machine.go +++ b/internal/data/payments_state_machine.go @@ -54,6 +54,12 @@ func PaymentStatuses() []PaymentStatus { return []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus} } +// PaymentInProgressStatuses returns a list of payment statuses that are in progress and could block potential new payments +// from being initiated if the distribution balance is low. +func PaymentInProgressStatuses() []PaymentStatus { + return []PaymentStatus{ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus} +} + // SourceStatuses returns a list of states that the payment status can transition from given the target state func (status PaymentStatus) SourceStatuses() []PaymentStatus { stateMachine := PaymentStateMachineWithInitialState(DraftPaymentStatus) diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go index fab47932b..47f6f2f40 100644 --- a/internal/data/payments_test.go +++ b/internal/data/payments_test.go @@ -343,7 +343,7 @@ func Test_PaymentModelGetAll(t *testing.T) { assert.Equal(t, []Payment{*expectedPayment1, *expectedPayment2}, actualPayments) }) - t.Run("returns payments successfully with filter", func(t *testing.T) { + t.Run("returns payments successfully with one status filter", func(t *testing.T) { filters := map[FilterKey]interface{}{ FilterKeyStatus: PendingPaymentStatus, } @@ -353,6 +353,19 @@ func Test_PaymentModelGetAll(t *testing.T) { assert.Equal(t, []Payment{*expectedPayment2}, actualPayments) }) + t.Run("returns payments successfully with list of status filters", func(t *testing.T) { + filters := map[FilterKey]interface{}{ + FilterKeyStatus: []PaymentStatus{ + DraftPaymentStatus, + PendingPaymentStatus, + }, + } + actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Filters: filters}, dbConnectionPool) + require.NoError(t, err) + assert.Equal(t, 2, len(actualPayments)) + assert.Equal(t, []Payment{*expectedPayment1, *expectedPayment2}, actualPayments) + }) + t.Run("should not return duplicated entries when receiver are in more than one disbursements with different wallets", func(t *testing.T) { models, err := NewModels(dbConnectionPool) require.NoError(t, err) diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index 6a54c51b1..c2248173e 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gocarina/gocsv" + "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" @@ -26,10 +27,12 @@ import ( ) type DisbursementHandler struct { - Models *data.Models - MonitorService monitor.MonitorServiceInterface - DBConnectionPool db.DBConnectionPool - AuthManager auth.AuthManager + Models *data.Models + MonitorService monitor.MonitorServiceInterface + DBConnectionPool db.DBConnectionPool + AuthManager auth.AuthManager + HorizonClient horizonclient.ClientInterface + DistributionPubKey string } type PostDisbursementRequest struct { @@ -169,7 +172,7 @@ func (d DisbursementHandler) GetDisbursements(w http.ResponseWriter, r *http.Req } ctx := r.Context() - disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager) + disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager, d.HorizonClient) resultWithTotal, err := disbursementManagementService.GetDisbursementsWithCount(ctx, queryParams) if err != nil { httperror.InternalError(ctx, "Cannot retrieve disbursements", err, nil).Render(w) @@ -278,7 +281,7 @@ func (d DisbursementHandler) GetDisbursement(w http.ResponseWriter, r *http.Requ return } - disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager) + disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager, d.HorizonClient) response, err := disbursementManagementService.AppendUserMetadata(ctx, []*data.Disbursement{disbursement}) if err != nil { httperror.NotFound("disbursement user metadata not found", err, nil).Render(w) @@ -304,7 +307,7 @@ func (d DisbursementHandler) GetDisbursementReceivers(w http.ResponseWriter, r * return } - disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager) + disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager, d.HorizonClient) resultWithTotal, err := disbursementManagementService.GetDisbursementReceiversWithCount(ctx, disbursementID, queryParams) if err != nil { if errors.Is(err, services.ErrDisbursementNotFound) { @@ -351,14 +354,14 @@ func (d DisbursementHandler) PatchDisbursementStatus(w http.ResponseWriter, r *h return } - disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager) + disbursementManagementService := services.NewDisbursementManagementService(d.Models, d.DBConnectionPool, d.AuthManager, d.HorizonClient) response := UpdateDisbursementStatusResponseBody{} ctx := r.Context() disbursementID := chi.URLParam(r, "id") switch toStatus { case data.StartedDisbursementStatus: - err = disbursementManagementService.StartDisbursement(ctx, disbursementID) + err = disbursementManagementService.StartDisbursement(ctx, disbursementID, d.DistributionPubKey) response.Message = "Disbursement started" case data.PausedDisbursementStatus: err = disbursementManagementService.PauseDisbursement(ctx, disbursementID) @@ -381,6 +384,8 @@ func (d DisbursementHandler) PatchDisbursementStatus(w http.ResponseWriter, r *h httperror.Forbidden("Disbursement can't be started by its creator. Approval by another user is required.", err, nil).Render(w) case errors.Is(err, services.ErrDisbursementWalletDisabled): httperror.BadRequest(services.ErrDisbursementWalletDisabled.Error(), err, nil).Render(w) + case errors.Is(err, services.ErrDisbursementWalletInsufficientBalance): + httperror.Conflict(services.ErrDisbursementWalletInsufficientBalance.Error(), err, nil).Render(w) default: msg := fmt.Sprintf("Cannot update disbursement ID %s with status: %s", disbursementID, toStatus) httperror.InternalError(ctx, msg, err, nil).Render(w) diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index 132fcb8d8..a85446719 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -14,27 +14,23 @@ import ( "testing" "time" - "github.com/stretchr/testify/mock" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" - "github.com/go-chi/chi/v5" + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/protocols/horizon/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" - + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) func Test_DisbursementHandler_PostDisbursement(t *testing.T) { @@ -1257,12 +1253,18 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Email: "email@email.com", } require.NotNil(t, user) + authManagerMock := &auth.AuthManagerMock{} + hMock := &horizonclient.MockClient{} + distributionPubKey := "ABC" + asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) handler := &DisbursementHandler{ - Models: models, - DBConnectionPool: models.DBConnectionPool, - AuthManager: authManagerMock, + Models: models, + DBConnectionPool: models.DBConnectionPool, + AuthManager: authManagerMock, + HorizonClient: hMock, + DistributionPubKey: distributionPubKey, } r := chi.NewRouter() @@ -1355,6 +1357,20 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) t.Run("disbursement can be started by approver who is not a creator", func(t *testing.T) { + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + data.EnableDisbursementApproval(t, ctx, handler.Models.Organizations) defer data.DisableDisbursementApproval(t, ctx, handler.Models.Organizations) @@ -1363,6 +1379,16 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Status: data.ReadyDisbursementStatus, StatusHistory: readyStatusHistory, }) + wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.DraftReceiversWalletStatus) + data.CreatePaymentFixture(t, ctx, dbConnectionPool, handler.Models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: readyDisbursement, + Asset: *asset, + Amount: "300", + Status: data.DraftPaymentStatus, + }) approverUser := &auth.User{ ID: "valid-approver-user-id", @@ -1388,6 +1414,20 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) t.Run("disbursement started - then paused", func(t *testing.T) { + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + authManagerMock. On("GetUser", mock.Anything, token). Return(user, nil). @@ -1397,6 +1437,16 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Status: data.ReadyDisbursementStatus, StatusHistory: readyStatusHistory, }) + wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.DraftReceiversWalletStatus) + data.CreatePaymentFixture(t, ctx, dbConnectionPool, handler.Models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: readyDisbursement, + Asset: *asset, + Amount: "300", + Status: data.DraftPaymentStatus, + }) err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "Started"}) require.NoError(t, err) @@ -1477,6 +1527,7 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) authManagerMock.AssertExpectations(t) + hMock.AssertExpectations(t) } func Test_DisbursementHandler_GetDisbursementInstructions(t *testing.T) { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index b29070355..11e89caf3 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -251,10 +251,12 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.Route("/disbursements", func(r chi.Router) { handler := httphandler.DisbursementHandler{ - Models: o.Models, - MonitorService: o.MonitorService, - DBConnectionPool: o.dbConnectionPool, - AuthManager: authManager, + Models: o.Models, + MonitorService: o.MonitorService, + DBConnectionPool: o.dbConnectionPool, + AuthManager: authManager, + HorizonClient: o.horizonClient, + DistributionPubKey: o.DistributionPublicKey, } r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). Post("/", handler.PostDisbursement) diff --git a/internal/services/disbursement_management_service.go b/internal/services/disbursement_management_service.go index 889af4d6e..240f5e034 100644 --- a/internal/services/disbursement_management_service.go +++ b/internal/services/disbursement_management_service.go @@ -5,7 +5,10 @@ import ( "database/sql" "errors" "fmt" + "strconv" + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/support/log" "golang.org/x/exp/maps" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" @@ -20,6 +23,7 @@ type DisbursementManagementService struct { models *data.Models dbConnectionPool db.DBConnectionPool authManager auth.AuthManager + horizonClient horizonclient.ClientInterface } type UserReference struct { @@ -35,21 +39,28 @@ type DisbursementWithUserMetadata struct { } var ( - ErrDisbursementNotFound = errors.New("disbursement not found") - ErrDisbursementNotReadyToStart = errors.New("disbursement is not ready to be started") - ErrDisbursementNotReadyToPause = errors.New("disbursement is not ready to be paused") - ErrDisbursementWalletDisabled = errors.New("disbursement wallet is disabled") + ErrDisbursementNotFound = errors.New("disbursement not found") + ErrDisbursementNotReadyToStart = errors.New("disbursement is not ready to be started") + ErrDisbursementNotReadyToPause = errors.New("disbursement is not ready to be paused") + ErrDisbursementWalletDisabled = errors.New("disbursement wallet is disabled") + ErrDisbursementWalletInsufficientBalance = errors.New("disbursement wallet has insufficient balance to fulfill disbursement and current pending payments") ErrDisbursementStatusCantBeChanged = errors.New("disbursement status can't be changed to the requested status") ErrDisbursementStartedByCreator = errors.New("disbursement can't be started by its creator") ) // NewDisbursementManagementService is a factory function for creating a new DisbursementManagementService. -func NewDisbursementManagementService(models *data.Models, dbConnectionPool db.DBConnectionPool, authManager auth.AuthManager) *DisbursementManagementService { +func NewDisbursementManagementService( + models *data.Models, + dbConnectionPool db.DBConnectionPool, + authManager auth.AuthManager, + horizonClient horizonclient.ClientInterface, +) *DisbursementManagementService { return &DisbursementManagementService{ models: models, dbConnectionPool: dbConnectionPool, authManager: authManager, + horizonClient: horizonClient, } } @@ -167,9 +178,9 @@ func (s *DisbursementManagementService) GetDisbursementReceiversWithCount(ctx co } // StartDisbursement starts a disbursement and all its payments and receivers wallets. -func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, disbursementID string) error { +func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, disbursementID string, distributionPubKey string) error { return db.RunInTransaction(ctx, s.dbConnectionPool, nil, func(dbTx db.DBTransaction) error { - disbursement, err := s.models.Disbursements.Get(ctx, dbTx, disbursementID) + disbursement, err := s.models.Disbursements.GetWithStatistics(ctx, disbursementID) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { return ErrDisbursementNotFound @@ -210,19 +221,84 @@ func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, d } } - // 4. Update all correct payment status to `ready` + // 4. Check if there is enough balance from the distribution wallet for this disbursement along with any pending disbursements + rootAccount, err := s.horizonClient.AccountDetail( + horizonclient.AccountRequest{AccountID: distributionPubKey}) + if err != nil { + return fmt.Errorf("cannot get details for root account from horizon client: %w", err) + } + + var distributionBalance float64 + for _, b := range rootAccount.Balances { + if b.Asset.Code == disbursement.Asset.Code && b.Asset.Issuer == disbursement.Asset.Issuer { + distributionBalance, err = strconv.ParseFloat(b.Balance, 64) + if err != nil { + return fmt.Errorf("cannot convert Horizon distribution account balance %s into float: %w", b.Balance, err) + } + } + } + + disbursementAmount, err := strconv.ParseFloat(disbursement.TotalAmount, 64) + if err != nil { + return fmt.Errorf( + "cannot convert total amount %s for disbursement id %s into float: %w", + disbursement.TotalAmount, + disbursementID, + err, + ) + } + + var totalPendingAmount float64 = 0.0 + incompletePayments, err := s.models.Payment.GetAll(ctx, &data.QueryParams{ + Filters: map[data.FilterKey]interface{}{ + data.FilterKeyStatus: data.PaymentInProgressStatuses(), + }, + }, dbTx) + if err != nil { + return fmt.Errorf("cannot retrieve incomplete payments: %w", err) + } + + for _, ip := range incompletePayments { + if ip.Disbursement.ID == disbursementID { + continue + } + + paymentAmount, parsePaymentAmountErr := strconv.ParseFloat(ip.Amount, 64) + if parsePaymentAmountErr != nil { + return fmt.Errorf( + "cannot convert amount %s for paymment id %s into float: %w", + ip.Amount, + ip.ID, + err, + ) + } + totalPendingAmount += paymentAmount + } + + if (distributionBalance - (disbursementAmount + totalPendingAmount)) < 0 { + log.Ctx(ctx).Errorf( + "Insufficient distribution account balance %f to fulfill amount %f for disbursement id %s and total pending amount %f", + distributionBalance, + disbursementAmount, + disbursementID, + totalPendingAmount, + ) + return ErrDisbursementWalletInsufficientBalance + } + + // 5. Update all correct payment status to `ready` err = s.models.Payment.UpdateStatusByDisbursementID(ctx, dbTx, disbursementID, data.ReadyPaymentStatus) if err != nil { return fmt.Errorf("error updating payment status to ready for disbursement with id %s: %w", disbursementID, err) } - // 5. Update all receiver_wallets from `draft` to `ready` + // 6. Update all receiver_wallets from `draft` to `ready` err = s.models.ReceiverWallet.UpdateStatusByDisbursementID(ctx, dbTx, disbursementID, data.DraftReceiversWalletStatus, data.ReadyReceiversWalletStatus) if err != nil { return fmt.Errorf("error updating receiver wallet status to ready for disbursement with id %s: %w", disbursementID, err) } - // 6. Update disbursement status to `started` + // 7. Update disbursement status to `started` err = s.models.Disbursements.UpdateStatus(ctx, dbTx, user.ID, disbursementID, data.StartedDisbursementStatus) if err != nil { return fmt.Errorf("error updating disbursement status to started for disbursement with id %s: %w", disbursementID, err) diff --git a/internal/services/disbursement_management_service_test.go b/internal/services/disbursement_management_service_test.go index 2265e5996..85d555886 100644 --- a/internal/services/disbursement_management_service_test.go +++ b/internal/services/disbursement_management_service_test.go @@ -2,16 +2,23 @@ package services import ( "context" + "fmt" + "strings" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/protocols/horizon/base" + "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) func Test_DisbursementManagementService_GetDisbursementsWithCount(t *testing.T) { @@ -61,7 +68,7 @@ func Test_DisbursementManagementService_GetDisbursementsWithCount(t *testing.T) On("GetUsersByID", mock.Anything, []string{users[1].ID, users[0].ID}). Return(users, nil) - service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock) + service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock, nil) ctx := context.Background() t.Run("disbursements list empty", func(t *testing.T) { @@ -128,7 +135,7 @@ func Test_DisbursementManagementService_GetDisbursementReceiversWithCount(t *tes models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) - service := NewDisbursementManagementService(models, models.DBConnectionPool, nil) + service := NewDisbursementManagementService(models, models.DBConnectionPool, nil, nil) disbursement := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{}) ctx := context.Background() @@ -190,12 +197,15 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { token := "token" ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) authManagerMock := &auth.AuthManagerMock{} - service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock) + hMock := &horizonclient.MockClient{} + distributionPubKey := "ABC" + + service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock, hMock) // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) - asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) // create disbursements @@ -262,19 +272,19 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { t.Run("disbursement doesn't exist", func(t *testing.T) { id := "5e1f1c7f5b6c9c0001c1b1b1" - err = service.StartDisbursement(context.Background(), id) + err = service.StartDisbursement(context.Background(), id, distributionPubKey) require.ErrorIs(t, err, ErrDisbursementNotFound) }) t.Run("disbursement wallet is disabled", func(t *testing.T) { data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, wallet.ID) defer data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, true, wallet.ID) - err = service.StartDisbursement(context.Background(), draftDisbursement.ID) + err = service.StartDisbursement(context.Background(), draftDisbursement.ID, distributionPubKey) require.ErrorIs(t, err, ErrDisbursementWalletDisabled) }) t.Run("disbursement not ready to start", func(t *testing.T) { - err = service.StartDisbursement(context.Background(), draftDisbursement.ID) + err = service.StartDisbursement(context.Background(), draftDisbursement.ID, distributionPubKey) require.ErrorIs(t, err, ErrDisbursementNotReadyToStart) }) @@ -314,7 +324,7 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) require.NoError(t, err) - err = service.StartDisbursement(ctx, disbursement.ID) + err = service.StartDisbursement(ctx, disbursement.ID, distributionPubKey) require.ErrorIs(t, err, ErrDisbursementStartedByCreator) // rollback changes @@ -335,6 +345,21 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { UserID: userID, }, } + + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + disbursement := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{ Name: "disbursement #2", Status: data.ReadyDisbursementStatus, @@ -343,6 +368,13 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { Country: country, StatusHistory: statusHistory, }) + data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.DraftPaymentStatus, + }) user := &auth.User{ ID: "another user id", @@ -359,7 +391,7 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) require.NoError(t, err) - err = service.StartDisbursement(ctx, disbursement.ID) + err = service.StartDisbursement(ctx, disbursement.ID, distributionPubKey) require.NoError(t, err) // check disbursement status @@ -384,20 +416,34 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { Return(user, nil). Once() - err = service.StartDisbursement(ctx, readyDisbursement.ID) + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + + err = service.StartDisbursement(ctx, readyDisbursement.ID, distributionPubKey) require.NoError(t, err) // check disbursement status - disbursement, err := models.Disbursements.Get(context.Background(), models.DBConnectionPool, readyDisbursement.ID) - require.NoError(t, err) + disbursement, getDisbursementErr := models.Disbursements.Get(context.Background(), models.DBConnectionPool, readyDisbursement.ID) + require.NoError(t, getDisbursementErr) require.Equal(t, data.StartedDisbursementStatus, disbursement.Status) // check disbursement history require.Equal(t, disbursement.StatusHistory[1].UserID, user.ID) // check receivers wallets status - receiverWallets, err := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, models.DBConnectionPool, receiverIds, wallet.ID) - require.NoError(t, err) + receiverWallets, getReceiversErr := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, models.DBConnectionPool, receiverIds, wallet.ID) + require.NoError(t, getReceiversErr) require.Equal(t, 4, len(receiverWallets)) rwExpectedStatuses := map[string]data.ReceiversWalletStatus{ rwDraft1.ID: data.ReadyReceiversWalletStatus, @@ -411,13 +457,79 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { // check payments status for _, p := range payments { - payment, err := models.Payment.Get(ctx, p.ID, dbConnectionPool) - require.NoError(t, err) + payment, getPaymentErr := models.Payment.Get(ctx, p.ID, dbConnectionPool) + require.NoError(t, getPaymentErr) require.Equal(t, data.ReadyPaymentStatus, payment.Status) } }) + t.Run("disbursement cannot be started because insufficient balance on distribution account", func(t *testing.T) { + user := &auth.User{ + ID: "user-id", + Email: "email@email.com", + } + + authManagerMock. + On("GetUser", ctx, token). + Return(user, nil). + Once() + + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + + disbursement := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "disbursement #3", + Status: data.ReadyDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + }) + data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "10001", + Status: data.ReadyPaymentStatus, + }) + + buf := new(strings.Builder) + log.DefaultLogger.SetOutput(buf) + + err = service.StartDisbursement(ctx, disbursement.ID, distributionPubKey) + require.EqualError( + t, + err, + fmt.Sprintf( + "running atomic function in RunInTransactionWithResult: %s", + ErrDisbursementWalletInsufficientBalance.Error(), + ), + ) + + // PendingTotal includes payments associated with 'readyDisbursement' that were moved from the draft to ready status + assert.Contains( + t, + buf.String(), + fmt.Sprintf("Insufficient distribution account balance %f to fulfill amount %f for disbursement id %s and total pending amount %f", + 10000.0, + 10001.0, + disbursement.ID, + 1100.0), + ) + }) + authManagerMock.AssertExpectations(t) + hMock.AssertExpectations(t) } func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { @@ -443,11 +555,15 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { On("GetUser", ctx, token). Return(user, nil) - service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock) + asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) + + hMock := &horizonclient.MockClient{} + distributionPubKey := "ABC" + + service := NewDisbursementManagementService(models, models.DBConnectionPool, authManagerMock, hMock) // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) - asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUSA) // create disbursements @@ -520,6 +636,20 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { }) t.Run("disbursement paused", func(t *testing.T) { + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + err := service.PauseDisbursement(ctx, startedDisbursement.ID) require.NoError(t, err) @@ -543,7 +673,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { } // change the disbursement back to started - err = service.StartDisbursement(ctx, startedDisbursement.ID) + err = service.StartDisbursement(ctx, startedDisbursement.ID, distributionPubKey) require.NoError(t, err) // check disbursement is started again @@ -553,6 +683,20 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { }) t.Run("start -> pause -> start -> pause", func(t *testing.T) { + hMock.On( + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + ).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000", + Asset: base.Asset{ + Code: asset.Code, + Issuer: asset.Issuer, + }, + }, + }, + }, nil).Once() + // 1. Pause Disbursement err := service.PauseDisbursement(ctx, startedDisbursement.ID) require.NoError(t, err) @@ -577,7 +721,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { } // 2. Start disbursement again - err = service.StartDisbursement(ctx, startedDisbursement.ID) + err = service.StartDisbursement(ctx, startedDisbursement.ID, distributionPubKey) require.NoError(t, err) // check disbursement is started again @@ -624,4 +768,5 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { }) authManagerMock.AssertExpectations(t) + hMock.AssertExpectations(t) } From f41b1693662f610dc96d3582f6f427153539a19b Mon Sep 17 00:00:00 2001 From: Erica Date: Wed, 24 Jan 2024 16:09:36 -0800 Subject: [PATCH 28/39] bump ver --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 32771ad60..0e1910078 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.0.1" +const Version = "1.0.2" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" From 577f9908fe24be680b8e61eb398582ff7d41661a Mon Sep 17 00:00:00 2001 From: Erica Date: Wed, 24 Jan 2024 17:09:34 -0800 Subject: [PATCH 29/39] changelog --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50266df6..fd69edb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +## [1.0.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...release/1.0.2) + +### Changed + +- Automatic cancellation of payments in `READY` status after a certain time period [#121] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) +- Change `POST /disbursements` to accept different verification types [#103](https://github.com/stellar/stellar-disbursement-platform-backend/pull/103) +- Change `SEP-24` Flow to display different verifications based on disbursement verification type [#116](https://github.com/stellar/stellar-disbursement-platform-backend/pull/116) - Add sorting to `GET /users` endpoint [#104](https://github.com/stellar/stellar-disbursement-platform-backend/pull/104) +- Add read permission for receiver details for business roles [#144](https://github.com/stellar/stellar-disbursement-platform-backend/pull/144) +- Add unique payment ID to disbursement instructions file as an optional field in `GET /payments/{id}` [#131](https://github.com/stellar/stellar-disbursement-platform-backend/pull/131) +- Add SMS preview & editing before sending a new disbursement [#146](https://github.com/stellar/stellar-disbursement-platform-backend/pull/146) + + +### Added + +- API endpoint for cancelling payments in `READY` status: `PATCH /payments/{id}/status` [#130](https://github.com/stellar/stellar-disbursement-platform-backend/pull/130) + +### Fixed + +- Verification DOB validation missing when date is in the future [#101](https://github.com/stellar/stellar-disbursement-platform-backend/pull/101) +- Support disbursements from two or more wallet providers to the same address [#87](https://github.com/stellar/stellar-disbursement-platform-backend/pull/87) +- [TSS] Stale channel account not cleared after switching distribution keys [#91](https://github.com/stellar/stellar-disbursement-platform-backend/pull/91) +- Make setup-wallets-for-network tests more flexible [#95](https://github.com/stellar/stellar-disbursement-platform-backend/pull/95) +- Make `POST /assets` idempotent [#122](https://github.com/stellar/stellar-disbursement-platform-backend/pull/122) +- Add missing space when building query [#121](https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) + +### Security +- Stellar Protocol 20 Horizon SDK upgrade [#107](https://github.com/stellar/stellar-disbursement-platform-backend/pull/107) +- Coinspect Issues: + - Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage [#138](https://github.com/stellar/stellar-disbursement-platform-backend/pull/138) + - Add "Secure Operation Manual" section and updated the code to enforce MFA and reCAPTCHA [#150] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/150) + - Coinspect SDP-006 Weak password policy [#143] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/143) ## [1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0...1.0.1) From 543bfa6a964f23c2424f480428836d9d7edfcb13 Mon Sep 17 00:00:00 2001 From: Erica Date: Wed, 24 Jan 2024 20:35:16 -0800 Subject: [PATCH 30/39] add unreleased oops --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd69edb68..8eb2c6851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -## [1.0.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...release/1.0.2) +- Add metadata for users that created and started a disbursement in disbursement details `GET /disbursements`, `GET /disbursements/{id}` [#151](https://github.com/stellar/stellar-disbursement-platform-backend/pull/151) + +## [1.0.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...1.0.2) ### Changed @@ -33,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add missing space when building query [#121](https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) ### Security + - Stellar Protocol 20 Horizon SDK upgrade [#107](https://github.com/stellar/stellar-disbursement-platform-backend/pull/107) - Coinspect Issues: - Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage [#138](https://github.com/stellar/stellar-disbursement-platform-backend/pull/138) From 0e859f26d46196254b54ca5ebd3c5b86747e5ced Mon Sep 17 00:00:00 2001 From: Erica Date: Thu, 25 Jan 2024 13:53:09 -0800 Subject: [PATCH 31/39] change to 1.1.0 --- CHANGELOG.md | 5 +++-- helmchart/sdp/Chart.yaml | 2 +- main.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb2c6851..1375a96e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -- Add metadata for users that created and started a disbursement in disbursement details `GET /disbursements`, `GET /disbursements/{id}` [#151](https://github.com/stellar/stellar-disbursement-platform-backend/pull/151) +None -## [1.0.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...1.0.2) +## [1.1.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...1.1.0) ### Changed @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add read permission for receiver details for business roles [#144](https://github.com/stellar/stellar-disbursement-platform-backend/pull/144) - Add unique payment ID to disbursement instructions file as an optional field in `GET /payments/{id}` [#131](https://github.com/stellar/stellar-disbursement-platform-backend/pull/131) - Add SMS preview & editing before sending a new disbursement [#146](https://github.com/stellar/stellar-disbursement-platform-backend/pull/146) +- Add metadata for users that created and started a disbursement in disbursement details `GET /disbursements`, `GET /disbursements/{id}` [#151](https://github.com/stellar/stellar-disbursement-platform-backend/pull/151) ### Added diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 4f500ec08..6fb91c449 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) version: 0.9.4 -appVersion: "1.0.2" +appVersion: "1.1.0" type: application maintainers: - name: Stellar Development Foundation diff --git a/main.go b/main.go index 0e1910078..cee91925c 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.0.2" +const Version = "1.1.0" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" From 38ed91133ddd15e3f7d78216e301272c0006619a Mon Sep 17 00:00:00 2001 From: Erica Date: Thu, 25 Jan 2024 14:06:58 -0800 Subject: [PATCH 32/39] whitespace --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1375a96e6..346d820b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,6 @@ None - Add SMS preview & editing before sending a new disbursement [#146](https://github.com/stellar/stellar-disbursement-platform-backend/pull/146) - Add metadata for users that created and started a disbursement in disbursement details `GET /disbursements`, `GET /disbursements/{id}` [#151](https://github.com/stellar/stellar-disbursement-platform-backend/pull/151) - ### Added - API endpoint for cancelling payments in `READY` status: `PATCH /payments/{id}/status` [#130](https://github.com/stellar/stellar-disbursement-platform-backend/pull/130) @@ -40,8 +39,8 @@ None - Stellar Protocol 20 Horizon SDK upgrade [#107](https://github.com/stellar/stellar-disbursement-platform-backend/pull/107) - Coinspect Issues: - Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage [#138](https://github.com/stellar/stellar-disbursement-platform-backend/pull/138) - - Add "Secure Operation Manual" section and updated the code to enforce MFA and reCAPTCHA [#150] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/150) - - Coinspect SDP-006 Weak password policy [#143] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/143) + - Add "Secure Operation Manual" section and updated the code to enforce MFA and reCAPTCHA [#150](https://github.com/stellar/stellar-disbursement-platform-backend/pull/150) + - Coinspect SDP-006 Weak password policy [#143](https://github.com/stellar/stellar-disbursement-platform-backend/pull/143) ## [1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0...1.0.1) From 697632345b62275c611fa905ddde87f65648cb22 Mon Sep 17 00:00:00 2001 From: Erica Date: Wed, 31 Jan 2024 14:36:41 -0800 Subject: [PATCH 33/39] changelog most recent pr --- CHANGELOG.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 346d820b0..2a1d2c149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,22 @@ None ### Changed -- Automatic cancellation of payments in `READY` status after a certain time period [#121] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) - Change `POST /disbursements` to accept different verification types [#103](https://github.com/stellar/stellar-disbursement-platform-backend/pull/103) - Change `SEP-24` Flow to display different verifications based on disbursement verification type [#116](https://github.com/stellar/stellar-disbursement-platform-backend/pull/116) - Add sorting to `GET /users` endpoint [#104](https://github.com/stellar/stellar-disbursement-platform-backend/pull/104) -- Add read permission for receiver details for business roles [#144](https://github.com/stellar/stellar-disbursement-platform-backend/pull/144) -- Add unique payment ID to disbursement instructions file as an optional field in `GET /payments/{id}` [#131](https://github.com/stellar/stellar-disbursement-platform-backend/pull/131) -- Add SMS preview & editing before sending a new disbursement [#146](https://github.com/stellar/stellar-disbursement-platform-backend/pull/146) +- Change read permission for receiver details to include business roles [#144](https://github.com/stellar/stellar-disbursement-platform-backend/pull/144) +- Add support for unique payment ID to disbursement instructions file as an optional field in `GET /payments/{id}` [#131](https://github.com/stellar/stellar-disbursement-platform-backend/pull/131) +- Add support for SMS preview & editing before sending a new disbursement [#146](https://github.com/stellar/stellar-disbursement-platform-backend/pull/146) - Add metadata for users that created and started a disbursement in disbursement details `GET /disbursements`, `GET /disbursements/{id}` [#151](https://github.com/stellar/stellar-disbursement-platform-backend/pull/151) +- Update CI check to run the exhaustive validator [#163](https://github.com/stellar/stellar-disbursement-platform-backend/pull/163) +- Preload reCAPTCHA script in attempt to mitigate component loading issues upon login [#152](https://github.com/stellar/stellar-disbursement-platform-backend/pull/152) +- Validate distribution account balance before starting disbursement [#161](https://github.com/stellar/stellar-disbursement-platform-backend/pull/161) ### Added +- Support automatic cancellation of payments in `READY` status after a certain time period [#121](https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) - API endpoint for cancelling payments in `READY` status: `PATCH /payments/{id}/status` [#130](https://github.com/stellar/stellar-disbursement-platform-backend/pull/130) +- Use CI to m sure the helm README is up to date [#164](https://github.com/stellar/stellar-disbursement-platform-backend/pull/164) ### Fixed @@ -38,9 +42,10 @@ None - Stellar Protocol 20 Horizon SDK upgrade [#107](https://github.com/stellar/stellar-disbursement-platform-backend/pull/107) - Coinspect Issues: - - Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage [#138](https://github.com/stellar/stellar-disbursement-platform-backend/pull/138) - Add "Secure Operation Manual" section and updated the code to enforce MFA and reCAPTCHA [#150](https://github.com/stellar/stellar-disbursement-platform-backend/pull/150) - Coinspect SDP-006 Weak password policy [#143](https://github.com/stellar/stellar-disbursement-platform-backend/pull/143) + - Coinspect SDP-007: Log user activity when updating user info [#139](https://github.com/stellar/stellar-disbursement-platform-backend/pull/139) + - Coinspect SDP-012 Enhance User Awareness for SMS One-Time Password (OTP) Usage [#138](https://github.com/stellar/stellar-disbursement-platform-backend/pull/138) ## [1.0.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0...1.0.1) From ed5047a6a65619d091b767ee246f6ad72f761cc8 Mon Sep 17 00:00:00 2001 From: Erica Date: Wed, 31 Jan 2024 16:54:57 -0800 Subject: [PATCH 34/39] typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1d2c149..409e0a434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ None - Support automatic cancellation of payments in `READY` status after a certain time period [#121](https://github.com/stellar/stellar-disbursement-platform-backend/pull/121) - API endpoint for cancelling payments in `READY` status: `PATCH /payments/{id}/status` [#130](https://github.com/stellar/stellar-disbursement-platform-backend/pull/130) -- Use CI to m sure the helm README is up to date [#164](https://github.com/stellar/stellar-disbursement-platform-backend/pull/164) +- Use CI to make sure the helm README is up to date [#164](https://github.com/stellar/stellar-disbursement-platform-backend/pull/164) ### Fixed From 43644d6c358f1b1952c2b60134f69c10680309d5 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Wed, 31 Jan 2024 17:10:47 -0800 Subject: [PATCH 35/39] Sync main into develop (#169) * Hotfix/improve error logs for debugging (#125) ### What Add the client_domain when logging the message where the user with the {phone_number, client_domain} pair could not be found. Also, updated a log from error to warn. ### Why Better debuggability. * [SDP-1022] Hotfix: update Vibrant Assist's `client_domain` (#126) ### What Update client_domain on Vibrant Assist from api.vibrantapp.com to vibrantapp.com. ### Why It was incorrect. --------- Co-authored-by: Marwen Abid From b2b7a88034712393835da85781ec4c9665a985fd Mon Sep 17 00:00:00 2001 From: Marcelo Salloum dos Santos Date: Mon, 5 Feb 2024 16:33:59 -0800 Subject: [PATCH 36/39] [Hot-fix] TSS amount precision (#176) ### What Fix TSS's database amount precision to be compliant with the Stellar network amount precision. ### Why The database amount was configured to NUMERIC(10,7), which allows the maximum value of 999.9999999. This is not compliant with the Stellar network amounts though, which according with the [docs](https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/assets#amount-precision) supports numbers from 0.0000001 to 922,337,203,685.4775807. --- CHANGELOG.md | 6 ++++++ helmchart/sdp/Chart.yaml | 2 +- .../2023-07-05.0-tss-transactions-table-constraints.sql | 2 +- ...024-02-05.0-tss-transactions-table-amount-constraing.sql | 6 ++++++ internal/transactionsubmission/store/transactions_test.go | 6 ++++-- main.go | 2 +- 6 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 internal/db/migrations/2024-02-05.0-tss-transactions-table-amount-constraing.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9029fad..90f69ba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). None +## [1.1.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.0...1.1.1) + +### Fixed + +- TSS amount precision [#176](https://github.com/stellar/stellar-disbursement-platform-backend/pull/176) + ## [1.1.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.1...1.1.0) ### Changed diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 6fb91c449..f07325ecd 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) version: 0.9.4 -appVersion: "1.1.0" +appVersion: "1.1.1" type: application maintainers: - name: Stellar Development Foundation diff --git a/internal/db/migrations/2023-07-05.0-tss-transactions-table-constraints.sql b/internal/db/migrations/2023-07-05.0-tss-transactions-table-constraints.sql index 855b26361..729ea2424 100644 --- a/internal/db/migrations/2023-07-05.0-tss-transactions-table-constraints.sql +++ b/internal/db/migrations/2023-07-05.0-tss-transactions-table-constraints.sql @@ -6,7 +6,7 @@ ALTER TABLE public.submitter_transactions ADD COLUMN xdr_received TEXT UNIQUE, ALTER COLUMN external_id SET NOT NULL, ALTER COLUMN status SET DEFAULT 'PENDING', - ALTER COLUMN amount TYPE numeric(10,7), + ALTER COLUMN amount TYPE NUMERIC(19,7), ADD CONSTRAINT unique_stellar_transaction_hash UNIQUE (stellar_transaction_hash), ADD CONSTRAINT check_retry_count CHECK (retry_count >= 0); diff --git a/internal/db/migrations/2024-02-05.0-tss-transactions-table-amount-constraing.sql b/internal/db/migrations/2024-02-05.0-tss-transactions-table-amount-constraing.sql new file mode 100644 index 000000000..0185ad480 --- /dev/null +++ b/internal/db/migrations/2024-02-05.0-tss-transactions-table-amount-constraing.sql @@ -0,0 +1,6 @@ +-- +migrate Up + +ALTER TABLE public.submitter_transactions + ALTER COLUMN amount TYPE NUMERIC(19,7); + +-- +migrate Down diff --git a/internal/transactionsubmission/store/transactions_test.go b/internal/transactionsubmission/store/transactions_test.go index d0cc52373..8ebd5243c 100644 --- a/internal/transactionsubmission/store/transactions_test.go +++ b/internal/transactionsubmission/store/transactions_test.go @@ -124,14 +124,16 @@ func Test_TransactionModel_BulkInsert(t *testing.T) { ExternalID: "external-id-1", AssetCode: "USDC", AssetIssuer: keypair.MustRandom().Address(), - Amount: 1, + // Lowest number in the Stellar network (ref: https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/assets#amount-precision): + Amount: 0.0000001, Destination: keypair.MustRandom().Address(), } incomingTx2 := Transaction{ ExternalID: "external-id-2", AssetCode: "USDC", AssetIssuer: keypair.MustRandom().Address(), - Amount: 2, + // Largest number in the Stellar network (ref: https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/assets#amount-precision): + Amount: 922337203685.4775807, Destination: keypair.MustRandom().Address(), } insertedTransactions, err := txModel.BulkInsert(ctx, dbConnectionPool, []Transaction{incomingTx1, incomingTx2}) diff --git a/main.go b/main.go index cee91925c..0bdced276 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.1.0" +const Version = "1.1.1" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" From c01b50e028194cc1c2d1353ffb8f79a952d35f14 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Mon, 5 Feb 2024 12:15:08 -0800 Subject: [PATCH 37/39] Attempt to fix intermittent error in `transactionsubmission/utils/utils_test.go:47`, like https://github.com/stellar/stellar-disbursement-platform-backend/actions/runs/7790057714/job/21243057987?pr=172 --- internal/transactionsubmission/utils/utils_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/transactionsubmission/utils/utils_test.go b/internal/transactionsubmission/utils/utils_test.go index 4725aee5c..faee83e2d 100644 --- a/internal/transactionsubmission/utils/utils_test.go +++ b/internal/transactionsubmission/utils/utils_test.go @@ -2,8 +2,10 @@ package utils import ( "context" - "math/rand" + "crypto/rand" + "math/big" "testing" + "time" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" @@ -17,7 +19,9 @@ func TestAdvisoryLockAndRelease(t *testing.T) { defer dbt.Close() // Creates a database pool - lockKey := rand.Intn(100000) + randBigInt, err := rand.Int(rand.Reader, big.NewInt(90000)) + require.NoError(t, err) + lockKey := int(randBigInt.Int64()) dbConnectionPool1, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) @@ -38,7 +42,11 @@ func TestAdvisoryLockAndRelease(t *testing.T) { require.False(t, lockAcquired2) // Close the original connection which releases the lock + sqlQuery := "SELECT pg_advisory_unlock($1)" + _, err = dbConnectionPool1.ExecContext(ctx, sqlQuery, lockKey) + require.NoError(t, err) dbConnectionPool1.Close() + time.Sleep(200 * time.Millisecond) // try to acquire the lock again lockAcquired3, err := AcquireAdvisoryLock(ctx, dbConnectionPool2, lockKey) From 50929a27f68c29ac4f488a39fe4aeb0ec8b2c115 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Mon, 5 Feb 2024 21:26:43 -0800 Subject: [PATCH 38/39] Add missing recaptcha script. --- internal/htmltemplate/tmpl/receiver_register.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/htmltemplate/tmpl/receiver_register.tmpl b/internal/htmltemplate/tmpl/receiver_register.tmpl index f5b54660e..20d08cb76 100644 --- a/internal/htmltemplate/tmpl/receiver_register.tmpl +++ b/internal/htmltemplate/tmpl/receiver_register.tmpl @@ -218,6 +218,9 @@ {{.JWTToken}}
+ + + From 1038d1ac74fddc1110a435d09c85313eec18e5c9 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Mon, 5 Feb 2024 21:28:58 -0800 Subject: [PATCH 39/39] Hot-fix: re-add missing recaptcha script to HTML template in the SEP24 registration flow --- CHANGELOG.md | 6 ++++++ main.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f69ba23..cb59b5b90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). None +## [1.1.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.1...1.1.2) + +### Fixed + +- Re-add missing recaptcha script [#179](https://github.com/stellar/stellar-disbursement-platform-backend/pull/179) + ## [1.1.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.0...1.1.1) ### Fixed diff --git a/main.go b/main.go index 0bdced276..07a5d9ac2 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "1.1.1" +const Version = "1.1.2" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT"