Compare commits
2
Commits
30f0455122
...
7bf8b8087a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf8b8087a | ||
|
|
a584c8189a |
No files matched your search
@@ -33,9 +33,10 @@ API versions are installed explicitly by the product composition. See
|
|||||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||||
wiring remains follow-up work.
|
wiring remains follow-up work.
|
||||||
|
|
||||||
Sendico integration is not wired. Its organization context belongs only to the
|
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||||
copy only the wire definitions consumed by an implemented adapter.
|
contracts; local customers retain no platform tenancy. See
|
||||||
|
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||||
|
|
||||||
## Validate
|
## Validate
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Sendico *sendico.Config `yaml:"sendico"`
|
||||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||||
Versions []Mount `yaml:"versions"`
|
Versions []Mount `yaml:"versions"`
|
||||||
Notifications *notifications.Config `yaml:"notifications"`
|
Notifications *notifications.Config `yaml:"notifications"`
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ package runtime_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
|||||||
result = httptest.NewRecorder()
|
result = httptest.NewRecorder()
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||||
|
// Enabling the adapter validates credentials, composes once, and does not
|
||||||
|
// register any provider resource until a customer starts sign-up.
|
||||||
|
const source = "507f1f77bcf86cd799439011"
|
||||||
|
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
file := filepath.Join(t.TempDir(), "credential")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
|
||||||
|
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
|
||||||
|
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, dependencies.Auth.Facts)
|
||||||
|
require.NoError(t, finish(ctx))
|
||||||
|
example.API.Sendico.TokenFile = ""
|
||||||
|
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.Error(t, err)
|
||||||
|
example.API.Notifications = nil
|
||||||
|
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||||
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,14 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers"
|
"git.sendico.io/tech/sxchnge/product/customers"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -55,6 +58,18 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Auth.Sender = client
|
deps.Auth.Sender = client
|
||||||
|
if config.Sendico != nil {
|
||||||
|
provider, err := sendico.Open(*config.Sendico)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bindings, err := identity.OpenStore(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
deps.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
|
||||||
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -64,6 +79,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.Sendico != nil && deps.Auth == nil {
|
||||||
|
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||||
|
}
|
||||||
logger.Info("Product dependencies initialized")
|
logger.Info("Product dependencies initialized")
|
||||||
return deps, cleanup, nil
|
return deps, cleanup, nil
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,13 @@ import (
|
|||||||
|
|
||||||
const Cooldown = time.Minute
|
const Cooldown = time.Minute
|
||||||
|
|
||||||
|
type SignupFacts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
Ready(context.Context, bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
Facts SignupFacts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -85,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
if err = s.Limit(ctx, ip, email); err != nil {
|
if err = s.Limit(ctx, ip, email); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||||
if lookup != nil {
|
if lookup != nil {
|
||||||
return nil, lookup
|
return nil, lookup
|
||||||
}
|
}
|
||||||
// A retry after a queue failure may resume only with the original password.
|
// A retry after a queue failure may resume only with the original password.
|
||||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||||
return nil, merrors.DataConflict("email already registered")
|
return nil, merrors.DataConflict("email already registered")
|
||||||
}
|
}
|
||||||
a = existing
|
a = existing
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *Service) activationKey(email, key string) string {
|
||||||
|
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
||||||
|
}
|
||||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||||
}
|
}
|
||||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
secretKey := s.activationKey(a.Login, key)
|
||||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||||
@@ -176,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil, merrors.Unauthorized("account cannot be activated")
|
return nil, merrors.Unauthorized("account cannot be activated")
|
||||||
}
|
}
|
||||||
|
if s.Facts != nil {
|
||||||
|
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
|||||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||||
service.Secret = []byte(strings.Repeat("b", 32))
|
service.Secret = []byte(strings.Repeat("b", 32))
|
||||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
|
||||||
|
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||||
|
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||||
|
var envelope struct {
|
||||||
|
Data struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
|
||||||
|
require.Equal(t, "signup_attention_required", envelope.Data.Error)
|
||||||
|
require.NotContains(t, response.Body.String(), "private upstream detail")
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
|||||||
return pr.loginRateLimited(details.RetryAfter)
|
return pr.loginRateLimited(details.RetryAfter)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrFailedPrecondition):
|
||||||
|
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
|
||||||
case errors.Is(err, verification.ErrTokenExpired):
|
case errors.Is(err, verification.ErrTokenExpired):
|
||||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +36,38 @@ type capturedMail struct {
|
|||||||
fail bool
|
fail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupProvider struct {
|
||||||
|
registrations, submissions int
|
||||||
|
fail bool
|
||||||
|
last identity.Facts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*signupProvider) Namespace() string { return "test-provider" }
|
||||||
|
func (p *signupProvider) Register(context.Context, string) (string, error) {
|
||||||
|
p.registrations++
|
||||||
|
return "subject", nil
|
||||||
|
}
|
||||||
|
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||||
|
p.submissions++
|
||||||
|
p.last = facts
|
||||||
|
if p.fail {
|
||||||
|
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
|
||||||
|
}
|
||||||
|
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type activationGuard struct {
|
||||||
|
flow.SignupFacts
|
||||||
|
block bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
if g.block {
|
||||||
|
return merrors.ServiceUnavailable("intake receipt unavailable")
|
||||||
|
}
|
||||||
|
return g.SignupFacts.Ready(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||||
m.messages = append(m.messages, r)
|
m.messages = append(m.messages, r)
|
||||||
if m.fail {
|
if m.fail {
|
||||||
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
mail := &capturedMail{}
|
mail := &capturedMail{}
|
||||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||||
require.NoError(t, service.Validate())
|
require.NoError(t, service.Validate())
|
||||||
|
bindings, err := identity.OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
provider := &signupProvider{fail: true}
|
||||||
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -83,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||||
w := post("signup", signup)
|
w := post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Equal(t, 1, provider.registrations)
|
||||||
|
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
// Only the original password can resume a pending local customer.
|
||||||
|
signup["password"] = "different password"
|
||||||
|
require.Equal(t, 400, post("signup", signup).Code)
|
||||||
|
signup["password"] = " correct password "
|
||||||
|
signup["name"] = "Changed retry name"
|
||||||
|
provider.fail = false
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
|
require.Equal(t, 1, provider.registrations)
|
||||||
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
|
require.Equal(t, "user@example.com", provider.last.Email)
|
||||||
require.Len(t, mail.messages, 1)
|
require.Len(t, mail.messages, 1)
|
||||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||||
@@ -95,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||||
|
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||||
|
service.Facts = guard
|
||||||
|
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||||
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, a.IsActive())
|
||||||
|
guard.block = false
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ database:
|
|||||||
uri_env: SXCHNGE_MONGO_URI
|
uri_env: SXCHNGE_MONGO_URI
|
||||||
database: sxchnge
|
database: sxchnge
|
||||||
api:
|
api:
|
||||||
|
# Optional public Sendico facts integration. Enable with provisioned values;
|
||||||
|
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
|
||||||
|
# sendico:
|
||||||
|
# api_url: https://sendico.example/api/v1
|
||||||
|
# organization_ref: <sxchnge-organization-object-id>
|
||||||
|
# source_account_ref: <integration-account-object-id>
|
||||||
|
# token_file: /run/sxchnge/sendico-token
|
||||||
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||||
# versions:
|
# versions:
|
||||||
# - version: v1
|
# - version: v1
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
return s.createAccount(ctx, acct, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
|
if len(key) < 32 || len(key) > 128 {
|
||||||
|
return "", merrors.InvalidArgument("server activation key required")
|
||||||
|
}
|
||||||
|
return s.createAccount(ctx, acct, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
if acct == nil {
|
if acct == nil {
|
||||||
return "", merrors.InvalidArgument("account is required")
|
return "", merrors.InvalidArgument("account is required")
|
||||||
}
|
}
|
||||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
|||||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.VerifyAccount(tx, saved)
|
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type AccountService interface {
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (verificationToken string, err error)
|
) (verificationToken string, err error)
|
||||||
|
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
|
||||||
|
// service replay an unsent initial token. The key must be server-secret-derived.
|
||||||
|
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
|
||||||
|
|
||||||
VerifyAccount(
|
VerifyAccount(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
|
||||||
|
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
|
||||||
|
type Facts struct {
|
||||||
|
FullName string `bson:"fullName"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
CustomerRef string `bson:"customerRef"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Receipt struct {
|
||||||
|
SubmissionRef string `bson:"submissionRef"`
|
||||||
|
SubjectRef string `bson:"subjectRef"`
|
||||||
|
AcceptedAt time.Time `bson:"acceptedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding uses the local customer ID as its primary key. Namespace is an opaque
|
||||||
|
// adapter configuration fingerprint; no platform tenancy enters the customer model.
|
||||||
|
type Binding struct {
|
||||||
|
storable.Base `bson:",inline"`
|
||||||
|
Namespace string `bson:"namespace"`
|
||||||
|
RegistrationRequest string `bson:"registrationRequest"`
|
||||||
|
Facts Facts `bson:"facts"`
|
||||||
|
Registering bool `bson:"registering"`
|
||||||
|
RegisteringAt time.Time `bson:"registeringAt"`
|
||||||
|
SubjectRef string `bson:"subjectRef"`
|
||||||
|
Receipt *Receipt `bson:"receipt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Binding) Collection() string { return "signup_facts" }
|
||||||
|
|
||||||
|
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Namespace() string
|
||||||
|
Register(context.Context, string) (string, error)
|
||||||
|
Submit(context.Context, string, string, Facts) (Receipt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Store Store
|
||||||
|
Provider Provider
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
|
||||||
|
|
||||||
|
// Ensure completes only after a provider receipt has been persisted. Registration
|
||||||
|
// has no idempotency contract, so its durable claim is never automatically expired.
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return merrors.InvalidArgument("customer required")
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
requestRef := ""
|
||||||
|
outcome := "unavailable"
|
||||||
|
defer func() {
|
||||||
|
requests.WithLabelValues(outcome).Inc()
|
||||||
|
duration.Observe(time.Since(started).Seconds())
|
||||||
|
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
|
||||||
|
if result != nil {
|
||||||
|
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
|
||||||
|
} else {
|
||||||
|
s.Logger.Info("Sign-up facts handoff completed", fields...)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
namespace := s.Provider.Namespace()
|
||||||
|
b, err := s.Store.Get(ctx, a.ID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
|
||||||
|
b.SetID(a.ID)
|
||||||
|
if err = s.Store.Create(ctx, b); err != nil {
|
||||||
|
if !errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
|
||||||
|
}
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
|
||||||
|
}
|
||||||
|
requestRef = b.RegistrationRequest
|
||||||
|
if b.Namespace != namespace {
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.FailedPrecondition("sign-up provider binding changed")
|
||||||
|
}
|
||||||
|
if b.Receipt != nil {
|
||||||
|
outcome = "replayed"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b.SubjectRef == "" {
|
||||||
|
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
// Another request may have completed while this request was claiming.
|
||||||
|
b, err = s.Store.Get(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
|
||||||
|
}
|
||||||
|
if b.SubjectRef == "" {
|
||||||
|
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.FailedPrecondition("individual registration requires reconciliation")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
|
||||||
|
if err != nil {
|
||||||
|
outcome = "attention_required"
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
|
||||||
|
}
|
||||||
|
// Persist even if the browser left while Sendico accepted registration.
|
||||||
|
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
|
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
outcome = "attention_required"
|
||||||
|
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
|
||||||
|
}
|
||||||
|
b.SubjectRef = subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
|
||||||
|
}
|
||||||
|
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
|
||||||
|
}
|
||||||
|
outcome = "accepted"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready checks confirmed intake without external I/O inside activation transactions.
|
||||||
|
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
b, err := s.Store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
|
||||||
|
}
|
||||||
|
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/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"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows map[bson.ObjectID]Binding
|
||||||
|
bindErr, acceptErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
b, ok := s.rows[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("missing")
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.rows[b.ID]; ok {
|
||||||
|
return merrors.DataConflict("duplicate")
|
||||||
|
}
|
||||||
|
s.rows[b.ID] = *b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
b := s.rows[id]
|
||||||
|
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
b.Registering = true
|
||||||
|
b.RegisteringAt = time.Now()
|
||||||
|
s.rows[id] = b
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.bindErr != nil {
|
||||||
|
return s.bindErr
|
||||||
|
}
|
||||||
|
b := s.rows[id]
|
||||||
|
b.SubjectRef = subject
|
||||||
|
b.Registering = false
|
||||||
|
s.rows[id] = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.acceptErr != nil {
|
||||||
|
return s.acceptErr
|
||||||
|
}
|
||||||
|
b := s.rows[id]
|
||||||
|
b.Receipt = &r
|
||||||
|
s.rows[id] = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerStub struct {
|
||||||
|
registrations, submissions int
|
||||||
|
registerErr, submitErr error
|
||||||
|
snapshots []Facts
|
||||||
|
sources []string
|
||||||
|
entered, release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*providerStub) Namespace() string { return "provider-1" }
|
||||||
|
func (p *providerStub) Register(context.Context, string) (string, error) {
|
||||||
|
p.registrations++
|
||||||
|
if p.entered != nil {
|
||||||
|
close(p.entered)
|
||||||
|
<-p.release
|
||||||
|
}
|
||||||
|
return "subject-1", p.registerErr
|
||||||
|
}
|
||||||
|
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
|
||||||
|
p.submissions++
|
||||||
|
p.snapshots = append(p.snapshots, f)
|
||||||
|
p.sources = append(p.sources, source)
|
||||||
|
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
|
||||||
|
}
|
||||||
|
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
|
||||||
|
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
|
||||||
|
provider := &providerStub{}
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
a.Name = "Ada Lovelace"
|
||||||
|
a.Login = "ada@example.com"
|
||||||
|
a.Password = "never transmit"
|
||||||
|
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
p.submitErr = merrors.ServiceUnavailable("upstream")
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
b, err := store.Get(context.Background(), a.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "subject-1", b.SubjectRef)
|
||||||
|
require.Nil(t, b.Receipt)
|
||||||
|
a.Name = "changed retry"
|
||||||
|
p.submitErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.NoError(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
require.Equal(t, p.snapshots[0], p.snapshots[1])
|
||||||
|
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
|
||||||
|
require.Equal(t, p.sources[0], p.sources[1])
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
other := a.Copy()
|
||||||
|
other.ID = bson.NewObjectID()
|
||||||
|
require.Error(t, s.Ready(context.Background(), other.ID))
|
||||||
|
}
|
||||||
|
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
store.acceptErr = merrors.ServiceUnavailable("storage")
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
store.acceptErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Equal(t, 2, p.submissions)
|
||||||
|
require.Equal(t, p.sources[0], p.sources[1])
|
||||||
|
}
|
||||||
|
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
|
||||||
|
for _, bindFailure := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
if bindFailure {
|
||||||
|
store.bindErr = merrors.ServiceUnavailable("storage")
|
||||||
|
} else {
|
||||||
|
p.registerErr = merrors.ServiceUnavailable("lost response")
|
||||||
|
}
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
b := store.rows[a.ID]
|
||||||
|
b.RegisteringAt = time.Now().Add(-time.Minute)
|
||||||
|
store.rows[a.ID] = b
|
||||||
|
store.bindErr = nil
|
||||||
|
p.registerErr = nil
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.Zero(t, p.submissions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
|
||||||
|
s, _, p, a := fixture()
|
||||||
|
p.entered = make(chan struct{})
|
||||||
|
p.release = make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- s.Ensure(context.Background(), a) }()
|
||||||
|
<-p.entered
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
|
||||||
|
close(p.release)
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
require.Equal(t, 1, p.registrations)
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Equal(t, 1, p.submissions)
|
||||||
|
}
|
||||||
|
func TestNamespaceChangeFailsClosed(t *testing.T) {
|
||||||
|
s, store, p, a := fixture()
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
b := store.rows[a.ID]
|
||||||
|
b.Namespace = "other-account-or-organization"
|
||||||
|
store.rows[a.ID] = b
|
||||||
|
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||||
|
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||||
|
require.Equal(t, 1, p.submissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
|
||||||
|
s, _, p, a := fixture()
|
||||||
|
core, logs := observer.New(zap.InfoLevel)
|
||||||
|
s.Logger = zap.New(core)
|
||||||
|
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
|
||||||
|
require.Error(t, s.Ensure(context.Background(), a))
|
||||||
|
p.submitErr = nil
|
||||||
|
require.NoError(t, s.Ensure(context.Background(), a))
|
||||||
|
require.Len(t, logs.All(), 2)
|
||||||
|
for _, entry := range logs.All() {
|
||||||
|
require.NotContains(t, entry.Message, a.Login)
|
||||||
|
require.NotContains(t, entry.Message, a.Name)
|
||||||
|
fields := entry.ContextMap()
|
||||||
|
require.Equal(t, a.ID.Hex(), fields["account_ref"])
|
||||||
|
require.NotEmpty(t, fields["request_ref"])
|
||||||
|
for _, value := range fields {
|
||||||
|
require.NotEqual(t, a.Login, value)
|
||||||
|
require.NotEqual(t, a.Name, value)
|
||||||
|
require.NotEqual(t, a.Password, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Get(context.Context, bson.ObjectID) (*Binding, error)
|
||||||
|
Create(context.Context, *Binding) error
|
||||||
|
Claim(context.Context, bson.ObjectID, string) (bool, error)
|
||||||
|
Bind(context.Context, bson.ObjectID, string, string) error
|
||||||
|
Accept(context.Context, bson.ObjectID, string, Receipt) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type mongoStore struct{ records repository.Repository }
|
||||||
|
|
||||||
|
func OpenStore(factory db.Factory) (Store, error) {
|
||||||
|
d, err := deps.ResolveDependencies(factory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
||||||
|
b := &Binding{}
|
||||||
|
err := s.records.Get(ctx, id, b)
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
|
||||||
|
return s.records.Insert(ctx, b, nil)
|
||||||
|
}
|
||||||
|
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
|
||||||
|
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
|
||||||
|
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
|
||||||
|
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
|
||||||
|
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return unavailable()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDurableBindingClaimsAndIsolation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
|
uri, err := container.ConnectionString(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("directConnection", "true")
|
||||||
|
// The handoff store must override unsafe caller defaults before claiming
|
||||||
|
// ownership of a non-idempotent external operation.
|
||||||
|
q.Set("w", "0")
|
||||||
|
q.Set("readPreference", "secondary")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(factory.CloseConnection)
|
||||||
|
store, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
|
||||||
|
b.SetID(bson.NewObjectID())
|
||||||
|
require.NoError(t, store.Create(ctx, b))
|
||||||
|
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var wins atomic.Int32
|
||||||
|
for range 12 {
|
||||||
|
wg.Go(func() {
|
||||||
|
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if claimed {
|
||||||
|
wins.Add(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
require.EqualValues(t, 1, wins.Load())
|
||||||
|
stored, err := store.Get(ctx, b.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, stored.Registering)
|
||||||
|
require.False(t, stored.RegisteringAt.IsZero())
|
||||||
|
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
||||||
|
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
||||||
|
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
||||||
|
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
||||||
|
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||||
|
receipt.SubjectRef = "subject"
|
||||||
|
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||||
|
// A separately opened store sees the durable receipt and cannot reclaim registration.
|
||||||
|
reopened, err := OpenStore(factory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stored, err = reopened.Get(ctx, b.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, receipt, *stored.Receipt)
|
||||||
|
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, claimed)
|
||||||
|
_, err = reopened.Get(ctx, bson.NewObjectID())
|
||||||
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
|
||||||
|
var receipt receiptDTO
|
||||||
|
token, err := c.token()
|
||||||
|
if err != nil {
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
var body []byte
|
||||||
|
if payload != nil {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "encode Sendico request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/"+resource+"/"+c.config.OrganizationID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create Sendico request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Request-ID", requestRef)
|
||||||
|
response, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Sendico request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
// Never decode or expose upstream error bodies, which can contain assertions.
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
||||||
|
var envelope envelopeDTO
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode Sendico receipt")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if decoder.Decode(&trailing) != io.EOF || envelope.Status != "success" {
|
||||||
|
return receipt, merrors.ServiceUnavailable("invalid Sendico response")
|
||||||
|
}
|
||||||
|
return envelope.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
||||||
|
dto, err := c.post(ctx, "individuals", requestRef, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return mapSubject(dto)
|
||||||
|
}
|
||||||
|
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||||
|
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
|
||||||
|
if err != nil {
|
||||||
|
return identity.Receipt{}, err
|
||||||
|
}
|
||||||
|
return mapReceipt(dto, subject)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const org = "507f1f77bcf86cd799439011"
|
||||||
|
const source = "507f1f77bcf86cd799439012"
|
||||||
|
const subject = "507f1f77bcf86cd799439013"
|
||||||
|
const submission = "507f1f77bcf86cd799439014"
|
||||||
|
|
||||||
|
func credential(t *testing.T, id string, expiry int64) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
|
||||||
|
}
|
||||||
|
func config(t *testing.T, endpoint string) Config {
|
||||||
|
t.Helper()
|
||||||
|
file := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
|
||||||
|
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
||||||
|
paths := []string{}
|
||||||
|
var token string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if strings.Contains(r.URL.Path, "/individuals/") {
|
||||||
|
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
||||||
|
require.Empty(t, body)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
||||||
|
} else {
|
||||||
|
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
cfg := config(t, server.URL)
|
||||||
|
token = credential(t, source, time.Now().Add(time.Hour).Unix())
|
||||||
|
client, err := Open(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ref, err := client.Register(context.Background(), "registration-request")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, subject, ref)
|
||||||
|
// File rotation takes effect without restarting; account identity stays fixed.
|
||||||
|
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
|
||||||
|
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, submission, receipt.SubmissionRef)
|
||||||
|
require.Equal(t, subject, receipt.SubjectRef)
|
||||||
|
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Len(t, paths, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectInvalidConfiguration(t *testing.T) {
|
||||||
|
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
|
||||||
|
_, err := Open(config(t, endpoint))
|
||||||
|
require.Error(t, err, endpoint)
|
||||||
|
}
|
||||||
|
cfg := config(t, "https://api.example")
|
||||||
|
cfg.OrganizationID = "../other"
|
||||||
|
_, err := Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
cfg = config(t, "https://api.example")
|
||||||
|
cfg.SourceID = ""
|
||||||
|
_, err = Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
cfg = config(t, "https://api.example")
|
||||||
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
|
||||||
|
_, err = Open(cfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"denied", 403, `{"secret":"private-assertion"}`},
|
||||||
|
{"conflict", 409, `{"secret":"private-assertion"}`},
|
||||||
|
{"unavailable", 503, `{"secret":"private-assertion"}`},
|
||||||
|
{"malformed", 200, `{"private-assertion":`},
|
||||||
|
{"empty", 200, `{"status":"success","data":{}}`},
|
||||||
|
{"wrong-status", 200, `{"status":"error","data":{}}`},
|
||||||
|
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
|
||||||
|
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
|
||||||
|
{"trailing", 200, `{"status":"success","data":{}} {}`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, err.Error(), "private-assertion")
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Register(context.Background(), "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err = client.Register(ctx, "registration-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
||||||
|
defer timeoutServer.Close()
|
||||||
|
client, err = Open(config(t, timeoutServer.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
client.http.Timeout = 20 * time.Millisecond
|
||||||
|
_, err = client.Register(context.Background(), "timeout-request")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package sendico consumes only Sendico's public HTTP contracts.
|
||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIURL string `yaml:"api_url"`
|
||||||
|
OrganizationID string `yaml:"organization_ref"`
|
||||||
|
SourceID string `yaml:"source_account_ref"`
|
||||||
|
TokenFile string `yaml:"token_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
base string
|
||||||
|
namespace string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(c Config) (*Client, error) {
|
||||||
|
u, err := url.Parse(c.APIURL)
|
||||||
|
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
|
||||||
|
}
|
||||||
|
for _, ref := range []string{c.OrganizationID, c.SourceID} {
|
||||||
|
id, err := bson.ObjectIDFromHex(ref)
|
||||||
|
if err != nil || id.IsZero() || ref != id.Hex() {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.TokenFile) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("Sendico token file required")
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(c.APIURL, "/")
|
||||||
|
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
|
||||||
|
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
if _, err := client.token(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Namespace() string { return c.namespace }
|
||||||
|
|
||||||
|
// Rotation may replace this file, but must retain the authenticated account.
|
||||||
|
// Claim inspection only protects the replay namespace from operator mistakes;
|
||||||
|
// Sendico remains responsible for signature validation and authorization.
|
||||||
|
func (c *Client) token() (string, error) {
|
||||||
|
raw, err := os.ReadFile(c.config.TokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(raw))
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(token) > 16384 || len(parts) != 3 {
|
||||||
|
return "", merrors.FailedPrecondition("invalid Sendico credential")
|
||||||
|
}
|
||||||
|
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Pending bool `json:"pending"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(claims, &payload); err != nil {
|
||||||
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
||||||
|
}
|
||||||
|
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
|
||||||
|
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only consumed wire fields are owned here; never import platform modules.
|
||||||
|
type factsDTO struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CustomerRef string `json:"external_customer_ref"`
|
||||||
|
}
|
||||||
|
type submissionDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
SourceRef string `json:"source_submission_ref"`
|
||||||
|
Facts factsDTO `json:"facts"`
|
||||||
|
}
|
||||||
|
type receiptDTO struct {
|
||||||
|
SubjectRef string `json:"subject_ref"`
|
||||||
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
|
SubmissionRef string `json:"submission_ref"`
|
||||||
|
AcceptedAt time.Time `json:"accepted_at"`
|
||||||
|
}
|
||||||
|
type envelopeDTO struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data receiptDTO `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRef(ref string) bool {
|
||||||
|
id, err := bson.ObjectIDFromHex(ref)
|
||||||
|
return err == nil && !id.IsZero() && id.Hex() == ref
|
||||||
|
}
|
||||||
|
func mapFacts(subject, source string, facts identity.Facts) submissionDTO {
|
||||||
|
return submissionDTO{SubjectRef: subject, SourceRef: source, Facts: factsDTO{FullName: facts.FullName, Email: facts.Email, CustomerRef: facts.CustomerRef}}
|
||||||
|
}
|
||||||
|
func mapSubject(dto receiptDTO) (string, error) {
|
||||||
|
if !validRef(dto.SubjectRef) || dto.RegisteredAt.IsZero() {
|
||||||
|
return "", merrors.ServiceUnavailable("invalid Sendico registration receipt")
|
||||||
|
}
|
||||||
|
return dto.SubjectRef, nil
|
||||||
|
}
|
||||||
|
func mapReceipt(dto receiptDTO, subject string) (identity.Receipt, error) {
|
||||||
|
if !validRef(dto.SubmissionRef) || dto.SubjectRef != subject || dto.AcceptedAt.IsZero() {
|
||||||
|
return identity.Receipt{}, merrors.ServiceUnavailable("invalid Sendico intake receipt")
|
||||||
|
}
|
||||||
|
return identity.Receipt{SubmissionRef: dto.SubmissionRef, SubjectRef: dto.SubjectRef, AcceptedAt: dto.AcceptedAt}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
|
||||||
|
)
|
||||||
|
|
||||||
|
type majority struct{ Source }
|
||||||
|
|
||||||
|
// Majority retains the source's lease and transaction lifecycle while requiring
|
||||||
|
// durable writes and primary majority reads for externally visible handoffs.
|
||||||
|
func Majority(database Source) Source { return &majority{Source: database} }
|
||||||
|
|
||||||
|
func (s *majority) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||||
|
database, scoped, release, err := s.Source.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, scoped, release, err
|
||||||
|
}
|
||||||
|
return database.Client().Database(database.Name(), options.Database().SetWriteConcern(writeconcern.Majority()).SetReadConcern(readconcern.Majority()).SetReadPreference(readpref.Primary())), scoped, release, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trackedSource struct {
|
||||||
|
database *mongo.Database
|
||||||
|
released bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trackedSource) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||||
|
return s.database, ctx, func() { s.released = true }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMajorityPreservesSourceLifetime(t *testing.T) {
|
||||||
|
client, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017").SetWriteConcern(&writeconcern.WriteConcern{W: 1}).SetReadPreference(readpref.Secondary()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Disconnect(context.Background())) })
|
||||||
|
underlying := &trackedSource{database: client.Database("handoff")}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
database, scoped, release, err := Majority(underlying).Acquire(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Same(t, client, database.Client())
|
||||||
|
require.Equal(t, "handoff", database.Name())
|
||||||
|
require.Equal(t, ctx, scoped)
|
||||||
|
require.False(t, underlying.released)
|
||||||
|
release()
|
||||||
|
require.True(t, underlying.released)
|
||||||
|
_, _, _, err = Majority(Fixed(nil)).Acquire(ctx)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
|
|||||||
|
|
||||||
| Route | Request | Successful result |
|
| Route | Request | Successful result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
|
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after configured Facts intake and activation email queue acceptance |
|
||||||
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
|
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
|
||||||
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
|
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
|
||||||
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
||||||
@@ -101,6 +101,14 @@ retries can create replacement messages; they are not distributed exactly-once
|
|||||||
operations. Tokens are stored before the RPC, so a process crash between those
|
operations. Tokens are stored before the RPC, so a process crash between those
|
||||||
steps requires user recovery; there is no product-side delivery outbox.
|
steps requires user recovery; there is no product-side delivery outbox.
|
||||||
|
|
||||||
|
When `api.sendico` is enabled, sign-up and activation resend first require a
|
||||||
|
durable Facts intake receipt. Activation checks that receipt in its transaction.
|
||||||
|
Initial account/token creation uses the same server-secret-derived activation
|
||||||
|
key as a same-key retry, so an intake outage does not force an extra cooldown for
|
||||||
|
an unsent initial token. Existing pending customers created before this change
|
||||||
|
retain the prior cooldown behavior. See [Facts integration](signup-facts.md) for
|
||||||
|
uncertain registration recovery and the `signup_attention_required` error.
|
||||||
|
|
||||||
Code verification, one-use proof consumption and session creation now commit in
|
Code verification, one-use proof consumption and session creation now commit in
|
||||||
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
||||||
response after commit requires restarting password login. The adapter
|
response after commit requires restarting password login. The adapter
|
||||||
|
|||||||
@@ -828,3 +828,58 @@ existing mount requires reauthentication at its new cookie path. Notification
|
|||||||
activation URLs remain configured frontend URLs. No live deployment, optional
|
activation URLs remain configured frontend URLs. No live deployment, optional
|
||||||
security scans or merge were performed. The unrelated local notifications sender
|
security scans or merge were performed. The unrelated local notifications sender
|
||||||
configuration remains uncommitted. See [API version installation](api-versions.md).
|
configuration remains uncommitted. See [API version installation](api-versions.md).
|
||||||
|
|
||||||
|
## Sign-up Facts intake — task #46
|
||||||
|
|
||||||
|
[Task #46](https://git.sendico.io/tech/sxchnge/issues/46), branch
|
||||||
|
`identity-facts-46`, connects the product sign-up service to Sendico's public
|
||||||
|
individual registry and immutable Facts intake through optional `api.sendico`
|
||||||
|
configuration. Owner: `tech`; project: SXCHNGE; labels: backend and frontend.
|
||||||
|
|
||||||
|
Delivered in [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting
|
||||||
|
`main`. Implementation commit: `a584c81`.
|
||||||
|
|
||||||
|
The adapter sends full name, normalized email and the local customer reference.
|
||||||
|
The BFF persists a customer-owned subject binding, immutable facts snapshot and
|
||||||
|
confirmed receipt before activation email or sign-up acceptance. Activation and
|
||||||
|
resend enforce the handoff. The shared database source can require majority
|
||||||
|
writes and primary majority reads without bypassing its connection/transaction
|
||||||
|
lifecycle. Initial account/token creation remains atomic and uses a secret-derived
|
||||||
|
activation key, allowing same-key recovery after an intake outage without an
|
||||||
|
extra cooldown for an unsent token. Archived customers cannot resume sign-up.
|
||||||
|
|
||||||
|
Backend and frontend completion reviews covered the full task contribution,
|
||||||
|
including the public contracts, customer ownership, stable replay namespace,
|
||||||
|
concurrent claims, error classification, credential handling, logging, metrics and
|
||||||
|
DTO/mapper/service/model/provider separation. UX review preserved input, loading
|
||||||
|
suppression and confirmation-based success. Typed support guidance reaches both
|
||||||
|
sign-up and verification resend/confirmation through the shared auth package;
|
||||||
|
EN/RU feedback preserves live-region semantics and existing responsive controls.
|
||||||
|
The new localized widget fixture initially double-disposed the app-owned locale
|
||||||
|
provider; the fixture ownership was corrected. Focused integration tests also
|
||||||
|
exposed the initial-token cooldown issue, which was fixed in the creation flow.
|
||||||
|
|
||||||
|
Individual registration is not idempotent upstream. A durable claim prevents
|
||||||
|
speculative repeat registration after an ambiguous result; reconciliation uses
|
||||||
|
the saved request reference and authoritative Sendico records. Intake replay is
|
||||||
|
safe after receipt loss and retains original assertions. Actual provider IDs,
|
||||||
|
the endpoint and a same-account Vault-rendered access token must be provisioned
|
||||||
|
before enabling this integration. Tests use simulated HTTP and disposable MongoDB;
|
||||||
|
no live provider resources, live email, deployment, merge or optional security
|
||||||
|
scan is part of this delivery. The unrelated notifications runtime configuration
|
||||||
|
is excluded. Configuration, rollout, telemetry and recovery details are in
|
||||||
|
[Sign-up Facts intake](signup-facts.md).
|
||||||
|
|
||||||
|
Final validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and
|
||||||
|
`make test-frontend` suites), `make lint-backend`,
|
||||||
|
`dart format --output=none --set-exit-if-changed` for all 11 changed Dart files,
|
||||||
|
and `flutter analyze` in `frontend/app` and `frontend/packages/sxchnge_auth`.
|
||||||
|
Focused Go race suites and Flutter backend/provider/widget tests also passed;
|
||||||
|
the diagnostic public API run
|
||||||
|
`go -C backend/product test -race -v ./auth/http/v1/public -count=1 -timeout=90s`
|
||||||
|
passed. The initial unrestricted `make test` failed when Testcontainers could
|
||||||
|
not start its reaper before the Mongo fixture deadline. Lower Go package
|
||||||
|
parallelism resolved that infrastructure contention. The next full run exposed
|
||||||
|
the new locale fixture disposal error noted above; the corrected final run
|
||||||
|
passed, including EN/RU support states and verification recovery. No checks were
|
||||||
|
weakened or unrelated work changed.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Sign-up Facts intake
|
||||||
|
|
||||||
|
Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
|
||||||
|
`identity-facts-46`. Primary module: identity-facts; secondary modules: auth,
|
||||||
|
customer creation, runtime, shared database source and Flutter error feedback.
|
||||||
|
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
|
||||||
|
|
||||||
|
## Behavior and boundaries
|
||||||
|
|
||||||
|
With `api.sendico` configured, the product BFF creates the local pending customer
|
||||||
|
and activation token atomically, then registers an independent individual through
|
||||||
|
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
||||||
|
intake receipt before sending activation email or returning sign-up success.
|
||||||
|
Activation resend runs the same handoff; activation checks the receipt in its
|
||||||
|
local transaction, without external I/O. A failed receipt check rolls back token
|
||||||
|
consumption and leaves the account pending. Existing active customers are not
|
||||||
|
backfilled or blocked at login.
|
||||||
|
|
||||||
|
The adapter consumes these existing Sendico contracts:
|
||||||
|
|
||||||
|
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration
|
||||||
|
receipt containing `subject_ref` and `registered_at`.
|
||||||
|
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
|
||||||
|
containing `submission_ref`, `subject_ref` and `accepted_at`.
|
||||||
|
|
||||||
|
The configured API prefix precedes both paths. See Sendico
|
||||||
|
[individual subjects](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/individual-subjects.md)
|
||||||
|
and [Facts intake](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/identity-facts-intake.md).
|
||||||
|
The only assertions sent are `full_name`, normalized `email` and
|
||||||
|
`external_customer_ref` (the local customer ID). Passwords, password hashes,
|
||||||
|
activation tokens, session credentials and device data never enter the adapter.
|
||||||
|
The input name is retained as a full name, without guessing name parts. No facts
|
||||||
|
are marked verified; acceptance does not establish identity, KYC completeness,
|
||||||
|
verification, limits or payout eligibility. The existing KYC preview is unchanged.
|
||||||
|
|
||||||
|
Only the BFF owns Sendico credentials and organization scope. Local customer
|
||||||
|
models have no organization, membership or platform permissions. HTTP DTOs and
|
||||||
|
mappers stay in `providers/sendico`; the handoff operates on domain facts and
|
||||||
|
receipts in `identity`. Flutter continues through its DTO, mapper, service,
|
||||||
|
model and provider layers. It never calls Sendico or supplies subject references.
|
||||||
|
|
||||||
|
## Configuration and rollout
|
||||||
|
|
||||||
|
Add this block under `api` in the product service configuration, retaining
|
||||||
|
`api.notifications`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sendico:
|
||||||
|
api_url: https://your-sendico-host/api/v1
|
||||||
|
organization_ref: <provisioned-SXCHNGE-organization-object-id>
|
||||||
|
source_account_ref: <provisioned-integration-account-object-id>
|
||||||
|
token_file: /run/sxchnge/sendico-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve the real IDs from the target Sendico deployment. The integration account
|
||||||
|
must be a member of the SXCHNGE organization with the existing organizations
|
||||||
|
`update` permission; reconciliation GET requests also require `read`. The token
|
||||||
|
must be a full, unexpired Sendico access JWT whose `id` claim is that account.
|
||||||
|
The adapter inspects claims only to protect the replay namespace; Sendico checks
|
||||||
|
the signature and authorizes every call. No login, refresh or privileged account
|
||||||
|
provisioning is performed by this adapter.
|
||||||
|
|
||||||
|
Keep the token in Vault at `services/bff/sendico`, field `access_token`. Render it
|
||||||
|
with the deployment's Vault Agent into the configured private runtime file; give
|
||||||
|
only the BFF policy read access to that KV path. Endpoint, organization and source
|
||||||
|
IDs remain in service YAML. For the local stack, configure
|
||||||
|
`infra/local/bff/runtime.yml`, render to the BFF's existing `/run/sxchnge` tmpfs,
|
||||||
|
and extend its Vault Agent policy/template for this provisioned secret. These
|
||||||
|
deployment values and secret provisioning are deliberately not guessed or
|
||||||
|
enabled by the example. Do not place tokens in Git, browser builds or shell
|
||||||
|
history. Refresh the file atomically before expiry using the same Sendico account;
|
||||||
|
the adapter rereads it on every request. Startup rejects missing, expired or
|
||||||
|
mismatched credentials and rejects Sendico configuration without notifications.
|
||||||
|
|
||||||
|
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
|
||||||
|
are rejected and each HTTP call has a four-second timeout. Non-200 responses,
|
||||||
|
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful
|
||||||
|
envelopes never count as acceptance. Upstream error bodies are not surfaced.
|
||||||
|
|
||||||
|
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
|
||||||
|
activation for previously pending customers; use activation resend to submit
|
||||||
|
their saved name/email before confirming their old link. There is no automatic
|
||||||
|
bulk migration. Changing endpoint, organization or source account changes the
|
||||||
|
adapter fingerprint and blocks existing bindings for explicit reconciliation.
|
||||||
|
Token rotation for the same account retains that fingerprint. Plan such changes
|
||||||
|
as migrations; never reset a binding just to bypass the check.
|
||||||
|
|
||||||
|
## Persistence, retries and recovery
|
||||||
|
|
||||||
|
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
|
||||||
|
primary key coordinates concurrent requests and separate BFF instances. It stores
|
||||||
|
an immutable sign-up facts snapshot, adapter fingerprint, random registration
|
||||||
|
request correlation, registration claim, subject and confirmed receipt. It uses
|
||||||
|
the shared database repository and connection lifecycle with majority writes and
|
||||||
|
primary majority reads, overriding weaker connection defaults. No external call
|
||||||
|
runs in a database transaction. Snapshots and bindings contain personal data and
|
||||||
|
must follow the customer data retention policy; no TTL silently removes replay
|
||||||
|
state, and customer deletion does not delete the immutable Sendico submission.
|
||||||
|
|
||||||
|
Once a subject is stored, intake retries use
|
||||||
|
`sxchnge:signup:<local-customer-id>` as `source_submission_ref`. Sendico's
|
||||||
|
organization/authenticated-account/source-reference uniqueness returns the
|
||||||
|
original receipt for that subject. Retries send the saved snapshot even if a
|
||||||
|
later request changes name or email. Losing the HTTP receipt or a local receipt
|
||||||
|
write is recoverable by replay. A saved receipt skips subsequent external calls.
|
||||||
|
Retrying sign-up still requires the original password; active, archived or
|
||||||
|
otherwise conflicting accounts cannot be resumed as a new customer.
|
||||||
|
|
||||||
|
**Individual registration is not idempotent.** The BFF commits a claim before
|
||||||
|
dispatch and never expires or automatically retries that registration. It cannot
|
||||||
|
prove that a timed-out request, malformed response or failed local binding write
|
||||||
|
did not create a subject. Another request sees an in-progress claim as temporarily
|
||||||
|
unavailable for 30 seconds; an older unresolved claim requires support. The
|
||||||
|
registration caller immediately reports assistance on an uncertain failure. An
|
||||||
|
accepted registration is saved with a short independent context even if the
|
||||||
|
browser disconnects. A crash before dispatch can also require reconciliation.
|
||||||
|
This trades automatic recovery for avoiding duplicate subjects under the current
|
||||||
|
upstream contract; there is no distributed exactly-once guarantee.
|
||||||
|
|
||||||
|
For an unresolved registration, operators must stop requests for the affected
|
||||||
|
customer and inspect the saved `registrationRequest` plus Sendico identity logs
|
||||||
|
(`X-Request-ID`, operation `subject_register`) under the configured account and
|
||||||
|
organization. A successful registration's server-issued `record_ref` identifies
|
||||||
|
the candidate subject. Confirm it using the organization-scoped individual GET
|
||||||
|
API before atomically setting the matching local binding's `subjectRef` and
|
||||||
|
clearing `registering`; retain its snapshot, fingerprint and request reference.
|
||||||
|
If a local binding write failed after receipt, BFF diagnostics also contain the
|
||||||
|
subject reference. If authoritative records prove no subject was created, an
|
||||||
|
operator may clear the claim after fixing the dependency. If neither result can
|
||||||
|
be established, leave it blocked; never register a speculative replacement.
|
||||||
|
Resume sign-up with the original credentials or activation resend after this
|
||||||
|
repair. These are controlled operational repairs, not customer API endpoints.
|
||||||
|
|
||||||
|
HTTP 503 `signup_attention_required` maps to typed shared auth and verification
|
||||||
|
state and localized EN/RU support guidance, including activation resend recovery.
|
||||||
|
Other intake/receipt failures remain temporary service
|
||||||
|
failures. Form input is retained, duplicate submissions stay disabled during
|
||||||
|
loading, and neither failure navigates to an email-sent screen or creates a
|
||||||
|
session. The existing support channel must be available to handle reconciliation.
|
||||||
|
|
||||||
|
## Measurement and UX review
|
||||||
|
|
||||||
|
The existing [auth journey](ux/auth-journeys.md) keeps its attempt, retry,
|
||||||
|
abandonment and 30-minute settled inactivity rules. `auth_signup_succeeded` now
|
||||||
|
requires server confirmation of configured intake plus activation queue acceptance;
|
||||||
|
it remains an intermediate step and email confirmation remains the terminal
|
||||||
|
onboarding outcome. Sign-up dependency failures and assistance outcomes use the
|
||||||
|
existing allowlisted `unavailable` analytics category; verification assistance
|
||||||
|
uses it too. Retries retain the journey
|
||||||
|
and operation semantics. No names, emails or provider response bodies are added.
|
||||||
|
|
||||||
|
`sxchnge_signup_facts_total{outcome}` records `accepted`, `replayed`,
|
||||||
|
`unavailable` and `attention_required` handoff attempts;
|
||||||
|
`sxchnge_signup_facts_duration_seconds` measures their latency. Counts are
|
||||||
|
attempts, not distinct users. Use unavailable/attention counts divided by total
|
||||||
|
handoff attempts for operational failure rates; use the existing correlated auth
|
||||||
|
journey for conversion, duration and abandonment. Structured handoff logs include
|
||||||
|
local customer and random registration request references for support correlation,
|
||||||
|
without asserted values, credentials or raw errors. IDs are never metric labels.
|
||||||
|
The product team uses the distinction to separate retryable intake outages from
|
||||||
|
registration reconciliation. The default client analytics sink remains in memory;
|
||||||
|
production retention and cross-device conversion correlation remain deployment
|
||||||
|
gaps, as documented by the existing journey contract.
|
||||||
|
|
||||||
|
Review covers loading suppression, input preservation, success only on confirmed
|
||||||
|
handoff, temporary retry and assistance states, existing navigation, mobile layout,
|
||||||
|
text scaling and live-region error feedback. Focused tests cover these states,
|
||||||
|
the mapped backend error, non-inflated client success, immutable retry payloads,
|
||||||
|
credential rotation, timeout/cancellation/redirect behavior, receipt validation,
|
||||||
|
privacy-safe errors, concurrent durable claims and activation rollback. The tests
|
||||||
|
use disposable local MongoDB and a simulated Sendico HTTP server; they do not
|
||||||
|
create live subjects or send email. Live integration and support operations must
|
||||||
|
be verified against provisioned deployment credentials before enabling the block.
|
||||||
|
The EN/RU error state is exercised at a 390px viewport with 1.5x text scaling;
|
||||||
|
existing keyboard regression tests and a code review of the live-region semantics
|
||||||
|
cover the unchanged controls. No manual browser or screen-reader walkthrough
|
||||||
|
against a live Sendico deployment was performed.
|
||||||
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
|
|||||||
AuthError.accountNotVerified => l.authVerifyEmail,
|
AuthError.accountNotVerified => l.authVerifyEmail,
|
||||||
AuthError.invalidCredentials => l.authInvalidCredentials,
|
AuthError.invalidCredentials => l.authInvalidCredentials,
|
||||||
AuthError.emailInUse => l.authEmailInUse,
|
AuthError.emailInUse => l.authEmailInUse,
|
||||||
|
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
|
||||||
AuthError.unavailable => l.authUnavailable,
|
AuthError.unavailable => l.authUnavailable,
|
||||||
AuthError.unexpected => l.authUnexpected,
|
AuthError.unexpected => l.authUnexpected,
|
||||||
AuthError.factorLocked => l.factorLocked,
|
AuthError.factorLocked => l.factorLocked,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
|
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
|
||||||
"appTitle": "SXCHNGE · Your next move",
|
"appTitle": "SXCHNGE · Your next move",
|
||||||
"nextChapter": "YOUR NEXT CHAPTER",
|
"nextChapter": "YOUR NEXT CHAPTER",
|
||||||
"loginHeading": "Hey, welcome\nback.",
|
"loginHeading": "Hey, welcome\nback.",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "ru",
|
"@@locale": "ru",
|
||||||
|
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
|
||||||
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
||||||
"nextChapter": "НОВАЯ ГЛАВА",
|
"nextChapter": "НОВАЯ ГЛАВА",
|
||||||
"loginHeading": "Рады видеть\nвас снова.",
|
"loginHeading": "Рады видеть\nвас снова.",
|
||||||
|
|||||||
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
(_provider.service as SimulationInfo).isSimulated) ||
|
(_provider.service as SimulationInfo).isSimulated) ||
|
||||||
state.isSimulated;
|
state.isSimulated;
|
||||||
final sent = status == VerificationStatus.sent;
|
final sent = status == VerificationStatus.sent;
|
||||||
|
final needsAssistance = state.error == VerificationError.needsAssistance;
|
||||||
final retryConfirmation =
|
final retryConfirmation =
|
||||||
status == VerificationStatus.error &&
|
status == VerificationStatus.error &&
|
||||||
|
!needsAssistance &&
|
||||||
!_lastWasSend &&
|
!_lastWasSend &&
|
||||||
widget.token != null;
|
widget.token != null;
|
||||||
final title = switch (status) {
|
final title = switch (status) {
|
||||||
@@ -139,14 +141,16 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
? l.verificationChecking
|
? l.verificationChecking
|
||||||
: l.verificationRequest,
|
: l.verificationRequest,
|
||||||
};
|
};
|
||||||
final body = switch (status) {
|
final body = needsAssistance
|
||||||
VerificationStatus.sent => l.verificationSent,
|
? l.authSignupNeedsAssistance
|
||||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
: switch (status) {
|
||||||
VerificationStatus.expired => l.verificationExpiredBody,
|
VerificationStatus.sent => l.verificationSent,
|
||||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||||
VerificationStatus.error => l.verificationInternalBody,
|
VerificationStatus.expired => l.verificationExpiredBody,
|
||||||
_ => null,
|
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||||
};
|
VerificationStatus.error => l.verificationInternalBody,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'signup requiring assistance preserves input and records failure ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
final locale = LocaleProvider(initialLocale: Locale(language));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: locale,
|
||||||
|
analyticsService: analytics,
|
||||||
|
signupService: const DummySignupService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
failure: AuthError.signupNeedsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final signupLabel = language == 'ru' ? 'Регистрация' : 'Sign up';
|
||||||
|
await tester.ensureVisible(find.text(signupLabel));
|
||||||
|
await tester.tap(find.text(signupLabel));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in {
|
||||||
|
'name': 'Ada Lovelace',
|
||||||
|
'email': 'ada@example.com',
|
||||||
|
'password': 'password preserved',
|
||||||
|
'confirmation': 'password preserved',
|
||||||
|
}.entries) {
|
||||||
|
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||||
|
}
|
||||||
|
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final feedback = find.byKey(const ValueKey('auth-error'));
|
||||||
|
await tester.ensureVisible(feedback);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
language == 'ru'
|
||||||
|
? 'Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.'
|
||||||
|
: 'We could not finish setting up your account. Please contact support.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(find.byKey(const ValueKey('password')))
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'password preserved',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where((event) => event.name == 'auth_signup_failed'),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_signup_succeeded',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('backend mode gates workspace and recovers unverified login', (
|
testWidgets('backend mode gates workspace and recovers unverified login', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ Future<void> openVerification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('resend assistance retains email and does not claim delivery', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await openVerification(
|
||||||
|
tester,
|
||||||
|
'/verification-sent',
|
||||||
|
service: const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
sendFailure: VerificationError.needsAssistance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final email = find.byKey(const ValueKey('verification-email'));
|
||||||
|
await tester.enterText(email, 'ada@example.com');
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('verification-resend')),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('verification-resend')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'We could not finish setting up your account. Please contact support.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.widget<TextFormField>(email).controller!.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
for (final scenario in {
|
for (final scenario in {
|
||||||
'demo-valid': 'verified',
|
'demo-valid': 'verified',
|
||||||
'demo-expired': 'expired',
|
'demo-expired': 'expired',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
|
|||||||
import '../models/auth.dart';
|
import '../models/auth.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
import '../models/two_factor.dart';
|
import '../models/two_factor.dart';
|
||||||
|
import '../models/verification.dart';
|
||||||
|
|
||||||
abstract final class BackendMapper {
|
abstract final class BackendMapper {
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto) {
|
||||||
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
|
|||||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||||
AuthException(switch (dto.error) {
|
AuthException(switch (dto.error) {
|
||||||
'email_in_use' => AuthError.emailInUse,
|
'email_in_use' => AuthError.emailInUse,
|
||||||
|
'signup_attention_required' => AuthError.signupNeedsAssistance,
|
||||||
'account_not_verified' => AuthError.accountNotVerified,
|
'account_not_verified' => AuthError.accountNotVerified,
|
||||||
_ when status == 401 => AuthError.invalidCredentials,
|
_ when status == 401 => AuthError.invalidCredentials,
|
||||||
_ when status == 429 => AuthError.factorCooldown,
|
_ when status == 429 => AuthError.factorCooldown,
|
||||||
_ => AuthError.unavailable,
|
_ => AuthError.unavailable,
|
||||||
});
|
});
|
||||||
|
static VerificationException verificationFailure(BackendDTO dto) =>
|
||||||
|
VerificationException(switch (dto.error) {
|
||||||
|
'expired' => VerificationError.expired,
|
||||||
|
'invalid' => VerificationError.invalid,
|
||||||
|
'signup_attention_required' => VerificationError.needsAssistance,
|
||||||
|
_ => VerificationError.internal,
|
||||||
|
});
|
||||||
static FactorException factorFailure(int status, BackendDTO dto) =>
|
static FactorException factorFailure(int status, BackendDTO dto) =>
|
||||||
FactorException(switch (dto.error) {
|
FactorException(switch (dto.error) {
|
||||||
'expired' => FactorError.expired,
|
'expired' => FactorError.expired,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ enum AuthError {
|
|||||||
invalidCredentials,
|
invalidCredentials,
|
||||||
accountNotVerified,
|
accountNotVerified,
|
||||||
emailInUse,
|
emailInUse,
|
||||||
|
signupNeedsAssistance,
|
||||||
unavailable,
|
unavailable,
|
||||||
unexpected,
|
unexpected,
|
||||||
factorLocked,
|
factorLocked,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum VerificationStatus {
|
|||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerificationError { expired, invalid, internal }
|
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||||
|
|
||||||
class VerificationException implements Exception {
|
class VerificationException implements Exception {
|
||||||
const VerificationException(this.code);
|
const VerificationException(this.code);
|
||||||
@@ -22,8 +22,9 @@ class VerificationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationState {
|
class VerificationState {
|
||||||
const VerificationState(this.status, {this.isSimulated = false});
|
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
||||||
final VerificationStatus status;
|
final VerificationStatus status;
|
||||||
|
final VerificationError? error;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
status == VerificationStatus.sending ||
|
status == VerificationStatus.sending ||
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
VerificationError.expired => VerificationStatus.expired,
|
VerificationError.expired => VerificationStatus.expired,
|
||||||
VerificationError.invalid => VerificationStatus.invalid,
|
VerificationError.invalid => VerificationStatus.invalid,
|
||||||
VerificationError.internal => VerificationStatus.error,
|
VerificationError.internal => VerificationStatus.error,
|
||||||
|
VerificationError.needsAssistance => VerificationStatus.error,
|
||||||
},
|
},
|
||||||
|
error: code,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class BackendAuthServices
|
|||||||
String path,
|
String path,
|
||||||
Map<String, Object?> body, {
|
Map<String, Object?> body, {
|
||||||
bool factor = false,
|
bool factor = false,
|
||||||
|
bool verification = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final uri = baseUri.replace(
|
final uri = baseUri.replace(
|
||||||
@@ -81,6 +82,7 @@ class BackendAuthServices
|
|||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
final dto = BackendDTO.decode(res.body);
|
final dto = BackendDTO.decode(res.body);
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
if (verification) throw BackendMapper.verificationFailure(dto);
|
||||||
throw factor
|
throw factor
|
||||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||||
: BackendMapper.authFailure(res.statusCode, dto);
|
: BackendMapper.authFailure(res.statusCode, dto);
|
||||||
@@ -90,7 +92,11 @@ class BackendAuthServices
|
|||||||
rethrow;
|
rethrow;
|
||||||
} on FactorException {
|
} on FactorException {
|
||||||
rethrow;
|
rethrow;
|
||||||
|
} on VerificationException {
|
||||||
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (verification)
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
if (factor) throw const FactorException(FactorError.unavailable);
|
if (factor) throw const FactorException(FactorError.unavailable);
|
||||||
throw const AuthException(AuthError.unavailable);
|
throw const AuthException(AuthError.unavailable);
|
||||||
}
|
}
|
||||||
@@ -148,16 +154,17 @@ class BackendAuthServices
|
|||||||
await _post('verification/send', {
|
await _post('verification/send', {
|
||||||
'email': normalized,
|
'email': normalized,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
}, factor: true);
|
}, verification: true);
|
||||||
_retryKeys.remove('activation:$normalized');
|
_retryKeys.remove('activation:$normalized');
|
||||||
return VerificationMapper.fromDTO(
|
return VerificationMapper.fromDTO(
|
||||||
const VerificationDTO(simulated: false),
|
const VerificationDTO(simulated: false),
|
||||||
);
|
);
|
||||||
} on FactorException catch (e) {
|
} on VerificationException catch (e) {
|
||||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
if (e.code == VerificationError.expired ||
|
||||||
|
e.code == VerificationError.invalid) {
|
||||||
_retryKeys.remove('activation:$normalized');
|
_retryKeys.remove('activation:$normalized');
|
||||||
}
|
}
|
||||||
throw const VerificationException(VerificationError.internal);
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const VerificationException(VerificationError.internal);
|
throw const VerificationException(VerificationError.internal);
|
||||||
}
|
}
|
||||||
@@ -165,18 +172,8 @@ class BackendAuthServices
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> confirm({required String token}) async {
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
try {
|
await _post('verification/confirm', {'token': token}, verification: true);
|
||||||
await _post('verification/confirm', {'token': token}, factor: true);
|
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
||||||
return VerificationMapper.fromDTO(
|
|
||||||
const VerificationDTO(simulated: false),
|
|
||||||
);
|
|
||||||
} on FactorException catch (e) {
|
|
||||||
throw VerificationException(switch (e.code) {
|
|
||||||
FactorError.expired => VerificationError.expired,
|
|
||||||
FactorError.invalid => VerificationError.invalid,
|
|
||||||
_ => VerificationError.internal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
|
|||||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||||
AuthError.unavailable => JourneyFailure.unavailable,
|
AuthError.unavailable => JourneyFailure.unavailable,
|
||||||
|
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
|
||||||
AuthError.unexpected => JourneyFailure.unexpected,
|
AuthError.unexpected => JourneyFailure.unexpected,
|
||||||
AuthError.factorLocked ||
|
AuthError.factorLocked ||
|
||||||
AuthError.factorCooldown => JourneyFailure.unavailable,
|
AuthError.factorCooldown => JourneyFailure.unavailable,
|
||||||
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
|
|||||||
VerificationError.expired => JourneyFailure.expired,
|
VerificationError.expired => JourneyFailure.expired,
|
||||||
VerificationError.invalid => JourneyFailure.invalid,
|
VerificationError.invalid => JourneyFailure.invalid,
|
||||||
VerificationError.internal => JourneyFailure.internal,
|
VerificationError.internal => JourneyFailure.internal,
|
||||||
|
VerificationError.needsAssistance => JourneyFailure.unavailable,
|
||||||
},
|
},
|
||||||
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
||||||
FactorException() => JourneyFailure.unavailable,
|
FactorException() => JourneyFailure.unavailable,
|
||||||
|
|||||||
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||||
http.Response(jsonEncode({'data': data}), status);
|
http.Response(jsonEncode({'data': data}), status);
|
||||||
|
test(
|
||||||
|
'signup distinguishes support recovery and keeps the retry key',
|
||||||
|
() async {
|
||||||
|
final keys = <String>[];
|
||||||
|
var failed = true;
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
||||||
|
return failed
|
||||||
|
? response({'error': 'signup_attention_required'}, 503)
|
||||||
|
: response({'userId': 'user'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
const input = SignupRequest(
|
||||||
|
name: 'Ada',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
password: 'password preserved',
|
||||||
|
locale: 'en',
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
service.signup(input),
|
||||||
|
throwsA(
|
||||||
|
isA<AuthException>().having(
|
||||||
|
(error) => error.code,
|
||||||
|
'code',
|
||||||
|
AuthError.signupNeedsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
failed = false;
|
||||||
|
expect((await service.signup(input)).isSimulated, isFalse);
|
||||||
|
expect(keys[0], keys[1]);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||||
final paths = <String>[];
|
final paths = <String>[];
|
||||||
final service = BackendAuthServices(
|
final service = BackendAuthServices(
|
||||||
@@ -148,6 +185,34 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
test(
|
||||||
|
'verification recovery preserves assistance instead of requesting retry',
|
||||||
|
() async {
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient(
|
||||||
|
(_) async => response({'error': 'signup_attention_required'}, 503),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
final provider = VerificationProvider(service);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.send(email: 'ada@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
expect(provider.state.error, VerificationError.needsAssistance);
|
||||||
|
await expectLater(
|
||||||
|
service.confirm(token: 'old-token'),
|
||||||
|
throwsA(
|
||||||
|
isA<VerificationException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
VerificationError.needsAssistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
test(
|
test(
|
||||||
'resend preserves server challenge and retries the same delivery key',
|
'resend preserves server challenge and retries the same delivery key',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in new issue
Block a user