package config import ( "bytes" "os" "path/filepath" "strings" "testing" ) func TestSaveLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") in := &Config{} in.SetToken("http://localhost:3998", "rickub_pat_abc") if err := in.SaveTo(path); err != nil { t.Fatalf("SaveTo: %v", err) } // File must be 0600. info, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("perm = %o, want 600", perm) } out, err := LoadFrom(path) if err != nil { t.Fatalf("LoadFrom: %v", err) } if out.Host != "http://localhost:3998" { t.Errorf("host round-trip: got %q", out.Host) } if got := out.TokenFor("http://localhost:3998"); got != "rickub_pat_abc" { t.Errorf("token round-trip: got %q", got) } } func TestLoadMissingIsEmpty(t *testing.T) { out, err := LoadFrom(filepath.Join(t.TempDir(), "nope.yaml")) if err != nil { t.Fatalf("LoadFrom missing: %v", err) } if out.Host != "" || len(out.Hosts) != 0 { t.Errorf("expected empty config, got %+v", out) } } func TestResolveHostPrecedence(t *testing.T) { cfg := &Config{Host: "http://config-host"} // config only t.Setenv(EnvHost, "") if got := ResolveHost("", cfg); got != "http://config-host" { t.Errorf("config host: got %q", got) } // env overrides config t.Setenv(EnvHost, "http://env-host/") if got := ResolveHost("", cfg); got != "http://env-host" { t.Errorf("env host (trailing slash trimmed): got %q", got) } // flag overrides env if got := ResolveHost("http://flag-host", cfg); got != "http://flag-host" { t.Errorf("flag host: got %q", got) } // default when nothing set t.Setenv(EnvHost, "") if got := ResolveHost("", &Config{}); got != DefaultHost { t.Errorf("default host: got %q", got) } } func TestResolveTokenPrecedence(t *testing.T) { const host = "https://rickub.com" cfg := &Config{} cfg.SetToken(host, "cfg-token") t.Setenv(EnvToken, "") if got := ResolveToken("", cfg, host); got != "cfg-token" { t.Errorf("config token: got %q", got) } t.Setenv(EnvToken, "env-token") if got := ResolveToken("", cfg, host); got != "env-token" { t.Errorf("env token: got %q", got) } if got := ResolveToken("flag-token", cfg, host); got != "flag-token" { t.Errorf("flag token: got %q", got) } } // The stored token must never follow the host around: this is the leak the // per-host binding exists to prevent. func TestResolveTokenIsBoundToItsHost(t *testing.T) { t.Setenv(EnvToken, "") cfg := &Config{} cfg.SetToken("https://rickub.com", "rickub_pat_prod") if got := ResolveToken("", cfg, "https://evil.example"); got != "" { t.Errorf("stored token leaked to another host: got %q", got) } if got := ResolveToken("", cfg, "http://localhost:3000"); got != "" { t.Errorf("stored token leaked to a local host: got %q", got) } if got := ResolveToken("", cfg, "https://rickub.com"); got != "rickub_pat_prod" { t.Errorf("token not returned for its own host: got %q", got) } // An explicit token is the caller's own choice and works anywhere. if got := ResolveToken("explicit", cfg, "https://evil.example"); got != "explicit" { t.Errorf("explicit token: got %q", got) } t.Setenv(EnvToken, "env-token") if got := ResolveToken("", cfg, "https://evil.example"); got != "env-token" { t.Errorf("env token: got %q", got) } } func TestMultipleHostsKeepSeparateTokens(t *testing.T) { cfg := &Config{} cfg.SetToken("https://rickub.com", "prod") cfg.SetToken("http://localhost:3000/", "dev") if got := cfg.TokenFor("https://rickub.com"); got != "prod" { t.Errorf("prod token: got %q", got) } if got := cfg.TokenFor("http://localhost:3000"); got != "dev" { t.Errorf("dev token: got %q", got) } // SetToken makes the host it saved active. if cfg.Host != "http://localhost:3000" { t.Errorf("active host: got %q", cfg.Host) } // Logout only affects the named host. if !cfg.ClearToken("http://localhost:3000") { t.Error("ClearToken reported nothing removed") } if got := cfg.TokenFor("http://localhost:3000"); got != "" { t.Errorf("dev token survived logout: got %q", got) } if got := cfg.TokenFor("https://rickub.com"); got != "prod" { t.Errorf("prod token removed by dev logout: got %q", got) } if cfg.ClearToken("http://localhost:3000") { t.Error("ClearToken reported a removal on an empty host") } } func TestNormalizeHost(t *testing.T) { cases := map[string]string{ "https://RickUB.com/": "https://rickub.com", "HTTPS://rickub.com": "https://rickub.com", " https://rickub.com ": "https://rickub.com", "http://localhost:3000": "http://localhost:3000", "": "", } for in, want := range cases { if got := NormalizeHost(in); got != want { t.Errorf("NormalizeHost(%q) = %q, want %q", in, got, want) } } } func TestInsecureHostDetection(t *testing.T) { insecure := []string{"http://evil.example", "http://192.0.2.10:3000", "HTTP://Evil.Example/"} for _, h := range insecure { if !IsInsecureHost(h) { t.Errorf("IsInsecureHost(%q) = false, want true", h) } } secure := []string{ "https://rickub.com", "http://localhost:3000", "http://127.0.0.1:3000", "http://[::1]:3000", "", } for _, h := range secure { if IsInsecureHost(h) { t.Errorf("IsInsecureHost(%q) = true, want false", h) } } } func TestWarnIfInsecure(t *testing.T) { var buf bytes.Buffer if !WarnIfInsecure(&buf, "http://evil.example") { t.Fatal("expected a warning for a plain-HTTP remote host") } if !strings.Contains(buf.String(), "evil.example") { t.Errorf("warning does not name the host: %q", buf.String()) } buf.Reset() if WarnIfInsecure(&buf, "http://localhost:3000") { t.Error("warned about loopback") } if buf.Len() != 0 { t.Errorf("unexpected output: %q", buf.String()) } }