mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-10 05:28:14 +00:00
047a653446
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
397 lines
10 KiB
Go
397 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// writeConfigFile writes yaml content to a temp path and returns it.
|
|
func writeConfigFile(t *testing.T, yaml string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// resetViper clears all global viper state between tests.
|
|
func resetViper(t *testing.T) {
|
|
t.Helper()
|
|
viper.Reset()
|
|
}
|
|
|
|
// minimalValidYAML is the smallest configuration that passes Validate.
|
|
const minimalValidYAML = `
|
|
server:
|
|
host: "0.0.0.0"
|
|
port: 8080
|
|
environment: development
|
|
garage:
|
|
endpoint: http://garage:3900
|
|
admin_endpoint: http://garage:3903
|
|
admin_token: supersecret
|
|
`
|
|
|
|
func TestLoad_YAMLOnly(t *testing.T) {
|
|
resetViper(t)
|
|
path := writeConfigFile(t, minimalValidYAML)
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.Host != "0.0.0.0" {
|
|
t.Errorf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host)
|
|
}
|
|
if cfg.Server.Port != 8080 {
|
|
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
|
|
}
|
|
if cfg.Server.Environment != "development" {
|
|
t.Errorf("Server.Environment = %q, want development", cfg.Server.Environment)
|
|
}
|
|
if cfg.Garage.Endpoint != "http://garage:3900" {
|
|
t.Errorf("Garage.Endpoint = %q", cfg.Garage.Endpoint)
|
|
}
|
|
if cfg.Garage.AdminToken != "supersecret" {
|
|
t.Errorf("Garage.AdminToken = %q", cfg.Garage.AdminToken)
|
|
}
|
|
}
|
|
|
|
func TestLoad_EnvOnly_MissingFile(t *testing.T) {
|
|
resetViper(t)
|
|
// Point at a path that definitely does not exist. Load tolerates missing
|
|
// files and falls back to env + viper defaults.
|
|
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
|
|
|
// Every required field provided via env.
|
|
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
|
t.Setenv("GARAGE_UI_GARAGE_ENDPOINT", "http://g:3900")
|
|
t.Setenv("GARAGE_UI_GARAGE_ADMIN_ENDPOINT", "http://g:3903")
|
|
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-token")
|
|
|
|
cfg, err := Load(missing)
|
|
if err != nil {
|
|
t.Fatalf("Load with env-only: %v", err)
|
|
}
|
|
if cfg.Server.Port != 9090 {
|
|
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
|
|
}
|
|
if cfg.Garage.AdminToken != "env-token" {
|
|
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
|
|
}
|
|
}
|
|
|
|
func TestLoad_EnvOverridesYAML(t *testing.T) {
|
|
resetViper(t)
|
|
path := writeConfigFile(t, minimalValidYAML)
|
|
|
|
// YAML has port=8080; env should win.
|
|
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
|
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.Port != 9090 {
|
|
t.Errorf("Server.Port = %d, want 9090 (env override)", cfg.Server.Port)
|
|
}
|
|
if cfg.Garage.AdminToken != "env-wins" {
|
|
t.Errorf("Garage.AdminToken = %q, want env-wins", cfg.Garage.AdminToken)
|
|
}
|
|
// Host was not overridden; YAML value should persist.
|
|
if cfg.Server.Host != "0.0.0.0" {
|
|
t.Errorf("Server.Host = %q, want 0.0.0.0 (from YAML)", cfg.Server.Host)
|
|
}
|
|
}
|
|
|
|
func TestLoad_MalformedYAMLReturnsError(t *testing.T) {
|
|
resetViper(t)
|
|
// Deliberately broken YAML: unindented key after a mapping start.
|
|
path := writeConfigFile(t, "server:\n port: 8080\n:: not: valid ::\n")
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed YAML, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "error reading config file") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoad_ValidationFailurePropagates(t *testing.T) {
|
|
resetViper(t)
|
|
// Valid YAML syntax but Garage.Endpoint is blank → Validate must fail.
|
|
path := writeConfigFile(t, `
|
|
server:
|
|
port: 8080
|
|
garage:
|
|
endpoint: ""
|
|
admin_endpoint: http://g:3903
|
|
admin_token: t
|
|
`)
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid configuration") {
|
|
t.Errorf("expected wrapped invalid-config error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "garage endpoint is required") {
|
|
t.Errorf("expected endpoint-required message, got %v", err)
|
|
}
|
|
}
|
|
|
|
// validBaseConfig returns a deep copy of a minimal Config that passes Validate.
|
|
func validBaseConfig() Config {
|
|
return Config{
|
|
Server: ServerConfig{Port: 8080},
|
|
Garage: GarageConfig{
|
|
Endpoint: "http://g:3900",
|
|
AdminEndpoint: "http://g:3903",
|
|
AdminToken: "t",
|
|
},
|
|
}
|
|
}
|
|
|
|
// applyValidOIDC fills OIDC with all required fields.
|
|
func applyValidOIDC(c *Config) {
|
|
c.Auth.OIDC.Enabled = true
|
|
c.Auth.OIDC.ClientID = "client-xyz"
|
|
c.Auth.OIDC.IssuerURL = "https://idp.example/realms/test"
|
|
c.Auth.OIDC.Scopes = []string{"openid"}
|
|
c.Auth.OIDC.AdminRole = "admin"
|
|
c.Server.RootURL = "https://garage-ui.example"
|
|
}
|
|
|
|
// Note on spec coverage: spec/2026-04-17-backend-test-suite-design.md lists
|
|
// "invalid log level/format" as a Validate case, but the current Validate does
|
|
// not check Logging.Level or Logging.Format. That's a code-vs-spec gap to
|
|
// resolve in a follow-up plan; Stage 2 tests the current behavior only.
|
|
func TestValidate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Config)
|
|
wantErrContains string // empty = expect no error
|
|
}{
|
|
{
|
|
name: "valid minimal config",
|
|
mutate: func(c *Config) {},
|
|
},
|
|
{
|
|
name: "port zero is invalid",
|
|
mutate: func(c *Config) { c.Server.Port = 0 },
|
|
wantErrContains: "invalid server port",
|
|
},
|
|
{
|
|
name: "port negative is invalid",
|
|
mutate: func(c *Config) { c.Server.Port = -1 },
|
|
wantErrContains: "invalid server port",
|
|
},
|
|
{
|
|
name: "port above 65535 is invalid",
|
|
mutate: func(c *Config) { c.Server.Port = 70000 },
|
|
wantErrContains: "invalid server port",
|
|
},
|
|
{
|
|
name: "port at 65535 is valid",
|
|
mutate: func(c *Config) { c.Server.Port = 65535 },
|
|
wantErrContains: "",
|
|
},
|
|
{
|
|
name: "missing garage endpoint",
|
|
mutate: func(c *Config) { c.Garage.Endpoint = "" },
|
|
wantErrContains: "garage endpoint is required",
|
|
},
|
|
{
|
|
name: "missing garage admin_endpoint",
|
|
mutate: func(c *Config) { c.Garage.AdminEndpoint = "" },
|
|
wantErrContains: "admin_endpoint is required",
|
|
},
|
|
{
|
|
name: "missing garage admin_token",
|
|
mutate: func(c *Config) { c.Garage.AdminToken = "" },
|
|
wantErrContains: "admin_token is required",
|
|
},
|
|
{
|
|
name: "admin auth enabled without username",
|
|
mutate: func(c *Config) {
|
|
c.Auth.Admin.Enabled = true
|
|
c.Auth.Admin.Password = "p"
|
|
},
|
|
wantErrContains: "admin auth username and password are required",
|
|
},
|
|
{
|
|
name: "admin auth enabled without password",
|
|
mutate: func(c *Config) {
|
|
c.Auth.Admin.Enabled = true
|
|
c.Auth.Admin.Username = "u"
|
|
},
|
|
wantErrContains: "admin auth username and password are required",
|
|
},
|
|
{
|
|
name: "admin auth enabled with both set is valid",
|
|
mutate: func(c *Config) {
|
|
c.Auth.Admin.Enabled = true
|
|
c.Auth.Admin.Username = "u"
|
|
c.Auth.Admin.Password = "p"
|
|
},
|
|
wantErrContains: "",
|
|
},
|
|
{
|
|
name: "admin auth disabled ignores missing credentials",
|
|
mutate: func(c *Config) {
|
|
c.Auth.Admin.Enabled = false
|
|
c.Auth.Admin.Username = ""
|
|
c.Auth.Admin.Password = ""
|
|
},
|
|
wantErrContains: "",
|
|
},
|
|
{
|
|
name: "oidc enabled without client_id",
|
|
mutate: func(c *Config) {
|
|
applyValidOIDC(c)
|
|
c.Auth.OIDC.ClientID = ""
|
|
},
|
|
wantErrContains: "oidc client_id is required",
|
|
},
|
|
{
|
|
name: "oidc enabled without issuer_url",
|
|
mutate: func(c *Config) {
|
|
applyValidOIDC(c)
|
|
c.Auth.OIDC.IssuerURL = ""
|
|
},
|
|
wantErrContains: "oidc issuer_url is required",
|
|
},
|
|
{
|
|
name: "oidc enabled without server.root_url",
|
|
mutate: func(c *Config) {
|
|
applyValidOIDC(c)
|
|
c.Server.RootURL = ""
|
|
},
|
|
wantErrContains: "server.root_url is required",
|
|
},
|
|
{
|
|
name: "oidc enabled without scopes",
|
|
mutate: func(c *Config) {
|
|
applyValidOIDC(c)
|
|
c.Auth.OIDC.Scopes = nil
|
|
},
|
|
wantErrContains: "oidc scopes are required",
|
|
},
|
|
{
|
|
name: "oidc enabled without admin_role rejected for safety",
|
|
mutate: func(c *Config) {
|
|
applyValidOIDC(c)
|
|
c.Auth.OIDC.AdminRole = ""
|
|
},
|
|
wantErrContains: "oidc admin_role is required",
|
|
},
|
|
{
|
|
name: "oidc fully configured is valid",
|
|
mutate: applyValidOIDC,
|
|
wantErrContains: "",
|
|
},
|
|
{
|
|
name: "oidc disabled ignores missing client_id",
|
|
mutate: func(c *Config) {
|
|
c.Auth.OIDC.Enabled = false
|
|
c.Auth.OIDC.ClientID = ""
|
|
},
|
|
wantErrContains: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := validBaseConfig()
|
|
tc.mutate(&cfg)
|
|
err := cfg.Validate()
|
|
|
|
if tc.wantErrContains == "" {
|
|
if err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErrContains) {
|
|
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAddress(t *testing.T) {
|
|
tests := []struct {
|
|
host string
|
|
port int
|
|
want string
|
|
}{
|
|
{"localhost", 8080, "localhost:8080"},
|
|
{"0.0.0.0", 80, "0.0.0.0:80"},
|
|
{"", 443, ":443"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.want, func(t *testing.T) {
|
|
cfg := &Config{Server: ServerConfig{Host: tc.host, Port: tc.port}}
|
|
if got := cfg.GetAddress(); got != tc.want {
|
|
t.Errorf("GetAddress() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDevelopment(t *testing.T) {
|
|
tests := []struct {
|
|
env string
|
|
want bool
|
|
}{
|
|
{"development", true},
|
|
{"production", false},
|
|
{"", false},
|
|
// Case-sensitive per current impl; lock in that behavior.
|
|
{"Development", false},
|
|
{"DEV", false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.env, func(t *testing.T) {
|
|
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
|
if got := cfg.IsDevelopment(); got != tc.want {
|
|
t.Errorf("IsDevelopment(%q) = %v, want %v", tc.env, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsProduction(t *testing.T) {
|
|
tests := []struct {
|
|
env string
|
|
want bool
|
|
}{
|
|
{"production", true},
|
|
{"development", false},
|
|
{"", false},
|
|
{"Production", false},
|
|
{"PROD", false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.env, func(t *testing.T) {
|
|
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
|
if got := cfg.IsProduction(); got != tc.want {
|
|
t.Errorf("IsProduction(%q) = %v, want %v", tc.env, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|