diff --git a/cmd/main.go b/cmd/main.go index 121efaab..617e00c9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,19 +10,22 @@ import ( "time" "github.com/bitcoin-sv/spv-wallet/config" - "github.com/bitcoin-sv/spv-wallet/dictionary" _ "github.com/bitcoin-sv/spv-wallet/docs" + "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/logging" "github.com/bitcoin-sv/spv-wallet/server" ) +// version of the application that can be overridden with ldflags during build +// (e.g. go build -ldflags "-X main.version=1.2.3"). +var version = "development" + // main method starts everything for the SPV Wallet // @title SPV Wallet -// @version v0.12.0 +// @version v1.0.0-beta // @securityDefinitions.apikey x-auth-xpub // @in header // @name x-auth-xpub - // @securityDefinitions.apikey callback-auth // @in header // @name authorization @@ -31,40 +34,43 @@ func main() { defaultLogger := logging.GetDefaultLogger() // Load the Application Configuration - appConfig, err := config.Load(defaultLogger) + appConfig, err := config.Load(version, defaultLogger) if err != nil { - defaultLogger.Fatal().Msgf(dictionary.GetInternalMessage(dictionary.ErrorLoadingConfig), err.Error()) + defaultLogger.Fatal().Err(err).Msg("Error while loading configuration") return } // Validate configuration (before services have been loaded) if err = appConfig.Validate(); err != nil { - defaultLogger.Fatal().Msgf(dictionary.GetInternalMessage(dictionary.ErrorLoadingConfig), err.Error()) + defaultLogger.Fatal().Err(err).Msg("Invalid configuration") return } - // Load the Application Services - var services *config.AppServices - if services, err = appConfig.LoadServices(context.Background()); err != nil { - defaultLogger.Fatal().Msgf(dictionary.GetInternalMessage(dictionary.ErrorLoadingService), config.ApplicationName, err.Error()) + logger, err := logging.CreateLoggerWithConfig(appConfig) + if err != nil { + defaultLogger.Fatal().Err(err).Msg("Error while creating logger") return } - // Try to ping the Block Headers Service if enabled - appConfig.CheckBlockHeadersService(context.Background(), services.Logger) - - // (debugging: show services that are enabled or not) - if appConfig.Debug { - services.Logger.Debug().Msgf( - "datastore: %s | cachestore: %s | taskmanager: %s", - appConfig.Db.Datastore.Engine.String(), - appConfig.Cache.Engine.String(), - appConfig.TaskManager.Factory.String(), - ) + appCtx := context.Background() + + opts, err := appConfig.ToEngineOptions(logger) + if err != nil { + defaultLogger.Fatal().Err(err).Msg("Error while creating engine options") + return + } + + spvWalletEngine, err := engine.NewClient(appCtx, opts...) + if err != nil { + defaultLogger.Fatal().Err(err).Msg("Error while creating SPV Wallet Engine") + return } + // Try to ping the Block Headers Service if enabled + appConfig.CheckBlockHeadersService(appCtx, &logger) + // Create a new app server - appServer := server.NewServer(appConfig, services) + appServer := server.NewServer(appConfig, spvWalletEngine, logger) idleConnectionsClosed := make(chan struct{}) go func() { @@ -73,17 +79,26 @@ func main() { <-sigint // We received an interrupt signal, shut down. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(appCtx, 5*time.Second) defer cancel() + fatal := false + if err = spvWalletEngine.Close(ctx); err != nil { + logger.Error().Err(err).Msg("error when closing the engine") + fatal = true + } + if err = appServer.Shutdown(ctx); err != nil { - services.Logger.Fatal().Msgf("error shutting down: %s", err.Error()) + logger.Error().Err(err).Msg("error shutting down the server") + fatal = true } close(idleConnectionsClosed) + if fatal { + os.Exit(1) + } }() // Listen and serve - services.Logger.Debug().Msgf("starting %s server version %s at port %d...", config.ApplicationName, config.Version, appConfig.Server.Port) appServer.Serve() <-idleConnectionsClosed diff --git a/config.example.yaml b/config.example.yaml index 3ba57cfc..cfcbe981 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,8 +56,6 @@ db: max_open_connections: 0 shared: true table_prefix: "" -# enable debug mode -debug: true # enable endpoints that provides profiling information debug_profiling: true # enable (ITC) incoming transaction checking @@ -121,3 +119,8 @@ task_manager: # Prometheus metrics configuration metrics: enabled: false +logging: + # log level: trace, debug, info, warn, error + level: info + # log format: json, console + format: console diff --git a/config/check_block_header_service.go b/config/check_block_header_service.go index 51acecff..bbcafc86 100644 --- a/config/check_block_header_service.go +++ b/config/check_block_header_service.go @@ -29,12 +29,12 @@ const ( // CheckBlockHeadersService tries to make a request to the Block Headers Service to check if it is online and ready to verify transactions. // AppConfig should be validated before calling this method. // This method returns nothing, instead it logs either an error or a warning based on the state of the Block Headers Service. -func (config *AppConfig) CheckBlockHeadersService(ctx context.Context, logger *zerolog.Logger) { - if !config.BlockHeadersServiceEnabled() { +func (c *AppConfig) CheckBlockHeadersService(ctx context.Context, logger *zerolog.Logger) { + if !c.BlockHeadersServiceEnabled() { // this method works only with Beef/Block Headers Service enabled return } - b := config.Paymail.Beef + b := c.Paymail.Beef logger.Info().Msg("checking Block Headers Service") @@ -83,6 +83,6 @@ func (config *AppConfig) CheckBlockHeadersService(ctx context.Context, logger *z } // BlockHeadersServiceEnabled returns true if the Block Headers Service is enabled in the AppConfig -func (config *AppConfig) BlockHeadersServiceEnabled() bool { - return config.Paymail != nil && config.Paymail.Beef.enabled() +func (c *AppConfig) BlockHeadersServiceEnabled() bool { + return c.Paymail != nil && c.Paymail.Beef.enabled() } diff --git a/config/config.go b/config/config.go index 375fc30d..314e80bc 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( + "fmt" "time" "github.com/bitcoin-sv/spv-wallet/engine/cluster" @@ -10,20 +11,24 @@ import ( "github.com/mrz1836/go-cachestore" ) +const ( + applicationName = "SPV Wallet" + envPrefix = "SPVWALLET" +) + // Config constants used for spv-wallet const ( - ApplicationName = "SPVWallet" APIVersion = "v1" HealthRequestPath = "health" - Version = "v0.12.0" ConfigFilePathKey = "config_file" DefaultConfigFilePath = "config.yaml" - EnvPrefix = "SPVWALLET" BroadcastCallbackRoute = "/transaction/broadcast/callback" ) // AppConfig is the configuration values and associated env vars type AppConfig struct { + // Version is the version of the application. + Version string `json:"version" mapstructure:"version"` // TaskManager is a configuration for Task Manager in SPV Wallet. TaskManager *TaskManagerConfig `json:"task_manager" mapstructure:"task_manager"` // Authentication is the configuration for keys authentication in SPV Wallet. @@ -50,8 +55,6 @@ type AppConfig struct { BHS *BHSConfig `json:"block_headers_service" mapstructure:"block_headers_service"` // ImportBlockHeaders is a URL from where the headers can be downloaded. ImportBlockHeaders string `json:"import_block_headers" mapstructure:"import_block_headers"` - // Debug is a flag for enabling additional information from SPV Wallet. - Debug bool `json:"debug" mapstructure:"debug"` // DebugProfiling is a flag for enabling additinal debug profiling. DebugProfiling bool `json:"debug_profiling" mapstructure:"debug_profiling"` // DisableITC is a flag for disabling Incoming Transaction Checking. @@ -241,6 +244,6 @@ type ExperimentalConfig struct { } // GetUserAgent will return the outgoing user agent -func (a *AppConfig) GetUserAgent() string { - return "SPV Wallet " + Version +func (c *AppConfig) GetUserAgent() string { + return fmt.Sprintf("%s version %s", applicationName, c.Version) } diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 816429e2..00000000 --- a/config/config_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package config - -import ( - "context" - "testing" - - "github.com/bitcoin-sv/spv-wallet/engine/datastore" - "github.com/mrz1836/go-cachestore" - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newTestConfig will make a new test config -func newTestConfig(t *testing.T) (ac *AppConfig) { - nop := zerolog.Nop() - ac, err := Load(&nop) - - require.NoError(t, err) - require.NotNil(t, ac) - return -} - -func baseTestConfig(t *testing.T) (*AppConfig, *AppServices) { - app := newTestConfig(t) - require.NotNil(t, app) - - services := newTestServices(context.Background(), t, app) - require.NotNil(t, services) - - return app, services -} - -// TestAppConfig_Validate will test the method Validate() -func TestAppConfig_Validate(t *testing.T) { - t.Parallel() - - t.Run("new test config", func(t *testing.T) { - app := newTestConfig(t) - require.NotNil(t, app) - }) - - t.Run("validate test config json", func(t *testing.T) { - app, services := baseTestConfig(t) - require.NotNil(t, services) - err := app.Validate() - assert.NoError(t, err) - }) - - t.Run("authentication - invalid admin_key", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Authentication.AdminKey = "12345678" - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("authentication - invalid scheme", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Authentication.Scheme = "BAD" - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("cachestore - invalid engine", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Cache.Engine = cachestore.Empty - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("datastore - invalid engine", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Db.Datastore.Engine = datastore.Empty - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("paymail - no domains", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Paymail.Domains = nil - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("server - no port", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Server.Port = 0 - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("cachestore - invalid redis url", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Cache.Engine = cachestore.Redis - app.Cache.Redis.URL = "" - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("cachestore - invalid redis config", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Cache.Engine = cachestore.Redis - app.Cache.Redis = nil - err := app.Validate() - assert.Error(t, err) - }) - - t.Run("cachestore - valid freecache", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Cache.Engine = cachestore.FreeCache - err := app.Validate() - assert.NoError(t, err) - }) - - t.Run("datastore - invalid sqlite config", func(t *testing.T) { - app, _ := baseTestConfig(t) - app.Db.Datastore.Engine = datastore.SQLite - app.Db.SQLite = nil - err := app.Validate() - assert.Error(t, err) - }) -} diff --git a/config/config_to_options.go b/config/config_to_options.go new file mode 100644 index 00000000..55c908ed --- /dev/null +++ b/config/config_to_options.go @@ -0,0 +1,290 @@ +package config + +import ( + "crypto/tls" + "fmt" + "net/url" + + broadcastclient "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" + "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/engine/cluster" + "github.com/bitcoin-sv/spv-wallet/engine/datastore" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" + "github.com/bitcoin-sv/spv-wallet/engine/utils" + "github.com/bitcoin-sv/spv-wallet/metrics" + "github.com/go-redis/redis/v8" + "github.com/mrz1836/go-cachestore" + "github.com/rs/zerolog" +) + +// ToEngineOptions converts the AppConfig to a slice of engine.ClientOps that can be used to create a new engine.Client. +func (c *AppConfig) ToEngineOptions(logger zerolog.Logger) (options []engine.ClientOps, err error) { + options = c.addUserAgentOpts(options) + + options = c.addMetricsOpts(options) + + options = c.addLoggerOpts(logger, options) + + options = c.addDebugOpts(options) + + options = c.addCacheStoreOpts(options) + + if options, err = c.addClusterOpts(options); err != nil { + return nil, err + } + + if options, err = c.addDataStoreOpts(options); err != nil { + return nil, err + } + + options = c.addPaymailOpts(options) + + options = c.addTaskManagerOpts(options) + + options = c.addNotificationOpts(options) + + options = c.addARCOpts(options) + + options = c.addBroadcastClientOpts(options, logger) + + if options, err = c.addCallbackOpts(options); err != nil { + return nil, err + } + + options = c.addFeeQuotes(options) + + return options, nil +} + +func (c *AppConfig) addFeeQuotes(options []engine.ClientOps) []engine.ClientOps { + options = append(options, engine.WithFeeQuotes(c.ARC.UseFeeQuotes)) + + if c.ARC.FeeUnit != nil { + options = append(options, engine.WithFeeUnit(&utils.FeeUnit{ + Satoshis: c.ARC.FeeUnit.Satoshis, + Bytes: c.ARC.FeeUnit.Bytes, + })) + } + + return options +} + +func (c *AppConfig) addUserAgentOpts(options []engine.ClientOps) []engine.ClientOps { + return append(options, engine.WithUserAgent(c.GetUserAgent())) +} + +func (c *AppConfig) addLoggerOpts(logger zerolog.Logger, options []engine.ClientOps) []engine.ClientOps { + serviceLogger := logger.With().Str("service", "spv-wallet").Logger() + return append(options, engine.WithLogger(&serviceLogger)) +} + +func (c *AppConfig) addMetricsOpts(options []engine.ClientOps) []engine.ClientOps { + if c.Metrics.Enabled { + collector := metrics.EnableMetrics() + options = append(options, engine.WithMetrics(collector)) + } + return options +} + +func (c *AppConfig) addDebugOpts(options []engine.ClientOps) []engine.ClientOps { + if c.Logging.Level == "debug" || c.Logging.Level == "trace" { + options = append(options, engine.WithDebugging()) + } + return options +} + +func (c *AppConfig) addCacheStoreOpts(options []engine.ClientOps) []engine.ClientOps { + if c.Cache.Engine == cachestore.Redis { + options = append(options, engine.WithRedis(&cachestore.RedisConfig{ + DependencyMode: c.Cache.Redis.DependencyMode, + MaxActiveConnections: c.Cache.Redis.MaxActiveConnections, + MaxConnectionLifetime: c.Cache.Redis.MaxConnectionLifetime, + MaxIdleConnections: c.Cache.Redis.MaxIdleConnections, + MaxIdleTimeout: c.Cache.Redis.MaxIdleTimeout, + URL: c.Cache.Redis.URL, + UseTLS: c.Cache.Redis.UseTLS, + })) + } else if c.Cache.Engine == cachestore.FreeCache { + options = append(options, engine.WithFreeCache()) + } + + return options +} + +func (c *AppConfig) addClusterOpts(options []engine.ClientOps) ([]engine.ClientOps, error) { + if c.Cache.Cluster == nil { + return options, nil + } + if c.Cache.Cluster.Coordinator == cluster.CoordinatorRedis { + var redisOptions *redis.Options + + if c.Cache.Cluster.Redis != nil { + redisURL, err := url.Parse(c.Cache.Cluster.Redis.URL) + if err != nil { + return options, spverrors.Wrapf(err, "error parsing redis url") + } + password, _ := redisURL.User.Password() + redisOptions = &redis.Options{ + Addr: fmt.Sprintf("%s:%s", redisURL.Hostname(), redisURL.Port()), + Username: redisURL.User.Username(), + Password: password, + IdleTimeout: c.Cache.Cluster.Redis.MaxIdleTimeout, + } + if c.Cache.Cluster.Redis.UseTLS { + redisOptions.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + } else if c.Cache.Redis != nil { + redisOptions = &redis.Options{ + Addr: c.Cache.Redis.URL, + IdleTimeout: c.Cache.Redis.MaxIdleTimeout, + } + if c.Cache.Redis.UseTLS { + redisOptions.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + } else { + return options, spverrors.Newf("could not load redis cluster coordinator") + } + options = append(options, engine.WithClusterRedis(redisOptions)) + } + if c.Cache.Cluster.Prefix != "" { + options = append(options, engine.WithClusterKeyPrefix(c.Cache.Cluster.Prefix)) + } + + return options, nil +} + +func (c *AppConfig) addDataStoreOpts(options []engine.ClientOps) ([]engine.ClientOps, error) { + // Select the datastore + if c.Db.Datastore.Engine == datastore.SQLite { + tablePrefix := c.Db.Datastore.TablePrefix + if len(c.Db.SQLite.TablePrefix) > 0 { + tablePrefix = c.Db.SQLite.TablePrefix + } + options = append(options, engine.WithSQLite(&datastore.SQLiteConfig{ + CommonConfig: datastore.CommonConfig{ + Debug: c.Db.Datastore.Debug, + MaxConnectionIdleTime: c.Db.SQLite.MaxConnectionIdleTime, + MaxConnectionTime: c.Db.SQLite.MaxConnectionTime, + MaxIdleConnections: c.Db.SQLite.MaxIdleConnections, + MaxOpenConnections: c.Db.SQLite.MaxOpenConnections, + TablePrefix: tablePrefix, + }, + DatabasePath: c.Db.SQLite.DatabasePath, // "" for in memory + Shared: c.Db.SQLite.Shared, + })) + } else if c.Db.Datastore.Engine == datastore.PostgreSQL { + tablePrefix := c.Db.Datastore.TablePrefix + if len(c.Db.SQL.TablePrefix) > 0 { + tablePrefix = c.Db.SQL.TablePrefix + } + + options = append(options, engine.WithSQL(c.Db.Datastore.Engine, &datastore.SQLConfig{ + CommonConfig: datastore.CommonConfig{ + Debug: c.Db.Datastore.Debug, + MaxConnectionIdleTime: c.Db.SQL.MaxConnectionIdleTime, + MaxConnectionTime: c.Db.SQL.MaxConnectionTime, + MaxIdleConnections: c.Db.SQL.MaxIdleConnections, + MaxOpenConnections: c.Db.SQL.MaxOpenConnections, + TablePrefix: tablePrefix, + }, + Driver: c.Db.Datastore.Engine.String(), + Host: c.Db.SQL.Host, + Name: c.Db.SQL.Name, + Password: c.Db.SQL.Password, + Port: c.Db.SQL.Port, + TimeZone: c.Db.SQL.TimeZone, + TxTimeout: c.Db.SQL.TxTimeout, + User: c.Db.SQL.User, + SslMode: c.Db.SQL.SslMode, + })) + + } else { + return nil, spverrors.Newf("unsupported datastore engine: %s", c.Db.Datastore.Engine.String()) + } + + options = append(options, engine.WithAutoMigrate(engine.BaseModels...)) + + return options, nil +} + +func (c *AppConfig) addPaymailOpts(options []engine.ClientOps) []engine.ClientOps { + pm := c.Paymail + options = append(options, engine.WithPaymailSupport( + pm.Domains, + pm.DefaultFromPaymail, + pm.DomainValidationEnabled, + pm.SenderValidationEnabled, + )) + if pm.Beef.enabled() { + options = append(options, engine.WithPaymailBeefSupport(pm.Beef.BlockHeadersServiceHeaderValidationURL, pm.Beef.BlockHeadersServiceAuthToken)) + } + if c.ExperimentalFeatures.PikeContactsEnabled { + options = append(options, engine.WithPaymailPikeContactSupport()) + } + if c.ExperimentalFeatures.PikePaymentEnabled { + options = append(options, engine.WithPaymailPikePaymentSupport()) + } + + return options +} + +func (c *AppConfig) addTaskManagerOpts(options []engine.ClientOps) []engine.ClientOps { + var ops []taskmanager.TasqOps + if c.TaskManager.Factory == taskmanager.FactoryRedis { + ops = append(ops, taskmanager.WithRedis(c.Cache.Redis.URL)) + } + + return append(options, engine.WithTaskqConfig( + taskmanager.DefaultTaskQConfig(TaskManagerQueueName, ops...), + )) +} + +func (c *AppConfig) addNotificationOpts(options []engine.ClientOps) []engine.ClientOps { + if c.Notifications != nil && c.Notifications.Enabled { + options = append(options, engine.WithNotifications()) + } + return options +} + +func (c *AppConfig) addARCOpts(options []engine.ClientOps) []engine.ClientOps { + return append(options, engine.WithARC(c.ARC.URL, c.ARC.Token, c.ARC.DeploymentID)) +} + +func (c *AppConfig) addBroadcastClientOpts(options []engine.ClientOps, logger zerolog.Logger) []engine.ClientOps { + bcLogger := logger.With().Str("service", "broadcast-client").Logger() + + broadcastClient := broadcastclient.Builder(). + WithArc(broadcastclient.ArcClientConfig{ + Token: c.ARC.Token, + APIUrl: c.ARC.URL, + DeploymentID: c.ARC.DeploymentID, + }, &bcLogger). + Build() + + return append( + options, + engine.WithBroadcastClient(broadcastClient), + ) +} + +func (c *AppConfig) addCallbackOpts(options []engine.ClientOps) ([]engine.ClientOps, error) { + if !c.ARC.Callback.Enabled { + return options, nil + } + + if c.ARC.Callback.Token == "" { + callbackToken, err := utils.HashAdler32(DefaultAdminXpub) + if err != nil { + return nil, spverrors.Wrapf(err, "error while generating callback token") + } + c.ARC.Callback.Token = callbackToken + } + + options = append(options, engine.WithCallback(c.ARC.Callback.Host+BroadcastCallbackRoute, c.ARC.Callback.Token)) + return options, nil +} diff --git a/config/defaults.go b/config/defaults.go index 839b5326..f45423b2 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -10,12 +10,16 @@ import ( // DefaultAdminXpub is the default admin xpub used for authenticate requests. const DefaultAdminXpub = "xpub661MyMwAqRbcFgfmdkPgE2m5UjHXu9dj124DbaGLSjaqVESTWfCD4VuNmEbVPkbYLCkykwVZvmA8Pbf8884TQr1FgdG2nPoHR8aB36YdDQh" -func getDefaultAppConfig() *AppConfig { +// TaskManagerQueueName is the default queue name for the task manager. +const TaskManagerQueueName = "spv_wallet_queue" + +// GetDefaultAppConfig returns the default configuration for the application. +func GetDefaultAppConfig() *AppConfig { return &AppConfig{ + Version: "development", Authentication: getAuthConfigDefaults(), Cache: getCacheDefaults(), Db: getDbDefaults(), - Debug: true, DebugProfiling: true, DisableITC: true, ImportBlockHeaders: "", diff --git a/config/flags.go b/config/flags.go index 785ba245..060f1bb9 100644 --- a/config/flags.go +++ b/config/flags.go @@ -15,7 +15,7 @@ type cliFlags struct { dumpConfig bool `mapstructure:"dump_config"` } -func loadFlags() error { +func loadFlags(cfg *AppConfig) error { if !anyFlagsPassed() { return nil } @@ -37,7 +37,7 @@ func loadFlags() error { return err } - parseCliFlags(appFlags, cli) + parseCliFlags(cfg, appFlags, cli) return nil } @@ -54,14 +54,14 @@ func initFlags(fs *pflag.FlagSet, cliFlags *cliFlags) { fs.BoolVarP(&cliFlags.dumpConfig, "dump_config", "d", false, "dump config to file, specified by config_file flag") } -func parseCliFlags(fs *pflag.FlagSet, cli *cliFlags) { +func parseCliFlags(cfg *AppConfig, fs *pflag.FlagSet, cli *cliFlags) { if cli.showHelp { fs.PrintDefaults() os.Exit(0) } if cli.showVersion { - fmt.Println("spv-wallet", "version", Version) + fmt.Println(cfg.GetUserAgent()) os.Exit(0) } diff --git a/config/load.go b/config/load_config.go similarity index 63% rename from config/load.go rename to config/load_config.go index c4821e7c..9d6dca84 100644 --- a/config/load.go +++ b/config/load_config.go @@ -18,17 +18,19 @@ import ( var viperLock sync.Mutex // Load all AppConfig -func Load(logger *zerolog.Logger) (appConfig *AppConfig, err error) { +func Load(versionToSet string, logger zerolog.Logger) (appConfig *AppConfig, err error) { viperLock.Lock() defer viperLock.Unlock() - if err = setDefaults(); err != nil { + appConfig = GetDefaultAppConfig() + appConfig.Version = versionToSet + if err = setDefaults(appConfig); err != nil { return nil, err } envConfig() - if err = loadFlags(); err != nil { + if err = loadFlags(appConfig); err != nil { return nil, err } @@ -36,40 +38,26 @@ func Load(logger *zerolog.Logger) (appConfig *AppConfig, err error) { return nil, err } - appConfig = getDefaultAppConfig() if err = unmarshallToAppConfig(appConfig); err != nil { return nil, err } - if appConfig.Debug { + logger.Debug().MsgFunc(func() string { cfg, err := json.MarshalIndent(appConfig, "", " ") if err != nil { - logger.Error().Msg("Unable to decode App Config to json") - } else { - logger.Debug().Msgf("loaded config: %s", cfg) + return "Unable to decode App Config to json" } - } + return fmt.Sprintf("loaded config: %s", cfg) + }) return appConfig, nil } -// LoadForTest returns test AppConfig -func LoadForTest() (appConfig *AppConfig) { - - appConfig = getDefaultAppConfig() - appConfig.Debug = false - appConfig.DebugProfiling = false - appConfig.Logging.Level = zerolog.LevelErrorValue - appConfig.Logging.Format = "console" - - return appConfig -} - -func setDefaults() error { +func setDefaults(config *AppConfig) error { viper.SetDefault(ConfigFilePathKey, DefaultConfigFilePath) defaultsMap := make(map[string]interface{}) - if err := mapstructure.Decode(getDefaultAppConfig(), &defaultsMap); err != nil { + if err := mapstructure.Decode(config, &defaultsMap); err != nil { err = spverrors.Wrapf(err, "error occurred while setting defaults") return err } @@ -82,21 +70,20 @@ func setDefaults() error { } func envConfig() { - viper.SetEnvPrefix(EnvPrefix) + viper.SetEnvPrefix(envPrefix) viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() } -func loadFromFile(logger *zerolog.Logger) error { +func loadFromFile(logger zerolog.Logger) error { configFilePath := viper.GetString(ConfigFilePathKey) if configFilePath == DefaultConfigFilePath { - _, err := os.Stat(DefaultConfigFilePath) + _, err := os.Stat(configFilePath) if os.IsNotExist(err) { logger.Debug().Msg("Config file not specified. Using defaults") return nil } - configFilePath = DefaultConfigFilePath } viper.SetConfigFile(configFilePath) @@ -111,7 +98,7 @@ func loadFromFile(logger *zerolog.Logger) error { func unmarshallToAppConfig(appConfig *AppConfig) error { if err := viper.Unmarshal(appConfig); err != nil { - err = fmt.Errorf(dictionary.GetInternalMessage(dictionary.ErrorViper), err.Error()) + err = spverrors.Wrapf(err, "error when unmarshalling config to App Config") return err } return nil diff --git a/config/load_test.go b/config/load_config_test.go similarity index 63% rename from config/load_test.go rename to config/load_config_test.go index 6012870a..51996150 100644 --- a/config/load_test.go +++ b/config/load_config_test.go @@ -1,42 +1,42 @@ -package config +package config_test import ( "os" "testing" - "github.com/bitcoin-sv/spv-wallet/logging" + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/bitcoin-sv/spv-wallet/engine/tester" "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) -// TestLoadConfig will test the method Load() func TestLoadConfig(t *testing.T) { t.Run("empty configFilePath", func(t *testing.T) { // given - defaultLogger := logging.GetDefaultLogger() + logger := tester.Logger(t) // when - _, err := Load(defaultLogger) + _, err := config.Load("test", logger) // then assert.NoError(t, err) - assert.Equal(t, viper.GetString(ConfigFilePathKey), DefaultConfigFilePath) + assert.Equal(t, viper.GetString(config.ConfigFilePathKey), config.DefaultConfigFilePath) }) t.Run("custom configFilePath overridden by ENV", func(t *testing.T) { // given anotherPath := "anotherPath.yml" - defaultLogger := logging.GetDefaultLogger() + logger := tester.Logger(t) // when // IMPORTANT! If you need to change the name of this variable, it means you're // making backwards incompatible changes. Please inform all SPV Wallet adopters and // update your configs on all servers and scripts. os.Setenv("SPVWALLET_CONFIG_FILE", anotherPath) - _, err := Load(defaultLogger) + _, err := config.Load("test", logger) // then - assert.Equal(t, viper.GetString(ConfigFilePathKey), anotherPath) + assert.Equal(t, viper.GetString(config.ConfigFilePathKey), anotherPath) assert.Error(t, err) // cleanup diff --git a/config/services.go b/config/services.go deleted file mode 100644 index 82ae3c2e..00000000 --- a/config/services.go +++ /dev/null @@ -1,389 +0,0 @@ -package config - -import ( - "context" - "crypto/tls" - "fmt" - "net" - "net/url" - "regexp" - "strings" - - broadcastclient "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client" - "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/bitcoin-sv/spv-wallet/engine/cluster" - "github.com/bitcoin-sv/spv-wallet/engine/datastore" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "github.com/bitcoin-sv/spv-wallet/engine/taskmanager" - "github.com/bitcoin-sv/spv-wallet/engine/utils" - "github.com/bitcoin-sv/spv-wallet/logging" - "github.com/bitcoin-sv/spv-wallet/metrics" - "github.com/go-redis/redis/v8" - "github.com/mrz1836/go-cachestore" - "github.com/rs/zerolog" -) - -// explicitHTTPURLRegex is a regex pattern to check the callback URL (host) -var explicitHTTPURLRegex = regexp.MustCompile(`^https?://`) - -// AppServices is the loaded services via config -type ( - AppServices struct { - SpvWalletEngine engine.ClientInterface - Logger *zerolog.Logger - } -) - -// LoadServices will load and return new set of services, updating the AppConfig -func (a *AppConfig) LoadServices(ctx context.Context) (*AppServices, error) { - // Start services - _services := new(AppServices) - var err error - - logger, err := logging.CreateLogger(a.Logging.InstanceName, a.Logging.Format, a.Logging.Level, a.Logging.LogOrigin) - if err != nil { - err = spverrors.Wrapf(err, "error creating logger") - return nil, err - } - - _services.Logger = logger - - // Load SPV Wallet - if err := _services.loadSPVWallet(ctx, a, false, logger); err != nil { - return nil, err - } - - // Return the services - return _services, nil -} - -// LoadTestServices will load the "minimum" for testing -func (a *AppConfig) LoadTestServices(ctx context.Context) (*AppServices, error) { - // Start services - _services := new(AppServices) - - nopLogger := zerolog.Nop() - _services.Logger = &nopLogger - - // Load SPV Wallet for testing - if err := _services.loadSPVWallet(ctx, a, true, _services.Logger); err != nil { - return nil, err - } - - // Return the services - return _services, nil -} - -// CloseAll will close all connections to all services -func (s *AppServices) CloseAll(ctx context.Context) { - // Close SPV Wallet Engine - if s.SpvWalletEngine != nil { - _ = s.SpvWalletEngine.Close(ctx) - s.SpvWalletEngine = nil - } - - // All services closed! - if s.Logger != nil { - s.Logger.Debug().Msg("all services have been closed") - } -} - -// loadSPVWallet will load the SPV Wallet client (including CacheStore and DataStore) -func (s *AppServices) loadSPVWallet(ctx context.Context, appConfig *AppConfig, testMode bool, logger *zerolog.Logger) (err error) { - var options []engine.ClientOps - - if appConfig.Metrics.Enabled { - collector := metrics.EnableMetrics() - options = append(options, engine.WithMetrics(collector)) - } - - options = append(options, engine.WithUserAgent(appConfig.GetUserAgent())) - - if logger != nil { - serviceLogger := logger.With().Str("service", "spv-wallet").Logger() - options = append(options, engine.WithLogger(&serviceLogger)) - } - - if appConfig.Debug { - options = append(options, engine.WithDebugging()) - } - - options = loadCachestore(appConfig, options) - - if options, err = loadCluster(appConfig, options); err != nil { - return err - } - - // Set the datastore - if options, err = loadDatastore(options, appConfig, testMode); err != nil { - return err - } - - options = loadPaymail(appConfig, options) - - options = loadTaskManager(appConfig, options) - - if appConfig.Notifications != nil && appConfig.Notifications.Enabled { - options = append(options, engine.WithNotifications()) - } - - options = loadBroadcastClientArc(appConfig, options, logger) - - options = append(options, engine.WithARC(appConfig.ARC.URL, appConfig.ARC.Token, appConfig.ARC.DeploymentID)) - - options, err = configureCallback(options, appConfig) - if err != nil { - logger.Err(err).Msg("error while configuring callback") - } - - options = append(options, engine.WithFeeQuotes(appConfig.ARC.UseFeeQuotes)) - - if appConfig.ARC.FeeUnit != nil { - options = append(options, engine.WithFeeUnit(&utils.FeeUnit{ - Satoshis: appConfig.ARC.FeeUnit.Satoshis, - Bytes: appConfig.ARC.FeeUnit.Bytes, - })) - } - - // Create the new client - s.SpvWalletEngine, err = engine.NewClient(ctx, options...) - - return -} - -func loadCachestore(appConfig *AppConfig, options []engine.ClientOps) []engine.ClientOps { - if appConfig.Cache.Engine == cachestore.Redis { - options = append(options, engine.WithRedis(&cachestore.RedisConfig{ - DependencyMode: appConfig.Cache.Redis.DependencyMode, - MaxActiveConnections: appConfig.Cache.Redis.MaxActiveConnections, - MaxConnectionLifetime: appConfig.Cache.Redis.MaxConnectionLifetime, - MaxIdleConnections: appConfig.Cache.Redis.MaxIdleConnections, - MaxIdleTimeout: appConfig.Cache.Redis.MaxIdleTimeout, - URL: appConfig.Cache.Redis.URL, - UseTLS: appConfig.Cache.Redis.UseTLS, - })) - } else if appConfig.Cache.Engine == cachestore.FreeCache { - options = append(options, engine.WithFreeCache()) - } - - return options -} - -func loadCluster(appConfig *AppConfig, options []engine.ClientOps) ([]engine.ClientOps, error) { - if appConfig.Cache.Cluster != nil { - if appConfig.Cache.Cluster.Coordinator == cluster.CoordinatorRedis { - var redisOptions *redis.Options - - if appConfig.Cache.Cluster.Redis != nil { - redisURL, err := url.Parse(appConfig.Cache.Cluster.Redis.URL) - if err != nil { - return options, spverrors.Wrapf(err, "error parsing redis url") - } - password, _ := redisURL.User.Password() - redisOptions = &redis.Options{ - Addr: fmt.Sprintf("%s:%s", redisURL.Hostname(), redisURL.Port()), - Username: redisURL.User.Username(), - Password: password, - IdleTimeout: appConfig.Cache.Cluster.Redis.MaxIdleTimeout, - } - if appConfig.Cache.Cluster.Redis.UseTLS { - redisOptions.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - } - } - } else if appConfig.Cache.Redis != nil { - redisOptions = &redis.Options{ - Addr: appConfig.Cache.Redis.URL, - IdleTimeout: appConfig.Cache.Redis.MaxIdleTimeout, - } - if appConfig.Cache.Redis.UseTLS { - redisOptions.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - } - } - } else { - return options, spverrors.Newf("could not load redis cluster coordinator") - } - options = append(options, engine.WithClusterRedis(redisOptions)) - } - if appConfig.Cache.Cluster.Prefix != "" { - options = append(options, engine.WithClusterKeyPrefix(appConfig.Cache.Cluster.Prefix)) - } - } - - return options, nil -} - -func loadPaymail(appConfig *AppConfig, options []engine.ClientOps) []engine.ClientOps { - pm := appConfig.Paymail - options = append(options, engine.WithPaymailSupport( - pm.Domains, - pm.DefaultFromPaymail, - pm.DomainValidationEnabled, - pm.SenderValidationEnabled, - )) - if pm.Beef.enabled() { - options = append(options, engine.WithPaymailBeefSupport(pm.Beef.BlockHeadersServiceHeaderValidationURL, pm.Beef.BlockHeadersServiceAuthToken)) - } - if appConfig.ExperimentalFeatures.PikeContactsEnabled { - options = append(options, engine.WithPaymailPikeContactSupport()) - } - if appConfig.ExperimentalFeatures.PikePaymentEnabled { - options = append(options, engine.WithPaymailPikePaymentSupport()) - } - - return options -} - -// loadDatastore will load the correct datastore based on the engine -func loadDatastore(options []engine.ClientOps, appConfig *AppConfig, testMode bool) ([]engine.ClientOps, error) { - // Set the datastore options - if testMode { - var err error - // Set the unique table prefix - if appConfig.Db.SQLite.TablePrefix, err = utils.RandomHex(8); err != nil { - err = spverrors.Wrapf(err, "error generating random hex") - return options, err - } - - // Defaults for safe thread testing - appConfig.Db.SQLite.MaxIdleConnections = 1 - appConfig.Db.SQLite.MaxOpenConnections = 1 - } - - // Select the datastore - if appConfig.Db.Datastore.Engine == datastore.SQLite { - tablePrefix := appConfig.Db.Datastore.TablePrefix - if len(appConfig.Db.SQLite.TablePrefix) > 0 { - tablePrefix = appConfig.Db.SQLite.TablePrefix - } - options = append(options, engine.WithSQLite(&datastore.SQLiteConfig{ - CommonConfig: datastore.CommonConfig{ - Debug: appConfig.Db.Datastore.Debug, - MaxConnectionIdleTime: appConfig.Db.SQLite.MaxConnectionIdleTime, - MaxConnectionTime: appConfig.Db.SQLite.MaxConnectionTime, - MaxIdleConnections: appConfig.Db.SQLite.MaxIdleConnections, - MaxOpenConnections: appConfig.Db.SQLite.MaxOpenConnections, - TablePrefix: tablePrefix, - }, - DatabasePath: appConfig.Db.SQLite.DatabasePath, // "" for in memory - Shared: appConfig.Db.SQLite.Shared, - })) - } else if appConfig.Db.Datastore.Engine == datastore.PostgreSQL { - tablePrefix := appConfig.Db.Datastore.TablePrefix - if len(appConfig.Db.SQL.TablePrefix) > 0 { - tablePrefix = appConfig.Db.SQL.TablePrefix - } - - options = append(options, engine.WithSQL(appConfig.Db.Datastore.Engine, &datastore.SQLConfig{ - CommonConfig: datastore.CommonConfig{ - Debug: appConfig.Db.Datastore.Debug, - MaxConnectionIdleTime: appConfig.Db.SQL.MaxConnectionIdleTime, - MaxConnectionTime: appConfig.Db.SQL.MaxConnectionTime, - MaxIdleConnections: appConfig.Db.SQL.MaxIdleConnections, - MaxOpenConnections: appConfig.Db.SQL.MaxOpenConnections, - TablePrefix: tablePrefix, - }, - Driver: appConfig.Db.Datastore.Engine.String(), - Host: appConfig.Db.SQL.Host, - Name: appConfig.Db.SQL.Name, - Password: appConfig.Db.SQL.Password, - Port: appConfig.Db.SQL.Port, - TimeZone: appConfig.Db.SQL.TimeZone, - TxTimeout: appConfig.Db.SQL.TxTimeout, - User: appConfig.Db.SQL.User, - SslMode: appConfig.Db.SQL.SslMode, - })) - - } else { - return nil, spverrors.Newf("unsupported datastore engine: %s", appConfig.Db.Datastore.Engine.String()) - } - - options = append(options, engine.WithAutoMigrate(engine.BaseModels...)) - - return options, nil -} - -func loadTaskManager(appConfig *AppConfig, options []engine.ClientOps) []engine.ClientOps { - ops := []taskmanager.TasqOps{} - if appConfig.TaskManager.Factory == taskmanager.FactoryRedis { - ops = append(ops, taskmanager.WithRedis(appConfig.Cache.Redis.URL)) - } - options = append(options, engine.WithTaskqConfig( - taskmanager.DefaultTaskQConfig(TaskManagerQueueName, ops...), - )) - return options -} - -func loadBroadcastClientArc(appConfig *AppConfig, options []engine.ClientOps, logger *zerolog.Logger) []engine.ClientOps { - builder := broadcastclient.Builder() - var bcLogger zerolog.Logger - if logger == nil { - bcLogger = zerolog.Nop() - } else { - bcLogger = logger.With().Str("service", "broadcast-client").Logger() - } - builder.WithArc(broadcastclient.ArcClientConfig{ - Token: appConfig.ARC.Token, - APIUrl: appConfig.ARC.URL, - DeploymentID: appConfig.ARC.DeploymentID, - }, &bcLogger) - broadcastClient := builder.Build() - options = append( - options, - engine.WithBroadcastClient(broadcastClient), - ) - return options -} - -func configureCallback(options []engine.ClientOps, appConfig *AppConfig) ([]engine.ClientOps, error) { - if appConfig.ARC.Callback.Enabled { - if !isValidURL(appConfig.ARC.Callback.Host) { - return nil, spverrors.Newf("invalid callback host: %s - must be a valid external url - not a localhost", appConfig.ARC.Callback.Host) - } - - if appConfig.ARC.Callback.Token == "" { - callbackToken, err := utils.HashAdler32(DefaultAdminXpub) - if err != nil { - return nil, spverrors.Wrapf(err, "error while generating callback token") - } - appConfig.ARC.Callback.Token = callbackToken - } - - options = append(options, engine.WithCallback(appConfig.ARC.Callback.Host+BroadcastCallbackRoute, appConfig.ARC.Callback.Token)) - } - return options, nil -} - -func isLocal(hostname string) bool { - if strings.Contains(hostname, "localhost") { - return true - } - - ip := net.ParseIP(hostname) - if ip != nil { - _, private10, _ := net.ParseCIDR("10.0.0.0/8") - _, private172, _ := net.ParseCIDR("172.16.0.0/12") - _, private192, _ := net.ParseCIDR("192.168.0.0/16") - _, loopback, _ := net.ParseCIDR("127.0.0.0/8") - _, linkLocal, _ := net.ParseCIDR("169.254.0.0/16") - - return private10.Contains(ip) || private172.Contains(ip) || private192.Contains(ip) || loopback.Contains(ip) || linkLocal.Contains(ip) - } - - return false -} - -func isValidURL(rawURL string) bool { - if !explicitHTTPURLRegex.MatchString(rawURL) { - return false - } - u, err := url.Parse(rawURL) - if err != nil { - return false - } - - hostname := u.Hostname() - - return !isLocal(hostname) -} diff --git a/config/services_test.go b/config/services_test.go deleted file mode 100644 index 4deafc27..00000000 --- a/config/services_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package config - -import ( - "context" - "testing" - - "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newTestServices will make a new test services -func newTestServices(ctx context.Context, t *testing.T, - appConfig *AppConfig, -) *AppServices { - s, err := appConfig.LoadTestServices(ctx) - require.NoError(t, err) - require.NotNil(t, s) - return s -} - -// TestAppServices_CloseAll will test the method CloseAll() -func TestAppServices_CloseAll(t *testing.T) { - t.Parallel() - - t.Run("no services", func(_ *testing.T) { - s := new(AppServices) - s.CloseAll(context.Background()) - }) - - t.Run("close all services", func(t *testing.T) { - ac := newTestConfig(t) - require.NotNil(t, ac) - s := newTestServices(context.Background(), t, ac) - require.NotNil(t, s) - s.CloseAll(context.Background()) - - assert.Nil(t, s.SpvWalletEngine) - }) -} - -// TestAppConfig_GetUserAgent will test the method GetUserAgent() -func TestAppConfig_GetUserAgent(t *testing.T) { - t.Parallel() - - t.Run("get valid user agent", func(t *testing.T) { - ac := newTestConfig(t) - require.NotNil(t, ac) - agent := ac.GetUserAgent() - assert.Equal(t, "SPV Wallet "+Version, agent) - }) -} - -// TestCallback_HostPattern will test the callback host pattern defined by the regex -func TestCallback_HostPattern(t *testing.T) { - validURLs := []string{ - "http://example.com", - "https://example.com", - "http://subdomain.example.com", - "https://subdomain.example.com", - "https://subdomain.example.com:3003", - } - - invalidURLs := []string{ - "example.com", - "ftp://example.com", - "localhost", - "http//example.com", - "https//example.com", - "https://localhost", - "https://127.0.0.1", - } - - for _, url := range validURLs { - if !isValidURL(url) { - t.Errorf("expected %v to be valid, but it was not", url) - } - } - - for _, url := range invalidURLs { - if isValidURL(url) { - t.Errorf("expected %v to be invalid, but it was not", url) - } - } -} - -// TestCallback_ConfigureCallback will test the method configureCallback() -func TestCallback_ConfigureCallback(t *testing.T) { - tests := []struct { - appConfig AppConfig - name string - expectedErr string - expectedOpts int - }{ - { - appConfig: AppConfig{ - ARC: &ARCConfig{ - Callback: &CallbackConfig{ - Host: "http://example.com", - Token: "", - Enabled: true, - }, - }, - }, - name: "Valid URL with empty token and http", - expectedErr: "", - expectedOpts: 1, - }, - { - appConfig: AppConfig{ - ARC: &ARCConfig{ - Callback: &CallbackConfig{ - Host: "https://example.com", - Token: "existingToken", - Enabled: true, - }, - }, - }, - name: "Valid URL with existing token and https", - expectedErr: "", - expectedOpts: 1, - }, - { - appConfig: AppConfig{ - ARC: &ARCConfig{ - Callback: &CallbackConfig{ - Host: "ftp://example.com", - Token: "", - Enabled: true, - }, - }, - }, - name: "Invalid URL without http/https", - expectedErr: "invalid callback host: ftp://example.com - must be a valid external url - not a localhost", - expectedOpts: 0, - }, - { - appConfig: AppConfig{ - ARC: &ARCConfig{ - Callback: &CallbackConfig{ - Host: "http://localhost:3003", - Token: "", - Enabled: true, - }, - }, - }, - name: "Invalid URL with localhost", - expectedErr: "invalid callback host: http://localhost:3003 - must be a valid external url - not a localhost", - expectedOpts: 0, - }, - { - appConfig: AppConfig{ - ARC: &ARCConfig{ - Callback: &CallbackConfig{ - Host: "http://example.com", - Token: "", - Enabled: false, - }, - }, - }, - name: "Callback disabled", - expectedErr: "", - expectedOpts: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var options []engine.ClientOps - ops, err := configureCallback(options, &tt.appConfig) - if tt.expectedErr != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedErr) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.expectedOpts, len(ops)) - }) - } -} diff --git a/config/task_manager.go b/config/task_manager.go deleted file mode 100644 index 3bb1a062..00000000 --- a/config/task_manager.go +++ /dev/null @@ -1,6 +0,0 @@ -package config - -// TaskManager defaults -const ( - TaskManagerQueueName = "spv_wallet_queue" -) diff --git a/config/validate.go b/config/validate.go deleted file mode 100644 index 36e29d1a..00000000 --- a/config/validate.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -// Validate checks the configuration for specific rules -func (a *AppConfig) Validate() error { - var err error - - if err = a.Authentication.Validate(); err != nil { - return err - } - - if err = a.Cache.Validate(); err != nil { - return err - } - - if err = a.Db.Validate(); err != nil { - return err - } - - if err = a.Paymail.Validate(); err != nil { - return err - } - - if err = a.BHS.Validate(); err != nil { - return err - } - - if err = a.Server.Validate(); err != nil { - return err - } - - if err = a.ARC.Validate(); err != nil { - return err - } - - return nil -} diff --git a/config/validate_app_config.go b/config/validate_app_config.go new file mode 100644 index 00000000..16486c8c --- /dev/null +++ b/config/validate_app_config.go @@ -0,0 +1,36 @@ +package config + +// Validate checks the configuration for specific rules +func (c *AppConfig) Validate() error { + var err error + + if err = c.Authentication.Validate(); err != nil { + return err + } + + if err = c.Cache.Validate(); err != nil { + return err + } + + if err = c.Db.Validate(); err != nil { + return err + } + + if err = c.Paymail.Validate(); err != nil { + return err + } + + if err = c.BHS.Validate(); err != nil { + return err + } + + if err = c.Server.Validate(); err != nil { + return err + } + + if err = c.ARC.Validate(); err != nil { + return err + } + + return nil +} diff --git a/config/validate_app_config_test.go b/config/validate_app_config_test.go new file mode 100644 index 00000000..a4746506 --- /dev/null +++ b/config/validate_app_config_test.go @@ -0,0 +1,21 @@ +package config_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/stretchr/testify/require" +) + +func TestValidateAppConfigForDefaultConfig(t *testing.T) { + t.Parallel() + + // given: + cfg := config.GetDefaultAppConfig() + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) +} diff --git a/config/validate_arc.go b/config/validate_arc.go new file mode 100644 index 00000000..bf7f168a --- /dev/null +++ b/config/validate_arc.go @@ -0,0 +1,73 @@ +package config + +import ( + "net" + "net/url" + "regexp" + "strings" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" +) + +// explicitHTTPURLRegex is a regex pattern to check the callback URL (host) +var explicitHTTPURLRegex = regexp.MustCompile(`^https?://`) + +// Validate checks the configuration for specific rules +func (n *ARCConfig) Validate() error { + if n == nil { + return spverrors.Newf("arc is not configured") + } + + if n.URL == "" { + return spverrors.Newf("arc url is not configured") + } + + if !n.isValidCallbackURL() { + return spverrors.Newf("invalid callback host: %s - must be a valid external url - not a localhost", n.Callback.Host) + } + + if !n.UseFeeQuotes && n.FeeUnit == nil { + return spverrors.Newf("fee unit is not configured, define nodes.fee_unit or set nodes.use_fee_quotes") + } + + return nil +} + +func (n *ARCConfig) isValidCallbackURL() bool { + if !n.Callback.Enabled { + return true + } + + callbackUrl := n.Callback.Host + + if !explicitHTTPURLRegex.MatchString(callbackUrl) { + return false + } + u, err := url.Parse(callbackUrl) + if err != nil { + return false + } + + hostname := u.Hostname() + + return !n.isLocalNetworkHost(hostname) +} + +func (n *ARCConfig) isLocalNetworkHost(hostname string) bool { + if strings.Contains(hostname, "localhost") { + return true + } + + ip := net.ParseIP(hostname) + if ip != nil { + _, private10, _ := net.ParseCIDR("10.0.0.0/8") + _, private172, _ := net.ParseCIDR("172.16.0.0/12") + _, private192, _ := net.ParseCIDR("192.168.0.0/16") + _, loopback, _ := net.ParseCIDR("127.0.0.0/8") + _, linkLocal, _ := net.ParseCIDR("169.254.0.0/16") + + return private10.Contains(ip) || private172.Contains(ip) || private192.Contains(ip) || loopback.Contains(ip) || linkLocal.Contains(ip) + } + + return false +} diff --git a/config/validate_arc_test.go b/config/validate_arc_test.go new file mode 100644 index 00000000..294aef05 --- /dev/null +++ b/config/validate_arc_test.go @@ -0,0 +1,136 @@ +package config_test + +import ( + "fmt" + "testing" + + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/stretchr/testify/require" +) + +// TestNewRelicConfig_Validate will test the method Validate() +func TestValidateArcConfig(t *testing.T) { + t.Parallel() + + t.Run("no arc url", func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.URL = "" + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + + t.Run("if callback is disabled, then empty callback url is valid", func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.Callback.Enabled = false + cfg.ARC.Callback.Host = "" + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) + + validCallbackURLTests := []string{ + "http://example.com", + "https://example.com", + "http://subdomain.example.com", + "https://subdomain.example.com", + "https://subdomain.example.com:3003", + } + for _, test := range validCallbackURLTests { + t.Run(fmt.Sprintf("url %s should be valid callback url", test), func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.Callback.Enabled = true + cfg.ARC.Callback.Host = test + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) + } + + invalidCallbackURLTests := map[string]struct { + url string + }{ + "empty callback url is invalid callback url": { + url: "", + }, + "external url without schema is invalid callback url": { + url: "example.com", + }, + "external url with ftp schema is invalid callback url": { + url: "ftp://example.com", + }, + "localhost is invalid callback url": { + url: "https://localhost", + }, + "localhost IP is invalid callback url": { + url: "https://127.0.0.1", + }, + "local network address is invalid callback url": { + url: "https://10.0.0.1", + }, + "url with wrong https schema part (no colon) is invalid callback url": { + url: "https//example.com", + }, + "url with wrong http schema part (no colon) is invalid callback url": { + url: "http//example.com", + }, + } + for name, test := range invalidCallbackURLTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.Callback.Enabled = true + cfg.ARC.Callback.Host = test.url + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } + + t.Run("fee unit must be set if not using fee quotes", func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.UseFeeQuotes = false + cfg.ARC.FeeUnit = nil + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + + t.Run("fee unit can be not set when using fee quotes", func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + cfg.ARC.UseFeeQuotes = true + cfg.ARC.FeeUnit = nil + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) +} diff --git a/config/validate_authentication_test.go b/config/validate_authentication_test.go index 091c21e2..541b8f56 100644 --- a/config/validate_authentication_test.go +++ b/config/validate_authentication_test.go @@ -1,76 +1,51 @@ -package config +package config_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/stretchr/testify/require" ) -const ( - testAdminKey = "12345678901234567890123456789012" -) - -// TestAuthenticationConfig_IsAdmin will test the method IsAdmin() -func TestAuthenticationConfig_IsAdmin(t *testing.T) { - t.Run("admin valid", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: AuthenticationSchemeXpub, - AdminKey: testAdminKey, - } - assert.Equal(t, true, a.IsAdmin(testAdminKey)) - }) - - t.Run("admin invalid", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: AuthenticationSchemeXpub, - AdminKey: testAdminKey, - } - assert.Equal(t, false, a.IsAdmin("invalid")) - }) - -} - -// TestAuthenticationConfig_Validate will test the method Validate() -func TestAuthenticationConfig_Validate(t *testing.T) { +func TestValidateAuthenticationConfig(t *testing.T) { t.Parallel() - t.Run("valid scheme and admin key", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: AuthenticationSchemeXpub, - AdminKey: testAdminKey, - } - assert.NoError(t, a.Validate()) - }) - - t.Run("empty scheme", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: "", - AdminKey: testAdminKey, - } - assert.Error(t, a.Validate()) - }) - - t.Run("invalid scheme", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: "invalid", - AdminKey: testAdminKey, - } - assert.Error(t, a.Validate()) - }) - - t.Run("invalid admin key (missing)", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: AuthenticationSchemeXpub, - AdminKey: "", - } - assert.Error(t, a.Validate()) - }) - - t.Run("invalid admin key (to short)", func(t *testing.T) { - a := AuthenticationConfig{ - Scheme: AuthenticationSchemeXpub, - AdminKey: "1234567", - } - assert.Error(t, a.Validate()) - }) + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "empty scheme": { + scenario: func(cfg *config.AppConfig) { + cfg.Authentication.Scheme = "" + }, + }, + "invalid scheme": { + scenario: func(cfg *config.AppConfig) { + cfg.Authentication.Scheme = "invalid" + }, + }, + "invalid admin key (missing)": { + scenario: func(cfg *config.AppConfig) { + cfg.Authentication.AdminKey = "" + }, + }, + "invalid admin key (to short)": { + scenario: func(cfg *config.AppConfig) { + cfg.Authentication.AdminKey = "1234567" + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } } diff --git a/config/validate_bhs_test.go b/config/validate_bhs_test.go index ce68e4f3..dc820768 100644 --- a/config/validate_bhs_test.go +++ b/config/validate_bhs_test.go @@ -1,49 +1,65 @@ -package config +package config_test import ( "testing" + "github.com/bitcoin-sv/spv-wallet/config" "github.com/stretchr/testify/require" ) -// TestBHSConfig_Validate will test the method Validate() -func TestBHSConfig_Validate(t *testing.T) { +func TestValidateBHSConfig(t *testing.T) { t.Parallel() - t.Run("no auth token", func(t *testing.T) { - b := BHSConfig{ - AuthToken: "", - URL: "http://localhost:8080", - } - - err := b.Validate() - require.NoError(t, err) - }) - - t.Run("no url", func(t *testing.T) { - b := BHSConfig{ - AuthToken: "token", - URL: "", - } - - err := b.Validate() - require.Error(t, err) - }) - - t.Run("config is nil", func(t *testing.T) { - var b *BHSConfig - - err := b.Validate() - require.Error(t, err) - }) - - t.Run("full config", func(t *testing.T) { - b := BHSConfig{ - AuthToken: "token", - URL: "http://localhost:8080", - } - - err := b.Validate() - require.NoError(t, err) - }) + validConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "valid with no auth token": { + scenario: func(cfg *config.AppConfig) { + cfg.BHS.AuthToken = "" + }, + }, + } + for name, test := range validConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) + } + + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "return error when url is empty": { + scenario: func(cfg *config.AppConfig) { + cfg.BHS.URL = "" + }, + }, + "return error when config is nil": { + scenario: func(cfg *config.AppConfig) { + cfg.BHS = nil + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } } diff --git a/config/validate_cachestore_test.go b/config/validate_cachestore_test.go index 4af4835f..7474364c 100644 --- a/config/validate_cachestore_test.go +++ b/config/validate_cachestore_test.go @@ -1,43 +1,54 @@ -package config +package config_test import ( "testing" + "github.com/bitcoin-sv/spv-wallet/config" "github.com/mrz1836/go-cachestore" "github.com/stretchr/testify/require" ) -// TestCachestoreConfig_Validate will test the method Validate() -func TestCachestoreConfig_Validate(t *testing.T) { +func TestValidateCacheStoreConfig(t *testing.T) { t.Parallel() - t.Run("valid cachestore config", func(t *testing.T) { - c := CacheConfig{ - Engine: cachestore.FreeCache, - } - require.NotNil(t, c) - - err := c.Validate() - require.NoError(t, err) - }) - - t.Run("empty cachestore", func(t *testing.T) { - c := CacheConfig{ - Engine: cachestore.Empty, - } - require.NotNil(t, c) - - err := c.Validate() - require.Error(t, err) - }) - - t.Run("invalid cachestore engine", func(t *testing.T) { - c := CacheConfig{ - Engine: "", - } - require.NotNil(t, c) - - err := c.Validate() - require.Error(t, err) - }) + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "invalid empty cachestore": { + scenario: func(cfg *config.AppConfig) { + cfg.Cache.Engine = cachestore.Empty + }, + }, + "invalid empty string as cachestore engine": { + scenario: func(cfg *config.AppConfig) { + cfg.Cache.Engine = "" + }, + }, + "invalid when cache engine is redis and redis config not provided": { + scenario: func(cfg *config.AppConfig) { + cfg.Cache.Engine = cachestore.Redis + cfg.Cache.Redis = nil + }, + }, + "invalid when cache engine is redis and redis url is empty": { + scenario: func(cfg *config.AppConfig) { + cfg.Cache.Engine = cachestore.Redis + cfg.Cache.Redis.URL = "" + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } } diff --git a/config/validate_datastore_test.go b/config/validate_datastore_test.go index a1ee99e3..b21fba31 100644 --- a/config/validate_datastore_test.go +++ b/config/validate_datastore_test.go @@ -1,49 +1,96 @@ -package config +package config_test import ( "testing" + "github.com/bitcoin-sv/spv-wallet/config" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/stretchr/testify/require" ) -// TestDatastoreConfig_Validate will test the method Validate() -func TestDatastoreConfig_Validate(t *testing.T) { +func TestValidateDataStoreConfig(t *testing.T) { t.Parallel() - t.Run("valid datastore config", func(t *testing.T) { - d := DbConfig{ - Datastore: &DatastoreConfig{Engine: datastore.SQLite}, - SQL: &datastore.SQLConfig{}, - SQLite: &datastore.SQLiteConfig{}, - } - require.NotNil(t, d) - - err := d.Validate() - require.NoError(t, err) - }) - - t.Run("empty datastore", func(t *testing.T) { - d := DbConfig{ - Datastore: &DatastoreConfig{Engine: datastore.Empty}, - SQL: &datastore.SQLConfig{}, - SQLite: &datastore.SQLiteConfig{}, - } - require.NotNil(t, d) - - err := d.Validate() - require.Error(t, err) - }) - - t.Run("invalid datastore engine", func(t *testing.T) { - d := DbConfig{ - Datastore: &DatastoreConfig{Engine: ""}, - SQL: &datastore.SQLConfig{}, - SQLite: &datastore.SQLiteConfig{}, - } - require.NotNil(t, d) - - err := d.Validate() - require.Error(t, err) - }) + validConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "valid default postgres config": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.PostgreSQL + }, + }, + } + for name, test := range validConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.NoError(t, err) + }) + } + + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "invalid when sqlite engine and sqlite config not set": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.SQLite + cfg.Db.SQLite = nil + }, + }, + "invalid when empty datastore engine": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.Empty + }, + }, + "invalid when unknown datastore engine": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = "" + }, + }, + "invalid when sqlite engine and sql config not set": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.PostgreSQL + cfg.Db.SQL = nil + }, + }, + "invalid when sqlite engine and host is empty": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.PostgreSQL + cfg.Db.SQL.Host = "" + }, + }, + "invalid when sqlite engine and user is empty": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.PostgreSQL + cfg.Db.SQL.User = "" + }, + }, + "invalid when sqlite engine and database name is empty": { + scenario: func(cfg *config.AppConfig) { + cfg.Db.Datastore.Engine = datastore.PostgreSQL + cfg.Db.SQL.Name = "" + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } } diff --git a/config/validate_nodes.go b/config/validate_nodes.go deleted file mode 100644 index b8359a9b..00000000 --- a/config/validate_nodes.go +++ /dev/null @@ -1,22 +0,0 @@ -package config - -import ( - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" -) - -// Validate checks the configuration for specific rules -func (n *ARCConfig) Validate() error { - if n == nil { - return spverrors.Newf("nodes are not configured") - } - - if n.URL == "" { - return spverrors.Newf("node url is not configured") - } - - if !n.UseFeeQuotes && n.FeeUnit == nil { - return spverrors.Newf("fee unit is not configured, define nodes.fee_unit or set nodes.use_fee_quotes") - } - - return nil -} diff --git a/config/validate_nodes_test.go b/config/validate_nodes_test.go deleted file mode 100644 index 7a6fa962..00000000 --- a/config/validate_nodes_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestNodesConfig_Validate will test the method Validate() -func TestNodesConfig_Validate(t *testing.T) { - t.Parallel() - - t.Run("valid default nodes config", func(t *testing.T) { - n := getARCDefaults() - assert.NoError(t, n.Validate()) - }) - - t.Run("no arc url", func(t *testing.T) { - n := getARCDefaults() - - n.URL = "" - assert.Error(t, n.Validate()) - }) -} diff --git a/config/validate_paymail_test.go b/config/validate_paymail_test.go index 9c7fc887..44306011 100644 --- a/config/validate_paymail_test.go +++ b/config/validate_paymail_test.go @@ -1,84 +1,101 @@ -package config +package config_test import ( "testing" + "github.com/bitcoin-sv/spv-wallet/config" "github.com/stretchr/testify/require" ) -// TestPaymailConfig_Validate will test the method Validate() -func TestPaymailConfig_Validate(t *testing.T) { +func TestValidatePaymailConfig(t *testing.T) { t.Parallel() - t.Run("no domains", func(t *testing.T) { - p := PaymailConfig{ - Domains: nil, - } - err := p.Validate() - require.Error(t, err) - }) + validConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "valid beef enabled": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Beef.UseBeef = true + cfg.Paymail.Beef.BlockHeadersServiceHeaderValidationURL = "http://localhost:8080/api/v1/chain/merkleroot/verify" + }, + }, + "valid multiple paymail domains": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{"test.com", "domain.com"} + }, + }, + } + for name, test := range validConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() - t.Run("zero domains", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{}, - } - err := p.Validate() - require.Error(t, err) - }) + test.scenario(cfg) - t.Run("empty domains", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{""}, - } - err := p.Validate() - require.Error(t, err) - }) + // when: + err := cfg.Validate() - t.Run("invalid hostname", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{"..."}, - } - err := p.Validate() - require.Error(t, err) - }) + // then: + require.NoError(t, err) + }) + } - t.Run("spaces in hostname", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{"spaces in domain"}, - } - err := p.Validate() - require.Error(t, err) - }) + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "invalid for paymail options not set": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail = nil + }, + }, + "invalid with nil domains": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = nil + }, + }, + "invalid with zero domains": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{} + }, + }, + "invalid with empty domain": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{""} + }, + }, + "invalid with empty domain in the middle": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{"test.com", "", "domain.com"} + }, + }, + "invalid with empty domain at the end": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{"test.com", "domain.com", ""} + }, + }, + "invalid for invalid domain": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{"..."} + }, + }, + "invalid for spaces in domain": { + scenario: func(cfg *config.AppConfig) { + cfg.Paymail.Domains = []string{"spaces in domain"} + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() - t.Run("valid domains", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{"test.com", "domain.com"}, - } - err := p.Validate() - require.NoError(t, err) - }) + test.scenario(cfg) - t.Run("invalid beef", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{"test.com", "domain.com"}, - Beef: &BeefConfig{ - UseBeef: true, - BlockHeadersServiceHeaderValidationURL: "", - }, - } - err := p.Validate() - require.Error(t, err) - }) + // when: + err := cfg.Validate() - t.Run("valid beef", func(t *testing.T) { - p := PaymailConfig{ - Domains: []string{"test.com", "domain.com"}, - Beef: &BeefConfig{ - UseBeef: true, - BlockHeadersServiceHeaderValidationURL: "http://localhost:8080/api/v1/chain/merkleroot/verify", - }, - } - err := p.Validate() - require.NoError(t, err) - }) + // then: + require.Error(t, err) + }) + } } diff --git a/config/validate_server_test.go b/config/validate_server_test.go index 42d2a74e..926a1e13 100644 --- a/config/validate_server_test.go +++ b/config/validate_server_test.go @@ -1,61 +1,56 @@ -package config +package config_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/bitcoin-sv/spv-wallet/config" + "github.com/stretchr/testify/require" ) -// TestServerConfig_Validate will test the method Validate() -func TestServerConfig_Validate(t *testing.T) { +func TestValidateServerConfig(t *testing.T) { t.Parallel() - defaultAppConfig := getDefaultAppConfig() - idleTimeout := defaultAppConfig.Server.IdleTimeout - readTimeout := defaultAppConfig.Server.ReadTimeout - writeTimeout := defaultAppConfig.Server.WriteTimeout - - t.Run("port is required", func(t *testing.T) { - s := ServerConfig{ - IdleTimeout: idleTimeout, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - // no port - } - err := s.Validate() - assert.Error(t, err) - }) - - t.Run("port is too big", func(t *testing.T) { - s := ServerConfig{ - IdleTimeout: idleTimeout, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - Port: 1234567, - } - err := s.Validate() - assert.Error(t, err) - }) - - t.Run("valid server config", func(t *testing.T) { - s := ServerConfig{ - IdleTimeout: idleTimeout, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - Port: 3000, - } - err := s.Validate() - assert.NoError(t, err) - }) - - t.Run("default timeouts", func(t *testing.T) { - s := ServerConfig{ - IdleTimeout: 0, - ReadTimeout: 0, - WriteTimeout: 0, - Port: 3000, - } - err := s.Validate() - assert.Error(t, err) - }) + invalidConfigTests := map[string]struct { + scenario func(cfg *config.AppConfig) + }{ + "invalid for missing port": { + scenario: func(cfg *config.AppConfig) { + cfg.Server.Port = 0 + }, + }, + "invalid for too high port number": { + scenario: func(cfg *config.AppConfig) { + cfg.Server.Port = 1234567890 + }, + }, + "invalid for missing idle timeout": { + scenario: func(cfg *config.AppConfig) { + cfg.Server.IdleTimeout = 0 + }, + }, + "invalid for missing read timeout": { + scenario: func(cfg *config.AppConfig) { + cfg.Server.ReadTimeout = 0 + }, + }, + "invalid for missing write timeout": { + scenario: func(cfg *config.AppConfig) { + cfg.Server.WriteTimeout = 0 + }, + }, + } + for name, test := range invalidConfigTests { + t.Run(name, func(t *testing.T) { + // given: + cfg := config.GetDefaultAppConfig() + + test.scenario(cfg) + + // when: + err := cfg.Validate() + + // then: + require.Error(t, err) + }) + } } diff --git a/engine/client.go b/engine/client.go index 44e4c1cf..349c3285 100644 --- a/engine/client.go +++ b/engine/client.go @@ -287,6 +287,10 @@ func (c *Client) Close(ctx context.Context) error { } c.options.taskManager.TaskEngine = nil } + + if c.options.notifications != nil && c.options.notifications.webhookManager != nil { + c.options.notifications.webhookManager.Stop() + } return nil } diff --git a/engine/logging/adapters.go b/engine/logging/adapters.go index 8e757b9b..908f47f7 100644 --- a/engine/logging/adapters.go +++ b/engine/logging/adapters.go @@ -48,19 +48,19 @@ func (a *GormLoggerAdapter) Trace(_ context.Context, begin time.Time, fc func() } elapsed := time.Since(begin) switch { - case err != nil && a.logLevel >= logger.Error && (!strings.Contains(err.Error(), "record not found")): + case err != nil && a.Logger.GetLevel() <= zerolog.ErrorLevel && (!strings.Contains(err.Error(), "record not found")): sql, rows := fc() event := prepareTraceEvent(a.Logger.Error(), elapsed, rows, sql) event.Str("error", err.Error()). Msg("warning executing query") - case elapsed > logger.SlowQueryThreshold && a.logLevel >= logger.Warn: + case elapsed > logger.SlowQueryThreshold && a.Logger.GetLevel() <= zerolog.WarnLevel: sql, rows := fc() event := prepareTraceEvent(a.Logger.Warn(), elapsed, rows, sql) event.Str("slow_log", fmt.Sprintf("SLOW SQL >= %v", logger.SlowQueryThreshold)). Msg("warning executing query") - case a.logLevel == logger.Info: + case a.Logger.GetLevel() == zerolog.TraceLevel: sql, rows := fc() - event := prepareTraceEvent(a.Logger.Info(), elapsed, rows, sql) + event := prepareTraceEvent(a.Logger.Trace(), elapsed, rows, sql) event.Msg("executing query") } } @@ -115,9 +115,7 @@ func CreateGormLoggerAdapter(zLog *zerolog.Logger, serviceName string) *GormLogg l = logger.Error } else if level == zerolog.WarnLevel { l = logger.Warn - } else if level == zerolog.InfoLevel { - l = logger.Info - } else if level == zerolog.DebugLevel { + } else if level <= zerolog.InfoLevel { l = logger.Info } else { l = logger.Silent diff --git a/engine/tester/logger.go b/engine/tester/logger.go index 1c80b38d..8f0b14a7 100644 --- a/engine/tester/logger.go +++ b/engine/tester/logger.go @@ -8,5 +8,6 @@ import ( // Logger returns a logger that can be used as a dependency in tests. func Logger(t testing.TB) zerolog.Logger { - return zerolog.New(zerolog.NewConsoleWriter(zerolog.ConsoleTestWriter(t))) + logger := zerolog.New(zerolog.NewConsoleWriter(zerolog.ConsoleTestWriter(t))) + return logger.Level(zerolog.DebugLevel) } diff --git a/logging/gin_loggers.go b/logging/gin_loggers.go index 3724ab9b..a1e5cc4c 100644 --- a/logging/gin_loggers.go +++ b/logging/gin_loggers.go @@ -16,7 +16,7 @@ func SetGinWriters(log *zerolog.Logger) { } // GinMiddleware returns a middleware that logs requests using zerolog. -func GinMiddleware(log *zerolog.Logger) gin.HandlerFunc { +func GinMiddleware(log zerolog.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path diff --git a/logging/logging.go b/logging/logging.go index 6799d0ac..8fac4b54 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/bitcoin-sv/spv-wallet/config" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/rs/zerolog" "go.elastic.co/ecszerolog" @@ -15,8 +16,22 @@ const ( jsonLogFormat = "json" ) -// CreateLogger create and configure zerolog logger based on app config. -func CreateLogger(instanceName, format, level string, logOrigin bool) (*zerolog.Logger, error) { +// GetDefaultLogger create and configure default zerolog logger. It should be used before config is loaded +func GetDefaultLogger() zerolog.Logger { + logger, err := createLogger("spv-wallet-default", jsonLogFormat, "debug", true) + if err != nil { + panic(err) + } + return logger +} + +// CreateLoggerWithConfig creates a logger based on the given config +func CreateLoggerWithConfig(config *config.AppConfig) (zerolog.Logger, error) { + loggingConfig := config.Logging + return createLogger(loggingConfig.InstanceName, loggingConfig.Format, loggingConfig.Level, loggingConfig.LogOrigin) +} + +func createLogger(instanceName, format, level string, logOrigin bool) (zerolog.Logger, error) { var writer io.Writer if format == consoleLogFormat { writer = zerolog.ConsoleWriter{ @@ -30,7 +45,7 @@ func CreateLogger(instanceName, format, level string, logOrigin bool) (*zerolog. parsedLevel, err := zerolog.ParseLevel(level) if err != nil { err = spverrors.Wrapf(err, "failed to parse log level") - return nil, err + return zerolog.Nop(), err } logLevel := ecszerolog.Level(parsedLevel) @@ -53,16 +68,5 @@ func CreateLogger(instanceName, format, level string, logOrigin bool) (*zerolog. return time.Now().In(time.Local) //nolint:gosmopolitan // We want local time inside logger. } - return &logger, nil -} - -// GetDefaultLogger create and configure default zerolog logger. It should be used before config is loaded -func GetDefaultLogger() *zerolog.Logger { - logger := ecszerolog.New(os.Stdout, ecszerolog.Level(zerolog.DebugLevel)). - With(). - Caller(). - Str("application", "spv-wallet-default"). - Logger() - - return &logger + return logger, nil } diff --git a/server/middleware/appcontext_middleware.go b/server/middleware/appcontext_middleware.go index 72220da3..00d39892 100644 --- a/server/middleware/appcontext_middleware.go +++ b/server/middleware/appcontext_middleware.go @@ -9,11 +9,11 @@ import ( ) // AppContextMiddleware is a middleware that sets the appConfig, engine and logger in the request context -func AppContextMiddleware(appConfig *config.AppConfig, engine engine.ClientInterface, logger *zerolog.Logger) gin.HandlerFunc { +func AppContextMiddleware(appConfig *config.AppConfig, engine engine.ClientInterface, logger zerolog.Logger) gin.HandlerFunc { return func(c *gin.Context) { reqctx.SetAppConfig(c, appConfig) reqctx.SetEngine(c, engine) - reqctx.SetLogger(c, logger) + reqctx.SetLogger(c, &logger) c.Next() } diff --git a/server/server.go b/server/server.go index 192af5a9..b295ec68 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/actions" "github.com/bitcoin-sv/spv-wallet/config" + "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/logging" "github.com/bitcoin-sv/spv-wallet/metrics" @@ -21,17 +22,19 @@ import ( // Server is the configuration, services, and actual web server type Server struct { - AppConfig *config.AppConfig - Router *gin.Engine - Services *config.AppServices - WebServer *http.Server + AppConfig *config.AppConfig + Router *gin.Engine + SpvWalletEngine engine.ClientInterface + WebServer *http.Server + Logger zerolog.Logger } // NewServer will return a new server service -func NewServer(appConfig *config.AppConfig, services *config.AppServices) *Server { +func NewServer(appConfig *config.AppConfig, spvWalletEngine engine.ClientInterface, logger zerolog.Logger) *Server { return &Server{ - AppConfig: appConfig, - Services: services, + AppConfig: appConfig, + SpvWalletEngine: spvWalletEngine, + Logger: logger, } } @@ -61,56 +64,51 @@ func (s *Server) Serve() { }, } - // Turn off keep alive - // s.WebServer.SetKeepAlivesEnabled(false) - + s.Logger.Debug().Msgf("starting %s server at port %d...", s.AppConfig.GetUserAgent(), s.AppConfig.Server.Port) // Listen and serve if err := s.WebServer.ListenAndServe(); err != nil { - s.Services.Logger.Debug().Msgf("shutting down %s server [%s] on port %d...", config.ApplicationName, err.Error(), s.AppConfig.Server.Port) + s.Logger.Info().Err(err).Msgf("shutting down %s server [%s] on port %d...", s.AppConfig.GetUserAgent(), err.Error(), s.AppConfig.Server.Port) } } // Shutdown will stop the web server func (s *Server) Shutdown(ctx context.Context) error { - s.Services.CloseAll(ctx) // Should have been executed in main.go, but might panic and not run? err := s.WebServer.Shutdown(ctx) if err != nil { err = spverrors.Wrapf(err, "error shutting down server") - return err } - return nil + return err } // Handlers will return handlers func (s *Server) Handlers() *gin.Engine { - - httpLogger := s.Services.Logger.With().Str("service", "http-server").Logger() + httpLogger := s.Logger.With().Str("service", "http-server").Logger() if httpLogger.GetLevel() > zerolog.DebugLevel { gin.SetMode(gin.ReleaseMode) } logging.SetGinWriters(&httpLogger) - engine := gin.New() - engine.Use(logging.GinMiddleware(&httpLogger), gin.Recovery()) - engine.Use(middleware.AppContextMiddleware(s.AppConfig, s.Services.SpvWalletEngine, s.Services.Logger)) - engine.Use(middleware.CorsMiddleware()) + ginEngine := gin.New() + ginEngine.Use(logging.GinMiddleware(httpLogger), gin.Recovery()) + ginEngine.Use(middleware.AppContextMiddleware(s.AppConfig, s.SpvWalletEngine, s.Logger)) + ginEngine.Use(middleware.CorsMiddleware()) - metrics.SetupGin(engine) + metrics.SetupGin(ginEngine) - engine.NoRoute(metrics.NoRoute, NotFound) - engine.NoMethod(MethodNotAllowed) + ginEngine.NoRoute(metrics.NoRoute, NotFound) + ginEngine.NoMethod(MethodNotAllowed) - s.Router = engine + s.Router = ginEngine - setupServerRoutes(s.AppConfig, s.Services, s.Router) + setupServerRoutes(s.AppConfig, s.SpvWalletEngine, s.Router) return s.Router } -func setupServerRoutes(appConfig *config.AppConfig, services *config.AppServices, ginEngine *gin.Engine) { +func setupServerRoutes(appConfig *config.AppConfig, spvWalletEngine engine.ClientInterface, ginEngine *gin.Engine) { handlersManager := handlers.NewManager(ginEngine, config.APIVersion) actions.Register(appConfig, handlersManager) - services.SpvWalletEngine.GetPaymailConfig().RegisterRoutes(ginEngine) + spvWalletEngine.GetPaymailConfig().RegisterRoutes(ginEngine) if appConfig.DebugProfiling { pprof.Register(ginEngine, "debug/pprof") diff --git a/server/server_test.go b/server/server_test.go index c6ec1177..5fc34626 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -39,7 +39,7 @@ func (ts *TestSuite) TearDownSuite() { func (ts *TestSuite) SetupTest() { ts.BaseSetupTest() - setupServerRoutes(ts.AppConfig, ts.Services, ts.Router) + setupServerRoutes(ts.AppConfig, ts.SpvWalletEngine, ts.Router) } // TearDownTest runs after each test @@ -126,7 +126,7 @@ func (ts *TestSuite) TestApiAuthentication() { ts.T().Run("valid value", func(t *testing.T) { w := httptest.NewRecorder() - xpub, err := ts.Services.SpvWalletEngine.NewXpub(context.Background(), testXpubAuth) + xpub, err := ts.SpvWalletEngine.NewXpub(context.Background(), testXpubAuth) require.NoError(t, err) require.NotNil(t, xpub) @@ -173,11 +173,11 @@ func (ts *TestSuite) TestBasicAuthentication() { ts.T().Run("valid value", func(t *testing.T) { w := httptest.NewRecorder() - xpub, err := ts.Services.SpvWalletEngine.NewXpub(context.Background(), testXpubAuth) + xpub, err := ts.SpvWalletEngine.NewXpub(context.Background(), testXpubAuth) require.NoError(t, err) require.NotNil(t, xpub) - destination, err := ts.Services.SpvWalletEngine.NewDestination(context.Background(), xpub.RawXpub(), 0, utils.ScriptTypePubKeyHash) + destination, err := ts.SpvWalletEngine.NewDestination(context.Background(), xpub.RawXpub(), 0, utils.ScriptTypePubKeyHash) require.NoError(t, err) require.NotNil(t, destination) diff --git a/tests/tests.go b/tests/tests.go index eae000ee..f71fec63 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -6,6 +6,9 @@ import ( "os" "github.com/bitcoin-sv/spv-wallet/config" + "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/engine/tester" + "github.com/bitcoin-sv/spv-wallet/engine/utils" "github.com/bitcoin-sv/spv-wallet/logging" "github.com/bitcoin-sv/spv-wallet/server/middleware" "github.com/gin-gonic/gin" @@ -16,27 +19,38 @@ import ( // TestSuite is for testing the entire package using real/mocked services type TestSuite struct { - AppConfig *config.AppConfig // App config - Router *gin.Engine // Gin router with handlers - Services *config.AppServices // Services - suite.Suite // Extends the suite.Suite package + AppConfig *config.AppConfig // App config + Router *gin.Engine // Gin router with handlers + Logger zerolog.Logger // Logger + SpvWalletEngine engine.ClientInterface // SPV Wallet Engine + suite.Suite // Extends the suite.Suite package } // BaseSetupSuite runs at the start of the suite func (ts *TestSuite) BaseSetupSuite() { - ts.AppConfig = config.LoadForTest() + cfg := config.GetDefaultAppConfig() + cfg.DebugProfiling = false + cfg.Logging.Level = zerolog.LevelDebugValue + cfg.Logging.Format = "console" + cfg.ARC.UseFeeQuotes = false + cfg.ARC.FeeUnit = &config.FeeUnitConfig{ + Satoshis: 1, + Bytes: 1000, + } + cfg.Notifications.Enabled = false + + // Defaults for safe thread testing + cfg.Db.SQLite.MaxIdleConnections = 1 + cfg.Db.SQLite.MaxOpenConnections = 1 + + ts.AppConfig = cfg } // BaseTearDownSuite runs after the suite finishes func (ts *TestSuite) BaseTearDownSuite() { - // Ensure all connections are closed - if ts.Services != nil { - ts.Services.CloseAll(context.Background()) - ts.Services = nil - } - ts.T().Cleanup(func() { _ = os.Remove("datastore.db") + _ = os.Remove("spv-wallet.db") }) } @@ -44,17 +58,24 @@ func (ts *TestSuite) BaseTearDownSuite() { func (ts *TestSuite) BaseSetupTest() { // Load the services var err error - nop := zerolog.Nop() + ts.Logger = tester.Logger(ts.T()) - ts.Services, err = ts.AppConfig.LoadTestServices(context.Background()) + ts.AppConfig.Db.SQLite.TablePrefix, err = utils.RandomHex(8) + require.NoError(ts.T(), err) + + opts, err := ts.AppConfig.ToEngineOptions(ts.Logger) + require.NoError(ts.T(), err) + + ts.SpvWalletEngine, err = engine.NewClient(context.Background(), opts...) + require.NoError(ts.T(), err) gin.SetMode(gin.ReleaseMode) - engine := gin.New() - engine.Use(logging.GinMiddleware(&nop), gin.Recovery()) - engine.Use(middleware.AppContextMiddleware(ts.AppConfig, ts.Services.SpvWalletEngine, ts.Services.Logger)) - engine.Use(middleware.CorsMiddleware()) + ginEngine := gin.New() + ginEngine.Use(logging.GinMiddleware(ts.Logger), gin.Recovery()) + ginEngine.Use(middleware.AppContextMiddleware(ts.AppConfig, ts.SpvWalletEngine, ts.Logger)) + ginEngine.Use(middleware.CorsMiddleware()) - ts.Router = engine + ts.Router = ginEngine require.NotNil(ts.T(), ts.Router) require.NoError(ts.T(), err) @@ -62,8 +83,8 @@ func (ts *TestSuite) BaseSetupTest() { // BaseTearDownTest runs after each test func (ts *TestSuite) BaseTearDownTest() { - if ts.Services != nil { - ts.Services.CloseAll(context.Background()) - ts.Services = nil + if ts.SpvWalletEngine != nil { + err := ts.SpvWalletEngine.Close(context.Background()) + require.NoError(ts.T(), err) } }