Skip to content

Commit 912d97f

Browse files
authored
Merge pull request #19 from OctopusDeploy/mattr/fix-config-parsing
Fix config parsing of bools
2 parents 94dd030 + 10278c0 commit 912d97f

File tree

4 files changed

+327
-107
lines changed

4 files changed

+327
-107
lines changed

config.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/spf13/pflag"
9+
"github.com/spf13/viper"
10+
)
11+
12+
type Config struct {
13+
URL string
14+
APIKey string
15+
Name string
16+
Version string
17+
Parent string
18+
Tags string
19+
SBOM string
20+
Poll bool
21+
Latest bool
22+
}
23+
24+
func (c *Config) validate() error {
25+
if c.URL == "" {
26+
return fmt.Errorf("missing required input: url (via --url or SBOM_UPLOADER_URL)")
27+
}
28+
if c.APIKey == "" {
29+
return fmt.Errorf("missing required input: api-key (via --api-key or SBOM_UPLOADER_API_KEY)")
30+
}
31+
if c.Name == "" {
32+
return fmt.Errorf("missing required input: name (via --name or SBOM_UPLOADER_NAME)")
33+
}
34+
if c.Parent == "" {
35+
return fmt.Errorf("missing required input: parent (via --parent or SBOM_UPLOADER_PARENT)")
36+
}
37+
if c.Version == "" {
38+
return fmt.Errorf("missing required input: version (via --version or SBOM_UPLOADER_VERSION)")
39+
}
40+
return nil
41+
}
42+
43+
// loadConfig resolves configuration from flags and environment variables.
44+
// Flags take precedence over env vars; env vars take precedence over defaults.
45+
//
46+
// AutomaticEnv + BindPFlags has a known issue where bool flags with a false
47+
// default have their pflag default shadow the env var. We work around it with
48+
// explicit BindEnv calls for bool flags.
49+
// See https://github.com/spf13/viper/issues/671
50+
func loadConfig(flags *pflag.FlagSet) (*Config, error) {
51+
v := viper.New()
52+
v.SetEnvPrefix("SBOM_UPLOADER")
53+
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
54+
v.AutomaticEnv()
55+
if err := errors.Join(
56+
v.BindPFlags(flags),
57+
v.BindEnv("poll", "SBOM_UPLOADER_POLL"),
58+
v.BindEnv("latest", "SBOM_UPLOADER_LATEST"),
59+
); err != nil {
60+
return nil, err
61+
}
62+
return &Config{
63+
URL: v.GetString("url"),
64+
APIKey: v.GetString("api-key"),
65+
Name: v.GetString("name"),
66+
Version: v.GetString("version"),
67+
Parent: v.GetString("parent"),
68+
Tags: v.GetString("tags"),
69+
SBOM: v.GetString("sbom"),
70+
Poll: v.GetBool("poll"),
71+
Latest: v.GetBool("latest"),
72+
}, nil
73+
}
74+
75+
func setFlags(s *pflag.FlagSet) {
76+
s.String("url", "", "Dependency-Track API base URL or env SBOM_UPLOADER_URL")
77+
s.String("api-key", "", "Dependency-Track API key or env SBOM_UPLOADER_API_KEY")
78+
s.String("name", "", "Project name or env SBOM_UPLOADER_NAME")
79+
s.String("version", "", "Project version or env SBOM_UPLOADER_VERSION")
80+
s.String("parent", "", "Parent project name or env SBOM_UPLOADER_PARENT")
81+
s.Bool("latest", true, "Mark as latest version (default true)")
82+
s.Bool("poll", false, "Poll until import completes or env SBOM_UPLOADER_POLL")
83+
s.String("tags", "", "Comma-separated project tags or env SBOM_UPLOADER_TAGS")
84+
s.String("sbom", "", "Path to SBOM file (optional; otherwise read from stdin)")
85+
}

