// Copyright 2022 Cloudbase Solutions SRL // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. package config import ( "context" "os" "path/filepath" "testing" "time" "github.com/bradleyfalzon/ghinstallation/v2" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "github.com/cloudbase/garm/util/appdefaults" ) var ( // nolint: golangci-lint,gosec EncryptionPassphrase = "bocyasicgatEtenOubwonIbsudNutDom" WeakEncryptionPassphrase = "1234567890abcdefghijklmnopqrstuv" ) func getDefaultSectionConfig(configDir string) Default { return Default{ CallbackURL: "https://garm.example.com/", MetadataURL: "https://garm.example.com/api/v1/metadata", LogFile: filepath.Join(configDir, "garm.log"), } } func getDefaultTLSConfig() TLSConfig { return TLSConfig{ CRT: "../testdata/certs/srv-pub.pem", Key: "../testdata/certs/srv-key.pem", } } func getDefaultAPIServerConfig() APIServer { return APIServer{ Bind: "0.0.0.0", Port: 9998, UseTLS: true, TLSConfig: getDefaultTLSConfig(), CORSOrigins: []string{}, } } func getMySQLDefaultConfig() MySQL { return MySQL{ Username: "test", Password: "test", Hostname: "127.0.0.1", DatabaseName: "garm", } } func getDefaultDatabaseConfig(dir string) Database { return Database{ Debug: false, DbBackend: SQLiteBackend, SQLite: SQLite{ DBFile: filepath.Join(dir, "garm.db"), }, Passphrase: EncryptionPassphrase, } } func getDefaultProvidersConfig() []Provider { return []Provider{} } func getDefaultGithubConfig() []Github { return []Github{ { Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "bogus", }, } } func getDefaultJWTCofig() JWTAuth { return JWTAuth{ Secret: EncryptionPassphrase, TimeToLive: "48h", } } func getDefaultConfig(t *testing.T) Config { dir, err := os.MkdirTemp("", "garm-config-test") if err != nil { t.Fatalf("failed to create temporary directory: %s", err) } t.Cleanup(func() { os.RemoveAll(dir) }) return Config{ Default: getDefaultSectionConfig(dir), APIServer: getDefaultAPIServerConfig(), Database: getDefaultDatabaseConfig(dir), Providers: getDefaultProvidersConfig(), Github: getDefaultGithubConfig(), JWTAuth: getDefaultJWTCofig(), } } func TestConfig(t *testing.T) { cfg := getDefaultConfig(t) err := cfg.Validate() require.Nil(t, err) } func TestDefaultSectionConfig(t *testing.T) { dir, err := os.MkdirTemp("", "garm-config-test") if err != nil { t.Fatalf("failed to create temporary directory: %s", err) } t.Cleanup(func() { os.RemoveAll(dir) }) cfg := getDefaultSectionConfig(dir) tests := []struct { name string cfg Default errString string }{ { name: "Config is valid", cfg: cfg, errString: "", }, { name: "CallbackURL must be valid if set", cfg: Default{ CallbackURL: "bogus_url", MetadataURL: cfg.MetadataURL, }, errString: "invalid callback_url", }, { name: "MetadataURL must be valid if set", cfg: Default{ CallbackURL: cfg.CallbackURL, MetadataURL: "bogus_url", }, errString: "invalid metadata_url", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestValidateAPIServerConfig(t *testing.T) { cfg := getDefaultAPIServerConfig() tests := []struct { name string cfg APIServer errString string }{ { name: "Config is valid", cfg: cfg, errString: "", }, { name: "Bind address is empty", cfg: APIServer{ Bind: "", Port: 9998, }, errString: "invalid IP address", }, { name: "Bind address is invalid", cfg: APIServer{ Bind: "not an IP", Port: 9998, }, errString: "invalid IP address", }, { name: "Bind address is valid IPv6", cfg: APIServer{ Bind: "::", Port: 9998, }, errString: "", }, { name: "Port is not set", cfg: APIServer{ Bind: cfg.Bind, Port: 0, }, errString: "invalid port nr 0", }, { name: "Port is not valid", cfg: APIServer{ Bind: cfg.Bind, Port: 65536, }, errString: "invalid port nr 65536", }, { name: "Invalid TLS config", cfg: APIServer{ Bind: cfg.Bind, Port: cfg.Port, TLSConfig: TLSConfig{}, UseTLS: true, }, errString: "invalid tls config: missing crt or key", }, { name: "Skip TLS config validation if UseTLS is false", cfg: APIServer{ Bind: cfg.Bind, Port: cfg.Port, TLSConfig: TLSConfig{}, UseTLS: false, }, errString: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestAPIBindAddress(t *testing.T) { cfg := getDefaultAPIServerConfig() err := cfg.Validate() require.Nil(t, err) require.Equal(t, cfg.BindAddress(), "0.0.0.0:9998") } func TestDatabaseConfig(t *testing.T) { dir, err := os.MkdirTemp("", "garm-config-test") if err != nil { t.Fatalf("failed to create temporary directory: %s", err) } t.Cleanup(func() { os.RemoveAll(dir) }) cfg := getDefaultDatabaseConfig(dir) tests := []struct { name string cfg Database errString string }{ { name: "Config is valid", cfg: cfg, errString: "", }, { name: "Missing backend", cfg: Database{ DbBackend: "", SQLite: cfg.SQLite, Passphrase: cfg.Passphrase, }, errString: "invalid databse configuration: backend is required", }, { name: "Invalid backend type", cfg: Database{ DbBackend: DBBackendType("bogus"), SQLite: cfg.SQLite, Passphrase: cfg.Passphrase, }, errString: "invalid database backend: bogus", }, { name: "Missing passphrase", cfg: Database{ DbBackend: cfg.DbBackend, SQLite: cfg.SQLite, Passphrase: "", }, errString: "passphrase must be set and it must be a string of 32 characters*", }, { name: "passphrase has invalid length", cfg: Database{ DbBackend: cfg.DbBackend, SQLite: cfg.SQLite, Passphrase: "testing", }, errString: "passphrase must be set and it must be a string of 32 characters*", }, { name: "passphrase is too weak", cfg: Database{ DbBackend: cfg.DbBackend, SQLite: cfg.SQLite, Passphrase: WeakEncryptionPassphrase, }, errString: "database passphrase is too weak", }, { name: "sqlite3 backend is missconfigured", cfg: Database{ DbBackend: cfg.DbBackend, SQLite: SQLite{ DBFile: "", }, Passphrase: cfg.Passphrase, }, errString: "validating sqlite3 config: no valid db_file was specified", }, { name: "mysql backend is missconfigured", cfg: Database{ DbBackend: MySQLBackend, MySQL: MySQL{}, Passphrase: cfg.Passphrase, }, errString: "validating mysql config: database, username, password, hostname are mandatory parameters for the database section", }, { name: "mysql backend is configured and valid", cfg: Database{ DbBackend: MySQLBackend, MySQL: getMySQLDefaultConfig(), Passphrase: cfg.Passphrase, }, errString: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestGormParams(t *testing.T) { dir, err := os.MkdirTemp("", "garm-config-test") if err != nil { t.Fatalf("failed to create temporary directory: %s", err) } t.Cleanup(func() { os.RemoveAll(dir) }) cfg := getDefaultDatabaseConfig(dir) dbType, uri, err := cfg.GormParams() require.Nil(t, err) require.Equal(t, SQLiteBackend, dbType) require.Equal(t, filepath.Join(dir, "garm.db?_journal_mode=WAL&_foreign_keys=ON"), uri) cfg.SQLite.BusyTimeoutSeconds = 5 dbType, uri, err = cfg.GormParams() require.Nil(t, err) require.Equal(t, SQLiteBackend, dbType) require.Equal(t, filepath.Join(dir, "garm.db?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000"), uri) cfg.DbBackend = MySQLBackend cfg.MySQL = getMySQLDefaultConfig() cfg.SQLite = SQLite{} dbType, uri, err = cfg.GormParams() require.Nil(t, err) require.Equal(t, MySQLBackend, dbType) require.Equal(t, "test:test@tcp(127.0.0.1)/garm?charset=utf8&parseTime=True&loc=Local&timeout=5s", uri) } func TestSQLiteConfig(t *testing.T) { dir, err := os.MkdirTemp("", "garm-config-test") if err != nil { t.Fatalf("failed to create temporary directory: %s", err) } t.Cleanup(func() { os.RemoveAll(dir) }) tests := []struct { name string cfg SQLite errString string }{ { name: "Config is valid", cfg: SQLite{ DBFile: filepath.Join(dir, "garm.db"), }, errString: "", }, { name: "db_file is empty", cfg: SQLite{ DBFile: "", }, errString: "no valid db_file was specified", }, { name: "db_file must not be a relative path", cfg: SQLite{ DBFile: "../test.db", }, errString: "please specify an absolute path for db_file", }, { name: "parent folder must exist", cfg: SQLite{ DBFile: "/i/dont/exist/test.db", }, errString: "parent directory of db_file does not exist: stat.*", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestJWTAuthConfig(t *testing.T) { cfg := JWTAuth{ Secret: EncryptionPassphrase, TimeToLive: "48h", } tests := []struct { name string cfg JWTAuth errString string }{ { name: "Config is valid", cfg: cfg, errString: "", }, { name: "secret is empty", cfg: JWTAuth{ Secret: "", TimeToLive: cfg.TimeToLive, }, errString: "invalid JWT secret", }, { name: "secret is weak", cfg: JWTAuth{ Secret: WeakEncryptionPassphrase, TimeToLive: cfg.TimeToLive, }, errString: "jwt_secret is too weak", }, { name: "time to live is invalid", cfg: JWTAuth{ Secret: cfg.Secret, TimeToLive: "bogus", }, errString: "invalid time_to_live: time: invalid duration*", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestTimeToLiveDuration(t *testing.T) { cfg := JWTAuth{ TimeToLive: "48h", } require.Equal(t, cfg.TimeToLive.Duration(), 48*time.Hour) cfg.TimeToLive = "1h" require.Equal(t, cfg.TimeToLive.Duration(), appdefaults.DefaultJWTTTL) cfg.TimeToLive = "72h" require.Equal(t, cfg.TimeToLive.Duration(), 72*time.Hour) cfg.TimeToLive = "2d" _, err := cfg.TimeToLive.ParseDuration() require.NotNil(t, err) require.EqualError(t, err, "time: unknown unit \"d\" in duration \"2d\"") } func TestNewConfig(t *testing.T) { cfg, err := NewConfig("testdata/test-valid-config.toml") require.Nil(t, err) require.NotNil(t, cfg) require.Equal(t, "https://garm.example.com/", cfg.Default.CallbackURL) require.Equal(t, "0.0.0.0", cfg.APIServer.Bind) require.Equal(t, 9998, cfg.APIServer.Port) require.Equal(t, false, cfg.APIServer.UseTLS) require.Equal(t, DBBackendType("mysql"), cfg.Database.DbBackend) require.Equal(t, "bocyasicgatEtenOubwonIbsudNutDom", cfg.Database.Passphrase) require.Equal(t, "test", cfg.Database.MySQL.Username) require.Equal(t, "test", cfg.Database.MySQL.Password) require.Equal(t, "127.0.0.1", cfg.Database.MySQL.Hostname) require.Equal(t, "garm", cfg.Database.MySQL.DatabaseName) require.Equal(t, "bocyasicgatEtenOubwonIbsudNutDom", cfg.JWTAuth.Secret) require.Equal(t, timeToLive("48h"), cfg.JWTAuth.TimeToLive) } func TestNewConfigInvalidTomlPath(t *testing.T) { cfg, err := NewConfig("this is not a file path") require.Nil(t, cfg) require.NotNil(t, err) require.Regexp(t, "decoding toml", err.Error()) } func TestNewConfigInvalidConfig(t *testing.T) { cfg, err := NewConfig("testdata/test-invalid-config.toml") require.Nil(t, cfg) require.NotNil(t, err) require.Regexp(t, "validating config", err.Error()) } func TestGithubConfig(t *testing.T) { cfg := getDefaultGithubConfig() tests := []struct { name string cfg Github errString string }{ { name: "Config is valid", cfg: cfg[0], errString: "", }, { name: "BaseURL is invalid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", BaseURL: "bogus", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, errString: "invalid base_url: parse.*", }, { name: "APIBaseURL is invalid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", APIBaseURL: "bogus", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, errString: "invalid api_base_url: parse.*", }, { name: "UploadBaseURL is invalid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", UploadBaseURL: "bogus", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, errString: "invalid upload_base_url: parse.*", }, { name: "BaseURL is set and is valid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", BaseURL: "https://github.example.com/", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, }, { name: "APIBaseURL is set and is valid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", APIBaseURL: "https://github.example.com/api/v3", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, }, { name: "UploadBaseURL is set and is valid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", UploadBaseURL: "https://github.example.com/uploads", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, }, { name: "OAuth2Token is empty", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", }, errString: "missing github oauth2 token", }, { name: "Name is empty", cfg: Github{ Name: "", Description: "dummy github credentials", OAuth2Token: "bogus", }, errString: "missing credentials name", }, { name: "OAuth token is set in the PAT section", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, }, }, { name: "OAuth token is empty in the PAT section", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "", }, }, errString: "missing github oauth2 token", }, { name: "Valid App section", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 99, PrivateKeyPath: "../testdata/certs/srv-key.pem", }, }, }, { name: "AppID is missing", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 0, InstallationID: 99, PrivateKeyPath: "../testdata/certs/srv-key.pem", }, }, errString: "missing app_id", }, { name: "InstallationID is missing", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 0, PrivateKeyPath: "../testdata/certs/srv-key.pem", }, }, errString: "missing installation_id", }, { name: "PrivateKeyPath is missing", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 99, PrivateKeyPath: "", }, }, errString: "missing private_key_path", }, { name: "PrivateKeyPath is invalid", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 99, PrivateKeyPath: "/i/dont/exist", }, }, errString: "invalid github app config: error accessing private_key_path: stat /i/dont/exist: no such file or directory", }, { name: "PrivateKeyPath is not a valid RSA private key", cfg: Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 99, PrivateKeyPath: "../testdata/certs/srv-pub.pem", }, }, errString: "invalid github app config: parsing private_key_path: asn1: structure error:.*", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if tc.errString == "" { require.Nil(t, err) } else { require.NotNil(t, err) require.Regexp(t, tc.errString, err.Error()) } }) } } func TestGithubAPIEndpoint(t *testing.T) { cfg := getDefaultGithubConfig() require.Equal(t, "https://api.github.com/", cfg[0].APIEndpoint()) } func TestGithubAPIEndpointIsSet(t *testing.T) { cfg := getDefaultGithubConfig() cfg[0].APIBaseURL = "https://github.example.com/api/v3" require.Equal(t, "https://github.example.com/api/v3", cfg[0].APIEndpoint()) } func TestUploadEndpoint(t *testing.T) { cfg := getDefaultGithubConfig() require.Equal(t, "https://uploads.github.com/", cfg[0].UploadEndpoint()) } func TestUploadEndpointIsSet(t *testing.T) { cfg := getDefaultGithubConfig() cfg[0].UploadBaseURL = "https://github.example.com/uploads" require.Equal(t, "https://github.example.com/uploads", cfg[0].UploadEndpoint()) } func TestGithubBaseURL(t *testing.T) { cfg := getDefaultGithubConfig() require.Equal(t, "https://github.com", cfg[0].BaseEndpoint()) } func TestGithubBaseURLIsSet(t *testing.T) { cfg := getDefaultGithubConfig() cfg[0].BaseURL = "https://github.example.com" require.Equal(t, "https://github.example.com", cfg[0].BaseEndpoint()) } func TestCACertBundle(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "bogus", CACertBundlePath: "../testdata/certs/srv-pub.pem", } cert, err := cfg.CACertBundle() require.Nil(t, err) require.NotNil(t, cert) } func TestCACertBundleInvalidPath(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "bogus", CACertBundlePath: "/i/dont/exist", } cert, err := cfg.CACertBundle() require.NotNil(t, err) require.EqualError(t, err, "error accessing ca_cert_bundle: stat /i/dont/exist: no such file or directory") require.Nil(t, cert) } func TestCACertBundleInvalidFile(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "bogus", CACertBundlePath: "../testdata/config.toml", } cert, err := cfg.CACertBundle() require.NotNil(t, err) require.EqualError(t, err, "failed to parse CA cert bundle") require.Nil(t, cert) } func TestGithubHTTPClientDeprecatedPAT(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", OAuth2Token: "bogus", } client, err := cfg.HTTPClient(context.Background()) require.Nil(t, err) require.NotNil(t, client) transport, ok := client.Transport.(*oauth2.Transport) require.True(t, ok) require.NotNil(t, transport) } func TestGithubHTTPClientPAT(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", AuthType: GithubAuthTypePAT, PAT: GithubPAT{ OAuth2Token: "bogus", }, } client, err := cfg.HTTPClient(context.Background()) require.Nil(t, err) require.NotNil(t, client) transport, ok := client.Transport.(*oauth2.Transport) require.True(t, ok) require.NotNil(t, transport) } func TestGithubHTTPClientApp(t *testing.T) { cfg := Github{ Name: "dummy_creds", Description: "dummy github credentials", AuthType: GithubAuthTypeApp, App: GithubApp{ AppID: 1, InstallationID: 99, PrivateKeyPath: "../testdata/certs/srv-key.pem", }, } client, err := cfg.HTTPClient(context.Background()) require.Nil(t, err) require.NotNil(t, client) transport, ok := client.Transport.(*ghinstallation.Transport) require.True(t, ok) require.NotNil(t, transport) }