taler-mailbox

Service for asynchronous wallet-to-wallet payment messages
Log | Files | Refs | Submodules | README | LICENSE

commit 1b46dc08ebce5ea8fbe6bfeaa17a7348517d986e
parent 6678d6a01acd7c130d546a3a3ecc8250b7d316d9
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Fri, 25 Apr 2025 15:12:33 +0200

add payment tests

Diffstat:
Mcmd/mailbox-server/main_test.go | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mgo.mod | 2+-
Mgo.sum | 4++--
Mpkg/rest/mailbox.go | 291++++++++++++++++++++++++++++++++++++++++---------------------------------------
4 files changed, 248 insertions(+), 150 deletions(-)

diff --git a/cmd/mailbox-server/main_test.go b/cmd/mailbox-server/main_test.go @@ -7,12 +7,16 @@ import ( "crypto/sha512" "encoding/binary" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" + "strings" "testing" gnunetutil "git.gnunet.org/gnunet-go.git/pkg/util" + "github.com/schanzen/taler-go/pkg/merchant" + "github.com/schanzen/taler-go/pkg/util" "taler.net/taler-mailbox/internal/gana" "taler.net/taler-mailbox/pkg/rest" ) @@ -23,6 +27,32 @@ var testWalletAlicePriv ed25519.PrivateKey var testWalletAlice ed25519.PublicKey var testWalletAliceString string +const merchantConfigResponse = `{ + "currency": "KUDOS", + "currencies": { + "KUDOS": { + "name": "Kudos (Taler Demonstrator)", + "currency": "KUDOS", + "num_fractional_input_digits": 2, + "num_fractional_normal_digits": 2, + "num_fractional_trailing_zero_digits": 2, + "alt_unit_names": { + "0": "ク" + } + } + }, + "exchanges": [ + { + "master_pub": "F80MFRG8HVH6R9CQ47KRFQSJP3T6DBJ4K1D9B703RJY3Z39TBMJ0", + "currency": "KUDOS", + "base_url": "https://exchange.demo.taler.net/" + } + ], + "implementation": "urn:net:taler:specs:taler-merchant:c-reference", + "name": "taler-merchant", + "version": "18:0:15" +}` + func executeRequest(req *http.Request) *httptest.ResponseRecorder { rr := httptest.NewRecorder() a.Router.ServeHTTP(rr, req) @@ -41,6 +71,41 @@ func TestMain(m *testing.M) { h := sha512.New() h.Write(testWalletAlice) testWalletAliceString = gnunetutil.EncodeBinaryToString(h.Sum(nil)) + + merchServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var orderResp merchant.PostOrderRequest + if r.URL.Path == "/config" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(merchantConfigResponse)) + return + } + if !strings.HasPrefix(r.URL.Path, "/private/orders") { + fmt.Printf("Expected to request '/private/orders', got: %s\n", r.URL.Path) + return + } + if r.Method == http.MethodPost { + err := json.NewDecoder(r.Body).Decode(&orderResp) + if err != nil { + fmt.Printf("Error %s\n", err) + } + jsonResp := fmt.Sprintf("{\"order_id\":\"%s\"}", orderResp.Order.OrderId) + w.WriteHeader(http.StatusOK) + w.Write([]byte(jsonResp)) + } else { + if r.Header.Get("PaidIndicator") == "yes" { + jsonResp := "{\"order_status\":\"paid\"}" + w.WriteHeader(http.StatusOK) + w.Write([]byte(jsonResp)) + } else { + jsonResp := "{\"order_status\":\"unpaid\", \"taler_pay_uri\": \"somepaytouri\"}" + w.WriteHeader(http.StatusOK) + w.Write([]byte(jsonResp)) + } + } + })) + defer merchServer.Close() + a.Merchant = merchant.NewMerchant(merchServer.URL, "") + code := m.Run() // Purge DB a.Db.Where("1 = 1").Delete(&mailbox.InboxEntry{}) @@ -74,6 +139,36 @@ func TestPostMessage(t *testing.T) { } } +func TestPostMessagePaid(t *testing.T) { + testMessage := make([]byte, 256) + + // Make paid + fee,err := util.ParseAmount("KUDOS:1") + if err != nil { + t.Errorf("%v", err) + } + a.MessageFee = fee + a.Db.Where("1 = 1").Delete(&mailbox.InboxEntry{}) + req, _ := http.NewRequest("POST", "/" + testWalletAliceString, bytes.NewReader(testMessage)) + response := executeRequest(req) + + checkResponseCode(t, http.StatusPaymentRequired, response.Code) + + // TODO check QR / payto response + + req, _ = http.NewRequest("POST", "/" + testWalletAliceString, bytes.NewReader(testMessage)) + req.Header.Add("PaidIndicator", "yes") + response = executeRequest(req) + + checkResponseCode(t, http.StatusPaymentRequired, response.Code) + + body := response.Body.String() + if body != "" { + t.Errorf("Expected empty response, Got %s", body) + } + a.MessageFee,_ = util.ParseAmount("KUDOS:0") +} + func TestPostThenDeleteMessage(t *testing.T) { // testMessage := make([]byte, 256) var deletionReq mailbox.MessageDeletionRequest @@ -114,11 +209,11 @@ func TestPostThenDeleteMessage(t *testing.T) { for i := 0; i < 10; i++ { h.Write(testMessages[i*256:(i+1)*256]) } - var signed_msg [64+4+4]byte + var signed_msg [64+4+4]byte size := signed_msg[0:4] - binary.BigEndian.PutUint32(size, 64+4+4) + binary.BigEndian.PutUint32(size, 64+4+4) purp := signed_msg[4:8] - binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_MESSAGES_DELETE) + binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_MESSAGES_DELETE) checksum := h.Sum(nil) copy(signed_msg[8:], checksum) sig := ed25519.Sign(testWalletAlicePriv, signed_msg[0:]) diff --git a/go.mod b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( git.gnunet.org/gnunet-go.git v0.1.28-0.20220717050634-369422be2512 github.com/gorilla/mux v1.8.0 - github.com/schanzen/taler-go v1.0.6 + github.com/schanzen/taler-go v1.0.8 gopkg.in/ini.v1 v1.66.6 gorm.io/driver/postgres v1.3.8 gorm.io/gorm v1.23.8 diff --git a/go.sum b/go.sum @@ -104,8 +104,8 @@ github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/schanzen/taler-go v1.0.6 h1:qf34AXE6aFBQkcJ3/V25apWcHLPv81JJgBVlSEUZaW0= -github.com/schanzen/taler-go v1.0.6/go.mod h1:+l2TVAPZkF2d15X/XPLYZI5R6PdW6gc6Wft12jrl7tA= +github.com/schanzen/taler-go v1.0.8 h1:vrFtYpE2hgfMrft/hzv3O1NjtjDOF0I44ossRw7pLT0= +github.com/schanzen/taler-go v1.0.8/go.mod h1:+l2TVAPZkF2d15X/XPLYZI5R6PdW6gc6Wft12jrl7tA= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= diff --git a/pkg/rest/mailbox.go b/pkg/rest/mailbox.go @@ -21,6 +21,7 @@ package mailbox import ( "bytes" "crypto/ed25519" + "crypto/sha256" "crypto/sha512" "encoding/binary" "encoding/json" @@ -200,14 +201,7 @@ func (m *Mailbox) sendMessageResponse(w http.ResponseWriter, r *http.Request) { http.Error(w, "Cannot read body", http.StatusBadRequest) return } - tx := m.Db.Where("h_mailbox = ?", vars["h_mailbox"]) - // FIXME max messages from config - // FIXME unclear if this is how the API is defined - if tx.RowsAffected > 10 { - w.WriteHeader(http.StatusTooManyRequests) - return - } - err = m.Db.First(&entry, "h_mailbox = ? AND body = ?", vars["h_mailbox"], body).Error + err = m.Db.First(&entry, "h_mailbox = ? AND body = ?", vars["h_mailbox"], body, true).Error if err != nil { entry.HMailbox = vars["h_mailbox"] entry.Body = body @@ -215,7 +209,14 @@ func (m *Mailbox) sendMessageResponse(w http.ResponseWriter, r *http.Request) { if !m.MessageFee.IsZero() { if len(entry.OrderID) == 0 { // Add new order for new entry - orderID, newOrderErr := m.Merchant.AddNewOrder(*m.MessageFee, "Mailbox message dispatch", m.BaseUrl) + var order merchant.CommonOrder + order.Amount = m.MessageFee.String() + order.Summary = "Mailbox message dispatch" + order.MerchantBaseUrl = m.BaseUrl + h := sha256.New() + h.Sum(body) + order.OrderId = gnunetutil.EncodeBinaryToString(h.Sum(nil)) + orderID, newOrderErr := m.Merchant.CreateOrder(order) if newOrderErr != nil { fmt.Println(newOrderErr) w.WriteHeader(http.StatusInternalServerError) @@ -224,168 +225,170 @@ func (m *Mailbox) sendMessageResponse(w http.ResponseWriter, r *http.Request) { entry.OrderID = orderID } // Check if order paid. - _, _, payto, paytoErr := m.Merchant.IsOrderPaid(entry.OrderID) + _, paymentStatus, payto, paytoErr := m.Merchant.IsOrderPaid(entry.OrderID) if paytoErr != nil { fmt.Println(paytoErr) w.WriteHeader(http.StatusInternalServerError) log.Println(paytoErr) return } - if len(payto) != 0 { - m.Db.Save(&entry) - w.WriteHeader(http.StatusPaymentRequired) - w.Header().Set("Taler", payto) - return - } - } - // In this case, this order was paid - m.Db.Save(&entry) - w.WriteHeader(http.StatusNoContent) + if paymentStatus != "paid" { + m.Db.Save(&entry) + w.WriteHeader(http.StatusPaymentRequired) + if payto != "" { + w.Header().Set("Taler", payto) + } + return + } + } + // In this case, this order was paid + m.Db.Save(&entry) + w.WriteHeader(http.StatusNoContent) } func (m *Mailbox) deleteMessagesResponse(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - var msg MessageDeletionRequest - var entries []InboxEntry - if r.Body == nil { - http.Error(w, "No request body", 400) - return - } - err := json.NewDecoder(r.Body).Decode(&msg) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - etag_hdr := r.Header.Get("If-Match") - if etag_hdr == "" { - http.Error(w, "If-Match header missing", 400) - return - } - if strings.Contains(etag_hdr, ",") { - http.Error(w, "If-Match contains multiple values", 400) - return - } - etag_expected, err := strconv.Atoi(etag_hdr) - if err != nil { - http.Error(w, "If-Match contains malformed etag number", 400) - return - } - pkey, err := gnunetutil.DecodeStringToBinary(vars["mailbox"], 32) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - checksum, err := gnunetutil.DecodeStringToBinary(msg.Checksum, 64) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - pk := ed25519.PublicKey(pkey) - sig, err := gnunetutil.DecodeStringToBinary(msg.WalletSig, 64) - if nil != err { - w.WriteHeader(http.StatusBadRequest) - return - } - var signed_msg [72]byte + vars := mux.Vars(r) + var msg MessageDeletionRequest + var entries []InboxEntry + if r.Body == nil { + http.Error(w, "No request body", 400) + return + } + err := json.NewDecoder(r.Body).Decode(&msg) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + etag_hdr := r.Header.Get("If-Match") + if etag_hdr == "" { + http.Error(w, "If-Match header missing", 400) + return + } + if strings.Contains(etag_hdr, ",") { + http.Error(w, "If-Match contains multiple values", 400) + return + } + etag_expected, err := strconv.Atoi(etag_hdr) + if err != nil { + http.Error(w, "If-Match contains malformed etag number", 400) + return + } + pkey, err := gnunetutil.DecodeStringToBinary(vars["mailbox"], 32) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + checksum, err := gnunetutil.DecodeStringToBinary(msg.Checksum, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + pk := ed25519.PublicKey(pkey) + sig, err := gnunetutil.DecodeStringToBinary(msg.WalletSig, 64) + if nil != err { + w.WriteHeader(http.StatusBadRequest) + return + } + var signed_msg [72]byte size := signed_msg[0:4] - binary.BigEndian.PutUint32(size, 64+4+4) + binary.BigEndian.PutUint32(size, 64+4+4) purp := signed_msg[4:8] - binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_MESSAGES_DELETE) + binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_MESSAGES_DELETE) copy(signed_msg[8:], checksum) if !ed25519.Verify(pk, signed_msg[0:], sig) { - w.WriteHeader(http.StatusForbidden) - return - } - h := sha512.New() - h.Write(pkey) - h_mailbox := gnunetutil.EncodeBinaryToString(h.Sum(nil)) - err = m.Db.Where("h_mailbox = ? AND id >= ?", h_mailbox, etag_expected).Limit(msg.Count).Find(&entries).Error - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - if len(entries) != msg.Count { - w.WriteHeader(http.StatusNotFound) - return - } - if entries[0].ID != uint(etag_expected) { - w.WriteHeader(http.StatusPreconditionFailed) - return - } - h_all := sha512.New() - for _, entry := range entries { - h_all.Write(entry.Body) - } - if !bytes.Equal(h_all.Sum(nil), checksum) { - w.WriteHeader(http.StatusNotFound) - return - } - m.Db.Delete(&entries) - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusForbidden) + return + } + h := sha512.New() + h.Write(pkey) + h_mailbox := gnunetutil.EncodeBinaryToString(h.Sum(nil)) + err = m.Db.Where("h_mailbox = ? AND id >= ?", h_mailbox, etag_expected).Limit(msg.Count).Find(&entries).Error + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(entries) != msg.Count { + w.WriteHeader(http.StatusNotFound) + return + } + if entries[0].ID != uint(etag_expected) { + w.WriteHeader(http.StatusPreconditionFailed) + return + } + h_all := sha512.New() + for _, entry := range entries { + h_all.Write(entry.Body) + } + if !bytes.Equal(h_all.Sum(nil), checksum) { + w.WriteHeader(http.StatusNotFound) + return + } + m.Db.Delete(&entries) + w.WriteHeader(http.StatusNoContent) } func (m *Mailbox) termsResponse(w http.ResponseWriter, r *http.Request) { - tos.ServiceTermsResponse(m.Cfg.Section("mailbox"), w, r) + tos.ServiceTermsResponse(m.Cfg.Section("mailbox"), w, r) } func (m *Mailbox) privacyResponse(w http.ResponseWriter, r *http.Request) { - tos.PrivacyPolicyResponse(m.Cfg.Section("mailbox"), w, r) + tos.PrivacyPolicyResponse(m.Cfg.Section("mailbox"), w, r) } func (m *Mailbox) setupHandlers() { - m.Router = mux.NewRouter().StrictSlash(true) + m.Router = mux.NewRouter().StrictSlash(true) - /* ToS API */ - m.Router.HandleFunc("/terms", m.termsResponse).Methods("GET") - m.Router.HandleFunc("/privacy", m.privacyResponse).Methods("GET") + /* ToS API */ + m.Router.HandleFunc("/terms", m.termsResponse).Methods("GET") + m.Router.HandleFunc("/privacy", m.privacyResponse).Methods("GET") - /* Config API */ - m.Router.HandleFunc("/config", m.configResponse).Methods("GET") + /* Config API */ + m.Router.HandleFunc("/config", m.configResponse).Methods("GET") - /* Mailbox API */ - m.Router.HandleFunc("/{h_mailbox}", m.sendMessageResponse).Methods("POST") - m.Router.HandleFunc("/{h_mailbox}", m.getMessagesResponse).Methods("GET") - m.Router.HandleFunc("/{mailbox}", m.deleteMessagesResponse).Methods("DELETE") + /* Mailbox API */ + m.Router.HandleFunc("/{h_mailbox}", m.sendMessageResponse).Methods("POST") + m.Router.HandleFunc("/{h_mailbox}", m.getMessagesResponse).Methods("GET") + m.Router.HandleFunc("/{mailbox}", m.deleteMessagesResponse).Methods("DELETE") } // Initialize the Mailbox instance with cfgfile func (m *Mailbox) Initialize(cfgfile string) { - _cfg, err := ini.Load(cfgfile) - if err != nil { - fmt.Printf("Failed to read config: %v", err) - os.Exit(1) - } - m.Cfg = _cfg - if m.Cfg.Section("mailbox").Key("production").MustBool(false) { - fmt.Println("Production mode enabled") - } - m.BaseUrl = m.Cfg.Section("mailbox").Key("base_url").MustString("https://example.com") - m.MessageBodyBytes = m.Cfg.Section("mailbox").Key("message_body_bytes").MustInt64(256) - // FIXME actual cost - m.MessageFee, err = talerutil.ParseAmount(m.Cfg.Section("mailbox").Key("message_fee").MustString("KUDOS:1")) - if err != nil { - fmt.Printf("Failed to parse cost: %v", err) - os.Exit(1) - } - psqlconn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", - m.Cfg.Section("mailbox-pq").Key("host").MustString("localhost"), - m.Cfg.Section("mailbox-pq").Key("port").MustInt64(5432), - m.Cfg.Section("mailbox-pq").Key("user").MustString("taler-mailbox"), - m.Cfg.Section("mailbox-pq").Key("password").MustString("secret"), - m.Cfg.Section("mailbox-pq").Key("db_name").MustString("taler-mailbox")) - _db, err := gorm.Open(postgres.Open(psqlconn), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - panic(err) - } - m.Db = _db - if err := m.Db.AutoMigrate(&InboxEntry{}); err != nil { - panic(err) - } - - merchURL := m.Cfg.Section("mailbox").Key("merchant_baseurl_private").MustString("http://merchant.mailbox/instances/myInstance") - merchToken := m.Cfg.Section("mailbox").Key("merchant_token").MustString("secretAccessToken") - m.Merchant = merchant.NewMerchant(merchURL, merchToken) - m.setupHandlers() + _cfg, err := ini.Load(cfgfile) + if err != nil { + fmt.Printf("Failed to read config: %v", err) + os.Exit(1) + } + m.Cfg = _cfg + if m.Cfg.Section("mailbox").Key("production").MustBool(false) { + fmt.Println("Production mode enabled") + } + m.BaseUrl = m.Cfg.Section("mailbox").Key("base_url").MustString("https://example.com") + m.MessageBodyBytes = m.Cfg.Section("mailbox").Key("message_body_bytes").MustInt64(256) + // FIXME actual cost + m.MessageFee, err = talerutil.ParseAmount(m.Cfg.Section("mailbox").Key("message_fee").MustString("KUDOS:1")) + if err != nil { + fmt.Printf("Failed to parse cost: %v", err) + os.Exit(1) + } + psqlconn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + m.Cfg.Section("mailbox-pq").Key("host").MustString("localhost"), + m.Cfg.Section("mailbox-pq").Key("port").MustInt64(5432), + m.Cfg.Section("mailbox-pq").Key("user").MustString("taler-mailbox"), + m.Cfg.Section("mailbox-pq").Key("password").MustString("secret"), + m.Cfg.Section("mailbox-pq").Key("db_name").MustString("taler-mailbox")) + _db, err := gorm.Open(postgres.Open(psqlconn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + panic(err) + } + m.Db = _db + if err := m.Db.AutoMigrate(&InboxEntry{}); err != nil { + panic(err) + } + + merchURL := m.Cfg.Section("mailbox").Key("merchant_baseurl_private").MustString("http://merchant.mailbox/instances/myInstance") + merchToken := m.Cfg.Section("mailbox").Key("merchant_token").MustString("secretAccessToken") + m.Merchant = merchant.NewMerchant(merchURL, merchToken) + m.setupHandlers() }