config_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/spf13/pflag"
8+
)
9+
10+
// newFlagSet returns a FlagSet populated with the same flags as the real CLI.
11+
func newFlagSet() *pflag.FlagSet {
12+
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
13+
setFlags(flags)
14+
return flags
15+
}
16+
17+
func TestLoadConfig_StringsFromEnvVars(t *testing.T) {
18+
t.Setenv("SBOM_UPLOADER_URL", "https://example.com")
19+
t.Setenv("SBOM_UPLOADER_API_KEY", "my-api-key")
20+
t.Setenv("SBOM_UPLOADER_NAME", "my-project")
21+
t.Setenv("SBOM_UPLOADER_VERSION", "1.2.3")
22+
t.Setenv("SBOM_UPLOADER_PARENT", "my-parent")
23+
t.Setenv("SBOM_UPLOADER_TAGS", "tag1,tag2")
24+
t.Setenv("SBOM_UPLOADER_SBOM", "/path/to/sbom.json")
25+
26+
cfg, err := loadConfig(newFlagSet())
27+
if err != nil {
28+
t.Fatalf("unexpected error: %v", err)
29+
}
30+
31+
if cfg.URL != "https://example.com" {
32+
t.Errorf("URL: got %q, want %q", cfg.URL, "https://example.com")
33+
}
34+
if cfg.APIKey != "my-api-key" {
35+
t.Errorf("APIKey: got %q, want %q", cfg.APIKey, "my-api-key")
36+
}
37+
if cfg.Name != "my-project" {
38+
t.Errorf("Name: got %q, want %q", cfg.Name, "my-project")
39+
}
40+
if cfg.Version != "1.2.3" {
41+
t.Errorf("Version: got %q, want %q", cfg.Version, "1.2.3")
42+
}
43+
if cfg.Parent != "my-parent" {
44+
t.Errorf("Parent: got %q, want %q", cfg.Parent, "my-parent")
45+
}
46+
if cfg.Tags != "tag1,tag2" {
47+
t.Errorf("Tags: got %q, want %q", cfg.Tags, "tag1,tag2")
48+
}
49+
if cfg.SBOM != "/path/to/sbom.json" {
50+
t.Errorf("SBOM: got %q, want %q", cfg.SBOM, "/path/to/sbom.json")
51+
}
52+
}
53+
54+
func TestLoadConfig_PollFromEnvVar(t *testing.T) {
55+
t.Setenv("SBOM_UPLOADER_POLL", "true")
56+
57+
cfg, err := loadConfig(newFlagSet())
58+
if err != nil {
59+
t.Fatalf("unexpected error: %v", err)
60+
}
61+
if !cfg.Poll {
62+
t.Error("Poll: expected true from SBOM_UPLOADER_POLL env var, got false")
63+
}
64+
}
65+
66+
func TestLoadConfig_LatestFromEnvVar(t *testing.T) {
67+
t.Setenv("SBOM_UPLOADER_LATEST", "false")
68+
69+
cfg, err := loadConfig(newFlagSet())
70+
if err != nil {
71+
t.Fatalf("unexpected error: %v", err)
72+
}
73+
if cfg.Latest {
74+
t.Error("Latest: expected false from SBOM_UPLOADER_LATEST env var, got true")
75+
}
76+
}
77+
78+
func TestLoadConfig_FromFlags(t *testing.T) {
79+
flags := newFlagSet()
80+
err := flags.Parse([]string{
81+
"--url", "https://from-flag.com",
82+
"--api-key", "flag-key",
83+
"--name", "flag-project",
84+
"--version", "2.0.0",
85+
"--parent", "flag-parent",
86+
"--tags", "a,b",
87+
"--sbom", "/flag/sbom.json",
88+
"--poll",
89+
"--latest=false",
90+
})
91+
if err != nil {
92+
t.Fatalf("failed to parse flags: %v", err)
93+
}
94+
95+
cfg, err := loadConfig(flags)
96+
if err != nil {
97+
t.Fatalf("unexpected error: %v", err)
98+
}
99+
100+
if cfg.URL != "https://from-flag.com" {
101+
t.Errorf("URL: got %q, want %q", cfg.URL, "https://from-flag.com")
102+
}
103+
if cfg.APIKey != "flag-key" {
104+
t.Errorf("APIKey: got %q, want %q", cfg.APIKey, "flag-key")
105+
}
106+
if cfg.Name != "flag-project" {
107+
t.Errorf("Name: got %q, want %q", cfg.Name, "flag-project")
108+
}
109+
if cfg.Version != "2.0.0" {
110+
t.Errorf("Version: got %q, want %q", cfg.Version, "2.0.0")
111+
}
112+
if cfg.Parent != "flag-parent" {
113+
t.Errorf("Parent: got %q, want %q", cfg.Parent, "flag-parent")
114+
}
115+
if cfg.Tags != "a,b" {
116+
t.Errorf("Tags: got %q, want %q", cfg.Tags, "a,b")
117+
}
118+
if cfg.SBOM != "/flag/sbom.json" {
119+
t.Errorf("SBOM: got %q, want %q", cfg.SBOM, "/flag/sbom.json")
120+
}
121+
if !cfg.Poll {
122+
t.Error("Poll: expected true from --poll flag, got false")
123+
}
124+
if cfg.Latest {
125+
t.Error("Latest: expected false from --latest=false flag, got true")
126+
}
127+
}
128+
129+
func TestLoadConfig_FlagOverridesEnvVar(t *testing.T) {
130+
t.Setenv("SBOM_UPLOADER_URL", "https://from-env.com")
131+
132+
flags := newFlagSet()
133+
if err := flags.Parse([]string{"--url", "https://from-flag.com"}); err != nil {
134+
t.Fatalf("failed to parse flags: %v", err)
135+
}
136+
137+
cfg, err := loadConfig(flags)
138+
if err != nil {
139+
t.Fatalf("unexpected error: %v", err)
140+
}
141+
if cfg.URL != "https://from-flag.com" {
142+
t.Errorf("URL: expected flag to override env var, got %q", cfg.URL)
143+
}
144+
}
145+
146+
func TestLoadConfig_Defaults(t *testing.T) {
147+
cfg, err := loadConfig(newFlagSet())
148+
if err != nil {
149+
t.Fatalf("unexpected error: %v", err)
150+
}
151+
if cfg.Poll {
152+
t.Error("Poll: expected false by default, got true")
153+
}
154+
if !cfg.Latest {
155+
t.Error("Latest: expected true by default, got false")
156+
}
157+
}
158+
159+
// --- Config.validate ---
160+
161+
func validConfig() *Config {
162+
return &Config{
163+
URL: "https://example.com",
164+
APIKey: "key",
165+
Name: "project",
166+
Parent: "parent",
167+
Version: "1.0.0",
168+
}
169+
}
170+
171+
func TestValidate_PassesWithAllRequiredFields(t *testing.T) {
172+
if err := validConfig().validate(); err != nil {
173+
t.Errorf("expected no error, got: %v", err)
174+
}
175+
}
176+
177+
func TestValidate_RequiredFields(t *testing.T) {
178+
tests := []struct {
179+
field string
180+
mutate func(*Config)
181+
wantMsg string
182+
}{
183+
{"URL", func(c *Config) { c.URL = "" }, "url"},
184+
{"APIKey", func(c *Config) { c.APIKey = "" }, "api-key"},
185+
{"Name", func(c *Config) { c.Name = "" }, "name"},
186+
{"Parent", func(c *Config) { c.Parent = "" }, "parent"},
187+
{"Version", func(c *Config) { c.Version = "" }, "version"},
188+
}
189+
190+
for _, tt := range tests {
191+
t.Run(tt.field, func(t *testing.T) {
192+
cfg := validConfig()
193+
tt.mutate(cfg)
194+
err := cfg.validate()
195+
if err == nil {
196+
t.Fatalf("expected error for missing %s, got nil", tt.field)
197+
}
198+
if !strings.Contains(err.Error(), tt.wantMsg) {
199+
t.Errorf("error %q does not mention %q", err.Error(), tt.wantMsg)
200+
}
201+
})
202+
}
203+
}

0 commit comments

Comments
 (0)