Skip to content

Commit 5041371

Browse files
committed
add more tests for nested arrays and maps
1 parent 774c9d1 commit 5041371

File tree

4 files changed

+218
-6
lines changed

4 files changed

+218
-6
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package config
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
// A nested service configuration with multiple tenants and lists of credentials.
11+
type tenantCfg struct {
12+
Credential Secret `mapstructure:"credential"`
13+
Passwords []Secret `mapstructure:"passwords"`
14+
}
15+
16+
type svcCfgComplex struct {
17+
ClientSecret Secret `mapstructure:"client_secret"`
18+
Tenants map[string]tenantCfg `mapstructure:"tenants"`
19+
}
20+
21+
func TestBindServiceConfig_NestedTenants_SecretsAndLists(t *testing.T) {
22+
// Prepare env vars and a temp file for fromFile
23+
t.Setenv("OPENTDF_TEST_CLIENT_SECRET", "client-secret")
24+
t.Setenv("OPENTDF_TENANT_A_CRED", "tenant-a-cred")
25+
t.Setenv("OPENTDF_PASS1", "p1")
26+
27+
dir := t.TempDir()
28+
filePath := filepath.Join(dir, "pass.txt")
29+
if err := os.WriteFile(filePath, []byte("from-file\n"), 0o600); err != nil {
30+
t.Fatalf("write temp file: %v", err)
31+
}
32+
33+
in := ServiceConfig{
34+
"client_secret": "env:OPENTDF_TEST_CLIENT_SECRET",
35+
"tenants": map[string]any{
36+
"tenantA": map[string]any{
37+
"credential": "env:OPENTDF_TENANT_A_CRED",
38+
"passwords": []any{
39+
"env:OPENTDF_PASS1",
40+
"literal:abc",
41+
map[string]any{"fromFile": filePath},
42+
},
43+
},
44+
"tenantB": map[string]any{
45+
"credential": "literal:credB",
46+
},
47+
},
48+
}
49+
50+
var out svcCfgComplex
51+
// Eagerly resolve to validate that nested secrets are materialized
52+
if err := BindServiceConfig(context.Background(), in, &out, WithEagerSecretResolution()); err != nil {
53+
t.Fatalf("bind: %v", err)
54+
}
55+
56+
// Assert top-level secret
57+
v, err := out.ClientSecret.Resolve(context.Background())
58+
if err != nil || v != "client-secret" {
59+
t.Fatalf("client_secret resolve: %v %q", err, v)
60+
}
61+
62+
// Assert tenant map
63+
tenantA, ok := out.Tenants["tenantA"]
64+
if !ok {
65+
t.Fatalf("expected tenant 'tenantA' present")
66+
}
67+
credA, err := tenantA.Credential.Resolve(context.Background())
68+
if err != nil || credA != "tenant-a-cred" {
69+
t.Fatalf("credential resolve: %v %q", err, credA)
70+
}
71+
if len(tenantA.Passwords) != 3 {
72+
t.Fatalf("expected 3 passwords, got %d", len(tenantA.Passwords))
73+
}
74+
p0, _ := tenantA.Passwords[0].Resolve(context.Background())
75+
p1, _ := tenantA.Passwords[1].Resolve(context.Background())
76+
p2, _ := tenantA.Passwords[2].Resolve(context.Background())
77+
if p0 != "p1" || p1 != "abc" || p2 != "from-file" {
78+
t.Fatalf("passwords mismatch: %q, %q, %q", p0, p1, p2)
79+
}
80+
81+
// Second tenant literal credential
82+
tenantB, ok := out.Tenants["tenantB"]
83+
if !ok {
84+
t.Fatalf("expected tenant 'tenantB' present")
85+
}
86+
credB, err := tenantB.Credential.Resolve(context.Background())
87+
if err != nil || credB != "credB" {
88+
t.Fatalf("credential resolve (tenantB): %v %q", err, credB)
89+
}
90+
}
91+
92+
func TestBindServiceConfig_NestedTenants_EagerFailureOnMissingEnv(t *testing.T) {
93+
in := ServiceConfig{
94+
"tenants": map[string]any{
95+
"tenantA": map[string]any{
96+
// Missing env value should cause eager resolution to fail
97+
"credential": "env:OPENTDF_TEST_MISSING_ENV_ABC123",
98+
},
99+
},
100+
}
101+
var out svcCfgComplex
102+
if err := BindServiceConfig(context.Background(), in, &out, WithEagerSecretResolution()); err == nil {
103+
t.Fatalf("expected bind failure on missing env in eager resolution")
104+
}
105+
}

