package config_test import ( "bytes" "errors" "flag" "io" "path/filepath" "strings" "testing" "time" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/config" ) // envVars enumerates every environment variable read by the config package. // Tests clear all of these to isolate from the ambient shell environment. var envVars = []string{ "FJMCP_BROKER_PUBLIC_URL", "FJMCP_BROKER_LISTEN", "FORGEJO_URL", "FORGEJO_OAUTH_CLIENT_ID", "FORGEJO_OAUTH_CLIENT_SECRET", "FORGEJO_OAUTH_SCOPES", "FJMCP_BROKER_MCP_BINARY", "FJMCP_BROKER_STORE", "FJMCP_BROKER_MAX_SESSIONS", "FJMCP_BROKER_IDLE_TIMEOUT", "FJMCP_BROKER_DEBUG", } // clearEnv wipes every known env var for the test's lifetime. Setting to "" // is sufficient because the config treats empty values as unset. func clearEnv(t *testing.T) { t.Helper() for _, k := range envVars { t.Setenv(k, "") } } // validBaseArgs returns the minimum required flag set, plus a writable // store path pointing inside a per-test TempDir. func validBaseArgs(t *testing.T) []string { t.Helper() storePath := filepath.Join(t.TempDir(), "broker.db") return []string{ "--public-url", "https://mcp.example.com", "--forgejo-url", "https://forgejo.example.com", "--forgejo-oauth-client-id", "client-id", "--forgejo-oauth-client-secret", "client-secret", "--store-path", storePath, } } func TestLoad_HappyPath_Defaults(t *testing.T) { clearEnv(t) cfg, err := config.Load(validBaseArgs(t), io.Discard) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Listen != config.DefaultListen { t.Errorf("Listen = %q, want %q", cfg.Listen, config.DefaultListen) } if cfg.ForgejoOAuthScopes != config.DefaultForgejoOAuthScopes { t.Errorf("ForgejoOAuthScopes = %q, want %q", cfg.ForgejoOAuthScopes, config.DefaultForgejoOAuthScopes) } if cfg.ForgejoMCPBinary != config.DefaultForgejoMCPBinary { t.Errorf("ForgejoMCPBinary = %q, want %q", cfg.ForgejoMCPBinary, config.DefaultForgejoMCPBinary) } if cfg.MaxSessions != config.DefaultMaxSessions { t.Errorf("MaxSessions = %d, want %d", cfg.MaxSessions, config.DefaultMaxSessions) } if cfg.SessionIdleTimeout != config.DefaultSessionIdleTimeout { t.Errorf("SessionIdleTimeout = %s, want %s", cfg.SessionIdleTimeout, config.DefaultSessionIdleTimeout) } if cfg.Debug { t.Error("Debug = true, want false by default") } } func TestLoad_Env_PopulatesAllFields(t *testing.T) { clearEnv(t) storePath := filepath.Join(t.TempDir(), "broker.db") t.Setenv("FJMCP_BROKER_PUBLIC_URL", "https://mcp.example.com") t.Setenv("FJMCP_BROKER_LISTEN", ":9090") t.Setenv("FORGEJO_URL", "https://forgejo.example.com") t.Setenv("FORGEJO_OAUTH_CLIENT_ID", "envcid") t.Setenv("FORGEJO_OAUTH_CLIENT_SECRET", "envcs") t.Setenv("FORGEJO_OAUTH_SCOPES", "read:user") t.Setenv("FJMCP_BROKER_MCP_BINARY", "/opt/forgejo-mcp") t.Setenv("FJMCP_BROKER_STORE", storePath) t.Setenv("FJMCP_BROKER_MAX_SESSIONS", "42") t.Setenv("FJMCP_BROKER_IDLE_TIMEOUT", "5m") t.Setenv("FJMCP_BROKER_DEBUG", "true") cfg, err := config.Load(nil, io.Discard) if err != nil { t.Fatalf("unexpected error: %v", err) } want := &config.Config{ PublicURL: "https://mcp.example.com", Listen: ":9090", ForgejoURL: "https://forgejo.example.com", ForgejoOAuthClientID: "envcid", ForgejoOAuthClientSecret: "envcs", ForgejoOAuthScopes: "read:user", ForgejoMCPBinary: "/opt/forgejo-mcp", StorePath: storePath, MaxSessions: 42, SessionIdleTimeout: 5 * time.Minute, Debug: true, } if *cfg != *want { t.Errorf("cfg mismatch\n got: %+v\nwant: %+v", *cfg, *want) } } func TestLoad_FlagBeatsEnv(t *testing.T) { clearEnv(t) storePath := filepath.Join(t.TempDir(), "broker.db") t.Setenv("FJMCP_BROKER_PUBLIC_URL", "https://env.example.com") t.Setenv("FORGEJO_URL", "https://env-forgejo.example.com") t.Setenv("FORGEJO_OAUTH_CLIENT_ID", "env-cid") t.Setenv("FORGEJO_OAUTH_CLIENT_SECRET", "env-cs") t.Setenv("FJMCP_BROKER_STORE", storePath) t.Setenv("FJMCP_BROKER_MAX_SESSIONS", "10") cfg, err := config.Load([]string{ "--public-url", "https://flag.example.com", "--max-sessions", "77", }, io.Discard) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.PublicURL != "https://flag.example.com" { t.Errorf("PublicURL = %q, want flag value", cfg.PublicURL) } if cfg.MaxSessions != 77 { t.Errorf("MaxSessions = %d, want 77", cfg.MaxSessions) } // Unoverridden fields should still come from env: if cfg.ForgejoOAuthClientID != "env-cid" { t.Errorf("ForgejoOAuthClientID = %q, want env-cid", cfg.ForgejoOAuthClientID) } } func TestLoad_EmptyEnvTreatedAsUnset(t *testing.T) { // An exported-but-empty variable must not clobber the default. This matches // the shell idiom where `export FOO=` is effectively a reset. clearEnv(t) t.Setenv("FJMCP_BROKER_LISTEN", "") // empty => use default cfg, err := config.Load(validBaseArgs(t), io.Discard) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Listen != config.DefaultListen { t.Errorf("Listen = %q, want default %q", cfg.Listen, config.DefaultListen) } } func TestLoad_MissingRequired_ListsAllFields(t *testing.T) { clearEnv(t) _, err := config.Load(nil, io.Discard) if err == nil { t.Fatal("expected error, got nil") } msg := err.Error() for _, want := range []string{ "public-url", "forgejo-url", "forgejo-oauth-client-id", "forgejo-oauth-client-secret", } { if !strings.Contains(msg, want) { t.Errorf("error message missing %q: %s", want, msg) } } } func TestLoad_InvalidIssuerURL(t *testing.T) { cases := []struct { name string url string want string }{ {"bad_scheme", "ftp://example.com", "scheme must be http or https"}, {"http_non_loopback", "http://example.com", "http scheme only allowed for loopback"}, {"no_host", "http:///path", "missing host"}, {"no_scheme", "example.com", "scheme must be http or https"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { clearEnv(t) args := validBaseArgs(t) // Replace the public-url value in args: for i, a := range args { if a == "--public-url" && i+1 < len(args) { args[i+1] = tc.url } } _, err := config.Load(args, io.Discard) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want error containing %q, got %v", tc.want, err) } }) } } func TestLoad_IssuerURL_LoopbackHTTPAllowed(t *testing.T) { // Local development should not require a TLS cert. Validate that the // loopback escape hatch works for the three canonical forms. for _, host := range []string{"http://localhost:8080", "http://127.0.0.1", "http://[::1]:9000"} { t.Run(host, func(t *testing.T) { clearEnv(t) args := validBaseArgs(t) for i, a := range args { if a == "--public-url" && i+1 < len(args) { args[i+1] = host } } if _, err := config.Load(args, io.Discard); err != nil { t.Errorf("loopback http should be allowed, got: %v", err) } }) } } func TestLoad_InvalidStorePath(t *testing.T) { t.Run("empty", func(t *testing.T) { clearEnv(t) args := validBaseArgs(t) for i, a := range args { if a == "--store-path" && i+1 < len(args) { args[i+1] = "" } } _, err := config.Load(args, io.Discard) if err == nil || !strings.Contains(err.Error(), "store-path") { t.Errorf("want store-path error, got %v", err) } }) t.Run("nonexistent_parent", func(t *testing.T) { clearEnv(t) args := validBaseArgs(t) bogus := filepath.Join(t.TempDir(), "definitely", "not", "here", "broker.db") for i, a := range args { if a == "--store-path" && i+1 < len(args) { args[i+1] = bogus } } _, err := config.Load(args, io.Discard) if err == nil || !strings.Contains(err.Error(), "store-path") { t.Errorf("want store-path error, got %v", err) } }) } func TestLoad_NumericBounds(t *testing.T) { cases := []struct { name string args []string want string }{ {"max_sessions_zero", []string{"--max-sessions", "0"}, "max-sessions must be > 0"}, {"max_sessions_negative", []string{"--max-sessions", "-5"}, "max-sessions must be > 0"}, {"idle_timeout_zero", []string{"--session-idle-timeout", "0"}, "session-idle-timeout must be > 0"}, {"idle_timeout_negative", []string{"--session-idle-timeout", "-10s"}, "session-idle-timeout must be > 0"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { clearEnv(t) args := append(validBaseArgs(t), tc.args...) _, err := config.Load(args, io.Discard) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want %q, got %v", tc.want, err) } }) } } func TestLoad_BadEnvParse(t *testing.T) { cases := []struct { envKey string value string want string }{ {"FJMCP_BROKER_MAX_SESSIONS", "not-a-number", "FJMCP_BROKER_MAX_SESSIONS"}, {"FJMCP_BROKER_IDLE_TIMEOUT", "forever", "FJMCP_BROKER_IDLE_TIMEOUT"}, {"FJMCP_BROKER_DEBUG", "maybe", "FJMCP_BROKER_DEBUG"}, } for _, tc := range cases { t.Run(tc.envKey, func(t *testing.T) { clearEnv(t) t.Setenv(tc.envKey, tc.value) _, err := config.Load(validBaseArgs(t), io.Discard) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want error containing %q, got %v", tc.want, err) } }) } } func TestLoad_Help_ListsAllFlagsAndEnvVars(t *testing.T) { clearEnv(t) var buf bytes.Buffer _, err := config.Load([]string{"-help"}, &buf) if !errors.Is(err, flag.ErrHelp) { t.Fatalf("want flag.ErrHelp, got %v", err) } out := buf.String() wantFlags := []string{ "-public-url", "-listen", "-forgejo-url", "-forgejo-oauth-client-id", "-forgejo-oauth-client-secret", "-forgejo-oauth-scopes", "-forgejo-mcp-binary", "-store-path", "-max-sessions", "-session-idle-timeout", "-debug", } for _, f := range wantFlags { if !strings.Contains(out, f) { t.Errorf("help missing flag %q", f) } } wantEnv := []string{ "FJMCP_BROKER_PUBLIC_URL", "FORGEJO_URL", "FORGEJO_OAUTH_CLIENT_ID", "FORGEJO_OAUTH_CLIENT_SECRET", "FJMCP_BROKER_STORE", "FJMCP_BROKER_IDLE_TIMEOUT", } for _, e := range wantEnv { if !strings.Contains(out, e) { t.Errorf("help missing env var %q", e) } } }