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) }