Repository navigation
Expand file tree
/
Copy pathmap_test.go
More file actions
323 lines (302 loc) · 9.95 KB
/
Copy pathmap_test.go
File metadata and controls
323 lines (302 loc) · 9.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
// Tests for CONFIG_SPEC §7.x — map fields.
//
// A map exists for the one case every other field type cannot serve: the KEY
// NAMES are not known when the struct is written. Feature flags, per-tenant
// limits and arbitrary extra headers are all "add an entry by editing .env, not
// by editing Go". Everything else should stay a named field, which is typed and
// where a typo is a compile error.
package cfgkit_test
import (
"strings"
"testing"
"time"
"github.com/ubgo/cfgkit"
)
type mapCfg struct {
Flags map[string]string `env:"FLAGS"`
Limits map[string]int `env:"LIMITS"`
Timeout map[string]time.Duration `env:"TIMEOUTS"`
}
func TestMapDecodesEntries(t *testing.T) {
cfg, _, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": "new-checkout:on,dark-mode:off"}),
))
if err != nil {
t.Fatal(err)
}
if len(cfg.Flags) != 2 || cfg.Flags["new-checkout"] != "on" || cfg.Flags["dark-mode"] != "off" {
t.Errorf("Flags = %#v, want both entries", cfg.Flags)
}
}
// TestMapDecodesValueTypes pins that a map value goes through the SAME decoder
// every other field uses. That is the whole reason maps needed no type zoo:
// int, Duration and anything with an UnmarshalText work for free.
func TestMapDecodesValueTypes(t *testing.T) {
cfg, _, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{
"LIMITS": "acme:1000,globex:500",
"TIMEOUTS": "read:30s,write:1m",
}),
))
if err != nil {
t.Fatal(err)
}
if cfg.Limits["acme"] != 1000 || cfg.Limits["globex"] != 500 {
t.Errorf("Limits = %#v", cfg.Limits)
}
if cfg.Timeout["read"] != 30*time.Second || cfg.Timeout["write"] != time.Minute {
t.Errorf("Timeout = %#v", cfg.Timeout)
}
}
// TestMapSplitsOnTheFirstSeparatorOnly is THE test for this feature.
//
// A map value is very often a URL or a host:port, so most entries contain more
// colons than the one that separates key from value. Splitting on every colon
// would corrupt every connection string — the most common thing a map holds.
func TestMapSplitsOnTheFirstSeparatorOnly(t *testing.T) {
type dsnCfg struct {
DSNs map[string]string `env:"DSNS"`
}
cfg, _, err := cfgkit.Load[dsnCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{
"DSNS": "primary:postgres://user@db1:5432/app,cache:redis://cache:6379",
}),
))
if err != nil {
t.Fatal(err)
}
if got, want := cfg.DSNs["primary"], "postgres://user@db1:5432/app"; got != want {
t.Errorf("primary = %q, want %q — only the FIRST colon separates", got, want)
}
if got, want := cfg.DSNs["cache"], "redis://cache:6379"; got != want {
t.Errorf("cache = %q, want %q", got, want)
}
}
// TestMapEmptyValueIsAnEmptyMap mirrors the slice rule: "FLAGS=" means no
// flags, never one flag with an empty name. Combined with §4.4, it is how a
// deployment clears a defaulted map.
func TestMapEmptyValueIsAnEmptyMap(t *testing.T) {
type defaulted struct {
Flags map[string]string `env:"FLAGS" default:"a:1,b:2"`
}
cfg, _, err := cfgkit.Load[defaulted](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": ""}),
))
if err != nil {
t.Fatal(err)
}
if cfg.Flags == nil {
t.Fatal("Flags = nil, want an allocated empty map — an empty map is a value")
}
if len(cfg.Flags) != 0 {
t.Errorf("Flags = %#v, want empty — a source wins over a default", cfg.Flags)
}
}
// TestMapAbsentStaysNil pins the other half: with nothing set, the field keeps
// its zero value, which for a map is nil. Callers can therefore distinguish
// "configured to hold nothing" (empty, non-nil) from "never configured" (nil).
func TestMapAbsentStaysNil(t *testing.T) {
cfg, _, err := cfgkit.Load[mapCfg]()
if err != nil {
t.Fatal(err)
}
if cfg.Flags != nil {
t.Errorf("Flags = %#v, want nil when no source and no default set it", cfg.Flags)
}
}
func TestMapDefaultTag(t *testing.T) {
type defaulted struct {
Flags map[string]string `env:"FLAGS" default:"beta:on"`
}
cfg, _, err := cfgkit.Load[defaulted]()
if err != nil {
t.Fatal(err)
}
if cfg.Flags["beta"] != "on" {
t.Errorf("Flags = %#v, want the default parsed", cfg.Flags)
}
}
// TestMapCustomDelimiters covers the escape hatch for values that contain the
// default separators — the reason both are tags rather than constants.
func TestMapCustomDelimiters(t *testing.T) {
type custom struct {
Rules map[string]string `env:"RULES" delim:";" kvdelim:"="`
}
cfg, _, err := cfgkit.Load[custom](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"RULES": "a=1,2,3;b=4"}),
))
if err != nil {
t.Fatal(err)
}
if cfg.Rules["a"] != "1,2,3" {
t.Errorf("Rules[a] = %q, want the commas kept as part of the value", cfg.Rules["a"])
}
if cfg.Rules["b"] != "4" {
t.Errorf("Rules[b] = %q", cfg.Rules["b"])
}
}
// TestMapMalformedEntryNamesTheProblem pins that the error is actionable. The
// usual cause is a missing separator, and a bare "invalid map" would send the
// reader hunting through a long line.
func TestMapMalformedEntryNamesTheProblem(t *testing.T) {
err := cfgkit.Check[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": "ok:yes,broken"}),
))
if err == nil {
t.Fatal("want an error for an entry with no separator")
}
msg := err.Error()
for _, want := range []string{"Flags", "FLAGS", "broken", `":"`} {
if !strings.Contains(msg, want) {
t.Errorf("error %q does not mention %s", msg, want)
}
}
}
func TestMapBadValueTypeNamesTheKey(t *testing.T) {
err := cfgkit.Check[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"LIMITS": "acme:lots"}),
))
if err == nil {
t.Fatal("want an error for a non-numeric value")
}
if !strings.Contains(err.Error(), "acme") {
t.Errorf("error %q must name the offending map key", err)
}
}
// TestMapDuplicateKeyTakesTheLast matches how the source chain itself resolves
// a repeat: the last statement wins.
func TestMapDuplicateKeyTakesTheLast(t *testing.T) {
cfg, _, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": "a:first,a:second"}),
))
if err != nil {
t.Fatal(err)
}
if cfg.Flags["a"] != "second" {
t.Errorf("Flags[a] = %q, want the last entry", cfg.Flags["a"])
}
}
// TestMapRendersDeterministically is why Explain and Document sort map entries:
// Go randomises map iteration, so unsorted output would make the "contract is
// stale" CI check in recipes.md fail at random.
func TestMapRendersDeterministically(t *testing.T) {
const runs = 20
var first string
for i := range runs {
_, res, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": "z:1,a:2,m:3,b:4,y:5"}),
))
if err != nil {
t.Fatal(err)
}
var got string
for _, f := range res.Fields() {
if f.Path == "Flags" {
got = f.Value
}
}
if i == 0 {
first = got
if want := "a:2,b:4,m:3,y:5,z:1"; got != want {
t.Fatalf("rendered %q, want %q — sorted by key", got, want)
}
continue
}
if got != first {
t.Fatalf("run %d rendered %q, run 0 rendered %q — output must not depend on map order", i, got, first)
}
}
}
// TestMapRoundTripsThroughItsOwnFormat is the property that keeps Document's
// output usable: what cfgkit prints must be what cfgkit can read back.
func TestMapRoundTripsThroughItsOwnFormat(t *testing.T) {
const raw = "a:1,b:2,c:3"
_, res, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": raw}),
))
if err != nil {
t.Fatal(err)
}
var rendered string
for _, f := range res.Fields() {
if f.Path == "Flags" {
rendered = f.Value
}
}
cfg2, _, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": rendered}),
))
if err != nil {
t.Fatalf("re-loading rendered output %q failed: %v", rendered, err)
}
if len(cfg2.Flags) != 3 || cfg2.Flags["b"] != "2" {
t.Errorf("round trip lost data: %#v", cfg2.Flags)
}
}
// TestMapIsNotWalkedInto pins that the walker treats a map as a LEAF. If it
// descended, a map field would contribute no key at all and would bind from
// nothing — silently empty in production.
func TestMapIsNotWalkedInto(t *testing.T) {
_, res, err := cfgkit.Load[mapCfg](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"FLAGS": "a:1"}),
))
if err != nil {
t.Fatal(err)
}
var found bool
for _, f := range res.Fields() {
if f.Path == "Flags" {
found = true
if f.Key != "FLAGS" {
t.Errorf("Flags key = %q, want FLAGS", f.Key)
}
}
}
if !found {
t.Error("Flags did not appear in the provenance record")
}
}
// TestMapSecretIsMasked pins that a map of credentials is masked whole. A map
// leaks in exactly the way a scalar does, and masking per entry would still
// reveal the key names, which are often enough to identify an account.
func TestMapSecretIsMasked(t *testing.T) {
type creds struct {
Keys map[string]string `env:"API_KEYS" secret:"true"`
}
_, res, err := cfgkit.Load[creds](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"API_KEYS": "stripe:sk_live_abc,aws:AKIAsecret"}),
))
if err != nil {
t.Fatal(err)
}
for _, f := range res.Fields() {
if strings.Contains(f.Value, "sk_live_abc") || strings.Contains(f.Value, "stripe") {
t.Errorf("secret map rendered as %q — neither values nor key names may leak", f.Value)
}
}
}
// TestMapBadKeyTypeIsReported covers the non-string key. Keys go through the
// same decoder as values, so map[int]string works — and fails honestly.
func TestMapBadKeyTypeIsReported(t *testing.T) {
type shards struct {
Hosts map[int]string `env:"SHARDS"`
}
cfg, _, err := cfgkit.Load[shards](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"SHARDS": "0:db-a,1:db-b"}),
))
if err != nil {
t.Fatal(err)
}
if cfg.Hosts[0] != "db-a" || cfg.Hosts[1] != "db-b" {
t.Errorf("Hosts = %#v, want integer keys decoded", cfg.Hosts)
}
err = cfgkit.Check[shards](cfgkit.WithSources(
cfgkit.FromMap(map[string]string{"SHARDS": "primary:db-a"}),
))
if err == nil {
t.Fatal("want an error for a non-integer key")
}
if !strings.Contains(err.Error(), "key") {
t.Errorf("error %q must say the KEY is the problem, not the value", err)
}
}