200 lines
6.5 KiB
Go
200 lines
6.5 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
"github.com/stretchr/testify/require"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type memoryStore struct {
|
|
mu sync.Mutex
|
|
records map[bson.ObjectID]Record
|
|
}
|
|
|
|
func (s *memoryStore) Create(_ context.Context, r *Record) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.records[r.ID] = *r
|
|
return nil
|
|
}
|
|
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.records[id]
|
|
if !ok || r.Customer != customer || r.Namespace != namespace {
|
|
return nil, merrors.ErrNoData
|
|
}
|
|
return &r, nil
|
|
}
|
|
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, r := range s.records {
|
|
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
|
|
return &r, nil
|
|
}
|
|
}
|
|
return nil, merrors.ErrNoData
|
|
}
|
|
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
rows := []Record{}
|
|
for _, r := range s.records {
|
|
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
|
|
rows = append(rows, r)
|
|
}
|
|
}
|
|
return rows, nil
|
|
}
|
|
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
current := s.records[r.ID]
|
|
if current.Key != "" {
|
|
return Uncertain
|
|
}
|
|
current.Key = key
|
|
s.records[r.ID] = current
|
|
return nil
|
|
}
|
|
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
current := s.records[r.ID]
|
|
current.Payment = id
|
|
s.records[r.ID] = current
|
|
return nil
|
|
}
|
|
|
|
type provider struct {
|
|
mu sync.Mutex
|
|
accepted int
|
|
lost bool
|
|
status string
|
|
key string
|
|
namespace string
|
|
setupCalls int
|
|
setupSubject string
|
|
setupErr error
|
|
}
|
|
|
|
func (p *provider) Namespace() string { return p.namespace }
|
|
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.setupCalls++
|
|
p.setupSubject = subject
|
|
return p.setupErr
|
|
}
|
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
|
}
|
|
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
|
|
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
|
|
}
|
|
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.key == "" {
|
|
p.accepted++
|
|
p.key = key
|
|
}
|
|
if p.key != key {
|
|
return RemoteOperation{}, Unavailable
|
|
}
|
|
if p.lost {
|
|
p.lost = false
|
|
return RemoteOperation{}, Unavailable
|
|
}
|
|
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
|
|
}
|
|
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
|
|
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
|
|
}
|
|
|
|
type identity struct{}
|
|
|
|
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
|
|
|
|
type factors struct {
|
|
calls int
|
|
subject bson.ObjectID
|
|
scope factor.Scope
|
|
}
|
|
|
|
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
|
|
f.subject = r.Subject
|
|
f.scope = r.Scope
|
|
return factor.Challenge{ID: "challenge"}, nil
|
|
}
|
|
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
|
|
return f.Begin(ctx, r)
|
|
}
|
|
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
|
|
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
|
|
return FactorRequired
|
|
}
|
|
f.calls++
|
|
return action(ctx)
|
|
}
|
|
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
|
|
ctx := context.Background()
|
|
a := &customer.Account{}
|
|
a.ID = bson.NewObjectID()
|
|
other := &customer.Account{}
|
|
other.ID = bson.NewObjectID()
|
|
store := &memoryStore{records: map[bson.ObjectID]Record{}}
|
|
p := &provider{lost: true, status: "pending", namespace: "provider"}
|
|
f := &factors{}
|
|
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
|
|
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
|
|
require.NoError(t, err)
|
|
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
|
|
require.ErrorIs(t, err, FactorRequired)
|
|
require.Zero(t, p.accepted)
|
|
_, err = s.Challenge(ctx, other, q.ID, "")
|
|
require.ErrorIs(t, err, NotFound)
|
|
_, err = s.Challenge(ctx, a, q.ID, "")
|
|
require.NoError(t, err)
|
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
|
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
|
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
|
|
require.Equal(t, 1, f.calls)
|
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
|
require.ErrorIs(t, err, Uncertain)
|
|
op, err := s.Reconcile(ctx, other, "authorized-key-123")
|
|
require.NoError(t, err)
|
|
require.Nil(t, op)
|
|
op, err = s.Reconcile(ctx, a, "authorized-key-123")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "pending", op.Status)
|
|
require.Equal(t, 1, p.accepted)
|
|
p.status = "completed"
|
|
snapshot, err := s.Fetch(ctx, a)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "completed", snapshot.Operations[0].Status)
|
|
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
|
|
// A changed provider namespace cannot execute the old quote.
|
|
p.namespace = "rotated-provider"
|
|
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
|
|
require.ErrorIs(t, err, NotFound)
|
|
}
|
|
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
|
|
a := &customer.Account{}
|
|
a.ID = bson.NewObjectID()
|
|
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
|
|
r.SetID(mustID(r.Quote.ID))
|
|
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
|
|
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
|
|
require.ErrorIs(t, err, ExpiredQuote)
|
|
}
|