Connect sign-up to Sendico Facts intake (#46)

This commit is contained in:
Stephan D committed 2026-10-15 00:55:19 +02:00
1 parent 30f0455122
commit a584c8189a
40 files changed
+1616 -35

No files matched your search

@@ -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)
}
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):
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
@@ -15,6 +15,7 @@ import (
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
@@ -26,6 +27,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -34,6 +36,38 @@ type capturedMail struct {
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 {
m.messages = append(m.messages, r)
if m.fail {
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
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"}
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.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
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"}
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, 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.Equal(t, "auth.activation", mail.messages[0].Template)
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")
require.NoError(t, err)
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"}
require.Equal(t, 403, post("login", login).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})
require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)