Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf8b8087a | ||
|
|
a584c8189a | ||
|
|
30f0455122 | ||
|
|
9f9525df56 | ||
|
|
259f84e0a1 | ||
|
|
d04c8b12ab |
No files matched your search
@@ -28,11 +28,15 @@ Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
Profile HTTP wiring remains follow-up work.
|
||||
API versions are installed explicitly by the product composition. See
|
||||
[version installation and compatibility](../../docs/api-versions.md) for selection,
|
||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||
wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
copy only the wire definitions consumed by an implemented adapter.
|
||||
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||
contracts; local customers retain no platform tenancy. See
|
||||
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||
|
||||
## Validate
|
||||
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
LifecycleContext() context.Context
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
ProfileConfig() ConfigProvider
|
||||
DBFactory() db.Factory
|
||||
RuntimeComponent(name string) any
|
||||
Register() Register
|
||||
// Dependencies are initialized once and shared by installed transports.
|
||||
type Dependencies struct {
|
||||
Stores *storage.Stores
|
||||
Customers accountservice.AccountService
|
||||
Auth *flow.Service
|
||||
Factors *twofactor.Service
|
||||
Token middleware.TokenConfig
|
||||
Signature middleware.Signature
|
||||
LoginLimits middleware.LoginRateLimits
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
// Scope belongs to one installation. Router paths are relative to Mount.
|
||||
type Scope struct {
|
||||
Router chi.Router
|
||||
Mount string
|
||||
URLs domainprovider.DomainProvider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
type Installer func(Scope, *Dependencies) error
|
||||
|
||||
type Mount struct {
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
@@ -3,10 +3,14 @@ package api
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Sendico *sendico.Config `yaml:"sendico"`
|
||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||
Versions []Mount `yaml:"versions"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
package api
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/shared/service"
|
||||
import (
|
||||
"context"
|
||||
|
||||
type ConfigProvider interface {
|
||||
SharedConfig() *Config
|
||||
}
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type ConfigProvider interface{ SharedConfig() *Config }
|
||||
type ConfigFactory func() ConfigProvider
|
||||
type RuntimeComponents map[string]any
|
||||
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
|
||||
type RuntimeFinishFunc func(API) error
|
||||
|
||||
// Bootstrap opens process-owned resources. Its cleanup is called once even if
|
||||
// installation fails. On error, bootstrap must return cleanup or clean its own
|
||||
// resources before returning, never both.
|
||||
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Services []ServiceFactory
|
||||
ConfigFactory ConfigFactory
|
||||
Bootstrap RuntimeBootstrapFunc
|
||||
Finish RuntimeFinishFunc
|
||||
}
|
||||
|
||||
type ServiceFactory struct {
|
||||
Name mservice.Type
|
||||
Factory MicroServiceFactoryT
|
||||
Bootstrap Bootstrap
|
||||
Versions map[string]Installer
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) RuntimeName() string {
|
||||
if cfg.Name == "" {
|
||||
return "edge"
|
||||
return "product"
|
||||
}
|
||||
return cfg.Name
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||
if cfg.ConfigFactory != nil {
|
||||
return cfg.ConfigFactory()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package v1 composes the product's first public HTTP contract.
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func Install(scope api.Scope, deps *api.Dependencies) error {
|
||||
if deps == nil || deps.Stores == nil {
|
||||
return merrors.InvalidArgument("v1 auth dependencies are required")
|
||||
}
|
||||
flows := public.Flows{Onboarding: deps.Auth}
|
||||
if deps.Factors != nil {
|
||||
flows.Factors = deps.Factors
|
||||
}
|
||||
// Signature is resolved at bootstrap, not by each version from the environment.
|
||||
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
|
||||
if deps.Factors != nil {
|
||||
factors.Install(routes, deps.Factors, scope.Logger)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,26 +2,12 @@ package httptransport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -34,33 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
mw.epdispatcher.Handler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
if mw.metrics != nil {
|
||||
mw.metrics.Finish()
|
||||
if mw.notifications != nil {
|
||||
_ = mw.notifications.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
mw.logger.Debug("Installing middleware stack...")
|
||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||
@@ -112,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||
}
|
||||
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
notificationConfig ...*notifications.Config,
|
||||
) (*Middleware, error) {
|
||||
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
}
|
||||
@@ -130,72 +92,12 @@ func CreateMiddleware(
|
||||
if err := config.CORS.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p := &Middleware{
|
||||
logger: logger.Named("middleware"),
|
||||
router: apiRouter,
|
||||
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||
}
|
||||
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
||||
p.installMiddleware(config, debug)
|
||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||
var err error
|
||||
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
var factors *twofactor.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err = authFlow.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.notifications, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow.Sender = p.notifications
|
||||
records, err := factorstore.Open(p.logger, db)
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications})
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
flows := public.Flows{Onboarding: authFlow}
|
||||
if factors != nil {
|
||||
flows.Factors = factors
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
flows,
|
||||
)
|
||||
if factors != nil {
|
||||
factorhttp.Install(p.epdispatcher, factors, p.logger)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
// Package profile selects the locally owned components of the copied BFF.
|
||||
// Package profile selects the product's supported API implementations.
|
||||
package profile
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/app/api/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
)
|
||||
|
||||
const Accounts = "accounts"
|
||||
|
||||
func Runtime() api.RuntimeConfig {
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
||||
}
|
||||
|
||||
func bootstrap(a api.API) (api.RuntimeComponents, error) {
|
||||
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.RuntimeComponents{Accounts: accounts}, nil
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
||||
}
|
||||
@@ -2,68 +2,20 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Microservices = []mservice.MicroService
|
||||
|
||||
// APIImp represents the structure of the APIImp
|
||||
type APIImp struct {
|
||||
lifecycleCtx context.Context
|
||||
type instance struct {
|
||||
logger mlogger.Logger
|
||||
db db.Factory
|
||||
domain domainprovider.DomainProvider
|
||||
config *api.Config
|
||||
profile api.ConfigProvider
|
||||
runtime api.RuntimeConfig
|
||||
components api.RuntimeComponents
|
||||
services Microservices
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
cleanup func(context.Context) error
|
||||
once sync.Once
|
||||
finishErr error
|
||||
}
|
||||
|
||||
func (a *APIImp) LifecycleContext() context.Context {
|
||||
return a.lifecycleCtx
|
||||
}
|
||||
|
||||
func (a *APIImp) Logger() mlogger.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIImp) Config() *api.Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||
return a.profile
|
||||
}
|
||||
|
||||
func (a *APIImp) DBFactory() db.Factory {
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||
return a.domain
|
||||
}
|
||||
|
||||
func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
}
|
||||
return a.components[name]
|
||||
}
|
||||
|
||||
func (a *APIImp) Name() string {
|
||||
return "api"
|
||||
}
|
||||
func (a *instance) Name() string { return "api" }
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -16,93 +19,67 @@ import (
|
||||
)
|
||||
|
||||
func CreateAPI(
|
||||
lifecycleCtx context.Context,
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
config *api.Config,
|
||||
profileConfig api.ConfigProvider,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
rootRouter, apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
runtime api.RuntimeConfig,
|
||||
) (mservice.MicroService, error) {
|
||||
p := &APIImp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
logger: logger.Named("api"),
|
||||
config: config,
|
||||
profile: profileConfig,
|
||||
db: db,
|
||||
runtime: runtime,
|
||||
) (_ mservice.MicroService, result error) {
|
||||
if config == nil || config.Mw == nil {
|
||||
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
||||
}
|
||||
|
||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||
if err := p.installServices(); err != nil {
|
||||
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installRuntimeBasics(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
) error {
|
||||
var err error
|
||||
if a.domain, err = domainprovider.CreateDomainProvider(
|
||||
a.logger,
|
||||
a.config.Mw.DomainEnv,
|
||||
a.config.Mw.APIProtocolEnv,
|
||||
a.config.Mw.EndPointEnv,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to initialize domain provider")
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Domain provider installed")
|
||||
|
||||
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
|
||||
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
|
||||
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
|
||||
return err
|
||||
}
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
a.config.Notifications,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||
if a.runtime.Bootstrap != nil {
|
||||
components, err := a.runtime.Bootstrap(a)
|
||||
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
||||
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
a.components = components
|
||||
a.logger.Info("Runtime profile bootstrap complete",
|
||||
zap.String("runtime", a.runtime.RuntimeName()),
|
||||
zap.Int("components", len(components)))
|
||||
if runtime.Bootstrap == nil {
|
||||
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
||||
}
|
||||
return nil
|
||||
// Resolve every URL before opening resources or publishing routes.
|
||||
scopes := make([]api.Scope, len(mounts))
|
||||
for i, mount := range mounts {
|
||||
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
|
||||
scopes[i].Router.Use(browser.Scope(mount.Path))
|
||||
}
|
||||
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := a.Finish(context.Background()); err != nil {
|
||||
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
|
||||
a.cleanup = cleanup
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, mount := range mounts {
|
||||
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
|
||||
}
|
||||
}
|
||||
// Attach only after every installer succeeds.
|
||||
for i, mount := range mounts {
|
||||
apiRouter.Mount(mount.Path, scopes[i].Router)
|
||||
scopes[i].Logger.Info("API version installed")
|
||||
}
|
||||
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return a, nil
|
||||
}
|
||||
@@ -2,16 +2,20 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -50,7 +54,7 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||
root.Mount("/", routes)
|
||||
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
result = httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||
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)
|
||||
}
|
||||
@@ -4,28 +4,20 @@ import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) Finish(ctx context.Context) error {
|
||||
func (a *instance) Finish(ctx context.Context) error {
|
||||
a.once.Do(func() {
|
||||
if a.health != nil {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
}
|
||||
if a.cleanup != nil {
|
||||
a.finishErr = a.cleanup(ctx)
|
||||
}
|
||||
if a.mw != nil {
|
||||
a.mw.Finish()
|
||||
var lastError error
|
||||
for i := len(a.services) - 1; i >= 0; i-- {
|
||||
if err := (a.services[i]).Finish(ctx); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing service",
|
||||
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
|
||||
} else {
|
||||
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
|
||||
}
|
||||
}
|
||||
if a.runtime.Finish != nil {
|
||||
if err := a.runtime.Finish(a); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return lastError
|
||||
})
|
||||
return a.finishErr
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func validMount(value string) bool {
|
||||
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
|
||||
if !validMount(endpoint) {
|
||||
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
|
||||
}
|
||||
if configured == nil {
|
||||
configured = []api.Mount{{Version: "v1", Path: endpoint}}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, mount := range configured {
|
||||
if supported[mount.Version] == nil {
|
||||
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
if seen[mount.Version] {
|
||||
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
seen[mount.Version] = true
|
||||
if !validMount(mount.Path) {
|
||||
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
|
||||
}
|
||||
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
|
||||
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
|
||||
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
|
||||
}
|
||||
}
|
||||
for _, previous := range configured[:i] {
|
||||
if overlaps(mount.Path, previous.Path) {
|
||||
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
|
||||
}
|
||||
}
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *api.Config {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var raw struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
||||
return &raw.API
|
||||
}
|
||||
|
||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
||||
initialized, closed := 0, 0
|
||||
deps := &api.Dependencies{}
|
||||
installer := func(version string) api.Installer {
|
||||
return func(scope api.Scope, got *api.Dependencies) error {
|
||||
require.Same(t, deps, got)
|
||||
link, err := scope.URLs.GetAPILink("accounts")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
||||
})
|
||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
initialized++
|
||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
||||
}}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
||||
require.NoError(t, err)
|
||||
root.Mount("/", routes)
|
||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
require.Equal(t, expected, result.Body.String())
|
||||
}
|
||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
||||
for _, mount := range config.Versions {
|
||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
||||
result := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
||||
root.ServeHTTP(result, request)
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
cookies := result.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
||||
if method == http.MethodDelete {
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
require.Equal(t, 1, initialized)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 3)
|
||||
for range 3 {
|
||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
||||
for name, mounts := range map[string][]api.Mount{
|
||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = mounts
|
||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
t.Fatal("bootstrap must not run")
|
||||
return nil, nil, nil
|
||||
}})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
||||
closed := 0
|
||||
cause := merrors.InvalidArgument("test installation failure")
|
||||
routes := chi.NewRouter()
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
||||
},
|
||||
Versions: map[string]api.Installer{
|
||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
return nil
|
||||
},
|
||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
||||
}})
|
||||
require.True(t, errors.Is(err, cause))
|
||||
require.Equal(t, 1, closed)
|
||||
result := httptest.NewRecorder()
|
||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
}
|
||||
|
||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, nil, nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
defer srv.Finish(context.Background())
|
||||
root.Mount("/", routes)
|
||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, status, result.Code, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
closed := 0
|
||||
cause := merrors.Internal("bootstrap failure")
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
||||
}})
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
||||
cause := merrors.Internal("cleanup failure")
|
||||
closed := 0
|
||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
@@ -1,39 +1,87 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"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/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
a.services = append(a.services, srv)
|
||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||
}
|
||||
|
||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||
srv, err := srvf(a)
|
||||
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
||||
limits, err := config.Mw.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
a.installMicroservice(srv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
for _, service := range a.runtime.Services {
|
||||
if service.Factory == nil {
|
||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := a.addMicroservice(service.Factory); err != nil {
|
||||
return err
|
||||
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
|
||||
var client *notifications.Client
|
||||
cleanup := func(context.Context) error {
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := cleanup(context.Background()); err != nil {
|
||||
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if c := config.Notifications; c != nil {
|
||||
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err := deps.Auth.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
|
||||
if err != nil {
|
||||
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")
|
||||
return deps, cleanup, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, setting string
|
||||
wantNil bool
|
||||
wantLength int
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "disabled", setting: " versions: []\n"},
|
||||
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
|
||||
if err := instance.decodeConfig([]byte(configured)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := instance.config.API.SharedConfig().Versions
|
||||
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
|
||||
t.Fatalf("unexpected versions: %#v", versions)
|
||||
}
|
||||
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
|
||||
t.Fatalf("lost version configuration: %#v", versions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (i *Imp) Start() error {
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
i.db.CloseConnection()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type DomainProvider interface {
|
||||
GetAPILink(linkElem ...string) (string, error)
|
||||
}
|
||||
|
||||
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
|
||||
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
|
||||
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package domainproviderimp
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
|
||||
return url.JoinPath(dp.apiEndpoint, linkElem...)
|
||||
}
|
||||
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
|
||||
if protocol != "http" && protocol != "https" {
|
||||
return nil, merrors.InvalidArgument("API protocol must be http or https")
|
||||
}
|
||||
parsed, err := url.Parse(protocol + "://" + domain)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
|
||||
}
|
||||
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
|
||||
}
|
||||
p := new(DomainProviderImp)
|
||||
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
|
||||
var err error
|
||||
apiPath := os.Getenv(apiPointEnv)
|
||||
p.domain = protocol + "://" + domain
|
||||
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
|
||||
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package domainproviderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExplicitOriginAndMount(t *testing.T) {
|
||||
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
|
||||
require.NoError(t, err)
|
||||
link, err := provider.GetAPILink("accounts", "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
|
||||
link, err = provider.GetFullLink("signup")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/signup", link)
|
||||
}
|
||||
func TestInvalidOrigin(t *testing.T) {
|
||||
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
|
||||
require.Error(t, err, domain)
|
||||
}
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
|
||||
for _, dir := range []string{"../app/runtime", "../app/http"} {
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range file.Imports {
|
||||
name, err := strconv.Unquote(im.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
|
||||
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,13 @@ import (
|
||||
|
||||
const Cooldown = time.Minute
|
||||
|
||||
type SignupFacts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
Ready(context.Context, bson.ObjectID) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Facts SignupFacts
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
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 {
|
||||
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) {
|
||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||
if lookup != nil {
|
||||
return nil, lookup
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
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) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
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.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||
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() {
|
||||
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 err
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
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)
|
||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
accounts.customer.Status = status
|
||||
routes := chi.NewRouter()
|
||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
||||
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
+1
-1
@@ -6,9 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
|
||||
service mservice.Type
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||
router.Use(jwtauth.Verifier(ja))
|
||||
router.Use(jwtauth.Authenticator(ja))
|
||||
File renamed without changes.
+9
-11
@@ -1,12 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -34,13 +32,15 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
|
||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewDispatcher(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
endpoint string,
|
||||
db account.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
token *middleware.TokenConfig,
|
||||
signature middleware.Signature,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
@@ -49,13 +49,11 @@ func NewDispatcher(
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
|
||||
return path.Join(er.apiEndpoint, service, handler)
|
||||
return path.Join("/", er.apiEndpoint, service, handler)
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
+1
-1
@@ -5,8 +5,8 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+2
-2
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
|
||||
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
PrivateKey: []byte("test-secret"),
|
||||
}
|
||||
|
||||
router := NewRouter(
|
||||
router := Install(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
+1
-1
@@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||
}
|
||||
return NewRouter(
|
||||
return Install(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+3
-3
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
@@ -41,7 +41,7 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
|
||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
+3
-1
@@ -11,9 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
@@ -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):
|
||||
+63
-1
@@ -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)
|
||||
@@ -71,7 +109,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||
require.NoError(t, err)
|
||||
routes := chi.NewRouter()
|
||||
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
@@ -9,6 +9,17 @@ database:
|
||||
uri_env: SXCHNGE_MONGO_URI
|
||||
database: sxchnge
|
||||
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.
|
||||
# versions:
|
||||
# - version: v1
|
||||
# path: /api/v1
|
||||
middleware:
|
||||
domain_env: SXCHNGE_DOMAIN
|
||||
api_endpoint_env: SXCHNGE_API_ENDPOINT
|
||||
|
||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (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 {
|
||||
return "", merrors.InvalidArgument("account is required")
|
||||
}
|
||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||
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 errors.Is(err, merrors.ErrDataConflict) {
|
||||
@@ -243,6 +254,13 @@ func NewAccountService(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &service{logger: logger, config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
|
||||
return FromStores(l, config, stores, dbf.TransactionFactory())
|
||||
}
|
||||
|
||||
func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) {
|
||||
if config == nil || stores == nil || transactions == nil {
|
||||
return nil, merrors.InvalidArgument("customer dependencies are required")
|
||||
}
|
||||
return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package accountservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -34,6 +36,9 @@ type AccountService interface {
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (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(
|
||||
ctx context.Context,
|
||||
@@ -61,3 +66,8 @@ func NewAccountService(
|
||||
) (AccountService, error) {
|
||||
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
||||
}
|
||||
|
||||
// FromStores composes a customer service using already initialized storage.
|
||||
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
|
||||
return accountserviceimp.FromStores(logger, config, stores, transactions)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -86,8 +86,10 @@ func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
|
||||
|
||||
func cookiePath(r *http.Request) string {
|
||||
const accountsSegment = "/accounts"
|
||||
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
|
||||
return path.Join("/", apiEndpoint, accountsSegment)
|
||||
if r != nil {
|
||||
if mount, ok := r.Context().Value(scopeKey{}).(string); ok {
|
||||
return path.Join(mount, accountsSegment)
|
||||
}
|
||||
}
|
||||
if r != nil && r.URL != nil {
|
||||
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
|
||||
@@ -119,3 +121,14 @@ func originAllowed(origin string, allowedOrigins []string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type scopeKey struct{}
|
||||
|
||||
// Scope binds cookie creation and deletion to a validated installed API mount.
|
||||
func Scope(mount string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, mount)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,15 @@ func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
|
||||
require.Positive(t, cookie.MaxAge)
|
||||
}
|
||||
|
||||
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
|
||||
func TestSetRefreshCookieUsesInstalledScopeOutsideAccountsRouter(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/gateway/v2/")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
|
||||
Scope("/gateway/v2")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
SetRefreshCookie(w, r, "refresh", time.Now().Add(time.Hour))
|
||||
})).ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,8 +11,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Installing product HTTP API versions
|
||||
|
||||
The product executable selects supported implementations in
|
||||
`backend/product/app/profile/runtime.go`. Each entry maps a version name to an
|
||||
`api.Installer`; v1 composes the existing authentication and second-factor
|
||||
contracts. Adding an implementation requires a package and a registry entry,
|
||||
without changing runtime installation code.
|
||||
|
||||
Select installations in the product YAML:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
versions:
|
||||
- version: v1
|
||||
path: /api/v1
|
||||
# Existing middleware, storage and notification configuration follows.
|
||||
```
|
||||
|
||||
Only v1 ships today. Once a v2 implementation is registered, a second entry with
|
||||
`version: v2` and `path: /api/v2` serves both versions in the same process. Each
|
||||
version can be selected once. Unknown/unavailable versions, duplicate versions,
|
||||
overlapping mounts, noncanonical paths and mounts occupying operational endpoints
|
||||
fail startup. Paths must be absolute, non-root, without trailing slashes, URL
|
||||
escapes, queries, fragments or router pattern syntax. Mounts use ASCII letters,
|
||||
digits, slashes, hyphens, underscores, periods and tildes.
|
||||
|
||||
Omitting `versions` retains compatibility: v1 mounts at the path in the environment
|
||||
variable named by `api.middleware.api_endpoint_env` (normally
|
||||
`SXCHNGE_API_ENDPOINT=/api/v1`). An explicit `versions: []` installs no application
|
||||
routes; process infrastructure and operational endpoints remain available.
|
||||
|
||||
The endpoint environment variable continues to locate the process health and
|
||||
metrics compatibility endpoints, including when explicit application mounts are
|
||||
used. With `/api/v1`, these are `/api/v1/health`, `/api/v1/metrics` and `/metrics`.
|
||||
Keep this value unchanged when adding a version. Health is outside application
|
||||
middleware; the existing HTTP collector excludes operational traffic. These
|
||||
operational endpoints are reserved and are never version-owned contracts.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
Runtime bootstrap opens shared stores once and injects those stores into the
|
||||
customer service, auth flow and second-factor service. Notification connections
|
||||
are opened once. Every installer receives the same typed dependencies and its
|
||||
own router, validated mount, URL provider and logger. Installers register routes
|
||||
relative to their mount and do not open or close process resources.
|
||||
|
||||
Auth v1 handlers, request DTOs and response mappers live under
|
||||
`backend/product/auth/http/v1`; factor transport lives under
|
||||
`backend/product/twofactor/http/v1`. Models, services and storage remain in their
|
||||
owning features. A future version owns its wire contract and reuses domain
|
||||
services, so changing its DTOs does not silently change v1. The generic runtime
|
||||
and HTTP middleware do not import concrete API versions.
|
||||
|
||||
All version routers are built before any application router is attached. An
|
||||
installer error aborts startup, preserves the cause and closes shared resources.
|
||||
Bootstrap must either return a cleanup callback for resources it opened or close
|
||||
them itself before returning an error; it must not do both. The built-in bootstrap
|
||||
cleans its partial initialization internally. Runtime invokes an acquired cleanup
|
||||
callback once, including on installation failure or repeated/concurrent shutdown.
|
||||
The server retains ownership of the shared database factory.
|
||||
|
||||
The removed generic service registry and untyped runtime component lookup have no
|
||||
product consumers. The typed dependencies and installer registry replace them.
|
||||
This changes internal Go composition APIs; public v1 JSON and HTTP paths remain
|
||||
compatible. No database migration is required.
|
||||
|
||||
## Cookies and links
|
||||
|
||||
Runtime binds browser cookie scope to the installed mount using request context.
|
||||
Set and clear operations use the same `<mount>/accounts` path. The existing v1
|
||||
path remains `/api/v1/accounts`; `/api/v2` would use `/api/v2/accounts`. Cookie
|
||||
name, host-only policy, Secure, HttpOnly and SameSite settings are unchanged.
|
||||
Changing an existing mount requires clients to authenticate at the new path;
|
||||
cookies at the old path do not migrate automatically. Versions share domain
|
||||
session state, so account-wide revocation continues to apply across versions.
|
||||
|
||||
`API_ENDPOINT` no longer overrides cookies globally. Direct standalone handler
|
||||
calls retain their request-path fallback; installed APIs always use explicit
|
||||
scope. Installers generate API links through `scope.URLs`, which combines the
|
||||
configured origin and that installation's mount. The origin must use HTTP or
|
||||
HTTPS with a hostname and optional port. Configured notification activation URLs
|
||||
remain frontend links and are not rewritten as API links.
|
||||
|
||||
## Validation
|
||||
|
||||
Runtime tests exercise independently behaving versions, shared dependency identity,
|
||||
disabled routes, invalid mounts, scoped links, cookie set/delete behavior, failed
|
||||
installation cleanup and repeated shutdown. The existing Mongo-backed startup
|
||||
test covers the default v1 installation on an empty database. Existing auth and
|
||||
factor tests moved with their transports and retain their contract assertions.
|
||||
Run `make test` and `make lint-backend` from the repository root.
|
||||
@@ -61,7 +61,7 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
|
||||
|
||||
| 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/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` |
|
||||
@@ -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
|
||||
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
|
||||
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
||||
response after commit requires restarting password login. The adapter
|
||||
|
||||
@@ -788,3 +788,98 @@ and absence of outgoing bearer metadata over plaintext and optional TLS.
|
||||
Resend API key. No email was sent; full product deployment still awaits the API
|
||||
key and configured sender. Delivery continues on `dev-env-42` in
|
||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43).
|
||||
|
||||
## Independently installed product API versions — task #44
|
||||
|
||||
[Task #44](https://git.sendico.io/tech/sxchnge/issues/44), branch
|
||||
`product-api-44`, is delivered in
|
||||
[PR #45](https://git.sendico.io/tech/sxchnge/pulls/45). The PR targets
|
||||
`dev-env-42`: merge [PR #43](https://git.sendico.io/tech/sxchnge/pulls/43) into
|
||||
`main` first, then retarget #45 to `main` before merging. Owner: `tech`; project:
|
||||
SXCHNGE; label: backend.
|
||||
|
||||
The product registry explicitly installs v1 and supports independently selected
|
||||
future versions through YAML. Runtime owns shared stores, customer/auth/factor
|
||||
services and the notification connection; middleware owns HTTP concerns.
|
||||
Version-owned transports retain their existing contracts and operate on scoped
|
||||
routers with explicit cookie paths and URL providers. Unknown versions and
|
||||
invalid, duplicate or overlapping mounts fail startup. Failed installation does
|
||||
not publish application routes, and cleanup runs once on failure or shutdown.
|
||||
An architecture test prevents generic runtime imports of concrete transports.
|
||||
|
||||
Backend review covered the entire task contribution, including transport moves,
|
||||
service reuse, DTO/model separation, project errors and structured lifecycle
|
||||
logging. Initial focused checks exposed compile references after renaming and a
|
||||
missing leading slash in relative route registration; these were fixed. Review
|
||||
also tightened mounts to characters safe in both HTTP and cookie paths, validated
|
||||
origins, preserved cleanup errors and avoided duplicate shutdown error logging.
|
||||
|
||||
Final validation passed: `make test` (backend race tests and all frontend suites),
|
||||
`make lint-backend` and `git diff --check`. Tests cover independently behaving
|
||||
versions, dependency identity, disabled routes, YAML selection, mount rejection,
|
||||
scoped URLs and cookie set/delete, bootstrap/installation failure cleanup and
|
||||
concurrent/repeated shutdown. The Mongo-backed default v1 startup and existing
|
||||
moved auth/factor contract tests pass. Implementation commit: `d04c8b1`.
|
||||
|
||||
Only v1 ships; simultaneous versions are demonstrated with distinct test
|
||||
installers. Default v1 paths, JSON and operational URLs remain compatible. No
|
||||
database migration is needed. Internal Go composition APIs changed, and moving an
|
||||
existing mount requires reauthentication at its new cookie path. Notification
|
||||
activation URLs remain configured frontend URLs. No live deployment, optional
|
||||
security scans or merge were performed. The unrelated local notifications sender
|
||||
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.invalidCredentials => l.authInvalidCredentials,
|
||||
AuthError.emailInUse => l.authEmailInUse,
|
||||
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
|
||||
AuthError.unavailable => l.authUnavailable,
|
||||
AuthError.unexpected => l.authUnexpected,
|
||||
AuthError.factorLocked => l.factorLocked,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
|
||||
"appTitle": "SXCHNGE · Your next move",
|
||||
"nextChapter": "YOUR NEXT CHAPTER",
|
||||
"loginHeading": "Hey, welcome\nback.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
|
||||
"appTitle": "SXCHNGE · Ваш следующий шаг",
|
||||
"nextChapter": "НОВАЯ ГЛАВА",
|
||||
"loginHeading": "Рады видеть\nвас снова.",
|
||||
|
||||
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
(_provider.service as SimulationInfo).isSimulated) ||
|
||||
state.isSimulated;
|
||||
final sent = status == VerificationStatus.sent;
|
||||
final needsAssistance = state.error == VerificationError.needsAssistance;
|
||||
final retryConfirmation =
|
||||
status == VerificationStatus.error &&
|
||||
!needsAssistance &&
|
||||
!_lastWasSend &&
|
||||
widget.token != null;
|
||||
final title = switch (status) {
|
||||
@@ -139,7 +141,9 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
? l.verificationChecking
|
||||
: l.verificationRequest,
|
||||
};
|
||||
final body = switch (status) {
|
||||
final body = needsAssistance
|
||||
? l.authSignupNeedsAssistance
|
||||
: switch (status) {
|
||||
VerificationStatus.sent => l.verificationSent,
|
||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||
VerificationStatus.expired => l.verificationExpiredBody,
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.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_app/main.dart';
|
||||
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
|
||||
}
|
||||
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -42,6 +42,37 @@ Future<void> openVerification(
|
||||
}
|
||||
|
||||
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 {
|
||||
'demo-valid': 'verified',
|
||||
'demo-expired': 'expired',
|
||||
|
||||
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
|
||||
import '../models/auth.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/two_factor.dart';
|
||||
import '../models/verification.dart';
|
||||
|
||||
abstract final class BackendMapper {
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
|
||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||
AuthException(switch (dto.error) {
|
||||
'email_in_use' => AuthError.emailInUse,
|
||||
'signup_attention_required' => AuthError.signupNeedsAssistance,
|
||||
'account_not_verified' => AuthError.accountNotVerified,
|
||||
_ when status == 401 => AuthError.invalidCredentials,
|
||||
_ when status == 429 => AuthError.factorCooldown,
|
||||
_ => 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) =>
|
||||
FactorException(switch (dto.error) {
|
||||
'expired' => FactorError.expired,
|
||||
|
||||
@@ -41,6 +41,7 @@ enum AuthError {
|
||||
invalidCredentials,
|
||||
accountNotVerified,
|
||||
emailInUse,
|
||||
signupNeedsAssistance,
|
||||
unavailable,
|
||||
unexpected,
|
||||
factorLocked,
|
||||
|
||||
@@ -9,7 +9,7 @@ enum VerificationStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
enum VerificationError { expired, invalid, internal }
|
||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||
|
||||
class VerificationException implements Exception {
|
||||
const VerificationException(this.code);
|
||||
@@ -22,8 +22,9 @@ class VerificationResult {
|
||||
}
|
||||
|
||||
class VerificationState {
|
||||
const VerificationState(this.status, {this.isSimulated = false});
|
||||
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
||||
final VerificationStatus status;
|
||||
final VerificationError? error;
|
||||
final bool isSimulated;
|
||||
bool get isLoading =>
|
||||
status == VerificationStatus.sending ||
|
||||
|
||||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
|
||||
VerificationError.expired => VerificationStatus.expired,
|
||||
VerificationError.invalid => VerificationStatus.invalid,
|
||||
VerificationError.internal => VerificationStatus.error,
|
||||
VerificationError.needsAssistance => VerificationStatus.error,
|
||||
},
|
||||
error: code,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@@ -63,6 +63,7 @@ class BackendAuthServices
|
||||
String path,
|
||||
Map<String, Object?> body, {
|
||||
bool factor = false,
|
||||
bool verification = false,
|
||||
}) async {
|
||||
try {
|
||||
final uri = baseUri.replace(
|
||||
@@ -81,6 +82,7 @@ class BackendAuthServices
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final dto = BackendDTO.decode(res.body);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
if (verification) throw BackendMapper.verificationFailure(dto);
|
||||
throw factor
|
||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||
: BackendMapper.authFailure(res.statusCode, dto);
|
||||
@@ -90,7 +92,11 @@ class BackendAuthServices
|
||||
rethrow;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on VerificationException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
if (verification)
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
if (factor) throw const FactorException(FactorError.unavailable);
|
||||
throw const AuthException(AuthError.unavailable);
|
||||
}
|
||||
@@ -148,16 +154,17 @@ class BackendAuthServices
|
||||
await _post('verification/send', {
|
||||
'email': normalized,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
}, verification: true);
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
||||
} on VerificationException catch (e) {
|
||||
if (e.code == VerificationError.expired ||
|
||||
e.code == VerificationError.invalid) {
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
}
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
}
|
||||
@@ -165,18 +172,8 @@ class BackendAuthServices
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
try {
|
||||
await _post('verification/confirm', {'token': token}, factor: true);
|
||||
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,
|
||||
});
|
||||
}
|
||||
await _post('verification/confirm', {'token': token}, verification: true);
|
||||
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
|
||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||
AuthError.unavailable => JourneyFailure.unavailable,
|
||||
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
|
||||
AuthError.unexpected => JourneyFailure.unexpected,
|
||||
AuthError.factorLocked ||
|
||||
AuthError.factorCooldown => JourneyFailure.unavailable,
|
||||
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
|
||||
VerificationError.expired => JourneyFailure.expired,
|
||||
VerificationError.invalid => JourneyFailure.invalid,
|
||||
VerificationError.internal => JourneyFailure.internal,
|
||||
VerificationError.needsAssistance => JourneyFailure.unavailable,
|
||||
},
|
||||
FactorException(code: FactorError.expired) => JourneyFailure.expired,
|
||||
FactorException() => JourneyFailure.unavailable,
|
||||
|
||||
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
void main() {
|
||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||
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 {
|
||||
final paths = <String>[];
|
||||
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(
|
||||
'resend preserves server challenge and retries the same delivery key',
|
||||
() async {
|
||||
|
||||
Reference in new issue
Block a user