mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-28 12:45:47 +00:00
feat: implement backend logic for multiple oauth providers
This commit is contained in:
20
cmd/root.go
20
cmd/root.go
@@ -27,11 +27,6 @@ var rootCmd = &cobra.Command{
|
|||||||
log.Fatal().Err(err).Msg("Failed to parse config")
|
log.Fatal().Err(err).Msg("Failed to parse config")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if secrets have a file associated with them
|
|
||||||
conf.GithubClientSecret = utils.GetSecret(conf.GithubClientSecret, conf.GithubClientSecretFile)
|
|
||||||
conf.GoogleClientSecret = utils.GetSecret(conf.GoogleClientSecret, conf.GoogleClientSecretFile)
|
|
||||||
conf.GenericClientSecret = utils.GetSecret(conf.GenericClientSecret, conf.GenericClientSecretFile)
|
|
||||||
|
|
||||||
// Validate config
|
// Validate config
|
||||||
v := validator.New()
|
v := validator.New()
|
||||||
|
|
||||||
@@ -80,21 +75,6 @@ func init() {
|
|||||||
{"users", "", "Comma separated list of users in the format username:hash."},
|
{"users", "", "Comma separated list of users in the format username:hash."},
|
||||||
{"users-file", "", "Path to a file containing users in the format username:hash."},
|
{"users-file", "", "Path to a file containing users in the format username:hash."},
|
||||||
{"secure-cookie", false, "Send cookie over secure connection only."},
|
{"secure-cookie", false, "Send cookie over secure connection only."},
|
||||||
{"github-client-id", "", "Github OAuth client ID."},
|
|
||||||
{"github-client-secret", "", "Github OAuth client secret."},
|
|
||||||
{"github-client-secret-file", "", "Github OAuth client secret file."},
|
|
||||||
{"google-client-id", "", "Google OAuth client ID."},
|
|
||||||
{"google-client-secret", "", "Google OAuth client secret."},
|
|
||||||
{"google-client-secret-file", "", "Google OAuth client secret file."},
|
|
||||||
{"generic-client-id", "", "Generic OAuth client ID."},
|
|
||||||
{"generic-client-secret", "", "Generic OAuth client secret."},
|
|
||||||
{"generic-client-secret-file", "", "Generic OAuth client secret file."},
|
|
||||||
{"generic-scopes", "", "Generic OAuth scopes."},
|
|
||||||
{"generic-auth-url", "", "Generic OAuth auth URL."},
|
|
||||||
{"generic-token-url", "", "Generic OAuth token URL."},
|
|
||||||
{"generic-user-url", "", "Generic OAuth user info URL."},
|
|
||||||
{"generic-name", "Generic", "Generic OAuth provider name."},
|
|
||||||
{"generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider."},
|
|
||||||
{"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."},
|
{"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."},
|
||||||
{"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"},
|
{"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"},
|
||||||
{"session-expiry", 86400, "Session (cookie) expiration time in seconds."},
|
{"session-expiry", 86400, "Session (cookie) expiration time in seconds."},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"tinyauth/internal/config"
|
"tinyauth/internal/config"
|
||||||
"tinyauth/internal/controller"
|
"tinyauth/internal/controller"
|
||||||
@@ -45,6 +46,13 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get OAuth configs
|
||||||
|
oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.Config.AppURL)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Get cookie domain
|
// Get cookie domain
|
||||||
cookieDomain, err := utils.GetCookieDomain(app.Config.AppURL)
|
cookieDomain, err := utils.GetCookieDomain(app.Config.AppURL)
|
||||||
|
|
||||||
@@ -112,7 +120,7 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
// Create services
|
// Create services
|
||||||
dockerService := service.NewDockerService()
|
dockerService := service.NewDockerService()
|
||||||
authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
|
authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
|
||||||
oauthBrokerService := service.NewOAuthBrokerService(app.getOAuthBrokerConfig())
|
oauthBrokerService := service.NewOAuthBrokerService(oauthProviders)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
services := []Service{
|
services := []Service{
|
||||||
@@ -132,13 +140,39 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configured providers
|
// Configured providers
|
||||||
var configuredProviders []string
|
babysit := map[string]string{
|
||||||
|
"google": "Google",
|
||||||
|
"github": "GitHub",
|
||||||
|
}
|
||||||
|
configuredProviders := make([]controller.Provider, 0)
|
||||||
|
|
||||||
if authService.UserAuthConfigured() || ldapService != nil {
|
for id, provider := range oauthProviders {
|
||||||
configuredProviders = append(configuredProviders, "username")
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.Name == "" && babysit[id] != "" {
|
||||||
|
provider.Name = babysit[id]
|
||||||
|
} else {
|
||||||
|
provider.Name = utils.Capitalize(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredProviders = append(configuredProviders, controller.Provider{
|
||||||
|
Name: provider.Name,
|
||||||
|
ID: id,
|
||||||
|
OAuth: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredProviders = append(configuredProviders, oauthBrokerService.GetConfiguredServices()...)
|
if authService.UserAuthConfigured() || ldapService != nil {
|
||||||
|
configuredProviders = append(configuredProviders, controller.Provider{
|
||||||
|
Name: "Username",
|
||||||
|
ID: "username",
|
||||||
|
OAuth: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
|
||||||
|
|
||||||
if len(configuredProviders) == 0 {
|
if len(configuredProviders) == 0 {
|
||||||
return fmt.Errorf("no authentication providers configured")
|
return fmt.Errorf("no authentication providers configured")
|
||||||
@@ -179,9 +213,8 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
|
|
||||||
// Create controllers
|
// Create controllers
|
||||||
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
||||||
ConfiguredProviders: configuredProviders,
|
Providers: configuredProviders,
|
||||||
Title: app.Config.Title,
|
Title: app.Config.Title,
|
||||||
GenericName: app.Config.GenericName,
|
|
||||||
AppURL: app.Config.AppURL,
|
AppURL: app.Config.AppURL,
|
||||||
CookieDomain: cookieDomain,
|
CookieDomain: cookieDomain,
|
||||||
ForgotPasswordMessage: app.Config.ForgotPasswordMessage,
|
ForgotPasswordMessage: app.Config.ForgotPasswordMessage,
|
||||||
@@ -235,30 +268,3 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporary
|
|
||||||
func (app *BootstrapApp) getOAuthBrokerConfig() map[string]config.OAuthServiceConfig {
|
|
||||||
return map[string]config.OAuthServiceConfig{
|
|
||||||
"google": {
|
|
||||||
ClientID: app.Config.GoogleClientId,
|
|
||||||
ClientSecret: app.Config.GoogleClientSecret,
|
|
||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", app.Config.AppURL),
|
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
ClientID: app.Config.GithubClientId,
|
|
||||||
ClientSecret: app.Config.GithubClientSecret,
|
|
||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", app.Config.AppURL),
|
|
||||||
},
|
|
||||||
"generic": {
|
|
||||||
ClientID: app.Config.GenericClientId,
|
|
||||||
ClientSecret: app.Config.GenericClientSecret,
|
|
||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", app.Config.AppURL),
|
|
||||||
Scopes: strings.Split(app.Config.GenericScopes, ","),
|
|
||||||
AuthURL: app.Config.GenericAuthURL,
|
|
||||||
TokenURL: app.Config.GenericTokenURL,
|
|
||||||
UserinfoURL: app.Config.GenericUserURL,
|
|
||||||
InsecureSkipVerify: app.Config.GenericSkipSSL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,45 +15,30 @@ var RedirectCookieName = "tinyauth-redirect"
|
|||||||
// Main app config
|
// Main app config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `mapstructure:"port" validate:"required"`
|
Port int `mapstructure:"port" validate:"required"`
|
||||||
Address string `validate:"required,ip4_addr" mapstructure:"address"`
|
Address string `validate:"required,ip4_addr" mapstructure:"address"`
|
||||||
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
||||||
Users string `mapstructure:"users"`
|
Users string `mapstructure:"users"`
|
||||||
UsersFile string `mapstructure:"users-file"`
|
UsersFile string `mapstructure:"users-file"`
|
||||||
SecureCookie bool `mapstructure:"secure-cookie"`
|
SecureCookie bool `mapstructure:"secure-cookie"`
|
||||||
GithubClientId string `mapstructure:"github-client-id"`
|
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||||
GithubClientSecret string `mapstructure:"github-client-secret"`
|
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"`
|
||||||
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
|
SessionExpiry int `mapstructure:"session-expiry"`
|
||||||
GoogleClientId string `mapstructure:"google-client-id"`
|
LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"`
|
||||||
GoogleClientSecret string `mapstructure:"google-client-secret"`
|
Title string `mapstructure:"app-title"`
|
||||||
GoogleClientSecretFile string `mapstructure:"google-client-secret-file"`
|
LoginTimeout int `mapstructure:"login-timeout"`
|
||||||
GenericClientId string `mapstructure:"generic-client-id"`
|
LoginMaxRetries int `mapstructure:"login-max-retries"`
|
||||||
GenericClientSecret string `mapstructure:"generic-client-secret"`
|
ForgotPasswordMessage string `mapstructure:"forgot-password-message"`
|
||||||
GenericClientSecretFile string `mapstructure:"generic-client-secret-file"`
|
BackgroundImage string `mapstructure:"background-image" validate:"required"`
|
||||||
GenericScopes string `mapstructure:"generic-scopes"`
|
LdapAddress string `mapstructure:"ldap-address"`
|
||||||
GenericAuthURL string `mapstructure:"generic-auth-url"`
|
LdapBindDN string `mapstructure:"ldap-bind-dn"`
|
||||||
GenericTokenURL string `mapstructure:"generic-token-url"`
|
LdapBindPassword string `mapstructure:"ldap-bind-password"`
|
||||||
GenericUserURL string `mapstructure:"generic-user-url"`
|
LdapBaseDN string `mapstructure:"ldap-base-dn"`
|
||||||
GenericName string `mapstructure:"generic-name"`
|
LdapInsecure bool `mapstructure:"ldap-insecure"`
|
||||||
GenericSkipSSL bool `mapstructure:"generic-skip-ssl"`
|
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
|
||||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
ResourcesDir string `mapstructure:"resources-dir"`
|
||||||
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"`
|
DatabasePath string `mapstructure:"database-path" validate:"required"`
|
||||||
SessionExpiry int `mapstructure:"session-expiry"`
|
TrustedProxies string `mapstructure:"trusted-proxies"`
|
||||||
LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"`
|
|
||||||
Title string `mapstructure:"app-title"`
|
|
||||||
LoginTimeout int `mapstructure:"login-timeout"`
|
|
||||||
LoginMaxRetries int `mapstructure:"login-max-retries"`
|
|
||||||
ForgotPasswordMessage string `mapstructure:"forgot-password-message"`
|
|
||||||
BackgroundImage string `mapstructure:"background-image" validate:"required"`
|
|
||||||
LdapAddress string `mapstructure:"ldap-address"`
|
|
||||||
LdapBindDN string `mapstructure:"ldap-bind-dn"`
|
|
||||||
LdapBindPassword string `mapstructure:"ldap-bind-password"`
|
|
||||||
LdapBaseDN string `mapstructure:"ldap-base-dn"`
|
|
||||||
LdapInsecure bool `mapstructure:"ldap-insecure"`
|
|
||||||
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
|
|
||||||
ResourcesDir string `mapstructure:"resources-dir"`
|
|
||||||
DatabasePath string `mapstructure:"database-path" validate:"required"`
|
|
||||||
TrustedProxies string `mapstructure:"trusted-proxies"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth/OIDC config
|
// OAuth/OIDC config
|
||||||
|
|||||||
@@ -22,22 +22,26 @@ type UserContextResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppContextResponse struct {
|
type AppContextResponse struct {
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ConfiguredProviders []string `json:"configuredProviders"`
|
Providers []Provider `json:"providers"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
GenericName string `json:"genericName"`
|
AppURL string `json:"appUrl"`
|
||||||
AppURL string `json:"appUrl"`
|
CookieDomain string `json:"cookieDomain"`
|
||||||
CookieDomain string `json:"cookieDomain"`
|
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
BackgroundImage string `json:"backgroundImage"`
|
||||||
BackgroundImage string `json:"backgroundImage"`
|
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
}
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
OAuth bool `json:"oauth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextControllerConfig struct {
|
type ContextControllerConfig struct {
|
||||||
ConfiguredProviders []string
|
Providers []Provider
|
||||||
Title string
|
Title string
|
||||||
GenericName string
|
|
||||||
AppURL string
|
AppURL string
|
||||||
CookieDomain string
|
CookieDomain string
|
||||||
ForgotPasswordMessage string
|
ForgotPasswordMessage string
|
||||||
@@ -96,9 +100,8 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
|||||||
c.JSON(200, AppContextResponse{
|
c.JSON(200, AppContextResponse{
|
||||||
Status: 200,
|
Status: 200,
|
||||||
Message: "Success",
|
Message: "Success",
|
||||||
ConfiguredProviders: controller.config.ConfiguredProviders,
|
Providers: controller.config.Providers,
|
||||||
Title: controller.config.Title,
|
Title: controller.config.Title,
|
||||||
GenericName: controller.config.GenericName,
|
|
||||||
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
||||||
CookieDomain: controller.config.CookieDomain,
|
CookieDomain: controller.config.CookieDomain,
|
||||||
ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
|
ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
|
||||||
|
|||||||
@@ -12,9 +12,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var controllerCfg = controller.ContextControllerConfig{
|
var controllerCfg = controller.ContextControllerConfig{
|
||||||
ConfiguredProviders: []string{"github", "google", "generic"},
|
Providers: []controller.Provider{
|
||||||
|
{
|
||||||
|
Name: "Username",
|
||||||
|
ID: "username",
|
||||||
|
OAuth: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Google",
|
||||||
|
ID: "google",
|
||||||
|
OAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
Title: "Test App",
|
Title: "Test App",
|
||||||
GenericName: "Generic",
|
|
||||||
AppURL: "http://localhost:8080",
|
AppURL: "http://localhost:8080",
|
||||||
CookieDomain: "localhost",
|
CookieDomain: "localhost",
|
||||||
ForgotPasswordMessage: "Contact admin to reset your password.",
|
ForgotPasswordMessage: "Contact admin to reset your password.",
|
||||||
@@ -58,9 +68,8 @@ func TestAppContextHandler(t *testing.T) {
|
|||||||
expectedRes := controller.AppContextResponse{
|
expectedRes := controller.AppContextResponse{
|
||||||
Status: 200,
|
Status: 200,
|
||||||
Message: "Success",
|
Message: "Success",
|
||||||
ConfiguredProviders: controllerCfg.ConfiguredProviders,
|
Providers: controllerCfg.Providers,
|
||||||
Title: controllerCfg.Title,
|
Title: controllerCfg.Title,
|
||||||
GenericName: controllerCfg.GenericName,
|
|
||||||
AppURL: controllerCfg.AppURL,
|
AppURL: controllerCfg.AppURL,
|
||||||
CookieDomain: controllerCfg.CookieDomain,
|
CookieDomain: controllerCfg.CookieDomain,
|
||||||
ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage,
|
ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage,
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func GetLogLevel(level string) zerolog.Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOAuthProvidersConfig(env []string, args []string) (map[string]config.OAuthServiceConfig, error) {
|
func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) {
|
||||||
providers := make(map[string]config.OAuthServiceConfig)
|
providers := make(map[string]config.OAuthServiceConfig)
|
||||||
|
|
||||||
// Get from environment variables
|
// Get from environment variables
|
||||||
@@ -181,6 +181,18 @@ func GetOAuthProvidersConfig(env []string, args []string) (map[string]config.OAu
|
|||||||
providers[name] = provider
|
providers[name] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have google/github providers and no redirect URL babysit them
|
||||||
|
babysitProviders := []string{"google", "github"}
|
||||||
|
|
||||||
|
for _, name := range babysitProviders {
|
||||||
|
if provider, exists := providers[name]; exists {
|
||||||
|
if provider.RedirectURL == "" {
|
||||||
|
provider.RedirectURL = appUrl + "/api/oauth/callback/" + name
|
||||||
|
providers[name] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return combined providers
|
// Return combined providers
|
||||||
return providers, nil
|
return providers, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := utils.GetOAuthProvidersConfig(env, args)
|
result, err := utils.GetOAuthProvidersConfig(env, args, "")
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
args = []string{"/tinyauth/tinyauth"}
|
args = []string{"/tinyauth/tinyauth"}
|
||||||
expected = map[string]config.OAuthServiceConfig{}
|
expected = map[string]config.OAuthServiceConfig{}
|
||||||
|
|
||||||
result, err = utils.GetOAuthProvidersConfig(env, args)
|
result, err = utils.GetOAuthProvidersConfig(env, args, "")
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
@@ -250,7 +250,22 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = utils.GetOAuthProvidersConfig(env, args)
|
result, err = utils.GetOAuthProvidersConfig(env, args, "")
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
|
// Case with google provider and no redirect URL
|
||||||
|
env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"}
|
||||||
|
args = []string{"/tinyauth/tinyauth"}
|
||||||
|
expected = map[string]config.OAuthServiceConfig{
|
||||||
|
"google": {
|
||||||
|
ClientID: "google-id",
|
||||||
|
ClientSecret: "google-secret",
|
||||||
|
RedirectURL: "http://app.url/api/oauth/callback/google",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url")
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user