master
go 498 lines 14.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package secretresolver
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "os"
10 "path/filepath"
11 "runtime"
12 "testing"
13
14 "github.com/netdata/netdata/go/plugins/logger"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
18
19 func TestResolve(t *testing.T) {
20 tests := map[string]struct {
21 buildCfg func(t *testing.T) map[string]any
22 wantErrContains string
23 assertCfg func(t *testing.T, cfg map[string]any)
24 }{
25 "env ref": {
26 buildCfg: func(t *testing.T) map[string]any {
27 t.Setenv("TEST_SECRET_USER", "admin")
28 return map[string]any{"username": "${env:TEST_SECRET_USER}"}
29 },
30 assertCfg: func(t *testing.T, cfg map[string]any) {
31 assert.Equal(t, "admin", cfg["username"])
32 },
33 },
34 "env ref trims surrounding whitespace": {
35 buildCfg: func(t *testing.T) map[string]any {
36 t.Setenv("TEST_SECRET_TRIMMED", " admin \n")
37 return map[string]any{"username": "${env:TEST_SECRET_TRIMMED}"}
38 },
39 assertCfg: func(t *testing.T, cfg map[string]any) {
40 assert.Equal(t, "admin", cfg["username"])
41 },
42 },
43 "file ref": {
44 buildCfg: func(t *testing.T) map[string]any {
45 path := filepath.Join(t.TempDir(), "secret.txt")
46 require.NoError(t, os.WriteFile(path, []byte(" s3cret\n"), 0600))
47 return map[string]any{"password": "${file:" + path + "}"}
48 },
49 assertCfg: func(t *testing.T, cfg map[string]any) {
50 assert.Equal(t, "s3cret", cfg["password"])
51 },
52 },
53 "nested maps": {
54 buildCfg: func(t *testing.T) map[string]any {
55 t.Setenv("TEST_SECRET_HOST", "db.local")
56 return map[string]any{
57 "database": map[string]any{
58 "host": "${env:TEST_SECRET_HOST}",
59 "port": 5432,
60 },
61 }
62 },
63 assertCfg: func(t *testing.T, cfg map[string]any) {
64 inner := cfg["database"].(map[string]any)
65 assert.Equal(t, "db.local", inner["host"])
66 assert.Equal(t, 5432, inner["port"])
67 },
68 },
69 "map any any": {
70 buildCfg: func(t *testing.T) map[string]any {
71 t.Setenv("TEST_SECRET_LABEL", "prod")
72 return map[string]any{
73 "labels": map[any]any{"env": "${env:TEST_SECRET_LABEL}"},
74 }
75 },
76 assertCfg: func(t *testing.T, cfg map[string]any) {
77 labels := cfg["labels"].(map[any]any)
78 assert.Equal(t, "prod", labels["env"])
79 },
80 },
81 "array with strings": {
82 buildCfg: func(t *testing.T) map[string]any {
83 t.Setenv("TEST_SECRET_ITEM", "resolved")
84 return map[string]any{
85 "items": []any{"plain", "${env:TEST_SECRET_ITEM}", 42},
86 }
87 },
88 assertCfg: func(t *testing.T, cfg map[string]any) {
89 items := cfg["items"].([]any)
90 assert.Equal(t, "plain", items[0])
91 assert.Equal(t, "resolved", items[1])
92 assert.Equal(t, 42, items[2])
93 },
94 },
95 "multiple refs in one string": {
96 buildCfg: func(t *testing.T) map[string]any {
97 t.Setenv("TEST_SECRET_DB_USER", "root")
98 t.Setenv("TEST_SECRET_DB_PASS", "p@ss")
99 return map[string]any{
100 "dsn": "${env:TEST_SECRET_DB_USER}:${env:TEST_SECRET_DB_PASS}@tcp(localhost)/db",
101 }
102 },
103 assertCfg: func(t *testing.T, cfg map[string]any) {
104 assert.Equal(t, "root:p@ss@tcp(localhost)/db", cfg["dsn"])
105 },
106 },
107 "uppercase no-scheme left alone": {
108 buildCfg: func(t *testing.T) map[string]any {
109 return map[string]any{"token": "${MY_TOKEN}"}
110 },
111 assertCfg: func(t *testing.T, cfg map[string]any) {
112 assert.Equal(t, "${MY_TOKEN}", cfg["token"])
113 },
114 },
115 "lowercase no-scheme left alone": {
116 buildCfg: func(t *testing.T) map[string]any {
117 return map[string]any{"template": "${lower_case}"}
118 },
119 assertCfg: func(t *testing.T, cfg map[string]any) {
120 assert.Equal(t, "${lower_case}", cfg["template"])
121 },
122 },
123 "unknown scheme error": {
124 buildCfg: func(t *testing.T) map[string]any {
125 return map[string]any{"val": "${nosuchprovider:secret/data/pass}"}
126 },
127 wantErrContains: "unknown secret provider 'nosuchprovider'",
128 },
129 "legacy remote vault syntax is rejected": {
130 buildCfg: func(t *testing.T) map[string]any {
131 return map[string]any{"val": "${vault:secret/data/pass#key}"}
132 },
133 wantErrContains: "unknown secret provider 'vault'",
134 },
135 "legacy remote aws syntax is rejected": {
136 buildCfg: func(t *testing.T) map[string]any {
137 return map[string]any{"val": "${aws-sm:mysecret}"}
138 },
139 wantErrContains: "unknown secret provider 'aws-sm'",
140 },
141 "missing env var error": {
142 buildCfg: func(t *testing.T) map[string]any {
143 return map[string]any{"password": "${env:DEFINITELY_NOT_SET_12345}"}
144 },
145 wantErrContains: "environment variable 'DEFINITELY_NOT_SET_12345' is not set",
146 },
147 "missing file error": {
148 buildCfg: func(t *testing.T) map[string]any {
149 path := filepath.Join(t.TempDir(), "nonexistent_secret_file")
150 return map[string]any{"secret": "${file:" + path + "}"}
151 },
152 wantErrContains: "resolving secret '${file:",
153 },
154 "relative file path error": {
155 buildCfg: func(t *testing.T) map[string]any {
156 return map[string]any{"secret": "${file:relative/secret.txt}"}
157 },
158 wantErrContains: "file path must be absolute",
159 },
160 "internal keys skipped": {
161 buildCfg: func(t *testing.T) map[string]any {
162 return map[string]any{
163 "__source__": "${env:SHOULD_NOT_RESOLVE}",
164 "__source_type__": "${env:SHOULD_NOT_RESOLVE}",
165 "__provider__": "${env:SHOULD_NOT_RESOLVE}",
166 }
167 },
168 assertCfg: func(t *testing.T, cfg map[string]any) {
169 assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source__"])
170 assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source_type__"])
171 assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__provider__"])
172 },
173 },
174 "non-string values untouched": {
175 buildCfg: func(t *testing.T) map[string]any {
176 return map[string]any{
177 "port": 8080,
178 "enabled": true,
179 "ratio": 3.14,
180 "nothing": nil,
181 }
182 },
183 assertCfg: func(t *testing.T, cfg map[string]any) {
184 assert.Equal(t, 8080, cfg["port"])
185 assert.Equal(t, true, cfg["enabled"])
186 assert.Equal(t, 3.14, cfg["ratio"])
187 assert.Nil(t, cfg["nothing"])
188 },
189 },
190 "no refs no changes": {
191 buildCfg: func(t *testing.T) map[string]any {
192 return map[string]any{"host": "localhost", "port": 3306}
193 },
194 assertCfg: func(t *testing.T, cfg map[string]any) {
195 assert.Equal(t, "localhost", cfg["host"])
196 assert.Equal(t, 3306, cfg["port"])
197 },
198 },
199 "empty map": {
200 buildCfg: func(t *testing.T) map[string]any { return map[string]any{} },
201 assertCfg: func(t *testing.T, cfg map[string]any) {
202 assert.Empty(t, cfg)
203 },
204 },
205 "missing uppercase no-scheme left alone": {
206 buildCfg: func(t *testing.T) map[string]any {
207 return map[string]any{"token": "${MISSING_SHORTHAND_VAR_12345}"}
208 },
209 assertCfg: func(t *testing.T, cfg map[string]any) {
210 assert.Equal(t, "${MISSING_SHORTHAND_VAR_12345}", cfg["token"])
211 },
212 },
213 "mixed refs and plain text": {
214 buildCfg: func(t *testing.T) map[string]any {
215 t.Setenv("TEST_SECRET_PROTO", "https")
216 return map[string]any{"url": "${env:TEST_SECRET_PROTO}://example.com/api"}
217 },
218 assertCfg: func(t *testing.T, cfg map[string]any) {
219 assert.Equal(t, "https://example.com/api", cfg["url"])
220 },
221 },
222 "deeply nested": {
223 buildCfg: func(t *testing.T) map[string]any {
224 t.Setenv("TEST_SECRET_DEEP", "found")
225 return map[string]any{
226 "level1": map[string]any{
227 "level2": map[string]any{"level3": "${env:TEST_SECRET_DEEP}"},
228 },
229 }
230 },
231 assertCfg: func(t *testing.T, cfg map[string]any) {
232 l1 := cfg["level1"].(map[string]any)
233 l2 := l1["level2"].(map[string]any)
234 assert.Equal(t, "found", l2["level3"])
235 },
236 },
237 "file ref and env ref together": {
238 buildCfg: func(t *testing.T) map[string]any {
239 t.Setenv("TEST_SECRET_FUSER", "admin")
240 path := filepath.Join(t.TempDir(), "pass.txt")
241 require.NoError(t, os.WriteFile(path, []byte("hunter2\n"), 0600))
242 return map[string]any{
243 "dsn": "${env:TEST_SECRET_FUSER}:${file:" + path + "}@host",
244 }
245 },
246 assertCfg: func(t *testing.T, cfg map[string]any) {
247 assert.Equal(t, "admin:hunter2@host", cfg["dsn"])
248 },
249 },
250 "internal key in nested map": {
251 buildCfg: func(t *testing.T) map[string]any {
252 return map[string]any{
253 "sub": map[string]any{
254 "__meta__": "${env:SHOULD_NOT_RESOLVE}",
255 "normal": "plain",
256 },
257 }
258 },
259 assertCfg: func(t *testing.T, cfg map[string]any) {
260 sub := cfg["sub"].(map[string]any)
261 assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", sub["__meta__"])
262 assert.Equal(t, "plain", sub["normal"])
263 },
264 },
265 "empty env name error": {
266 buildCfg: func(t *testing.T) map[string]any {
267 return map[string]any{"val": "${env:}"}
268 },
269 wantErrContains: "environment variable '' is not set",
270 },
271 "empty file name error": {
272 buildCfg: func(t *testing.T) map[string]any {
273 return map[string]any{"val": "${file:}"}
274 },
275 wantErrContains: "resolving secret",
276 },
277 "multiple refs one failure": {
278 buildCfg: func(t *testing.T) map[string]any {
279 t.Setenv("TEST_SECRET_OK", "good")
280 return map[string]any{
281 "dsn": "${env:TEST_SECRET_OK}:${env:MISSING_VAR_12345}@host",
282 }
283 },
284 wantErrContains: "MISSING_VAR_12345",
285 },
286 "array in nested map": {
287 buildCfg: func(t *testing.T) map[string]any {
288 t.Setenv("TEST_SECRET_ARR", "val")
289 return map[string]any{
290 "outer": map[string]any{
291 "list": []any{"${env:TEST_SECRET_ARR}", "static"},
292 },
293 }
294 },
295 assertCfg: func(t *testing.T, cfg map[string]any) {
296 outer := cfg["outer"].(map[string]any)
297 list := outer["list"].([]any)
298 assert.Equal(t, "val", list[0])
299 assert.Equal(t, "static", list[1])
300 },
301 },
302 }
303
304 for name, tc := range tests {
305 t.Run(name, func(t *testing.T) {
306 resolver := New()
307 cfg := tc.buildCfg(t)
308 err := resolver.Resolve(cfg)
309
310 if tc.wantErrContains != "" {
311 require.Error(t, err)
312 assert.Contains(t, err.Error(), tc.wantErrContains)
313 return
314 }
315
316 require.NoError(t, err)
317 if tc.assertCfg != nil {
318 tc.assertCfg(t, cfg)
319 }
320 })
321 }
322 }
323
324 func TestResolveWithStoreResolver(t *testing.T) {
325 tests := map[string]struct {
326 cfg map[string]any
327 storeResolver StoreRefResolver
328 wantErrContains string
329 assertCfg func(t *testing.T, cfg map[string]any)
330 }{
331 "store ref with resolver": {
332 cfg: map[string]any{
333 "password": "${store:vault:vault_prod:secret/data/mysql#password}",
334 },
335 storeResolver: func(ctx context.Context, ref, original string) (string, error) {
336 require.NotNil(t, ctx)
337 if ref == "vault:vault_prod:secret/data/mysql#password" && original == "${store:vault:vault_prod:secret/data/mysql#password}" {
338 return "resolved-secret", nil
339 }
340 return "", errors.New("unexpected ref")
341 },
342 assertCfg: func(t *testing.T, cfg map[string]any) {
343 assert.Equal(t, "resolved-secret", cfg["password"])
344 },
345 },
346 "store ref without resolver": {
347 cfg: map[string]any{
348 "password": "${store:vault:vault_prod:secret/data/mysql#password}",
349 },
350 wantErrContains: "secretstore resolver is not configured",
351 },
352 "store resolver error bubbles": {
353 cfg: map[string]any{
354 "password": "${store:vault:vault_prod:secret/data/mysql#password}",
355 },
356 storeResolver: func(ctx context.Context, ref, original string) (string, error) {
357 require.NotNil(t, ctx)
358 return "", errors.New("store not configured")
359 },
360 wantErrContains: "store not configured",
361 },
362 "mixed env and store refs": {
363 cfg: map[string]any{
364 "dsn": "${env:TEST_SR_USER}:${store:aws-sm:aws_prod:app/db#password}@host",
365 },
366 storeResolver: func(ctx context.Context, ref, original string) (string, error) {
367 require.NotNil(t, ctx)
368 assert.Equal(t, "aws-sm:aws_prod:app/db#password", ref)
369 return "p@ss", nil
370 },
371 assertCfg: func(t *testing.T, cfg map[string]any) {
372 assert.Equal(t, "admin:p@ss@host", cfg["dsn"])
373 },
374 },
375 "store resolver receives canceled context": {
376 cfg: map[string]any{
377 "password": "${store:vault:vault_prod:secret/data/mysql#password}",
378 },
379 storeResolver: func(ctx context.Context, ref, original string) (string, error) {
380 <-ctx.Done()
381 return "", ctx.Err()
382 },
383 wantErrContains: context.Canceled.Error(),
384 },
385 }
386
387 for name, tc := range tests {
388 t.Run(name, func(t *testing.T) {
389 if name == "mixed env and store refs" {
390 t.Setenv("TEST_SR_USER", "admin")
391 }
392
393 resolver := New()
394 ctx := context.Background()
395 if name == "store resolver receives canceled context" {
396 var cancel context.CancelFunc
397 ctx, cancel = context.WithCancel(context.Background())
398 cancel()
399 }
400
401 err := resolver.ResolveWithStoreResolver(ctx, tc.cfg, tc.storeResolver)
402
403 if tc.wantErrContains != "" {
404 require.Error(t, err)
405 assert.Contains(t, err.Error(), tc.wantErrContains)
406 return
407 }
408
409 require.NoError(t, err)
410 if tc.assertCfg != nil {
411 tc.assertCfg(t, tc.cfg)
412 }
413 })
414 }
415 }
416
417 func TestResolveRefUsesProviderRegistry(t *testing.T) {
418 resolver := New()
419
420 called := false
421 resolver.providers["stub"] = func(ctx context.Context, ref, original string) (string, error) {
422 called = true
423 require.NotNil(t, ctx)
424 assert.Equal(t, "name", ref)
425 assert.Equal(t, "${stub:name}", original)
426 return "resolved-by-stub", nil
427 }
428
429 cfg := map[string]any{
430 "value": "${stub:name}",
431 }
432
433 require.NoError(t, resolver.Resolve(cfg))
434 assert.True(t, called)
435 assert.Equal(t, "resolved-by-stub", cfg["value"])
436 }
437
438 func TestResolveWithStoreResolver_LogsDetailedBuiltinResolution(t *testing.T) {
439 modeFile := filepath.Join(t.TempDir(), "secret.txt")
440 require.NoError(t, os.WriteFile(modeFile, []byte("from-file\n"), 0o600))
441
442 tests := map[string]struct {
443 cfg map[string]any
444 onWindowsSkip bool
445 setup func(t *testing.T)
446 wantLog string
447 dontWantLogs []string
448 }{
449 "env": {
450 cfg: map[string]any{"value": "${env:TEST_SECRET_ENV}"},
451 setup: func(t *testing.T) {
452 t.Setenv("TEST_SECRET_ENV", "from-env")
453 },
454 wantLog: "resolved secret via env variable 'TEST_SECRET_ENV'",
455 dontWantLogs: []string{"from-env"},
456 },
457 "file": {
458 cfg: map[string]any{"value": "${file:" + modeFile + "}"},
459 wantLog: "resolved secret via file '" + modeFile + "'",
460 dontWantLogs: []string{"from-file"},
461 },
462 "cmd": {
463 cfg: map[string]any{"value": "${cmd:/bin/echo from-cmd}"},
464 onWindowsSkip: true,
465 wantLog: "resolved secret via command '/bin/echo'",
466 dontWantLogs: []string{"from-cmd"},
467 },
468 }
469
470 for name, tc := range tests {
471 t.Run(name, func(t *testing.T) {
472 if tc.onWindowsSkip && runtime.GOOS == "windows" {
473 t.Skip("skipping on windows")
474 }
475 if tc.setup != nil {
476 tc.setup(t)
477 }
478
479 out := captureResolverLoggerOutput(t, func(log *logger.Logger) {
480 ctx := logger.ContextWithLogger(context.Background(), log)
481 require.NoError(t, New().ResolveWithStoreResolver(ctx, tc.cfg, nil))
482 })
483
484 assert.Contains(t, out, tc.wantLog)
485 for _, s := range tc.dontWantLogs {
486 assert.NotContains(t, out, s)
487 }
488 })
489 }
490 }
491
492 func captureResolverLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
493 t.Helper()
494
495 var buf bytes.Buffer
496 fn(logger.NewWithWriter(&buf))
497 return buf.String()
498 }