service/pkg/config/decode.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,20 @@ func secretDecodeHook(from, to reflect.Type, data any) (any, error) {
4242
return NewLiteralSecret(s), nil
4343
}
4444
case reflect.Map:
45-
// Must be map[string]any
45+
// Must be map[string]any (case-insensitive key handling)
4646
m, okm := data.(map[string]any)
4747
if !okm {
4848
return nil, fmt.Errorf("invalid secret map type: %T", data)
4949
}
50-
if env, ok := m["fromEnv"].(string); ok && env != "" {
50+
// Normalize keys to lowercase for robust matching (Viper may lowercase keys)
51+
lower := make(map[string]any, len(m))
52+
for k, v := range m {
53+
lower[strings.ToLower(k)] = v
54+
}
55+
if env, ok := lower["fromenv"].(string); ok && env != "" {
5156
return NewEnvSecret(env), nil
5257
}
53-
if file, ok2 := m["fromFile"].(string); ok2 && file != "" {
58+
if file, ok2 := lower["fromfile"].(string); ok2 && file != "" {
5459
return NewFileSecret(file), nil
5560
}
5661
// Future: support {"fromURI":"aws-secretsmanager://..."}

service/pkg/config/walk.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,21 @@ func walkValue(rv reflect.Value, fn func(*Secret) error) error {
2525
rv = rv.Elem()
2626
}
2727

28-
//nolint:exhaustive // Only handling relevant kinds for Secret traversal
28+
//nolint:exhaustive // We only need to traverse struct, map, slice, and array kinds for secrets
2929
switch rv.Kind() {
3030
case reflect.Struct:
3131
rt := rv.Type()
3232
// Handle Secret itself
3333
if rt == reflect.TypeOf(Secret{}) {
3434
if rv.CanAddr() {
35-
s, ok := rv.Addr().Interface().(*Secret)
36-
if ok {
35+
if s, ok := rv.Addr().Interface().(*Secret); ok {
3736
return fn(s)
3837
}
38+
return nil
39+
}
40+
// For non-addressable Secret values (e.g., map index), operate on a copy.
41+
if s, ok := rv.Interface().(Secret); ok {
42+
return fn(&s)
3943
}
4044
return nil
4145
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package config
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/spf13/viper"
11+
)
12+
13+
type arrayCfg struct {
14+
Secrets []Secret `mapstructure:"secrets"`
15+
}
16+
17+
type provider struct {
18+
Name string `mapstructure:"name"`
19+
Password Secret `mapstructure:"password"`
20+
}
21+
22+
type providersCfg struct {
23+
Providers []provider `mapstructure:"providers"`
24+
}
25+
26+
func TestBind_FromYAMLArray_SecretsSlice(t *testing.T) {
27+
t.Setenv("OPENTDF_YAML_ARR", "arr-env")
28+
dir := t.TempDir()
29+
fp := filepath.Join(dir, "s.txt")
30+
if err := os.WriteFile(fp, []byte("from-file\n"), 0o600); err != nil {
31+
t.Fatalf("write: %v", err)
32+
}
33+
34+
yaml := "" +
35+
"secrets:\n" +
36+
" - \"env:OPENTDF_YAML_ARR\"\n" +
37+
" - { fromFile: \"" + fp + "\" }\n" +
38+
" - \"literal:abc\"\n"
39+
40+
v := viper.New()
41+
v.SetConfigType("yaml")
42+
if err := v.ReadConfig(bytes.NewBufferString(yaml)); err != nil {
43+
t.Fatalf("read yaml: %v", err)
44+
}
45+
46+
in := ServiceConfig{
47+
"secrets": v.Get("secrets"),
48+
}
49+
var out arrayCfg
50+
if err := BindServiceConfig(context.Background(), in, &out, WithEagerSecretResolution()); err != nil {
51+
t.Fatalf("bind: %v", err)
52+
}
53+
if len(out.Secrets) != 3 {
54+
t.Fatalf("expected 3 secrets, got %d", len(out.Secrets))
55+
}
56+
s0, _ := out.Secrets[0].Resolve(context.Background())
57+
s1, _ := out.Secrets[1].Resolve(context.Background())
58+
s2, _ := out.Secrets[2].Resolve(context.Background())
59+
if s0 != "arr-env" || s1 != "from-file" || s2 != "abc" {
60+
t.Fatalf("values mismatch: %q %q %q", s0, s1, s2)
61+
}
62+
}
63+
64+
func TestBind_FromYAMLArray_StructsWithSecretFields(t *testing.T) {
65+
t.Setenv("OPENTDF_PROVIDER_B_PASS", "b-pass")
66+
67+
yaml := "" +
68+
"providers:\n" +
69+
" - name: a\n" +
70+
" password: \"literal:alpha\"\n" +
71+
" - name: b\n" +
72+
" password: \"env:OPENTDF_PROVIDER_B_PASS\"\n"
73+
74+
v := viper.New()
75+
v.SetConfigType("yaml")
76+
if err := v.ReadConfig(bytes.NewBufferString(yaml)); err != nil {
77+
t.Fatalf("read yaml: %v", err)
78+
}
79+
80+
in := ServiceConfig{
81+
"providers": v.Get("providers"),
82+
}
83+
var out providersCfg
84+
if err := BindServiceConfig(context.Background(), in, &out, WithEagerSecretResolution()); err != nil {
85+
t.Fatalf("bind: %v", err)
86+
}
87+
if len(out.Providers) != 2 {
88+
t.Fatalf("expected 2 providers, got %d", len(out.Providers))
89+
}
90+
if out.Providers[0].Name != "a" || out.Providers[1].Name != "b" {
91+
t.Fatalf("names mismatch: %q %q", out.Providers[0].Name, out.Providers[1].Name)
92+
}
93+
p0, _ := out.Providers[0].Password.Resolve(context.Background())
94+
p1, _ := out.Providers[1].Password.Resolve(context.Background())
95+
if p0 != "alpha" || p1 != "b-pass" {
96+
t.Fatalf("passwords mismatch: %q %q", p0, p1)
97+
}
98+
}

0 commit comments

Comments
 (0)