From e3b825ae3601501a9a10e7c09c84ea638c45762c Mon Sep 17 00:00:00 2001 From: clD11 <23483715+clD11@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:59:53 +0100 Subject: [PATCH 1/2] feat: add radom checkout session integration for new orders (#2627) * feat: add radom checkout session integration for new orders * test: add no metadata test order item req * style: add new line to slice init in radom gateway * fix: set radom authorization header without bearer prefix * fix: rectify skus model tests after rebase * refactor: rename method createRadomSessID to createRadomSession * refactor: address comments * fix imports * update gateway to pointer * update radom checkout session request to pointer * rename order items to line items function * add radom product id getter on metadata * refactor: remove unused variable emptyCreateCheckoutSessionResp * refactor: remove unnecessary convert to UTC * refactor: sort imports * refactor: remove unused func mustDecimalFromString * feat: handle radom webhook events (#2640) * feat: add radom checkout session integration for new orders * fix: rectify skus model tests after rebase * refactor: address comments * fix imports * update gateway to pointer * update radom checkout session request to pointer * rename order items to line items function * add radom product id getter on metadata * feat: add radom checkout session integration for new orders * feat: handle radom webhook events * test: remove duplicate test and fix pointer after rebase * feat: set radom webhook verification key * refactor: use pointers for radom events * refactor: fix import order skus controller * refactor: simplify radom event processing checks * style: fix parse apostrophe * refactor: use pointer for radom subscription response * refactor: remove call to error when calling err on logging * refactor: use index directly on radom mdata * refactor: make radom action error naming less generic * refactor: use pointer for service in test * refactor: sort imports in radom.go * refactor: update radom empty payments error message * style: return from func call opposed to explicit return nil * refactor: use string in radom get subscription * refactor: add on conflict do nothing to order history insert statement * refactor: rename radom event to radom notification * test: remove duplicate test after rebase --- libs/clients/radom/instrumented.go | 50 - libs/clients/radom/mock.go | 20 - libs/clients/radom/radom.go | 212 ---- libs/context/keys.go | 11 - services/grant/cmd/grant.go | 7 - services/skus/controllers.go | 106 +- services/skus/controllers_test.go | 148 ++- services/skus/model/model.go | 156 ++- services/skus/model/model_test.go | 354 ++++--- services/skus/radom/notification.go | 198 ++++ services/skus/radom/notification_test.go | 985 ++++++++++++++++++ services/skus/radom/radom.go | 139 +++ services/skus/radom/radom_test.go | 154 +++ services/skus/service.go | 345 ++++-- services/skus/service_nonint_test.go | 554 ++++++++++ services/skus/service_test.go | 3 +- .../skus/storage/repository/order_history.go | 16 +- 17 files changed, 2755 insertions(+), 703 deletions(-) delete mode 100644 libs/clients/radom/instrumented.go delete mode 100644 libs/clients/radom/mock.go delete mode 100644 libs/clients/radom/radom.go create mode 100644 services/skus/radom/notification.go create mode 100644 services/skus/radom/notification_test.go create mode 100644 services/skus/radom/radom.go create mode 100644 services/skus/radom/radom_test.go diff --git a/libs/clients/radom/instrumented.go b/libs/clients/radom/instrumented.go deleted file mode 100644 index ae1cd4c35..000000000 --- a/libs/clients/radom/instrumented.go +++ /dev/null @@ -1,50 +0,0 @@ -package radom - -import ( - "context" - "time" - - "github.com/prometheus/client_golang/prometheus" -) - -type InstrumentedClient struct { - name string - cl *Client - vec *prometheus.SummaryVec -} - -// newInstrumentedClient returns an instance of the Client decorated with prometheus summary metric. -// This function panics if it cannot register the metric. -func newInstrumentedClient(name string, cl *Client) *InstrumentedClient { - v := prometheus.NewSummaryVec(prometheus.SummaryOpts{ - Name: "radom_client_duration_seconds", - Help: "client runtime duration and result", - MaxAge: time.Minute, - Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, - }, - []string{"instance_name", "method", "result"}, - ) - prometheus.MustRegister(v) - - result := &InstrumentedClient{ - name: name, - cl: cl, - vec: v, - } - - return result -} - -func (_d *InstrumentedClient) CreateCheckoutSession(ctx context.Context, cp1 *CheckoutSessionRequest) (cp2 *CheckoutSessionResponse, err error) { - _since := time.Now() - defer func() { - result := "ok" - if err != nil { - result = "error" - } - - _d.vec.WithLabelValues(_d.name, "CreateCheckoutSession", result).Observe(time.Since(_since).Seconds()) - }() - - return _d.cl.CreateCheckoutSession(ctx, cp1) -} diff --git a/libs/clients/radom/mock.go b/libs/clients/radom/mock.go deleted file mode 100644 index 3205ccb35..000000000 --- a/libs/clients/radom/mock.go +++ /dev/null @@ -1,20 +0,0 @@ -package radom - -import ( - "context" -) - -type MockClient struct { - FnCreateCheckoutSession func(ctx context.Context, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) -} - -func (c *MockClient) CreateCheckoutSession( - ctx context.Context, - req *CheckoutSessionRequest, -) (*CheckoutSessionResponse, error) { - if c.FnCreateCheckoutSession == nil { - return &CheckoutSessionResponse{}, nil - } - - return c.FnCreateCheckoutSession(ctx, req) -} diff --git a/libs/clients/radom/radom.go b/libs/clients/radom/radom.go deleted file mode 100644 index 0efe49545..000000000 --- a/libs/clients/radom/radom.go +++ /dev/null @@ -1,212 +0,0 @@ -package radom - -import ( - "context" - "crypto/subtle" - "errors" - "time" - - "github.com/shopspring/decimal" - - "github.com/brave-intl/bat-go/libs/clients" - appctx "github.com/brave-intl/bat-go/libs/context" -) - -var ( - ErrInvalidMetadataKey = errors.New("invalid metadata key") -) - -// CheckoutSessionRequest represents a request to create a checkout session. -type CheckoutSessionRequest struct { - SuccessURL string `json:"successUrl"` - CancelURL string `json:"cancelUrl"` - Currency string `json:"currency"` - ExpiresAt int64 `json:"expiresAt"` // in unix seconds - LineItems []LineItem `json:"lineItems"` - Metadata Metadata `json:"metadata"` - Customizations map[string]interface{} `json:"customizations"` - Total decimal.Decimal `json:"total"` - Gateway Gateway `json:"gateway"` -} - -// Gateway provides access to managed services configurations -type Gateway struct { - Managed Managed `json:"managed"` -} - -// Managed is the Radom managed services configuration -type Managed struct { - Methods []Method `json:"methods"` -} - -// Method is a Radom payment method type -type Method struct { - Network string `json:"network"` - Token string `json:"token"` -} - -// CheckoutSessionResponse represents the result of submission of a checkout session. -type CheckoutSessionResponse struct { - SessionID string `json:"checkoutSessionId"` - SessionURL string `json:"checkoutSessionUrl"` -} - -// LineItem is a line item for a checkout session request. -type LineItem struct { - ProductID string `json:"productId"` - ItemData map[string]interface{} `json:"itemData"` -} - -// Metadata represents metaadata in a checkout session request. -type Metadata []KeyValue - -// Get allows returns a value based on the key from the Radom metadata. -func (m Metadata) Get(key string) (string, error) { - for _, v := range m { - if subtle.ConstantTimeCompare([]byte(key), []byte(v.Key)) == 1 { - return v.Value, nil - } - } - - return "", ErrInvalidMetadataKey -} - -// KeyValue represents a key-value metadata pair. -type KeyValue struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// AutomatedEVMSubscripton defines an automated subscription -type AutomatedEVMSubscription struct { - BuyerAddress string `json:"buyerAddress"` - SubscriptionContractAddress string `json:"subscriptionContractAddress"` -} - -// Subscription is a radom subscription -type Subscription struct { - AutomatedEVMSubscription AutomatedEVMSubscription `json:"automatedEVMSubscription"` -} - -// NewSubscriptionData provides details about the new subscription -type NewSubscriptionData struct { - SubscriptionID string `json:"subscriptionId"` - Subscription Subscription `json:"subscriptionType"` - Network string `json:"network"` - Token string `json:"token"` - Amount decimal.Decimal `json:"amount"` - Currency string `json:"currency"` - Period string `json:"period"` - PeriodCustomDuration string `json:"periodCustomDuration"` - CreatedAt *time.Time `json:"createdAt"` - Tags map[string]string `json:"tags"` -} - -// Data is radom specific data attached to webhook calls -type Data struct { - CheckoutSession CheckoutSession `json:"checkoutSession"` -} - -// CheckoutSession describes a radom checkout session -type CheckoutSession struct { - CheckoutSessionID string `json:"checkoutSessionId"` - Metadata Metadata `json:"metadata"` -} - -// ManagedRecurringPayment provides details about the recurring payment from webhook -type ManagedRecurringPayment struct { - PaymentMethod Method `json:"paymentMethod"` - Amount decimal.Decimal `json:"amount"` -} - -// EventData encapsulates the webhook event -type EventData struct { - ManagedRecurringPayment *ManagedRecurringPayment `json:"managedRecurringPayment"` - NewSubscription *NewSubscriptionData `json:"newSubscription"` -} - -// WebhookRequest represents a radom webhook submission -type WebhookRequest struct { - EventType string `json:"eventType"` - EventData EventData `json:"eventData"` - Data Data `json:"radomData"` -} - -// Client communicates with Radom. -type Client struct { - client *clients.SimpleHTTPClient - gwMethodsProd []Method - gwMethods []Method -} - -// New returns a ready to use Client. -func New(srvURL, secret, proxyAddr string) (*Client, error) { - return newClient(srvURL, secret, proxyAddr) -} - -func NewInstrumented(srvURL, secret, proxyAddr string) (*InstrumentedClient, error) { - cl, err := newClient(srvURL, secret, proxyAddr) - if err != nil { - return nil, err - } - - return newInstrumentedClient("radom_client", cl), nil -} - -func newClient(srvURL, secret, proxyAddr string) (*Client, error) { - client, err := clients.NewWithProxy("radom", srvURL, secret, proxyAddr) - if err != nil { - return nil, err - } - - result := &Client{ - client: client, - gwMethodsProd: []Method{ - { - Network: "Polygon", - Token: "0x3cef98bb43d732e2f285ee605a8158cde967d219", - }, - - { - Network: "Ethereum", - Token: "0x0d8775f648430679a709e98d2b0cb6250d2887ef", - }, - }, - gwMethods: []Method{ - { - Network: "SepoliaTestnet", - Token: "0x5D684d37922dAf7Aa2013E65A22880a11C475e25", - }, - { - Network: "PolygonTestnet", - Token: "0xd445cAAbb9eA6685D3A512439256866563a16E93", - }, - }, - } - - return result, nil -} - -// CreateCheckoutSession creates a Radom checkout session. -func (c *Client) CreateCheckoutSession( - ctx context.Context, - req *CheckoutSessionRequest, -) (*CheckoutSessionResponse, error) { - // Get the environment so we know what is acceptable chain/tokens. - methods := c.methodsForEnv(ctx) - - req.Gateway = Gateway{ - Managed: Managed{Methods: methods}, - } - - return nil, errors.New("not implemented") -} - -func (c *Client) methodsForEnv(ctx context.Context) []Method { - env, ok := ctx.Value(appctx.EnvironmentCTXKey).(string) - if !ok || env != "production" { - return c.gwMethods - } - - return c.gwMethodsProd -} diff --git a/libs/context/keys.go b/libs/context/keys.go index 2e45a1874..b9f61baa5 100644 --- a/libs/context/keys.go +++ b/libs/context/keys.go @@ -115,17 +115,6 @@ const ( // DisableSolanaLinkingCTXKey - this informs if solana linking is enabled DisableSolanaLinkingCTXKey CTXKey = "disable_solana_linking" - // RadomWebhookSecretCTXKey - the webhook secret key for radom integration - RadomWebhookSecretCTXKey CTXKey = "radom_webhook_secret" - // RadomEnabledCTXKey - this informs if radom is enabled - RadomEnabledCTXKey CTXKey = "radom_enabled" - // RadomSellerAddressCTXKey is the seller address on radom - RadomSellerAddressCTXKey CTXKey = "radom_seller_address" - // RadomServerCTXKey is the server address on radom - RadomServerCTXKey CTXKey = "radom_server" - // RadomSecretCTXKey is the server secret on radom - RadomSecretCTXKey CTXKey = "radom_secret" - // stripe related keys // StripeEnabledCTXKey - this informs if stripe is enabled diff --git a/services/grant/cmd/grant.go b/services/grant/cmd/grant.go index 759b56b48..317255732 100644 --- a/services/grant/cmd/grant.go +++ b/services/grant/cmd/grant.go @@ -630,13 +630,6 @@ func GrantServer( ctx = context.WithValue(ctx, appctx.StripeWebhookSecretCTXKey, viper.GetString("stripe-webhook-secret")) ctx = context.WithValue(ctx, appctx.StripeSecretCTXKey, viper.GetString("stripe-secret")) - // Variables for Radom. - ctx = context.WithValue(ctx, appctx.RadomEnabledCTXKey, viper.GetBool("radom-enabled")) - ctx = context.WithValue(ctx, appctx.RadomWebhookSecretCTXKey, viper.GetString("radom-webhook-secret")) - ctx = context.WithValue(ctx, appctx.RadomSecretCTXKey, viper.GetString("radom-secret")) - ctx = context.WithValue(ctx, appctx.RadomServerCTXKey, viper.GetString("radom-server")) - ctx = context.WithValue(ctx, appctx.RadomSellerAddressCTXKey, viper.GetString("radom-seller-address")) - // require country present from uphold txs ctx = context.WithValue(ctx, appctx.RequireUpholdCountryCTXKey, viper.GetBool("require-uphold-destination-country")) diff --git a/services/skus/controllers.go b/services/skus/controllers.go index 48957e830..505310d2a 100644 --- a/services/skus/controllers.go +++ b/services/skus/controllers.go @@ -2,7 +2,6 @@ package skus import ( "context" - "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -19,7 +18,6 @@ import ( uuid "github.com/satori/go.uuid" "github.com/stripe/stripe-go/v72/webhook" - "github.com/brave-intl/bat-go/libs/clients/radom" appctx "github.com/brave-intl/bat-go/libs/context" "github.com/brave-intl/bat-go/libs/handlers" "github.com/brave-intl/bat-go/libs/inputs" @@ -30,6 +28,7 @@ import ( "github.com/brave-intl/bat-go/services/skus/handler" "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/radom" ) const ( @@ -998,7 +997,7 @@ func WebhookRouter(svc *Service) chi.Router { r := chi.NewRouter() r.Method(http.MethodPost, "/stripe", middleware.InstrumentHandler("HandleStripeWebhook", handleStripeWebhook(svc))) - r.Method(http.MethodPost, "/radom", middleware.InstrumentHandler("HandleRadomWebhook", HandleRadomWebhook(svc))) + r.Method(http.MethodPost, "/radom", middleware.InstrumentHandler("handleRadomWebhook", handleRadomWebhook(svc))) r.Method(http.MethodPost, "/android", middleware.InstrumentHandler("handleWebhookPlayStore", handleWebhookPlayStore(svc))) r.Method(http.MethodPost, "/ios", middleware.InstrumentHandler("handleWebhookAppStore", handleWebhookAppStore(svc))) @@ -1188,89 +1187,52 @@ func handleWebhookAppStoreH(w http.ResponseWriter, r *http.Request, svc *Service return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) } -// HandleRadomWebhook handles Radom checkout session webhooks. -func HandleRadomWebhook(service *Service) handlers.AppHandler { +// handleRadomWebhook handles Radom checkout session webhooks. +func handleRadomWebhook(s *Service) handlers.AppHandler { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { - ctx := r.Context() - - lg := logging.Logger(ctx, "payments").With().Str("func", "HandleRadomWebhook").Logger() - - // Get webhook secret. - endpointSecret, err := appctx.GetStringFromContext(ctx, appctx.RadomWebhookSecretCTXKey) - if err != nil { - lg.Error().Err(err).Msg("failed to get radom_webhook_secret from context") - return handlers.WrapError(err, "error getting radom_webhook_secret from context", http.StatusInternalServerError) - } - - // Check verification key. - if subtle.ConstantTimeCompare([]byte(r.Header.Get("radom-verification-key")), []byte(endpointSecret)) != 1 { - lg.Error().Err(err).Msg("invalid verification key from webhook") - return handlers.WrapError(err, "invalid verification key", http.StatusBadRequest) - } - - req := radom.WebhookRequest{} - if err := requestutils.ReadJSON(ctx, r.Body, &req); err != nil { - lg.Error().Err(err).Msg("failed to read request body") - return handlers.WrapError(err, "error reading request body", http.StatusServiceUnavailable) - } + return handleRadomWebhookH(w, r, s) + } +} - lg.Debug().Str("event_type", req.EventType).Str("data", fmt.Sprintf("%+v", req)).Msg("webhook event captured") +func handleRadomWebhookH(w http.ResponseWriter, r *http.Request, svc *Service) *handlers.AppError { + ctx := r.Context() - // Handle only successful payment events. - if req.EventType != "managedRecurringPayment" && req.EventType != "newSubscription" { - return handlers.WrapError(err, "event type not implemented", http.StatusBadRequest) - } + l := logging.Logger(ctx, "skus").With().Str("func", "handleRadomWebhookH").Logger() - // Lookup the order, the checkout session was created with orderId in metadata. - rawOrderID, err := req.Data.CheckoutSession.Metadata.Get("braveOrderId") - if err != nil || rawOrderID == "" { - return handlers.WrapError(err, "brave metadata not found in webhook", http.StatusBadRequest) - } + if err := svc.radomAuth.Authenticate(ctx, r.Header.Get("radom-verification-key")); err != nil { + l.Err(err).Msg("invalid request") - orderID, err := uuid.FromString(rawOrderID) - if err != nil { - return handlers.WrapError(err, "invalid braveOrderId in request", http.StatusBadRequest) - } + return handlers.WrapError(err, "invalid request", http.StatusUnauthorized) + } - // Set order id to paid, and update metadata values. - if err := service.Datastore.UpdateOrder(orderID, OrderStatusPaid); err != nil { - lg.Error().Err(err).Msg("failed to update order status") - return handlers.WrapError(err, "error updating order status", http.StatusInternalServerError) - } + b, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB)) + if err != nil { + l.Err(err).Msg("failed to read payload") - if err := service.Datastore.AppendOrderMetadata( - ctx, &orderID, "radomCheckoutSession", req.Data.CheckoutSession.CheckoutSessionID); err != nil { - lg.Error().Err(err).Msg("failed to update order metadata") - return handlers.WrapError(err, "error updating order metadata", http.StatusInternalServerError) - } + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) + } - if req.EventType == "newSubscription" { + ntf, err := radom.ParseNotification(b) + if err != nil { + l.Err(err).Msg("failed to parse radom event") - if err := service.Datastore.AppendOrderMetadata( - ctx, &orderID, "subscriptionId", req.EventData.NewSubscription.SubscriptionID); err != nil { - lg.Error().Err(err).Msg("failed to update order metadata") - return handlers.WrapError(err, "error updating order metadata", http.StatusInternalServerError) - } + return handlers.WrapError(err, "failed to parse radom event", http.StatusBadRequest) + } - if err := service.Datastore.AppendOrderMetadata( - ctx, &orderID, "subscriptionContractAddress", - req.EventData.NewSubscription.Subscription.AutomatedEVMSubscription.SubscriptionContractAddress); err != nil { + if err := svc.processRadomNotification(ctx, ntf); err != nil { + l.Err(err).Msg("failed to process radom notification") - lg.Error().Err(err).Msg("failed to update order metadata") - return handlers.WrapError(err, "error updating order metadata", http.StatusInternalServerError) - } + return handlers.WrapError(model.ErrSomethingWentWrong, "something went wrong", http.StatusInternalServerError) + } - } + msg := "skipped radom notification" + if ntf.ShouldProcess() { + msg = "processed radom notification" + } - // Set paymentProcessor to Radom. - if err := service.Datastore.AppendOrderMetadata(ctx, &orderID, "paymentProcessor", model.RadomPaymentMethod); err != nil { - lg.Error().Err(err).Msg("failed to update order to add the payment processor") - return handlers.WrapError(err, "failed to update order to add the payment processor", http.StatusInternalServerError) - } + l.Info().Str("ntf_type", ntf.NtfType()).Str("ntf_effect", ntf.Effect()).Msg(msg) - lg.Debug().Str("orderID", orderID.String()).Msg("order is now paid") - return handlers.RenderContent(ctx, "payment successful", w, http.StatusOK) - } + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) } func handleStripeWebhook(svc *Service) handlers.AppHandler { diff --git a/services/skus/controllers_test.go b/services/skus/controllers_test.go index 28a02d460..d157b4a1a 100644 --- a/services/skus/controllers_test.go +++ b/services/skus/controllers_test.go @@ -46,12 +46,13 @@ import ( timeutils "github.com/brave-intl/bat-go/libs/time" walletutils "github.com/brave-intl/bat-go/libs/wallet" "github.com/brave-intl/bat-go/libs/wallet/provider/uphold" - "github.com/brave-intl/bat-go/services/skus/handler" - "github.com/brave-intl/bat-go/services/skus/skustest" "github.com/brave-intl/bat-go/services/wallet" macaroon "github.com/brave-intl/bat-go/tools/macaroon/cmd" + "github.com/brave-intl/bat-go/services/skus/handler" "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/radom" + "github.com/brave-intl/bat-go/services/skus/skustest" "github.com/brave-intl/bat-go/services/skus/storage/repository" ) @@ -275,8 +276,8 @@ func (suite *ControllersTestSuite) AfterTest(sn, tn string) { suite.mockCtrl.Finish() } -func (s *ControllersTestSuite) TearDownSuite(sn, tn string) { - skustest.CleanDB(s.T(), s.storage.RawDB()) +func (suite *ControllersTestSuite) TearDownSuite(sn, tn string) { + skustest.CleanDB(suite.T(), suite.storage.RawDB()) } func (suite *ControllersTestSuite) setupCreateOrder(skuToken string, token macaroon.Token, quantity int) (Order, *Issuer) { @@ -1759,6 +1760,145 @@ func (suite *ControllersTestSuite) TestCreateOrderCreds_SingleUse_ExistingOrderC suite.Assert().Contains(appError.Error(), ErrCredsAlreadyExist.Error()) } +func (suite *ControllersTestSuite) TestCreateOrder_RadomPayable() { + suite.service.issuerRepo = &repository.MockIssuer{} + + sessID := uuid.NewV4().String() + + suite.service.radomClient = &mockRadomClient{ + fnCreateCheckoutSession: func(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) { + return radom.CheckoutSessionResponse{ + SessionID: sessID, + }, nil + }} + + oreq := model.CreateOrderRequestNew{ + Email: "example@example.com", + Currency: "USD", + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://example-success.com", + CancelURI: "https://example-cancel.com", + }, + PaymentMethods: []string{model.RadomPaymentMethod}, + Items: []model.OrderItemRequestNew{ + { + Quantity: 1, + SKU: "sku", + Location: "https://example.com", + Description: "description", + CredentialType: timeLimitedV2, + CredentialValidDuration: "P1M", + CredentialValidDurationEach: ptrTo("P1M"), + Price: decimal.NewFromInt(1), + RadomMetadata: &model.ItemRadomMetadata{ + ProductID: "product_1", + }, + }, + }, + } + + b, err := json.Marshal(oreq) + suite.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b)) + + rw := httptest.NewRecorder() + + oh := handlers.AppHandler(handler.NewOrder(suite.service).CreateNew) + svr := &http.Server{Addr: ":8080", Handler: oh} + + svr.Handler.ServeHTTP(rw, req) + + suite.Require().Equal(http.StatusCreated, rw.Code) + + var resp model.Order + { + err := json.Unmarshal(rw.Body.Bytes(), &resp) + suite.Require().NoError(err) + } + + order, err := suite.service.orderRepo.Get(context.Background(), suite.service.Datastore.RawDB(), resp.ID) + suite.Require().NoError(err) + + actual, ok := order.Metadata["radomCheckoutSessionId"].(string) + suite.Require().True(ok) + + suite.Equal(sessID, actual) +} + +func (suite *ControllersTestSuite) TestWebhook_Radom() { + ctx := context.Background() + + oreq := &model.OrderNew{ + Status: OrderStatusPending, + } + + res, err := suite.service.orderRepo.Create(ctx, suite.service.Datastore.RawDB(), oreq) + suite.Require().NoError(err) + + subID := uuid.NewV4() + + suite.service.radomClient = &mockRadomClient{ + fnGetSubscription: func(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) { + return &radom.SubscriptionResponse{ + ID: subID, + NextBillingDateAt: "2023-06-12T09:38:13.604410Z", + Payments: []radom.Payment{ + { + Date: "2023-06-12T09:38:13.604410Z", + }, + }, + }, nil + }, + } + + suite.service.radomAuth = radom.NewMessageAuthenticator(radom.MessageAuthConfig{ + Token: []byte("test-token"), + Enabled: true, + }) + + suite.service.payHistRepo = repository.NewOrderPayHistory() + + event := &radom.Notification{ + EventData: &radom.EventData{ + New: &radom.NewSubscription{ + SubscriptionID: subID, + }, + }, + RadomData: &radom.Data{ + CheckoutSession: &radom.CheckoutSession{ + Metadata: []radom.Metadata{ + { + Key: "brave_order_id", + Value: res.ID.String(), + }, + }, + }, + }, + } + + b, err := json.Marshal(event) + suite.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b)) + + req.Header.Add("radom-verification-key", "test-token") + + rw := httptest.NewRecorder() + + oh := handleRadomWebhook(suite.service) + svr := &http.Server{Addr: ":8080", Handler: oh} + + svr.Handler.ServeHTTP(rw, req) + + suite.Require().Equal(http.StatusOK, rw.Code) + + order, err := suite.service.orderRepo.Get(ctx, suite.service.Datastore.RawDB(), res.ID) + suite.Require().NoError(err) + + suite.Equal(model.OrderStatusPaid, order.Status) +} + // ReadSigningOrderRequestMessage reads messages from the unsigned order request topic func (suite *ControllersTestSuite) ReadSigningOrderRequestMessage(ctx context.Context, topic string) SigningOrderRequest { kafkaReader, err := kafkautils.NewKafkaReader(ctx, test.RandomString(), topic) diff --git a/services/skus/model/model.go b/services/skus/model/model.go index 8676ffd76..228aaac43 100644 --- a/services/skus/model/model.go +++ b/services/skus/model/model.go @@ -2,9 +2,7 @@ package model import ( - "context" "database/sql" - "fmt" "net/url" "sort" "strconv" @@ -14,7 +12,6 @@ import ( uuid "github.com/satori/go.uuid" "github.com/shopspring/decimal" - "github.com/brave-intl/bat-go/libs/clients/radom" "github.com/brave-intl/bat-go/libs/datastore" ) @@ -28,9 +25,6 @@ const ( ErrNoRowsChangedOrderPayHistory Error = "model: no rows changed in order_payment_history" ErrExpiredStripeCheckoutSessionIDNotFound Error = "model: expired stripeCheckoutSessionId not found" ErrInvalidOrderNoItems Error = "model: invalid order: no items" - ErrInvalidOrderNoSuccessURL Error = "model: invalid order: no success url" - ErrInvalidOrderNoCancelURL Error = "model: invalid order: no cancel url" - ErrInvalidOrderNoProductID Error = "model: invalid order: no product id" ErrNoStripeCheckoutSessID Error = "model: order: no stripe checkout session id" ErrInvalidOrderMetadataType Error = "model: order: invalid metadata type" @@ -83,10 +77,7 @@ const ( VendorGoogle Vendor = "android" ) -var ( - emptyCreateCheckoutSessionResp CreateCheckoutSessionResponse - emptyOrderTimeBounds OrderTimeBounds -) +var emptyOrderTimeBounds OrderTimeBounds // Vendor represents an app store vendor. type Vendor string @@ -95,10 +86,6 @@ func (v Vendor) String() string { return string(v) } -type radomClient interface { - CreateCheckoutSession(ctx context.Context, req *radom.CheckoutSessionRequest) (*radom.CheckoutSessionResponse, error) -} - // Order represents an individual order. type Order struct { ID uuid.UUID `json:"id" db:"id"` @@ -135,76 +122,6 @@ func (o *Order) ShouldSetTrialDays() bool { return !o.IsPaid() && o.IsStripePayable() } -// CreateRadomCheckoutSession creates a Radom checkout session for o. -func (o *Order) CreateRadomCheckoutSession( - ctx context.Context, - client radomClient, - sellerAddr string, -) (CreateCheckoutSessionResponse, error) { - return o.CreateRadomCheckoutSessionWithTime(ctx, client, sellerAddr, time.Now().Add(24*time.Hour)) -} - -// CreateRadomCheckoutSessionWithTime creates a Radom checkout session for o. -// -// TODO: This must be refactored before it's usable. Issues with the current implementation: -// - it assumes one item per order; -// - most of the logic does not belong in here; -// - metadata information must be passed explisictly instead of being parsed (it's known prior to this place); -// And more. -func (o *Order) CreateRadomCheckoutSessionWithTime( - ctx context.Context, - client radomClient, - sellerAddr string, - expiresAt time.Time, -) (CreateCheckoutSessionResponse, error) { - if len(o.Items) < 1 { - return EmptyCreateCheckoutSessionResponse(), ErrInvalidOrderNoItems - } - - successURI, ok := o.Items[0].Metadata["radom_success_uri"].(string) - if !ok { - return EmptyCreateCheckoutSessionResponse(), ErrInvalidOrderNoSuccessURL - } - - cancelURI, ok := o.Items[0].Metadata["radom_cancel_uri"].(string) - if !ok { - return EmptyCreateCheckoutSessionResponse(), ErrInvalidOrderNoCancelURL - } - - productID, ok := o.Items[0].Metadata["radom_product_id"].(string) - if !ok { - return EmptyCreateCheckoutSessionResponse(), ErrInvalidOrderNoProductID - } - - resp, err := client.CreateCheckoutSession(ctx, &radom.CheckoutSessionRequest{ - SuccessURL: successURI, - CancelURL: cancelURI, - // Gateway will be set by the client. - Metadata: radom.Metadata([]radom.KeyValue{ - { - Key: "braveOrderId", - Value: o.ID.String(), - }, - }), - LineItems: []radom.LineItem{ - { - ProductID: productID, - }, - }, - ExpiresAt: expiresAt.Unix(), - Customizations: map[string]interface{}{ - "leftPanelColor": "linear-gradient(125deg, rgba(0,0,128,1) 0%, RGBA(196,22,196,1) 100%)", - "primaryButtonColor": "#000000", - "slantedEdge": true, - }, - }) - if err != nil { - return EmptyCreateCheckoutSessionResponse(), fmt.Errorf("failed to get checkout session response: %w", err) - } - - return CreateCheckoutSessionResponse{SessionID: resp.SessionID}, nil -} - // IsPaid returns true if the order is paid. func (o *Order) IsPaid() bool { switch o.Status { @@ -370,6 +287,12 @@ func (x *OrderItem) StripeItemID() (string, bool) { return itemID, ok } +func (x *OrderItem) RadomProductID() (string, bool) { + itemID, ok := x.Metadata["radom_product_id"].(string) + + return itemID, ok +} + // OrderNew represents a request to create an order in the database. type OrderNew struct { MerchantID string `db:"merchant_id"` @@ -386,10 +309,6 @@ type CreateCheckoutSessionResponse struct { SessionID string `json:"checkoutSessionId"` } -func EmptyCreateCheckoutSessionResponse() CreateCheckoutSessionResponse { - return emptyCreateCheckoutSessionResp -} - type OrderItemList []OrderItem func (l OrderItemList) SetOrderID(orderID uuid.UUID) { @@ -475,6 +394,7 @@ type CreateOrderRequestNew struct { Email string `json:"email" validate:"required,email"` Currency string `json:"currency" validate:"required,iso4217"` StripeMetadata *OrderStripeMetadata `json:"stripe_metadata"` + RadomMetadata *OrderRadomMetadata `json:"radom_metadata"` PaymentMethods []string `json:"payment_methods"` Items []OrderItemRequestNew `json:"items" validate:"required,gt=0,dive"` } @@ -494,6 +414,7 @@ type OrderItemRequestNew struct { CredentialValidDurationEach *string `json:"each_credential_valid_duration"` IssuanceInterval *string `json:"issuance_interval"` StripeMetadata *ItemStripeMetadata `json:"stripe_metadata"` + RadomMetadata *ItemRadomMetadata `json:"radom_metadata"` } func (r *OrderItemRequestNew) TokenBufferOrDefault() int { @@ -536,6 +457,22 @@ func (r *OrderItemRequestNew) IsTLV2() bool { return r.CredentialType == "time-limited-v2" } +func (r *OrderItemRequestNew) Metadata() map[string]interface{} { + if r == nil { + return nil + } + + if r.StripeMetadata != nil { + return r.StripeMetadata.Metadata() + } + + if r.RadomMetadata != nil { + return r.RadomMetadata.Metadata() + } + + return nil +} + // OrderStripeMetadata holds data relevant to the order in Stripe. type OrderStripeMetadata struct { SuccessURI string `json:"success_uri" validate:"http_url"` @@ -584,6 +521,49 @@ func (m *ItemStripeMetadata) Metadata() map[string]interface{} { return result } +// OrderRadomMetadata holds data relevant to the order in Radom. +type OrderRadomMetadata struct { + SuccessURI string `json:"success_uri" validate:"http_url"` + CancelURI string `json:"cancel_uri" validate:"http_url"` +} + +func (m *OrderRadomMetadata) SuccessURL(oid string) (string, error) { + if m == nil { + return "", nil + } + + return addURLParam(m.SuccessURI, "order_id", oid) +} + +func (m *OrderRadomMetadata) CancelURL(oid string) (string, error) { + if m == nil { + return "", nil + } + + return addURLParam(m.CancelURI, "order_id", oid) +} + +// ItemRadomMetadata holds data about the product in Radom. +type ItemRadomMetadata struct { + ProductID string `json:"product_id"` +} + +// Metadata returns the contents of m as a map for datastore.Metadata. +// +// It can be called when m is nil. +func (m *ItemRadomMetadata) Metadata() map[string]interface{} { + if m == nil { + return nil + } + + result := make(map[string]interface{}) + if m.ProductID != "" { + result["radom_product_id"] = m.ProductID + } + + return result +} + // EnsureEqualPaymentMethods checks if the methods list equals the incoming list. // // This operation may change both slices due to sorting. diff --git a/services/skus/model/model_test.go b/services/skus/model/model_test.go index 8bc44525c..00221cd43 100644 --- a/services/skus/model/model_test.go +++ b/services/skus/model/model_test.go @@ -1,12 +1,9 @@ package model_test import ( - "context" "encoding/json" "errors" - "net" "testing" - "time" "github.com/lib/pq" uuid "github.com/satori/go.uuid" @@ -14,7 +11,6 @@ import ( should "github.com/stretchr/testify/assert" must "github.com/stretchr/testify/require" - "github.com/brave-intl/bat-go/libs/clients/radom" "github.com/brave-intl/bat-go/libs/datastore" "github.com/brave-intl/bat-go/services/skus/model" @@ -139,164 +135,6 @@ func TestEnsureEqualPaymentMethods(t *testing.T) { } } -func TestOrder_CreateRadomCheckoutSessionWithTime(t *testing.T) { - type tcGiven struct { - order *model.Order - client *radom.MockClient - saddr string - expiresAt time.Time - } - type tcExpected struct { - val model.CreateCheckoutSessionResponse - err error - } - type testCase struct { - name string - given tcGiven - exp tcExpected - } - tests := []testCase{ - { - name: "no_items", - given: tcGiven{ - order: &model.Order{}, - client: &radom.MockClient{}, - }, - exp: tcExpected{ - err: model.ErrInvalidOrderNoItems, - }, - }, - - { - name: "no_radom_success_uri", - given: tcGiven{ - order: &model.Order{ - Items: []model.OrderItem{{}}, - }, - client: &radom.MockClient{}, - }, - exp: tcExpected{ - err: model.ErrInvalidOrderNoSuccessURL, - }, - }, - - { - name: "no_radom_cancel_uri", - given: tcGiven{ - order: &model.Order{ - Items: []model.OrderItem{ - { - Metadata: datastore.Metadata{ - "radom_success_uri": "something", - }, - }, - }, - }, - client: &radom.MockClient{}, - }, - exp: tcExpected{ - err: model.ErrInvalidOrderNoCancelURL, - }, - }, - - { - name: "no_radom_product_id", - given: tcGiven{ - order: &model.Order{ - Items: []model.OrderItem{ - { - Metadata: datastore.Metadata{ - "radom_success_uri": "something_success", - "radom_cancel_uri": "something_cancel", - }, - }, - }, - }, - client: &radom.MockClient{}, - }, - exp: tcExpected{ - err: model.ErrInvalidOrderNoProductID, - }, - }, - - { - name: "client_error", - given: tcGiven{ - order: &model.Order{ - Items: []model.OrderItem{ - { - Metadata: datastore.Metadata{ - "radom_success_uri": "something_success", - "radom_cancel_uri": "something_cancel", - "radom_product_id": "something_id", - }, - }, - }, - }, - client: &radom.MockClient{ - FnCreateCheckoutSession: func(ctx context.Context, req *radom.CheckoutSessionRequest) (*radom.CheckoutSessionResponse, error) { - return nil, net.ErrClosed - }, - }, - }, - exp: tcExpected{ - err: net.ErrClosed, - }, - }, - - { - name: "client_success", - given: tcGiven{ - order: &model.Order{ - Items: []model.OrderItem{ - { - Metadata: datastore.Metadata{ - "radom_success_uri": "something_success", - "radom_cancel_uri": "something_cancel", - "radom_product_id": "something_id", - }, - }, - }, - }, - client: &radom.MockClient{ - FnCreateCheckoutSession: func(ctx context.Context, req *radom.CheckoutSessionRequest) (*radom.CheckoutSessionResponse, error) { - result := &radom.CheckoutSessionResponse{ - SessionID: "session_id", - SessionURL: "session_url", - } - - return result, nil - }, - }, - }, - exp: tcExpected{ - val: model.CreateCheckoutSessionResponse{ - SessionID: "session_id", - }, - }, - }, - } - - for i := range tests { - tc := tests[i] - t.Run(tc.name, func(t *testing.T) { - ctx := context.TODO() - act, err := tc.given.order.CreateRadomCheckoutSessionWithTime( - ctx, - tc.given.client, - tc.given.saddr, - tc.given.expiresAt, - ) - must.Equal(t, true, errors.Is(err, tc.exp.err)) - - if tc.exp.err != nil { - return - } - should.Equal(t, tc.exp.val, act) - }) - } -} - func TestOrderItemRequestNew_Unmarshal(t *testing.T) { type testCase struct { name string @@ -522,6 +360,61 @@ func TestOrderStripeMetadata(t *testing.T) { } } +func TestOrderRadomMetadata(t *testing.T) { + type tcGiven struct { + data *model.OrderRadomMetadata + oid string + } + + type tcExpected struct { + surl string + curl string + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "empty", + }, + + { + name: "add_id", + given: tcGiven{ + data: &model.OrderRadomMetadata{ + SuccessURI: "https://example.com/success", + CancelURI: "https://example.com/cancel", + }, + oid: "some_order_id", + }, + exp: tcExpected{ + surl: "https://example.com/success?order_id=some_order_id", + curl: "https://example.com/cancel?order_id=some_order_id", + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + act1, err := tc.given.data.SuccessURL(tc.given.oid) + must.Equal(t, nil, err) + + should.Equal(t, tc.exp.surl, act1) + + act2, err := tc.given.data.CancelURL(tc.given.oid) + must.Equal(t, nil, err) + + should.Equal(t, tc.exp.curl, act2) + }) + } +} + func TestOrderItemList_TotalCost(t *testing.T) { type testCase struct { name string @@ -1333,6 +1226,143 @@ func TestOrderItem_StripeItemID(t *testing.T) { } } +func TestOrderItem_RadomProductID(t *testing.T) { + type tcExpected struct { + val string + ok bool + } + + type testCase struct { + name string + given model.OrderItem + exp tcExpected + } + + tests := []testCase{ + { + name: "no_metadata", + }, + + { + name: "no_field", + given: model.OrderItem{ + Metadata: datastore.Metadata{"key": "value"}, + }, + }, + + { + name: "not_string", + given: model.OrderItem{ + Metadata: datastore.Metadata{ + "radom_product_id": 42, + }, + }, + }, + + { + name: "empty_string", + given: model.OrderItem{ + Metadata: datastore.Metadata{ + "radom_product_id": "", + }, + }, + exp: tcExpected{ok: true}, + }, + + { + name: "radom_product_id", + given: model.OrderItem{ + Metadata: datastore.Metadata{ + "radom_product_id": "radom_product_id", + }, + }, + exp: tcExpected{val: "radom_product_id", ok: true}, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, ok := tc.given.RadomProductID() + should.Equal(t, tc.exp.ok, ok) + should.Equal(t, tc.exp.val, actual) + }) + } +} + +func TestOrderItemRequestNew_Metadata(t *testing.T) { + type tcGiven struct { + oreq model.OrderItemRequestNew + } + + type tcExpected struct { + metadata map[string]interface{} + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "nil", + }, + + { + name: "stripe_metadata", + given: tcGiven{ + oreq: model.OrderItemRequestNew{ + StripeMetadata: &model.ItemStripeMetadata{ + ProductID: "product_1", + ItemID: "item_1", + }, + }, + }, + exp: tcExpected{ + metadata: map[string]interface{}{ + "stripe_product_id": "product_1", + "stripe_item_id": "item_1", + }, + }, + }, + + { + name: "radom_metadata", + given: tcGiven{ + oreq: model.OrderItemRequestNew{ + RadomMetadata: &model.ItemRadomMetadata{ + ProductID: "product_1", + }, + }, + }, + exp: tcExpected{ + metadata: map[string]interface{}{ + "radom_product_id": "product_1", + }, + }, + }, + + { + name: "no_metadata", + given: tcGiven{ + oreq: model.OrderItemRequestNew{}, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.oreq.Metadata() + should.Equal(t, tc.exp.metadata, actual) + }) + } +} + func TestOrderItemRequestNew_TokenBufferOrDefault(t *testing.T) { type testCase struct { name string diff --git a/services/skus/radom/notification.go b/services/skus/radom/notification.go new file mode 100644 index 000000000..3004cb725 --- /dev/null +++ b/services/skus/radom/notification.go @@ -0,0 +1,198 @@ +package radom + +import ( + "context" + "crypto/subtle" + "encoding/json" + + uuid "github.com/satori/go.uuid" +) + +const ( + ErrUnsupportedEvent = Error("radom: unsupported event") + ErrNoCheckoutSessionData = Error("radom: no checkout session data") + ErrBraveOrderIDNotFound = Error("radom: brave order id not found") + ErrNoRadomPaymentData = Error("radom: no radom payment data") + + ErrDisabled = Error("radom: disabled") + ErrVerificationKeyEmpty = Error("radom: verification key is empty") + ErrVerificationKeyInvalid = Error("radom: verification key is invalid") +) + +type Notification struct { + EventType string `json:"eventType"` + EventData *EventData `json:"eventData"` + RadomData *Data `json:"radomData"` +} + +type EventData struct { + New *NewSubscription `json:"newSubscription"` + Payment *SubscriptionPayment `json:"subscriptionPayment"` + Cancelled *SubscriptionCancelled `json:"subscriptionCancelled"` + Expired *SubscriptionExpired `json:"subscriptionExpired"` +} + +type NewSubscription struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type SubscriptionPayment struct { + RadomData *Data `json:"radomData"` +} + +type SubscriptionCancelled struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type SubscriptionExpired struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type Data struct { + CheckoutSession *CheckoutSession `json:"checkoutSession"` + Subscription *Subscription `json:"subscription"` +} + +type CheckoutSession struct { + CheckoutSessionID string `json:"checkoutSessionId"` + Metadata []Metadata `json:"metadata"` +} + +type Subscription struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +func (n *Notification) OrderID() (uuid.UUID, error) { + switch { + case n.EventData == nil || n.EventData.New == nil: + return uuid.Nil, ErrUnsupportedEvent + + case n.RadomData == nil || n.RadomData.CheckoutSession == nil: + return uuid.Nil, ErrNoCheckoutSessionData + + default: + mdata := n.RadomData.CheckoutSession.Metadata + + for i := range mdata { + if mdata[i].Key == "brave_order_id" { + return uuid.FromString(mdata[i].Value) + } + } + + return uuid.Nil, ErrBraveOrderIDNotFound + } +} + +func (n *Notification) SubID() (uuid.UUID, error) { + switch { + case n.EventData == nil: + return uuid.Nil, ErrUnsupportedEvent + + case n.EventData.New != nil: + return n.EventData.New.SubscriptionID, nil + + case n.EventData.Payment != nil: + if n.EventData.Payment.RadomData == nil || n.EventData.Payment.RadomData.Subscription == nil { + return uuid.Nil, ErrNoRadomPaymentData + } + + return n.EventData.Payment.RadomData.Subscription.SubscriptionID, nil + + case n.EventData.Cancelled != nil: + return n.EventData.Cancelled.SubscriptionID, nil + + case n.EventData.Expired != nil: + return n.EventData.Expired.SubscriptionID, nil + + default: + return uuid.Nil, ErrUnsupportedEvent + } +} + +func (n *Notification) IsNewSub() bool { + return n.EventData != nil && n.EventData.New != nil +} + +func (n *Notification) ShouldRenew() bool { + return n.EventData != nil && n.EventData.Payment != nil +} + +func (n *Notification) ShouldCancel() bool { + switch { + case n.EventData == nil: + return false + + case n.EventData.Cancelled != nil: + return true + + case n.EventData.Expired != nil: + return true + + default: + return false + } +} + +func (n *Notification) ShouldProcess() bool { + return n.IsNewSub() || n.ShouldRenew() || n.ShouldCancel() +} + +func (n *Notification) Effect() string { + switch { + case n.IsNewSub(): + return "new" + + case n.ShouldRenew(): + return "renew" + + case n.ShouldCancel(): + return "cancel" + + default: + return "skip" + } +} + +func (n *Notification) NtfType() string { + return n.EventType +} + +func ParseNotification(b []byte) (*Notification, error) { + ntf := &Notification{} + if err := json.Unmarshal(b, ntf); err != nil { + return nil, err + } + + return ntf, nil +} + +type MessageAuthConfig struct { + Enabled bool + Token []byte +} + +type MessageAuthenticator struct { + cfg MessageAuthConfig +} + +func NewMessageAuthenticator(cfg MessageAuthConfig) *MessageAuthenticator { + return &MessageAuthenticator{ + cfg: cfg, + } +} + +func (r *MessageAuthenticator) Authenticate(_ context.Context, token string) error { + if !r.cfg.Enabled { + return ErrDisabled + } + + if token == "" { + return ErrVerificationKeyEmpty + } + + if subtle.ConstantTimeCompare(r.cfg.Token, []byte(token)) != 1 { + return ErrVerificationKeyInvalid + } + + return nil +} diff --git a/services/skus/radom/notification_test.go b/services/skus/radom/notification_test.go new file mode 100644 index 000000000..7748d1d6f --- /dev/null +++ b/services/skus/radom/notification_test.go @@ -0,0 +1,985 @@ +package radom + +import ( + "context" + "testing" + + uuid "github.com/satori/go.uuid" + should "github.com/stretchr/testify/assert" + must "github.com/stretchr/testify/require" +) + +func TestParseNotification(t *testing.T) { + type tcGiven struct { + rawEvent string + } + + type tcExpected struct { + ntf *Notification + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + rawEvent: `{ + "eventType": "newSubscription", + "eventData": { + "newSubscription": { + "subscriptionId": "54453f86-8cfa-4eee-8818-050fc61f560b" + } + }, + "radomData": { + "checkoutSession": { + "checkoutSessionId": "71da4f76-0ac7-47ee-bb51-9d9577232245", + "metadata": [ + { + "key": "brave_order_id", + "value": "b5269191-1d8d-4934-b105-3221da010222" + } + ] + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "newSubscription", + EventData: &EventData{ + New: &NewSubscription{ + SubscriptionID: uuid.FromStringOrNil("54453f86-8cfa-4eee-8818-050fc61f560b"), + }, + }, + RadomData: &Data{ + CheckoutSession: &CheckoutSession{ + CheckoutSessionID: "71da4f76-0ac7-47ee-bb51-9d9577232245", + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "b5269191-1d8d-4934-b105-3221da010222", + }, + }, + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_payment", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionPayment", + "eventData": { + "subscriptionPayment": { + "radomData": { + "subscription": { + "subscriptionId": "2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe" + } + } + } + }, + "radomData": { + "subscription": { + "subscriptionId": "2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe" + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "subscriptionPayment", + EventData: &EventData{ + Payment: &SubscriptionPayment{ + RadomData: &Data{ + Subscription: &Subscription{ + SubscriptionID: uuid.FromStringOrNil("2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe"), + }, + }, + }, + }, + RadomData: &Data{ + Subscription: &Subscription{ + SubscriptionID: uuid.FromStringOrNil("2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionCancelled", + "eventData": { + "subscriptionCancelled": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "subscriptionCancelled", + EventData: &EventData{ + Cancelled: &SubscriptionCancelled{ + SubscriptionID: uuid.FromStringOrNil("56786d4e-a994-4392-952a-a648a0d2870a"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_expired", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionExpired", + "eventData": { + "subscriptionExpired": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "subscriptionExpired", + EventData: &EventData{ + Expired: &SubscriptionExpired{ + SubscriptionID: uuid.FromStringOrNil("56786d4e-a994-4392-952a-a648a0d2870a"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "unknown_event", + given: tcGiven{ + rawEvent: `{ + "eventType": "unknownEvent", + "eventData": { + "unknownEvent": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "unknownEvent", + EventData: &EventData{}, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "unknown_event_data", + given: tcGiven{ + rawEvent: `{ + "eventType": "unknownEvent", + "unknownEventData": { + "unknownEvent": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + ntf: &Notification{ + EventType: "unknownEvent", + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := ParseNotification([]byte(tc.given.rawEvent)) + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.ntf, actual) + }) + } +} + +func TestEvent_OrderID(t *testing.T) { + type tcGiven struct { + ntf Notification + } + + type tcExpected struct { + oid uuid.UUID + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "no_event_data", + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrUnsupportedEvent) + }, + }, + }, + + { + name: "unsupported_event", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{}, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrUnsupportedEvent) + }, + }, + }, + + { + name: "no_radom_data", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrNoCheckoutSessionData) + }, + }, + }, + + { + name: "no_checkout_data", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + RadomData: &Data{}, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrNoCheckoutSessionData) + }, + }, + }, + + { + name: "invalid_id", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + RadomData: &Data{ + CheckoutSession: &CheckoutSession{ + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "invalid_uuid", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "incorrect UUID") + }, + }, + }, + + { + name: "order_id_found", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + RadomData: &Data{ + CheckoutSession: &CheckoutSession{ + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "053e0244-4e37-48c3-8539-49952ec73f37", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + oid: uuid.FromStringOrNil("053e0244-4e37-48c3-8539-49952ec73f37"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "order_id_not_found", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + RadomData: &Data{ + CheckoutSession: &CheckoutSession{ + Metadata: []Metadata{ + { + Key: "some_key", + Value: "053e0244-4e37-48c3-8539-49952ec73f37", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrBraveOrderIDNotFound) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + + actual, err := tc.given.ntf.OrderID() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.oid, actual) + }) + } +} + +func TestNotification_SubID(t *testing.T) { + type tcGiven struct { + ntf Notification + } + + type tcExpected struct { + sid uuid.UUID + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + New: &NewSubscription{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_payment_no_radom_data", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{}, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrNoRadomPaymentData) + }, + }, + }, + + { + name: "subscription_payment_no_subscription", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{ + RadomData: &Data{}, + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrNoRadomPaymentData) + }, + }, + }, + + { + name: "subscription_payment", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{ + RadomData: &Data{ + Subscription: &Subscription{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + Cancelled: &SubscriptionCancelled{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_expired", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{ + Expired: &SubscriptionExpired{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "no_event_data", + given: tcGiven{ntf: Notification{}}, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrUnsupportedEvent) + }, + }, + }, + + { + name: "unknown_event", + given: tcGiven{ + ntf: Notification{ + EventData: &EventData{}, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrUnsupportedEvent) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + + actual, err := tc.given.ntf.SubID() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.sid, actual) + }) + } +} + +func TestParseNotification2_IsNewSub(t *testing.T) { + type tcGiven struct { + ntf *Notification + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_new_subscription", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{}, + }, + }, + exp: false, + }, + + { + name: "not_new_subscription_event_data", + given: tcGiven{ + ntf: &Notification{}, + }, + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.ntf.IsNewSub() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestNotification_ShouldRenew(t *testing.T) { + type tcGiven struct { + ntf *Notification + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "subscription_payment", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_subscription_payment", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{}, + }, + }, + exp: false, + }, + + { + name: "no_event_data", + given: tcGiven{ + ntf: &Notification{}, + }, + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.ntf.ShouldRenew() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestNotification_ShouldCancel(t *testing.T) { + type tcGiven struct { + ntf *Notification + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "event_data_nil", + given: tcGiven{ + ntf: &Notification{}, + }, + exp: false, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Cancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: true, + }, + + { + name: "subscription_expired", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Expired: &SubscriptionExpired{}, + }, + }, + }, + exp: true, + }, + + { + name: "unknown_action", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{}, + }, + }, + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.ntf.ShouldCancel() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestNotification_ShouldProcess(t *testing.T) { + type tcGiven struct { + ntf *Notification + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + }, + }, + exp: true, + }, + + { + name: "subscription_payment", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{}, + }, + }, + }, + exp: true, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Cancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: true, + }, + + { + name: "subscription_expired", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Expired: &SubscriptionExpired{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_should_process", + given: tcGiven{ + ntf: &Notification{}, + }, + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.ntf.ShouldProcess() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestNotification_Effect(t *testing.T) { + type tcGiven struct { + ntf *Notification + } + + type testCase struct { + name string + given tcGiven + exp string + } + + tests := []testCase{ + { + name: "new", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + New: &NewSubscription{}, + }, + }, + }, + exp: "new", + }, + + { + name: "renew", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Payment: &SubscriptionPayment{}, + }, + }, + }, + exp: "renew", + }, + + { + name: "cancel", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Cancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: "cancel", + }, + + { + name: "expired", + given: tcGiven{ + ntf: &Notification{ + EventData: &EventData{ + Expired: &SubscriptionExpired{}, + }, + }, + }, + exp: "cancel", + }, + + { + name: "skip", + given: tcGiven{ + ntf: &Notification{}, + }, + exp: "skip", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.ntf.Effect() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestMessageAuthenticator_Authenticate(t *testing.T) { + type tcGiven struct { + mAuth MessageAuthenticator + token string + } + + type tcExpected struct { + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "disabled", + given: tcGiven{ + mAuth: MessageAuthenticator{}, + }, + exp: tcExpected{ + err: ErrDisabled, + }, + }, + + { + name: "verification_key_empty", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + Enabled: true, + Token: []byte("token"), + }, + }, + }, + exp: tcExpected{ + err: ErrVerificationKeyEmpty, + }, + }, + + { + name: "verification_key_invalid", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + Enabled: true, + Token: []byte("token_1"), + }, + }, + token: "token_2", + }, + exp: tcExpected{ + err: ErrVerificationKeyInvalid, + }, + }, + + { + name: "success", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + Enabled: true, + Token: []byte("token_1"), + }, + }, + token: "token_1", + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + actual := tc.given.mAuth.Authenticate(ctx, tc.given.token) + should.ErrorIs(t, tc.exp.err, actual) + }) + } +} diff --git a/services/skus/radom/radom.go b/services/skus/radom/radom.go new file mode 100644 index 000000000..5287e158a --- /dev/null +++ b/services/skus/radom/radom.go @@ -0,0 +1,139 @@ +package radom + +import ( + "context" + "net/http" + "time" + + "github.com/brave-intl/bat-go/libs/clients" +) + +type Client struct { + client *clients.SimpleHTTPClient + authToken string +} + +func New(srvURL, authToken string) (*Client, error) { + cl, err := clients.New(srvURL, "") + if err != nil { + return nil, err + } + + return &Client{client: cl, authToken: authToken}, nil +} + +type Gateway struct { + Managed Managed `json:"managed"` +} + +type Managed struct { + Methods []Method `json:"methods"` +} + +type Method struct { + Network string `json:"network"` + Token string `json:"token"` +} + +type LineItem struct { + ProductID string `json:"productId"` +} + +type Metadata struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type CheckoutSessionRequest struct { + LineItems []LineItem `json:"lineItems"` + Gateway *Gateway `json:"gateway"` + SuccessURL string `json:"successUrl"` + CancelURL string `json:"cancelUrl"` + Metadata []Metadata `json:"metadata"` + ExpiresAt int64 `json:"expiresAt"` // in unix seconds +} + +type CheckoutSessionResponse struct { + SessionID string `json:"checkoutSessionId"` + SessionURL string `json:"checkoutSessionUrl"` +} + +func (c *Client) CreateCheckoutSession(ctx context.Context, creq *CheckoutSessionRequest) (CheckoutSessionResponse, error) { + req, err := c.client.NewRequest(ctx, http.MethodPost, "/checkout_session", creq, nil) + if err != nil { + return CheckoutSessionResponse{}, err + } + + req.Header.Add("Authorization", c.authToken) + + var resp CheckoutSessionResponse + if _, err := c.client.Do(ctx, req, &resp); err != nil { + return CheckoutSessionResponse{}, err + } + + return resp, nil +} + +func (c *Client) GetSubscription(ctx context.Context, subID string) (*SubscriptionResponse, error) { + req, err := c.client.NewRequest(ctx, http.MethodGet, "/subscription/"+subID, nil, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", c.authToken) + + resp := &SubscriptionResponse{} + if _, err := c.client.Do(ctx, req, resp); err != nil { + return nil, err + } + + return resp, nil +} + +type SubscriptionResponse struct { + ID string `json:"id"` + NextBillingDateAt string `json:"nextBillingDateAt"` + Payments []Payment `json:"payments"` +} + +type Payment struct { + Date string `json:"date"` +} + +func (s *SubscriptionResponse) NextBillingDate() (time.Time, error) { + nxtB, err := time.Parse(time.RFC3339, s.NextBillingDateAt) + if err != nil { + return time.Time{}, err + } + + return nxtB.UTC(), nil +} + +const ErrSubPaymentsEmpty = Error("radom: subscription payments empty") + +func (s *SubscriptionResponse) LastPaid() (time.Time, error) { + if len(s.Payments) <= 0 { + return time.Time{}, ErrSubPaymentsEmpty + } + + var paidAt time.Time + + for i := range s.Payments { + pat, err := time.Parse(time.RFC3339, s.Payments[i].Date) + if err != nil { + return time.Time{}, err + } + + if pat.After(paidAt) { + paidAt = pat + } + } + + return paidAt.UTC(), nil +} + +type Error string + +func (e Error) Error() string { + return string(e) +} diff --git a/services/skus/radom/radom_test.go b/services/skus/radom/radom_test.go new file mode 100644 index 000000000..d2b1101ba --- /dev/null +++ b/services/skus/radom/radom_test.go @@ -0,0 +1,154 @@ +package radom + +import ( + "testing" + "time" + + should "github.com/stretchr/testify/assert" + must "github.com/stretchr/testify/require" +) + +func TestSubscriptionResponse_NextBillingDate(t *testing.T) { + type tcGiven struct { + subResp SubscriptionResponse + } + + type tcExpected struct { + nxtB time.Time + mustErr func(t must.TestingT, err error, i ...interface{}) + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "next_billing_date", + given: tcGiven{ + subResp: SubscriptionResponse{ + NextBillingDateAt: "2024-01-01T00:00:00.000000Z", + }, + }, + exp: tcExpected{ + nxtB: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "next_billing_date_invalid_format", + given: tcGiven{ + subResp: SubscriptionResponse{ + NextBillingDateAt: "invalid_date_format", + }, + }, + exp: tcExpected{ + nxtB: time.Time{}, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "cannot parse \"invalid_date_format\"") + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := tc.given.subResp.NextBillingDate() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.nxtB, actual) + }) + } +} + +func TestSubscriptionResponse_LastPaid(t *testing.T) { + type tcGiven struct { + subResp SubscriptionResponse + } + + type tcExpected struct { + lastPaid time.Time + mustErr func(t must.TestingT, err error, i ...interface{}) + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "last_paid_empty", + exp: tcExpected{ + lastPaid: time.Time{}, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubPaymentsEmpty) + }, + }, + }, + + { + name: "last_paid_invalid_format", + given: tcGiven{ + subResp: SubscriptionResponse{ + Payments: []Payment{ + { + Date: "invalid_date_format", + }, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "cannot parse \"invalid_date_format\"") + }, + }, + }, + + { + name: "last_paid", + given: tcGiven{ + subResp: SubscriptionResponse{ + Payments: []Payment{ + { + Date: "2024-01-01T00:00:00.000000Z", + }, + + { + Date: "2024-02-01T00:00:00.000000Z", + }, + + { + Date: "2024-03-01T00:00:00.000000Z", + }, + }, + }, + }, + exp: tcExpected{ + lastPaid: time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := tc.given.subResp.LastPaid() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.lastPaid, actual) + }) + } +} diff --git a/services/skus/service.go b/services/skus/service.go index ce9f7ac33..70832195a 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -30,7 +30,6 @@ import ( "github.com/brave-intl/bat-go/libs/backoff" "github.com/brave-intl/bat-go/libs/clients/cbr" "github.com/brave-intl/bat-go/libs/clients/gemini" - "github.com/brave-intl/bat-go/libs/clients/radom" appctx "github.com/brave-intl/bat-go/libs/context" "github.com/brave-intl/bat-go/libs/cryptography" "github.com/brave-intl/bat-go/libs/datastore" @@ -46,6 +45,7 @@ import ( "github.com/brave-intl/bat-go/services/wallet" "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/radom" "github.com/brave-intl/bat-go/services/skus/xstripe" ) @@ -84,7 +84,6 @@ const ( errSetRetryAfter = model.Error("set retry-after") errClosingResource = model.Error("error closing resource") - errInvalidRadomURL = model.Error("service: invalid radom url") errGeminiClientNotConfigured = model.Error("service: gemini client not configured") errLegacyOutboxNotFound = model.Error("error no order credentials have been submitted for signing") errWrongOrderIDForRequestID = model.Error("signed request order id does not belong to request id") @@ -128,6 +127,15 @@ type stripeClient interface { } // Service contains datastore +type radomClient interface { + CreateCheckoutSession(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) + GetSubscription(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) +} + +type radomMessageAuthenticator interface { + Authenticate(ctx context.Context, token string) error +} + type Service struct { orderRepo orderStoreSvc orderItemRepo orderItemStore @@ -138,20 +146,22 @@ type Service struct { // TODO: Eventually remove it. Datastore Datastore - wallet *wallet.Service - cbClient cbr.Client - geminiClient gemini.Client - geminiConf *gemini.Conf - stripeCl stripeClient - codecs map[string]*goavro.Codec - kafkaWriter *kafka.Writer - kafkaDialer *kafka.Dialer - jobs []srv.Job - pauseVoteUntil time.Time - pauseVoteUntilMu sync.RWMutex - retry backoff.RetryFunc - radomClient *radom.InstrumentedClient - radomSellerAddress string + wallet *wallet.Service + cbClient cbr.Client + geminiClient gemini.Client + geminiConf *gemini.Conf + stripeCl stripeClient + codecs map[string]*goavro.Codec + kafkaWriter *kafka.Writer + kafkaDialer *kafka.Dialer + jobs []srv.Job + pauseVoteUntil time.Time + pauseVoteUntilMu sync.RWMutex + retry backoff.RetryFunc + + radomClient radomClient + radomGateway *radom.Gateway + radomAuth radomMessageAuthenticator vendorReceiptValid vendorReceiptValidator gpsAuth gpsMessageAuthenticator @@ -213,7 +223,7 @@ func InitService( payHistRepo orderPayHistoryStore, tlv2repo tlv2Store, ) (*Service, error) { - sublogger := logging.Logger(ctx, "payments").With().Str("func", "InitService").Logger() + lg := logging.Logger(ctx, "payments").With().Str("func", "InitService").Logger() scClient := &client.API{} if enabled, ok := ctx.Value(appctx.StripeEnabledCTXKey).(bool); ok && enabled { @@ -222,41 +232,59 @@ func InitService( var err error stripe.Key, err = appctx.GetStringFromContext(ctx, appctx.StripeSecretCTXKey) if err != nil { - sublogger.Panic().Err(err).Msg("failed to get Stripe secret from context, and Stripe enabled") + lg.Panic().Err(err).Msg("failed to get Stripe secret from context, and Stripe enabled") } scClient.Init(stripe.Key, nil) } - var ( - radomSellerAddress string - radomClient *radom.InstrumentedClient - ) + env, err := appctx.GetStringFromContext(ctx, appctx.EnvironmentCTXKey) + if err != nil { + return nil, err + } - // setup radom if exists in context and enabled - if enabled, ok := ctx.Value(appctx.RadomEnabledCTXKey).(bool); ok && enabled { - sublogger.Debug().Msg("radom enabled") - var err error - radomSellerAddress, err = appctx.GetStringFromContext(ctx, appctx.RadomSellerAddressCTXKey) - if err != nil { - sublogger.Error().Err(err).Msg("failed to get Stripe secret from context, and Stripe enabled") - return nil, err - } + var radomCl *radom.Client + var radomGateway *radom.Gateway + var radomAuthCfg radom.MessageAuthConfig + if enabled, _ := strconv.ParseBool(os.Getenv("RADOM_ENABLED")); enabled { srvURL := os.Getenv("RADOM_SERVER") if srvURL == "" { - return nil, errInvalidRadomURL + return nil, model.Error("skus: invalid radom url") } - rdSecret := os.Getenv("RADOM_SECRET") - proxyAddr := os.Getenv("HTTP_PROXY") + authToken := os.Getenv("RADOM_SECRET") + if authToken == "" { + return nil, model.Error("skus: radom secret not found") + } - radomClient, err = radom.NewInstrumented(srvURL, rdSecret, proxyAddr) - if err != nil { - return nil, err + { + var err error + + radomCl, err = radom.New(srvURL, authToken) + if err != nil { + return nil, err + } + + radomGateway, err = newRadomGateway(env) + if err != nil { + return nil, err + } + } + + radKey := os.Getenv("RADOM_VERIFICATION_KEY") + if radKey == "" { + return nil, model.Error("skus: radom verification key not found") + } + + radomAuthCfg = radom.MessageAuthConfig{ + Enabled: enabled, + Token: []byte(radKey), } } + radomAuth := radom.NewMessageAuthenticator(radomAuthCfg) + cbClient, err := cbr.New() if err != nil { return nil, err @@ -300,11 +328,6 @@ func InitService( return nil, err } - env, err := appctx.GetStringFromContext(ctx, appctx.EnvironmentCTXKey) - if err != nil { - return nil, err - } - idv, err := idtoken.NewValidator(ctx, option.WithTelemetryDisabled()) if err != nil { return nil, err @@ -312,22 +335,22 @@ func InitService( disabled, _ := strconv.ParseBool(os.Getenv("GCP_PUSH_NOTIFICATION")) if disabled { - sublogger.Warn().Msg("gcp push notification is disabled") + lg.Warn().Msg("gcp push notification is disabled") } aud := os.Getenv("GCP_PUSH_SUBSCRIPTION_AUDIENCE") if aud == "" { - sublogger.Warn().Msg("gcp push subscription audience is empty") + lg.Warn().Msg("gcp push subscription audience is empty") } iss := os.Getenv("GCP_CERT_ISSUER") if iss == "" { - sublogger.Warn().Msg("gcp cert issuer is empty") + lg.Warn().Msg("gcp cert issuer is empty") } sa := os.Getenv("GCP_PUSH_SUBSCRIPTION_SERVICE_ACCOUNT") if sa == "" { - sublogger.Warn().Msg("gcp push subscription service account is empty") + lg.Warn().Msg("gcp push subscription service account is empty") } gpsCfg := gpsValidatorConfig{ @@ -346,15 +369,17 @@ func InitService( Datastore: datastore, - wallet: walletService, - geminiClient: geminiClient, - geminiConf: geminiConf, - cbClient: cbClient, - stripeCl: xstripe.NewClient(scClient), - pauseVoteUntilMu: sync.RWMutex{}, - retry: backoff.Retry, - radomClient: radomClient, - radomSellerAddress: radomSellerAddress, + wallet: walletService, + geminiClient: geminiClient, + geminiConf: geminiConf, + cbClient: cbClient, + stripeCl: xstripe.NewClient(scClient), + pauseVoteUntilMu: sync.RWMutex{}, + retry: backoff.Retry, + + radomClient: radomCl, + radomGateway: radomGateway, + radomAuth: radomAuth, vendorReceiptValid: rcptValidator, gpsAuth: newGPSNtfAuthenticator(gpsCfg, idv), @@ -364,7 +389,6 @@ func InitService( newItemReqSet: newOrderItemReqNewMobileSet(env), } - // setup runnable jobs service.jobs = []srv.Job{ { Func: service.RunNextVoteDrainJob, @@ -1828,7 +1852,7 @@ func (s *Service) createOrderPremium(ctx context.Context, req *model.CreateOrder // Backporting this from the legacy method CreateOrderFromRequest. case order.IsRadomPayable(): - ssid, err := s.createRadomSessID(ctx, req, order) + ssid, err := s.createRadomSession(ctx, req, order) if err != nil { return nil, fmt.Errorf("failed to create checkout session: %w", err) } @@ -1922,14 +1946,64 @@ func (s *Service) createStripeSession(ctx context.Context, req *model.CreateOrde return createStripeSession(ctx, s.stripeCl, sreq) } -// TODO: Refactor the Radom-related logic. -func (s *Service) createRadomSessID(ctx context.Context, req *model.CreateOrderRequestNew, order *model.Order) (string, error) { - sess, err := order.CreateRadomCheckoutSession(ctx, s.radomClient, s.radomSellerAddress) +func (s *Service) createRadomSession(ctx context.Context, req *model.CreateOrderRequestNew, order *model.Order) (string, error) { + oid := order.ID.String() + + surl, err := req.RadomMetadata.SuccessURL(oid) + if err != nil { + return "", err + } + + curl, err := req.RadomMetadata.CancelURL(oid) if err != nil { return "", err } - return sess.SessionID, nil + items, err := orderItemsToRadomLineItems(order.Items) + if err != nil { + return "", err + } + + reqx := &radom.CheckoutSessionRequest{ + LineItems: items, + Gateway: s.radomGateway, + SuccessURL: surl, + CancelURL: curl, + Metadata: []radom.Metadata{ + { + Key: "brave_order_id", + Value: oid, + }, + }, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + + resp, err := s.radomClient.CreateCheckoutSession(ctx, reqx) + if err != nil { + return "", err + } + + return resp.SessionID, nil +} + +const errRadomProductIDNotFound = model.Error("product id not found in metadata") + +func orderItemsToRadomLineItems(orderItems []model.OrderItem) ([]radom.LineItem, error) { + lineItems := make([]radom.LineItem, 0, len(orderItems)) + for i := range orderItems { + pid, ok := orderItems[i].RadomProductID() + if !ok { + return nil, errRadomProductIDNotFound + } + + item := radom.LineItem{ + ProductID: pid, + } + + lineItems = append(lineItems, item) + } + + return lineItems, nil } func (s *Service) redeemBlindedCred(ctx context.Context, w http.ResponseWriter, kind string, cred *cbr.CredentialRedemption) *handlers.AppError { @@ -2164,6 +2238,114 @@ func (s *Service) processSubmitReceipt(ctx context.Context, req model.ReceiptReq return rcpt, nil } +const errRadomUnknownAction = model.Error("skus: unknown radom action") + +func (s *Service) processRadomNotification(ctx context.Context, ntf *radom.Notification) error { + if !ntf.ShouldProcess() { + return nil + } + + tx, err := s.Datastore.RawDB().BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := s.processRadomNotificationTx(ctx, tx, ntf); err != nil { + return err + } + + return tx.Commit() +} + +func (s *Service) processRadomNotificationTx(ctx context.Context, dbi sqlx.ExtContext, ntf *radom.Notification) error { + switch { + case ntf.IsNewSub(): + oid, err := ntf.OrderID() + if err != nil { + return err + } + + subID, err := ntf.SubID() + if err != nil { + return err + } + + rsub, err := s.radomClient.GetSubscription(ctx, subID.String()) + if err != nil { + return err + } + + nxtB, err := rsub.NextBillingDate() + if err != nil { + return err + } + + expAt := nxtB.Add(24 * time.Hour) + + paidAt, err := rsub.LastPaid() + if err != nil { + return err + } + + if err := s.renewOrderWithExpPaidTimeTx(ctx, dbi, oid, expAt, paidAt); err != nil { + return err + } + + if err := s.orderRepo.AppendMetadata(ctx, dbi, oid, "externalID", subID.String()); err != nil { + return err + } + + return s.orderRepo.AppendMetadata(ctx, dbi, oid, "paymentProcessor", model.RadomPaymentMethod) + + case ntf.ShouldRenew(): + subID, err := ntf.SubID() + if err != nil { + return err + } + + ord, err := s.orderRepo.GetByExternalID(ctx, dbi, subID.String()) + if err != nil { + return err + } + + rsub, err := s.radomClient.GetSubscription(ctx, subID.String()) + if err != nil { + return err + } + + nxtB, err := rsub.NextBillingDate() + if err != nil { + return err + } + + expAt := nxtB.Add(24 * time.Hour) + + paidAt, err := rsub.LastPaid() + if err != nil { + return err + } + + return s.renewOrderWithExpPaidTimeTx(ctx, dbi, ord.ID, expAt, paidAt) + + case ntf.ShouldCancel(): + subID, err := ntf.SubID() + if err != nil { + return err + } + + ord, err := s.orderRepo.GetByExternalID(ctx, dbi, subID.String()) + if err != nil { + return err + } + + return s.orderRepo.SetStatus(ctx, dbi, ord.ID, model.OrderStatusCanceled) + + default: + return errRadomUnknownAction + } +} + func checkOrderReceipt(ctx context.Context, dbi sqlx.QueryerContext, repo orderStoreSvc, orderID uuid.UUID, extID string) error { ord, err := repo.GetByExternalID(ctx, dbi, extID) if err != nil { @@ -2400,7 +2582,7 @@ func createOrderItem(req *model.OrderItemRequestNew) (*model.OrderItem, error) { }, }, Quantity: req.Quantity, - Metadata: req.StripeMetadata.Metadata(), + Metadata: req.Metadata(), Subtotal: req.Price.Mul(decimal.NewFromInt(int64(req.Quantity))), IssuerConfig: &model.IssuerConfig{ Buffer: req.TokenBufferOrDefault(), @@ -2583,3 +2765,42 @@ func shouldRetryRedeemFn(kind, issuer string, err error) bool { return kind == timeLimitedV2 && issuer == leo && err.Error() == cbr.ErrBadRequest.Error() } + +func newRadomGateway(env string) (*radom.Gateway, error) { + switch env { + case "development", "staging": + return &radom.Gateway{ + Managed: radom.Managed{ + Methods: []radom.Method{ + { + Network: "SepoliaTestnet", + Token: "0x5D684d37922dAf7Aa2013E65A22880a11C475e25", + }, + + { + Network: "PolygonTestnet", + Token: "0xd445cAAbb9eA6685D3A512439256866563a16E93", + }, + }, + }, + }, nil + case "production": + return &radom.Gateway{ + Managed: radom.Managed{ + Methods: []radom.Method{ + { + Network: "Polygon", + Token: "0x3cef98bb43d732e2f285ee605a8158cde967d219", + }, + + { + Network: "Ethereum", + Token: "0x0d8775f648430679a709e98d2b0cb6250d2887ef", + }, + }, + }, + }, nil + default: + return nil, model.Error("skus: unknown environment") + } +} diff --git a/services/skus/service_nonint_test.go b/services/skus/service_nonint_test.go index df7e8cfc4..b81506e4a 100644 --- a/services/skus/service_nonint_test.go +++ b/services/skus/service_nonint_test.go @@ -24,6 +24,7 @@ import ( "github.com/brave-intl/bat-go/libs/datastore" "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/radom" "github.com/brave-intl/bat-go/services/skus/storage/repository" "github.com/brave-intl/bat-go/services/skus/xstripe" ) @@ -4322,6 +4323,559 @@ func TestBuildStripeLineItems(t *testing.T) { } } +func TestService_createRadomSessID(t *testing.T) { + type tcExpected struct { + sessionID string + mustErr must.ErrorAssertionFunc + } + + type tcGiven struct { + req *model.CreateOrderRequestNew + order *model.Order + radCl radomClient + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "err_success_url", + given: tcGiven{ + order: &model.Order{}, + req: &model.CreateOrderRequestNew{ + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://invalid%.com", + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NotNil(t, err) + }, + }, + }, + + { + name: "err_cancel_url", + given: tcGiven{ + order: &model.Order{}, + req: &model.CreateOrderRequestNew{ + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://example.com", + CancelURI: "https://invalid%.com", + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NotNil(t, err) + }, + }, + }, + + { + name: "err_order_items_to_line_items", + given: tcGiven{ + order: &model.Order{ + Items: []OrderItem{{}}, + }, + req: &model.CreateOrderRequestNew{ + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://example.com", + CancelURI: "https://example.com", + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, errRadomProductIDNotFound) + }, + }, + }, + + { + name: "err_create_checkout_session", + given: tcGiven{ + order: &model.Order{ + Items: []OrderItem{ + { + Metadata: datastore.Metadata{ + "radom_product_id": "product_1", + }, + }, + }, + }, + req: &model.CreateOrderRequestNew{ + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://example.com", + CancelURI: "https://example.com", + }, + }, + radCl: &mockRadomClient{ + fnCreateCheckoutSession: func(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) { + return radom.CheckoutSessionResponse{}, model.Error("some error") + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, model.Error("some error")) + }, + }, + }, + + { + name: "success", + given: tcGiven{ + order: &model.Order{ + Items: []OrderItem{ + { + Metadata: datastore.Metadata{ + "radom_product_id": "product_1", + }, + }, + }, + }, + req: &model.CreateOrderRequestNew{ + RadomMetadata: &model.OrderRadomMetadata{ + SuccessURI: "https://example.com", + CancelURI: "https://example.com", + }, + }, + radCl: &mockRadomClient{ + fnCreateCheckoutSession: func(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) { + return radom.CheckoutSessionResponse{SessionID: "session_id"}, nil + }, + }, + }, + exp: tcExpected{ + sessionID: "session_id", + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.Nil(t, err) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + s := Service{ + radomClient: tc.given.radCl, + } + ctx := context.Background() + + actual, err := s.createRadomSession(ctx, tc.given.req, tc.given.order) + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.sessionID, actual) + }) + } +} + +func Test_orderItemsToLineItems(t *testing.T) { + type tcGiven struct { + orderItems []model.OrderItem + } + + type tcExpected struct { + lineItems []radom.LineItem + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "product_id_not_found", + given: tcGiven{ + orderItems: []OrderItem{{}}, + }, + exp: tcExpected{ + err: errRadomProductIDNotFound, + }, + }, + + { + name: "success", + given: tcGiven{ + orderItems: []OrderItem{ + { + Metadata: map[string]interface{}{ + "radom_product_id": "product_1", + }, + }, + { + Metadata: map[string]interface{}{ + "radom_product_id": "product_2", + }, + }, + }, + }, + exp: tcExpected{ + lineItems: []radom.LineItem{ + { + ProductID: "product_1", + }, + { + ProductID: "product_2", + }, + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := orderItemsToRadomLineItems(tc.given.orderItems) + must.Equal(t, tc.exp.err, err) + + should.Equal(t, tc.exp.lineItems, actual) + }) + } +} + +func Test_newRadomGateway(t *testing.T) { + type tcGiven struct { + env string + } + + type tcExpected struct { + gateway *radom.Gateway + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "development", + given: tcGiven{ + "development", + }, + exp: tcExpected{ + gateway: &radom.Gateway{ + Managed: radom.Managed{ + Methods: []radom.Method{ + { + Network: "SepoliaTestnet", + Token: "0x5D684d37922dAf7Aa2013E65A22880a11C475e25", + }, + { + Network: "PolygonTestnet", + Token: "0xd445cAAbb9eA6685D3A512439256866563a16E93", + }, + }, + }, + }, + }, + }, + + { + name: "staging", + given: tcGiven{ + "staging", + }, + exp: tcExpected{ + gateway: &radom.Gateway{ + Managed: radom.Managed{ + Methods: []radom.Method{ + { + Network: "SepoliaTestnet", + Token: "0x5D684d37922dAf7Aa2013E65A22880a11C475e25", + }, + { + Network: "PolygonTestnet", + Token: "0xd445cAAbb9eA6685D3A512439256866563a16E93", + }, + }, + }, + }, + }, + }, + + { + name: "production", + given: tcGiven{ + "production", + }, + exp: tcExpected{ + gateway: &radom.Gateway{ + Managed: radom.Managed{ + Methods: []radom.Method{ + { + Network: "Polygon", + Token: "0x3cef98bb43d732e2f285ee605a8158cde967d219", + }, + + { + Network: "Ethereum", + Token: "0x0d8775f648430679a709e98d2b0cb6250d2887ef", + }, + }, + }, + }, + }, + }, + + { + name: "unknown", + given: tcGiven{ + "random_env", + }, + exp: tcExpected{ + err: model.Error("skus: unknown environment"), + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := newRadomGateway(tc.given.env) + must.Equal(t, tc.exp.err, err) + + should.Equal(t, tc.exp.gateway, actual) + }) + } +} + +func TestService_processRadomEvent(t *testing.T) { + type tcGiven struct { + event *radom.Notification + } + + type tcExpected struct { + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "should_not_process", + given: tcGiven{ + event: &radom.Notification{}, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + s := Service{} + + actual := s.processRadomNotification(context.Background(), tc.given.event) + + should.ErrorIs(t, actual, tc.exp.err) + }) + } +} + +func TestService_processRadomEventTx(t *testing.T) { + type tcGiven struct { + event *radom.Notification + orderRepo orderStoreSvc + orderPayHistory orderPayHistoryStore + radomCl radomClient + } + + type tcExpected struct { + shouldErr should.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + event: &radom.Notification{ + EventData: &radom.EventData{ + New: &radom.NewSubscription{ + SubscriptionID: uuid.NewV4(), + }, + }, + RadomData: &radom.Data{ + CheckoutSession: &radom.CheckoutSession{ + Metadata: []radom.Metadata{ + { + Key: "brave_order_id", + Value: "d14c5b2e-b719-4504-b034-86e74a932295", + }, + }, + }, + }, + }, + orderRepo: &repository.MockOrder{}, + orderPayHistory: &repository.MockOrderPayHistory{}, + radomCl: &mockRadomClient{ + fnGetSubscription: func(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) { + return &radom.SubscriptionResponse{ + NextBillingDateAt: "2023-06-12T09:38:13.604410Z", + Payments: []radom.Payment{ + { + Date: "2023-06-12T09:38:13.604410Z", + }, + }, + }, nil + }, + }, + }, + exp: tcExpected{ + shouldErr: func(t should.TestingT, err error, i ...interface{}) bool { + return should.NoError(t, err) + }, + }, + }, + + { + name: "subscription_payment", + given: tcGiven{ + event: &radom.Notification{ + EventData: &radom.EventData{ + Payment: &radom.SubscriptionPayment{ + RadomData: &radom.Data{ + Subscription: &radom.Subscription{ + SubscriptionID: uuid.NewV4(), + }, + }, + }, + }, + }, + orderRepo: &repository.MockOrder{}, + orderPayHistory: &repository.MockOrderPayHistory{}, + radomCl: &mockRadomClient{ + fnGetSubscription: func(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) { + return &radom.SubscriptionResponse{ + NextBillingDateAt: "2023-06-12T09:38:13.604410Z", + Payments: []radom.Payment{ + { + Date: "2023-06-12T09:38:13.604410Z", + }, + }, + }, nil + }, + }, + }, + exp: tcExpected{ + shouldErr: func(t should.TestingT, err error, i ...interface{}) bool { + return should.NoError(t, err) + }, + }, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + event: &radom.Notification{ + EventData: &radom.EventData{ + Cancelled: &radom.SubscriptionCancelled{ + SubscriptionID: uuid.NewV4(), + }, + }, + }, + orderRepo: &repository.MockOrder{}, + }, + exp: tcExpected{ + shouldErr: func(t should.TestingT, err error, i ...interface{}) bool { + return should.NoError(t, err) + }, + }, + }, + + { + name: "subscription_expired", + given: tcGiven{ + event: &radom.Notification{ + EventData: &radom.EventData{ + Expired: &radom.SubscriptionExpired{ + SubscriptionID: uuid.NewV4(), + }, + }, + }, + orderRepo: &repository.MockOrder{}, + }, + exp: tcExpected{ + shouldErr: func(t should.TestingT, err error, i ...interface{}) bool { + return should.NoError(t, err) + }, + }, + }, + + { + name: "unknown_action", + given: tcGiven{ + event: &radom.Notification{}, + }, + exp: tcExpected{ + shouldErr: func(t should.TestingT, err error, i ...interface{}) bool { + return should.ErrorIs(t, err, errRadomUnknownAction) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + svc := &Service{orderRepo: tc.given.orderRepo, payHistRepo: tc.given.orderPayHistory, radomClient: tc.given.radomCl} + + ctx := context.Background() + + actual := svc.processRadomNotificationTx(ctx, nil, tc.given.event) + tc.exp.shouldErr(t, actual) + }) + } +} + +type mockRadomClient struct { + fnCreateCheckoutSession func(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) + fnGetSubscription func(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) +} + +func (m *mockRadomClient) CreateCheckoutSession(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) { + if m.fnCreateCheckoutSession == nil { + return radom.CheckoutSessionResponse{}, nil + } + + return m.fnCreateCheckoutSession(ctx, creq) +} + +func (m *mockRadomClient) GetSubscription(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) { + if m.fnGetSubscription == nil { + return &radom.SubscriptionResponse{}, nil + } + + return m.fnGetSubscription(ctx, subID) +} + type mockPaidOrderCreator struct { fnCreateOrderPremium func(ctx context.Context, req *model.CreateOrderRequestNew, ordNew *model.OrderNew, items []model.OrderItem) (*model.Order, error) fnRenewOrderWithExpPaidTime func(ctx context.Context, id uuid.UUID, expt, paidt time.Time) error diff --git a/services/skus/service_test.go b/services/skus/service_test.go index 4d4e26e02..c39c50000 100644 --- a/services/skus/service_test.go +++ b/services/skus/service_test.go @@ -6,13 +6,14 @@ import ( "testing" "time" - "github.com/brave-intl/bat-go/libs/ptr" "github.com/shopspring/decimal" should "github.com/stretchr/testify/assert" must "github.com/stretchr/testify/require" "github.com/brave-intl/bat-go/libs/datastore" + "github.com/brave-intl/bat-go/libs/ptr" timeutils "github.com/brave-intl/bat-go/libs/time" + "github.com/brave-intl/bat-go/services/skus/model" ) diff --git a/services/skus/storage/repository/order_history.go b/services/skus/storage/repository/order_history.go index 63dd3eba8..b969945ab 100644 --- a/services/skus/storage/repository/order_history.go +++ b/services/skus/storage/repository/order_history.go @@ -6,8 +6,6 @@ import ( "github.com/jmoiron/sqlx" uuid "github.com/satori/go.uuid" - - "github.com/brave-intl/bat-go/services/skus/model" ) type OrderPayHistory struct{} @@ -15,21 +13,11 @@ type OrderPayHistory struct{} func NewOrderPayHistory() *OrderPayHistory { return &OrderPayHistory{} } func (r *OrderPayHistory) Insert(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { - const q = `INSERT INTO order_payment_history (order_id, last_paid) VALUES ($1, $2)` - - result, err := dbi.ExecContext(ctx, q, id, when) - if err != nil { - return err - } + const q = `INSERT INTO order_payment_history (order_id, last_paid) VALUES ($1, $2) ON CONFLICT DO NOTHING` - numAffected, err := result.RowsAffected() - if err != nil { + if _, err := dbi.ExecContext(ctx, q, id, when); err != nil { return err } - if numAffected == 0 { - return model.ErrNoRowsChangedOrderPayHistory - } - return nil } From 35f7182b31c0b6c7625f4c3359f06df59f4c40d1 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Fri, 13 Sep 2024 01:20:10 +1200 Subject: [PATCH 2/2] feat: use same issuer for monthly and annual products (#2654) * feat: use same issuer for monthly and annual product * fix: fix typo in parseVerifyCredOpaque * fix: use better field name and preserve error response * fix: use more precise language in log messages --- services/skus/controllers.go | 118 +++++++++++++----- services/skus/controllers_noint_test.go | 149 ++++++++++++++++++++++ services/skus/credentials.go | 6 +- services/skus/credentials_test.go | 11 +- services/skus/input.go | 124 ------------------- services/skus/model/model.go | 70 +++++++++++ services/skus/model/model_pvt_test.go | 38 ++++++ services/skus/model/model_test.go | 157 ++++++++++++++++++++++++ services/skus/service.go | 28 ++--- 9 files changed, 526 insertions(+), 175 deletions(-) delete mode 100644 services/skus/input.go diff --git a/services/skus/controllers.go b/services/skus/controllers.go index 505310d2a..c5311e804 100644 --- a/services/skus/controllers.go +++ b/services/skus/controllers.go @@ -140,10 +140,12 @@ func Router( func CredentialRouter(svc *Service, authMwr middlewareFn) chi.Router { r := chi.NewRouter() + valid := validator.New() + r.Method( http.MethodPost, "/subscription/verifications", - middleware.InstrumentHandler("VerifyCredentialV1", authMwr(VerifyCredentialV1(svc))), + middleware.InstrumentHandler("handleVerifyCredV1", authMwr(handleVerifyCredV1(svc, valid))), ) return r @@ -153,10 +155,12 @@ func CredentialRouter(svc *Service, authMwr middlewareFn) chi.Router { func CredentialV2Router(svc *Service, authMwr middlewareFn) chi.Router { r := chi.NewRouter() + valid := validator.New() + r.Method( http.MethodPost, "/subscription/verifications", - middleware.InstrumentHandler("VerifyCredentialV2", authMwr(VerifyCredentialV2(svc))), + middleware.InstrumentHandler("handleVerifyCredV2", authMwr(handleVerifyCredV2(svc, valid))), ) return r @@ -942,54 +946,73 @@ func MerchantTransactions(service *Service) handlers.AppHandler { }) } -func VerifyCredentialV2(service *Service) handlers.AppHandler { +func handleVerifyCredV2(svc *Service, valid *validator.Validate) handlers.AppHandler { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { ctx := r.Context() - l := logging.Logger(ctx, "skus").With().Str("func", "VerifyCredentialV2").Logger() + lg := logging.Logger(ctx, "skus").With().Str("func", "handleVerifyCredV2").Logger() + + data, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB)) + if err != nil { + lg.Warn().Err(err).Msg("failed to read body") + + return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) + } + + req, err := parseVerifyCredRequestV2(data) + if err != nil { + lg.Warn().Err(err).Msg("failed to parse request") + + return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) + } + + if err := validateVerifyCredRequestV2(valid, req); err != nil { + lg.Warn().Err(err).Msg("failed to validate request") - req := &VerifyCredentialRequestV2{} - if err := inputs.DecodeAndValidateReader(ctx, req, r.Body); err != nil { - l.Error().Err(err).Msg("failed to read request") return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) } - appErr := service.verifyCredential(ctx, req, w) - if appErr != nil { - l.Error().Err(appErr).Msg("failed to verify credential") + aerr := svc.verifyCredential(ctx, req, w) + if aerr != nil { + lg.Err(aerr).Msg("failed to verify credential") } - return appErr + return aerr } } -// VerifyCredentialV1 is the handler for verifying subscription credentials -func VerifyCredentialV1(service *Service) handlers.AppHandler { +func handleVerifyCredV1(svc *Service, valid *validator.Validate) handlers.AppHandler { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { ctx := r.Context() - l := logging.Logger(r.Context(), "VerifyCredentialV1") - var req = new(VerifyCredentialRequestV1) + lg := logging.Logger(ctx, "skus").With().Str("func", "handleVerifyCredV1").Logger() - err := requestutils.ReadJSON(r.Context(), r.Body, &req) + data, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB)) if err != nil { - l.Error().Err(err).Msg("failed to read request") + lg.Warn().Err(err).Msg("failed to read body") + return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) } - l.Debug().Msg("read verify credential post body") - _, err = govalidator.ValidateStruct(req) - if err != nil { - l.Error().Err(err).Msg("failed to validate request") + req := &model.VerifyCredentialRequestV1{} + if err := json.Unmarshal(data, req); err != nil { + lg.Warn().Err(err).Msg("failed to parse request") + + return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) + } + + if err := valid.StructCtx(ctx, req); err != nil { + lg.Warn().Err(err).Msg("failed to validate request") + return handlers.WrapError(err, "Error in request validation", http.StatusBadRequest) } - appErr := service.verifyCredential(ctx, req, w) - if appErr != nil { - l.Error().Err(appErr).Msg("failed to verify credential") + aerr := svc.verifyCredential(ctx, req, w) + if aerr != nil { + lg.Err(aerr).Msg("failed to verify credential") } - return appErr + return aerr } } @@ -1345,7 +1368,7 @@ func handleSubmitReceipt(svc *Service, valid *validator.Validate) handlers.AppHa req, err := parseSubmitReceiptRequest(payload) if err != nil { - l.Warn().Err(err).Msg("failed to deserialize request") + l.Warn().Err(err).Msg("failed to parse request") return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()}) } @@ -1412,7 +1435,7 @@ func handleCreateOrderFromReceiptH(w http.ResponseWriter, r *http.Request, svc * req, err := parseSubmitReceiptRequest(raw) if err != nil { - lg.Warn().Err(err).Msg("failed to deserialize request") + lg.Warn().Err(err).Msg("failed to parse request") return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()}) } @@ -1480,7 +1503,7 @@ func handleCheckOrderReceiptH(w http.ResponseWriter, r *http.Request, svc *Servi req, err := parseSubmitReceiptRequest(raw) if err != nil { - lg.Warn().Err(err).Msg("failed to deserialize request") + lg.Warn().Err(err).Msg("failed to parse request") return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()}) } @@ -1601,3 +1624,42 @@ func collectValidationErrors(err error) (map[string]string, bool) { return result, true } + +func parseVerifyCredRequestV2(raw []byte) (*model.VerifyCredentialRequestV2, error) { + result := &model.VerifyCredentialRequestV2{} + + if err := json.Unmarshal(raw, result); err != nil { + return nil, err + } + + copaque, err := parseVerifyCredOpaque(result.Credential) + if err != nil { + return nil, err + } + + result.CredentialOpaque = copaque + + return result, nil +} + +func parseVerifyCredOpaque(raw string) (*model.VerifyCredentialOpaque, error) { + data, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + + result := &model.VerifyCredentialOpaque{} + if err = json.Unmarshal(data, result); err != nil { + return nil, err + } + + return result, nil +} + +func validateVerifyCredRequestV2(valid *validator.Validate, req *model.VerifyCredentialRequestV2) error { + if err := valid.Struct(req); err != nil { + return err + } + + return valid.Struct(req.CredentialOpaque) +} diff --git a/services/skus/controllers_noint_test.go b/services/skus/controllers_noint_test.go index e40bbb485..2a049ee3f 100644 --- a/services/skus/controllers_noint_test.go +++ b/services/skus/controllers_noint_test.go @@ -3,6 +3,7 @@ package skus import ( "context" "net/http" + "reflect" "testing" "github.com/go-playground/validator/v10" @@ -222,3 +223,151 @@ func TestHandleReceiptErr(t *testing.T) { }) } } + +func TestParseVerifyCredRequestV2(t *testing.T) { + type tcExpected struct { + val *model.VerifyCredentialRequestV2 + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given []byte + exp tcExpected + } + + tests := []testCase{ + { + name: "error_malformed_payload", + given: []byte(`nonsense`), + exp: tcExpected{ + mustErr: func(tt must.TestingT, err error, i ...interface{}) { + must.Equal(tt, true, err != nil) + }, + }, + }, + + { + name: "error_malformed_credential", + given: []byte(`{"sku":"sku","merchantId":"merchantId"}`), + exp: tcExpected{ + mustErr: func(tt must.TestingT, err error, i ...interface{}) { + must.Equal(tt, true, err != nil) + }, + }, + }, + + { + name: "success_complete", + given: []byte(`{"sku": "sku","merchantId": "merchantId","credential":"eyJ0eXBlIjoidGltZS1saW1pdGVkLXYyIiwicHJlc2VudGF0aW9uIjoiVG1GMGRYSmxJR0ZpYUc5eWN5QmhJSFpoWTNWMWJTNEsifQo="}`), + exp: tcExpected{ + val: &model.VerifyCredentialRequestV2{ + SKU: "sku", + MerchantID: "merchantId", + Credential: "eyJ0eXBlIjoidGltZS1saW1pdGVkLXYyIiwicHJlc2VudGF0aW9uIjoiVG1GMGRYSmxJR0ZpYUc5eWN5QmhJSFpoWTNWMWJTNEsifQo=", + CredentialOpaque: &model.VerifyCredentialOpaque{ + Type: "time-limited-v2", + Presentation: "TmF0dXJlIGFiaG9ycyBhIHZhY3V1bS4K", + }, + }, + mustErr: func(tt must.TestingT, err error, i ...interface{}) { + must.Equal(tt, true, err == nil) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := parseVerifyCredRequestV2(tc.given) + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.val, actual) + }) + } +} + +func TestValidateVerifyCredRequestV2(t *testing.T) { + type tcGiven struct { + valid *validator.Validate + req *model.VerifyCredentialRequestV2 + } + + tests := []struct { + name string + given tcGiven + exp error + }{ + { + name: "error_credential_opaque_nil", + given: tcGiven{ + valid: validator.New(), + req: &model.VerifyCredentialRequestV2{ + SKU: "sku", + MerchantID: "merchantId", + Credential: "eyJ0eXBlIjoic2luZ2xlLXVzZSIsInByZXNlbnRhdGlvbiI6IlRtRjBkWEpsSUdGaWFHOXljeUJoSUhaaFkzVjFiUzRLIn0K", + }, + }, + exp: &validator.InvalidValidationError{Type: reflect.TypeOf((*model.VerifyCredentialOpaque)(nil))}, + }, + + { + name: "valid_single_use", + given: tcGiven{ + valid: validator.New(), + req: &model.VerifyCredentialRequestV2{ + SKU: "sku", + MerchantID: "merchantId", + Credential: "eyJ0eXBlIjoic2luZ2xlLXVzZSIsInByZXNlbnRhdGlvbiI6IlRtRjBkWEpsSUdGaWFHOXljeUJoSUhaaFkzVjFiUzRLIn0K", + CredentialOpaque: &model.VerifyCredentialOpaque{ + Type: "single-use", + Presentation: "TmF0dXJlIGFiaG9ycyBhIHZhY3V1bS4K", + }, + }, + }, + }, + + { + name: "valid_time_limited", + given: tcGiven{ + valid: validator.New(), + req: &model.VerifyCredentialRequestV2{ + SKU: "sku", + MerchantID: "merchantId", + Credential: "eyJ0eXBlIjoidGltZS1saW1pdGVkIiwicHJlc2VudGF0aW9uIjoiVG1GMGRYSmxJR0ZpYUc5eWN5QmhJSFpoWTNWMWJTNEsifQo=", + CredentialOpaque: &model.VerifyCredentialOpaque{ + Type: "time-limited", + Presentation: "TmF0dXJlIGFiaG9ycyBhIHZhY3V1bS4K", + }, + }, + }, + }, + + { + name: "valid_time_limited_v2", + given: tcGiven{ + valid: validator.New(), + req: &model.VerifyCredentialRequestV2{ + SKU: "sku", + MerchantID: "merchantId", + Credential: "eyJ0eXBlIjoidGltZS1saW1pdGVkLXYyIiwicHJlc2VudGF0aW9uIjoiVG1GMGRYSmxJR0ZpYUc5eWN5QmhJSFpoWTNWMWJTNEsifQo=", + CredentialOpaque: &model.VerifyCredentialOpaque{ + Type: "time-limited-v2", + Presentation: "TmF0dXJlIGFiaG9ycyBhIHZhY3V1bS4K", + }, + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := validateVerifyCredRequestV2(tc.given.valid, tc.given.req) + should.Equal(t, tc.exp, actual) + }) + } +} diff --git a/services/skus/credentials.go b/services/skus/credentials.go index 60ac2fa65..71d2c7701 100644 --- a/services/skus/credentials.go +++ b/services/skus/credentials.go @@ -61,7 +61,7 @@ var ( // // This only happens in the event of a new sku being created. func (s *Service) CreateIssuer(ctx context.Context, dbi sqlx.QueryerContext, merchID string, item *OrderItem) error { - encMerchID, err := encodeIssuerID(merchID, item.SKU) + encMerchID, err := encodeIssuerID(merchID, item.SKUForIssuer()) if err != nil { return errorutils.Wrap(err, "error encoding issuer name") } @@ -114,7 +114,7 @@ func (s *Service) CreateIssuer(ctx context.Context, dbi sqlx.QueryerContext, mer // // This only happens in the event of a new sku being created. func (s *Service) CreateIssuerV3(ctx context.Context, dbi sqlx.QueryerContext, merchID string, item *OrderItem, issuerCfg model.IssuerConfig) error { - encMerchID, err := encodeIssuerID(merchID, item.SKU) + encMerchID, err := encodeIssuerID(merchID, item.SKUForIssuer()) if err != nil { return errorutils.Wrap(err, "error encoding issuer name") } @@ -266,7 +266,7 @@ func (s *Service) CreateOrderItemCredentials(ctx context.Context, orderID, itemI return err } - issuerID, err := encodeIssuerID(order.MerchantID, item.SKU) + issuerID, err := encodeIssuerID(order.MerchantID, item.SKUForIssuer()) if err != nil { return errorutils.Wrap(err, "error encoding issuer name") } diff --git a/services/skus/credentials_test.go b/services/skus/credentials_test.go index 48d2c9c47..60062a40f 100644 --- a/services/skus/credentials_test.go +++ b/services/skus/credentials_test.go @@ -237,7 +237,7 @@ func TestCreateIssuer_NewIssuer(t *testing.T) { EachCredentialValidForISO: ptr.FromString("P1D"), } - issuerID, err := encodeIssuerID(merchantID, orderItem.SKU) + issuerID, err := encodeIssuerID(merchantID, orderItem.SKUForIssuer()) must.Equal(t, nil, err) cbrClient := mock_cbr.NewMockClient(ctrl) @@ -291,7 +291,7 @@ func TestCreateIssuerV3_NewIssuer(t *testing.T) { EachCredentialValidForISO: ptr.FromString("P1D"), } - issuerID, err := encodeIssuerID(merchantID, orderItem.SKU) + issuerID, err := encodeIssuerID(merchantID, orderItem.SKUForIssuer()) must.Equal(t, nil, err) issuerConfig := model.IssuerConfig{ @@ -360,7 +360,7 @@ func TestCreateIssuer_AlreadyExists(t *testing.T) { EachCredentialValidForISO: ptr.FromString("P1D"), } - issuerID, err := encodeIssuerID(merchantID, orderItem.SKU) + issuerID, err := encodeIssuerID(merchantID, orderItem.SKUForIssuer()) must.Equal(t, nil, err) issuer := &Issuer{ @@ -402,7 +402,7 @@ func TestCreateIssuerV3_AlreadyExists(t *testing.T) { EachCredentialValidForISO: ptr.FromString("P1D"), } - issuerID, err := encodeIssuerID(merchantID, orderItem.SKU) + issuerID, err := encodeIssuerID(merchantID, orderItem.SKUForIssuer()) must.Equal(t, nil, err) issuer := &Issuer{ @@ -477,7 +477,7 @@ func TestCreateOrderCredentials(t *testing.T) { EachCredentialValidForISO: ptr.FromString("P1D"), } - issuerID, err := encodeIssuerID(merchantID, orderItem.SKU) + issuerID, err := encodeIssuerID(merchantID, orderItem.SKUForIssuer()) must.Equal(t, nil, err) issuer := &Issuer{ @@ -543,7 +543,6 @@ func TestDeduplicateCredentialBindings(t *testing.T) { } func TestIssuerID(t *testing.T) { - cases := []struct { MerchantID string SKU string diff --git a/services/skus/input.go b/services/skus/input.go deleted file mode 100644 index 8a3bedc21..000000000 --- a/services/skus/input.go +++ /dev/null @@ -1,124 +0,0 @@ -package skus - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - - "github.com/asaskevich/govalidator" - "github.com/brave-intl/bat-go/libs/logging" -) - -// VerifyCredentialRequestV1 includes an opaque subscription credential blob -type VerifyCredentialRequestV1 struct { - Version float64 `json:"version" valid:"-"` - Type string `json:"type" valid:"in(single-use|time-limited|time-limited-v2)"` - SKU string `json:"sku" valid:"-"` - MerchantID string `json:"merchantId" valid:"-"` - Presentation string `json:"presentation" valid:"base64"` -} - -// GetSku - implement credential interface -func (vcr *VerifyCredentialRequestV1) GetSku(ctx context.Context) string { - return vcr.SKU -} - -// GetType - implement credential interface -func (vcr *VerifyCredentialRequestV1) GetType(ctx context.Context) string { - return vcr.Type -} - -// GetMerchantID - implement credential interface -func (vcr *VerifyCredentialRequestV1) GetMerchantID(ctx context.Context) string { - return vcr.MerchantID -} - -// GetPresentation - implement credential interface -func (vcr *VerifyCredentialRequestV1) GetPresentation(ctx context.Context) string { - return vcr.Presentation -} - -// VerifyCredentialRequestV2 includes an opaque subscription credential blob -type VerifyCredentialRequestV2 struct { - SKU string `json:"sku" valid:"-"` - MerchantID string `json:"merchantId" valid:"-"` - Credential string `json:"credential" valid:"base64"` - CredentialOpaque *VerifyCredentialOpaque `json:"-" valid:"-"` -} - -// GetSku - implement credential interface -func (vcr *VerifyCredentialRequestV2) GetSku(ctx context.Context) string { - return vcr.SKU -} - -// GetType - implement credential interface -func (vcr *VerifyCredentialRequestV2) GetType(ctx context.Context) string { - if vcr.CredentialOpaque == nil { - return "" - } - return vcr.CredentialOpaque.Type -} - -// GetMerchantID - implement credential interface -func (vcr *VerifyCredentialRequestV2) GetMerchantID(ctx context.Context) string { - return vcr.MerchantID -} - -// GetPresentation - implement credential interface -func (vcr *VerifyCredentialRequestV2) GetPresentation(ctx context.Context) string { - if vcr.CredentialOpaque == nil { - return "" - } - return vcr.CredentialOpaque.Presentation -} - -// Decode - implement Decodable interface -func (vcr *VerifyCredentialRequestV2) Decode(ctx context.Context, data []byte) error { - logger := logging.Logger(ctx, "VerifyCredentialRequestV2.Decode") - logger.Debug().Msg("starting VerifyCredentialRequestV2.Decode") - var err error - - if err := json.Unmarshal(data, vcr); err != nil { - return fmt.Errorf("failed to json decode credential request payload: %w", err) - } - // decode the opaque credential - if vcr.CredentialOpaque, err = credentialOpaqueFromString(vcr.Credential); err != nil { - return fmt.Errorf("failed to decode opaque credential payload: %w", err) - } - return nil -} - -// Validate - implement Validable interface -func (vcr *VerifyCredentialRequestV2) Validate(ctx context.Context) error { - logger := logging.Logger(ctx, "VerifyCredentialRequestV2.Validate") - var err error - for _, v := range []interface{}{vcr, vcr.CredentialOpaque} { - _, err = govalidator.ValidateStruct(v) - if err != nil { - logger.Error().Err(err).Msg("failed to validate request") - return fmt.Errorf("failed to validate verify credential request: %w", err) - } - } - return nil -} - -// VerifyCredentialOpaque includes an opaque presentation blob -type VerifyCredentialOpaque struct { - Type string `json:"type" valid:"in(single-use|time-limited|time-limited-v2)"` - Version float64 `json:"version" valid:"-"` - Presentation string `json:"presentation" valid:"base64"` -} - -// credentialOpaqueFromString - given a base64 encoded "credential" unmarshal into a VerifyCredentialOpaque -func credentialOpaqueFromString(s string) (*VerifyCredentialOpaque, error) { - d, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return nil, fmt.Errorf("failed to base64 decode credential payload: %w", err) - } - var vcp = new(VerifyCredentialOpaque) - if err = json.Unmarshal(d, vcp); err != nil { - return nil, fmt.Errorf("failed to json decode credential payload: %w", err) - } - return vcp, nil -} diff --git a/services/skus/model/model.go b/services/skus/model/model.go index 228aaac43..3b11a065a 100644 --- a/services/skus/model/model.go +++ b/services/skus/model/model.go @@ -6,6 +6,7 @@ import ( "net/url" "sort" "strconv" + "strings" "time" "github.com/lib/pq" @@ -293,6 +294,10 @@ func (x *OrderItem) RadomProductID() (string, bool) { return itemID, ok } +func (x *OrderItem) SKUForIssuer() string { + return fixPremiumSKUForIssuer(x.SKU) +} + // OrderNew represents a request to create an order in the database. type OrderNew struct { MerchantID string `db:"merchant_id"` @@ -657,6 +662,71 @@ type CreateOrderWithReceiptResponse struct { ID string `json:"orderId"` } +type VerifyCredentialRequestV1 struct { + Type string `json:"type" validate:"oneof=single-use time-limited time-limited-v2"` + SKU string `json:"sku" validate:"-"` + MerchantID string `json:"merchantId" validate:"-"` + Presentation string `json:"presentation" validate:"base64"` + Version float64 `json:"version" validate:"-"` +} + +func (r *VerifyCredentialRequestV1) GetSKU() string { + return fixPremiumSKUForIssuer(r.SKU) +} + +func (r *VerifyCredentialRequestV1) GetType() string { + return r.Type +} + +func (r *VerifyCredentialRequestV1) GetMerchantID() string { + return r.MerchantID +} + +func (r *VerifyCredentialRequestV1) GetPresentation() string { + return r.Presentation +} + +type VerifyCredentialRequestV2 struct { + SKU string `json:"sku" validate:"-"` + MerchantID string `json:"merchantId" validate:"-"` + Credential string `json:"credential" validate:"base64"` + CredentialOpaque *VerifyCredentialOpaque `json:"-" validate:"-"` +} + +func (r *VerifyCredentialRequestV2) GetSKU() string { + return fixPremiumSKUForIssuer(r.SKU) +} + +func (r *VerifyCredentialRequestV2) GetType() string { + if r.CredentialOpaque == nil { + return "" + } + + return r.CredentialOpaque.Type +} + +func (r *VerifyCredentialRequestV2) GetMerchantID() string { + return r.MerchantID +} + +func (r *VerifyCredentialRequestV2) GetPresentation() string { + if r.CredentialOpaque == nil { + return "" + } + + return r.CredentialOpaque.Presentation +} + +type VerifyCredentialOpaque struct { + Type string `json:"type" validate:"oneof=single-use time-limited time-limited-v2"` + Presentation string `json:"presentation" validate:"base64"` + Version float64 `json:"version" validate:"-"` +} + +func fixPremiumSKUForIssuer(val string) string { + return strings.TrimSuffix(val, "-year") +} + func addURLParam(src, name, val string) (string, error) { raw, err := url.Parse(src) if err != nil { diff --git a/services/skus/model/model_pvt_test.go b/services/skus/model/model_pvt_test.go index 84bb72e23..32b29484e 100644 --- a/services/skus/model/model_pvt_test.go +++ b/services/skus/model/model_pvt_test.go @@ -83,3 +83,41 @@ func TestAddURLParam(t *testing.T) { }) } } + +func TestFixPremiumSKUForIssuer(t *testing.T) { + tests := []struct { + name string + given string + exp string + }{ + { + name: "empty", + }, + + { + name: "trimmed_empty", + given: "-year", + }, + + { + name: "untouched", + given: "anything", + exp: "anything", + }, + + { + name: "trimmed", + given: "anything-year", + exp: "anything", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := fixPremiumSKUForIssuer(tc.given) + should.Equal(t, tc.exp, actual) + }) + } +} diff --git a/services/skus/model/model_test.go b/services/skus/model/model_test.go index 00221cd43..d6608d38d 100644 --- a/services/skus/model/model_test.go +++ b/services/skus/model/model_test.go @@ -1291,6 +1291,93 @@ func TestOrderItem_RadomProductID(t *testing.T) { } } +func TestOrderItem_SKUForIssuer(t *testing.T) { + type testCase struct { + name string + given model.OrderItem + exp string + } + + tests := []testCase{ + { + name: "empty", + }, + + { + name: "talk", + given: model.OrderItem{ + SKU: "brave-talk-premium", + }, + exp: "brave-talk-premium", + }, + + { + name: "talka", + given: model.OrderItem{ + SKU: "brave-talk-premium-year", + }, + exp: "brave-talk-premium", + }, + + { + name: "search", + given: model.OrderItem{ + SKU: "brave-search-premium", + }, + exp: "brave-search-premium", + }, + + { + name: "searcha", + given: model.OrderItem{ + SKU: "brave-search-premium-year", + }, + exp: "brave-search-premium", + }, + + { + name: "vpn", + given: model.OrderItem{ + SKU: "brave-vpn-premium", + }, + exp: "brave-vpn-premium", + }, + + { + name: "vpna", + given: model.OrderItem{ + SKU: "brave-vpn-premium-year", + }, + exp: "brave-vpn-premium", + }, + + { + name: "leo", + given: model.OrderItem{ + SKU: "brave-leo-premium", + }, + exp: "brave-leo-premium", + }, + + { + name: "leoa", + given: model.OrderItem{ + SKU: "brave-leo-premium-year", + }, + exp: "brave-leo-premium", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.SKUForIssuer() + should.Equal(t, tc.exp, actual) + }) + } +} + func TestOrderItemRequestNew_Metadata(t *testing.T) { type tcGiven struct { oreq model.OrderItemRequestNew @@ -1512,6 +1599,76 @@ func TestOrderItemRequestNew_IsTLV2(t *testing.T) { } } +func TestVerifyCredentialRequestV1_GetSKU(t *testing.T) { + type testCase struct { + name string + given model.VerifyCredentialRequestV1 + exp string + } + + tests := []testCase{ + { + name: "anything", + given: model.VerifyCredentialRequestV1{ + SKU: "anything", + }, + exp: "anything", + }, + + { + name: "leoa", + given: model.VerifyCredentialRequestV1{ + SKU: "brave-leo-premium-year", + }, + exp: "brave-leo-premium", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.GetSKU() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestVerifyCredentialRequestV2_GetSKU(t *testing.T) { + type testCase struct { + name string + given model.VerifyCredentialRequestV2 + exp string + } + + tests := []testCase{ + { + name: "anything", + given: model.VerifyCredentialRequestV2{ + SKU: "anything", + }, + exp: "anything", + }, + + { + name: "leoa", + given: model.VerifyCredentialRequestV2{ + SKU: "brave-leo-premium-year", + }, + exp: "brave-leo-premium", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.GetSKU() + should.Equal(t, tc.exp, actual) + }) + } +} + func ptrTo[T any](v T) *T { return &v } diff --git a/services/skus/service.go b/services/skus/service.go index 70832195a..c844184da 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -1405,7 +1405,7 @@ func (s *Service) GetTimeLimitedCreds(ctx context.Context, order *Order, itemID, return nil, http.StatusInternalServerError, model.Error("unable to parse issuance interval for credentials") } - issuerID, err := encodeIssuerID(order.MerchantID, item.SKU) + issuerID, err := encodeIssuerID(order.MerchantID, item.SKUForIssuer()) if err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("error encoding issuer: %w", err) } @@ -1423,10 +1423,10 @@ func (s *Service) GetTimeLimitedCreds(ctx context.Context, order *Order, itemID, } type credential interface { - GetSku(context.Context) string - GetType(context.Context) string - GetMerchantID(context.Context) string - GetPresentation(context.Context) string + GetSKU() string + GetType() string + GetMerchantID() string + GetPresentation() string } // verifyCredential - given a credential, verify it. @@ -1441,21 +1441,21 @@ func (s *Service) verifyCredential(ctx context.Context, cred credential, w http. caveats := caveatsFromCtx(ctx) - if merchID := cred.GetMerchantID(ctx); merchID != merchant { + if merchID := cred.GetMerchantID(); merchID != merchant { logger.Warn().Str("req.MerchantID", merchID).Str("merchant", merchant).Msg("merchant does not match the key's merchant") return handlers.WrapError(nil, "Verify request merchant does not match authentication", http.StatusForbidden) } if caveats != nil { if sku, ok := caveats["sku"]; ok { - if csku := cred.GetSku(ctx); csku != sku { + if csku := cred.GetSKU(); csku != sku { logger.Warn().Str("req.SKU", csku).Str("sku", sku).Msg("sku caveat does not match") return handlers.WrapError(nil, "Verify request sku does not match authentication", http.StatusForbidden) } } } - kind := cred.GetType(ctx) + kind := cred.GetType() switch kind { case singleUse, timeLimitedV2: return s.verifyBlindedTokenCredential(ctx, cred, w) @@ -1468,7 +1468,7 @@ func (s *Service) verifyCredential(ctx context.Context, cred credential, w http. // verifyBlindedTokenCredential verifies a single use or time limited v2 credential. func (s *Service) verifyBlindedTokenCredential(ctx context.Context, req credential, w http.ResponseWriter) *handlers.AppError { - bytes, err := base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) + bytes, err := base64.StdEncoding.DecodeString(req.GetPresentation()) if err != nil { return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) } @@ -1479,7 +1479,7 @@ func (s *Service) verifyBlindedTokenCredential(ctx context.Context, req credenti } // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details. - issuerID, err := encodeIssuerID(req.GetMerchantID(ctx), req.GetSku(ctx)) + issuerID, err := encodeIssuerID(req.GetMerchantID(), req.GetSKU()) if err != nil { return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) } @@ -1488,12 +1488,12 @@ func (s *Service) verifyBlindedTokenCredential(ctx context.Context, req credenti return handlers.WrapError(nil, "Error, outer merchant and sku don't match issuer", http.StatusBadRequest) } - return s.redeemBlindedCred(ctx, w, req.GetType(ctx), decodedCred) + return s.redeemBlindedCred(ctx, w, req.GetType(), decodedCred) } // verifyTimeLimitedV1Credential verifies a time limited v1 credential. func (s *Service) verifyTimeLimitedV1Credential(ctx context.Context, req credential, w http.ResponseWriter) *handlers.AppError { - data, err := base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) + data, err := base64.StdEncoding.DecodeString(req.GetPresentation()) if err != nil { return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) } @@ -1503,10 +1503,10 @@ func (s *Service) verifyTimeLimitedV1Credential(ctx context.Context, req credent return handlers.WrapError(err, "Error in presentation formatting", http.StatusBadRequest) } - merchID := req.GetMerchantID(ctx) + merchID := req.GetMerchantID() // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details. - issuerID, err := encodeIssuerID(merchID, req.GetSku(ctx)) + issuerID, err := encodeIssuerID(merchID, req.GetSKU()) if err != nil { return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) }