feat(config): flag + env parsing with validation (forgejo-mcp-broker-9nq)

Implements internal/config. Flags override env; empty env values are treated
as unset so exported-but-empty vars don't clobber defaults. Validation
aggregates every problem via errors.Join so operators see the full list
at once, not one at a time.

Notable decisions:
- Issuer URL validation requires http/https and rejects plain http on
  non-loopback hosts (classic OAuth misconfig). Loopback http allowed so
  local dev doesn't need TLS.
- Store-path writability probed via os.CreateTemp in the parent dir — a
  real write test, not a string check. No side effects on the store file
  itself (that's the storage layer's job).
- Public API: Load(args, out) returns *Config or error. flag.ErrHelp is
  passed through so callers can exit 0 on -h.

Tests: 94.1% line coverage, covers env/flag precedence, empty-as-unset,
every required-field message, each URL failure mode, numeric bounds,
env-parse errors, and -help output.

Closes forgejo-mcp-broker-9nq.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-24 17:10:18 +02:00
commit 382356c61c
4 changed files with 593 additions and 6 deletions

View file

@ -0,0 +1,335 @@
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)
}
}
}