Skip to content

Commit ea09a0c

Browse files
committed
feat(config): add JSON schema validation and test cases for SOPS configuration
Signed-off-by: miiyakumo <miiyakumo@qq.com>
1 parent 6332269 commit ea09a0c

17 files changed

Lines changed: 940 additions & 1 deletion

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ test: vendor
7575
gpg --import pgp/sops_functional_tests_key.asc 2>&1 1>/dev/null || exit 0
7676
unset SOPS_AGE_KEY_FILE; unset SOPS_AGE_KEY_CMD; LANG=en_US.UTF-8 $(GO) test $(GO_TEST_FLAGS) ./...
7777

78+
.PHONY: test-schema
79+
test-schema: vendor
80+
$(GO) test -v -run TestSchema ./config
81+
7882
.PHONY: showcoverage
7983
showcoverage: test
8084
$(GO) tool cover -html=profile.out

config/config_schema_test.go

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"github.com/xeipuuv/gojsonschema"
12+
"go.yaml.in/yaml/v3"
13+
)
14+
15+
// loadJSONSchema loads the JSON schema from the schema directory
16+
func loadJSONSchema(t *testing.T) *gojsonschema.Schema {
17+
schemaPath := filepath.Join("..", "schema", "sops.json")
18+
schemaBytes, err := os.ReadFile(schemaPath)
19+
require.NoError(t, err, "Failed to read JSON schema file")
20+
21+
schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
22+
schema, err := gojsonschema.NewSchema(schemaLoader)
23+
require.NoError(t, err, "Failed to parse JSON schema")
24+
25+
return schema
26+
}
27+
28+
// validateYAMLAgainstSchema validates a YAML file against the JSON schema
29+
func validateYAMLAgainstSchema(t *testing.T, schema *gojsonschema.Schema, yamlPath string) *gojsonschema.Result {
30+
yamlBytes, err := os.ReadFile(yamlPath)
31+
require.NoError(t, err, "Failed to read YAML file: %s", yamlPath)
32+
33+
// Parse YAML to Go object
34+
var config interface{}
35+
err = yaml.Unmarshal(yamlBytes, &config)
36+
require.NoError(t, err, "Failed to parse YAML: %s", yamlPath)
37+
38+
// Convert to JSON for schema validation
39+
jsonBytes, err := json.Marshal(config)
40+
require.NoError(t, err, "Failed to convert to JSON: %s", yamlPath)
41+
42+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
43+
result, err := schema.Validate(documentLoader)
44+
require.NoError(t, err, "Schema validation failed with error: %s", yamlPath)
45+
46+
return result
47+
}
48+
49+
// TestSchemaValidTestCases tests that all valid test cases pass schema validation
50+
func TestSchemaValidTestCases(t *testing.T) {
51+
schema := loadJSONSchema(t)
52+
53+
validTestCases := []string{
54+
"valid-basic.yaml",
55+
"valid-complete.yaml",
56+
"valid-keygroups.yaml",
57+
"valid-stores.yaml",
58+
"valid-destination.yaml",
59+
"valid-azure.yaml",
60+
"valid-merge.yaml",
61+
}
62+
63+
for _, testCase := range validTestCases {
64+
t.Run(testCase, func(t *testing.T) {
65+
testPath := filepath.Join("..", "schema", "test-cases", testCase)
66+
result := validateYAMLAgainstSchema(t, schema, testPath)
67+
68+
if !result.Valid() {
69+
t.Errorf("Valid test case %s failed schema validation:", testCase)
70+
for _, err := range result.Errors() {
71+
t.Errorf(" - %s", err)
72+
}
73+
}
74+
assert.True(t, result.Valid(), "Valid test case should pass schema validation")
75+
})
76+
}
77+
}
78+
79+
// TestSchemaInvalidTestCases tests that all invalid test cases fail schema validation
80+
func TestSchemaInvalidTestCases(t *testing.T) {
81+
schema := loadJSONSchema(t)
82+
83+
invalidTestCases := []string{
84+
"invalid-unknown-field.yaml",
85+
"invalid-shamir-threshold.yaml",
86+
"invalid-kms-missing-arn.yaml",
87+
"invalid-azure-missing-key.yaml",
88+
"invalid-vault-version.yaml",
89+
"invalid-stores-unknown.yaml",
90+
}
91+
92+
for _, testCase := range invalidTestCases {
93+
t.Run(testCase, func(t *testing.T) {
94+
testPath := filepath.Join("..", "schema", "test-cases", testCase)
95+
result := validateYAMLAgainstSchema(t, schema, testPath)
96+
97+
if result.Valid() {
98+
t.Errorf("Invalid test case %s passed schema validation but should have failed", testCase)
99+
}
100+
assert.False(t, result.Valid(), "Invalid test case should fail schema validation")
101+
102+
// Log validation errors for debugging
103+
t.Logf("Expected validation errors for %s:", testCase)
104+
for _, err := range result.Errors() {
105+
t.Logf(" - %s", err)
106+
}
107+
})
108+
}
109+
}
110+
111+
// TestSchemaAgainstRootSopsYaml tests the schema against the root .sops.yaml file
112+
func TestSchemaAgainstRootSopsYaml(t *testing.T) {
113+
schema := loadJSONSchema(t)
114+
sopsYamlPath := filepath.Join("..", ".sops.yaml")
115+
116+
// Check if the file exists
117+
if _, err := os.Stat(sopsYamlPath); os.IsNotExist(err) {
118+
t.Skip("Root .sops.yaml file does not exist")
119+
return
120+
}
121+
122+
result := validateYAMLAgainstSchema(t, schema, sopsYamlPath)
123+
if !result.Valid() {
124+
t.Errorf("Root .sops.yaml failed schema validation:")
125+
for _, err := range result.Errors() {
126+
t.Errorf(" - %s", err)
127+
}
128+
}
129+
assert.True(t, result.Valid(), "Root .sops.yaml should pass schema validation")
130+
}
131+
132+
// TestSchemaStructureMatchesConfig tests that schema structure aligns with config structs
133+
func TestSchemaStructureMatchesConfig(t *testing.T) {
134+
schema := loadJSONSchema(t)
135+
136+
// Test that basic creation_rule fields are accepted
137+
basicConfig := map[string]interface{}{
138+
"creation_rules": []map[string]interface{}{
139+
{
140+
"path_regex": "\\.yaml$",
141+
"pgp": "ABC123",
142+
"age": "age1xxx",
143+
"kms": "arn:aws:kms:us-east-1:123456789012:key/xxx",
144+
},
145+
},
146+
}
147+
148+
jsonBytes, err := json.Marshal(basicConfig)
149+
require.NoError(t, err)
150+
151+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
152+
result, err := schema.Validate(documentLoader)
153+
require.NoError(t, err)
154+
assert.True(t, result.Valid(), "Basic config should be valid")
155+
}
156+
157+
// TestSchemaKeyGroupsMergeField tests that the merge field in key_groups is supported
158+
func TestSchemaKeyGroupsMergeField(t *testing.T) {
159+
schema := loadJSONSchema(t)
160+
161+
// Test key_groups with merge field
162+
configWithMerge := map[string]interface{}{
163+
"creation_rules": []map[string]interface{}{
164+
{
165+
"key_groups": []map[string]interface{}{
166+
{
167+
"merge": []map[string]interface{}{
168+
{
169+
"pgp": []string{"ABC123"},
170+
},
171+
{
172+
"age": []string{"age1xxx"},
173+
},
174+
},
175+
},
176+
},
177+
},
178+
},
179+
}
180+
181+
jsonBytes, err := json.Marshal(configWithMerge)
182+
require.NoError(t, err)
183+
184+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
185+
result, err := schema.Validate(documentLoader)
186+
require.NoError(t, err)
187+
188+
if !result.Valid() {
189+
for _, err := range result.Errors() {
190+
t.Logf("Validation error: %s", err)
191+
}
192+
}
193+
assert.True(t, result.Valid(), "Config with merge field should be valid")
194+
}
195+
196+
// TestSchemaHCVaultFieldVariants tests both hc_vault and hc_vault_transit_uri
197+
func TestSchemaHCVaultFieldVariants(t *testing.T) {
198+
schema := loadJSONSchema(t)
199+
200+
// Test with hc_vault (short form)
201+
configWithHCVault := map[string]interface{}{
202+
"creation_rules": []map[string]interface{}{
203+
{
204+
"key_groups": []map[string]interface{}{
205+
{
206+
"hc_vault": []string{"https://vault.example.com/v1/transit/keys/my-key"},
207+
},
208+
},
209+
},
210+
},
211+
}
212+
213+
jsonBytes, err := json.Marshal(configWithHCVault)
214+
require.NoError(t, err)
215+
216+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
217+
result, err := schema.Validate(documentLoader)
218+
require.NoError(t, err)
219+
assert.True(t, result.Valid(), "Config with hc_vault should be valid")
220+
221+
// Test with hc_vault_transit_uri (long form)
222+
configWithHCVaultTransit := map[string]interface{}{
223+
"creation_rules": []map[string]interface{}{
224+
{
225+
"hc_vault_transit_uri": "https://vault.example.com/v1/transit/keys/my-key",
226+
},
227+
},
228+
}
229+
230+
jsonBytes, err = json.Marshal(configWithHCVaultTransit)
231+
require.NoError(t, err)
232+
233+
documentLoader = gojsonschema.NewBytesLoader(jsonBytes)
234+
result, err = schema.Validate(documentLoader)
235+
require.NoError(t, err)
236+
assert.True(t, result.Valid(), "Config with hc_vault_transit_uri should be valid")
237+
}
238+
239+
// TestSchemaArrayAndStringFormats tests that both string and array formats are accepted
240+
func TestSchemaArrayAndStringFormats(t *testing.T) {
241+
schema := loadJSONSchema(t)
242+
243+
// Test with string format (comma-separated)
244+
configWithStrings := map[string]interface{}{
245+
"creation_rules": []map[string]interface{}{
246+
{
247+
"pgp": "ABC123,DEF456",
248+
"age": "age1xxx,age2yyy",
249+
},
250+
},
251+
}
252+
253+
jsonBytes, err := json.Marshal(configWithStrings)
254+
require.NoError(t, err)
255+
256+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
257+
result, err := schema.Validate(documentLoader)
258+
require.NoError(t, err)
259+
assert.True(t, result.Valid(), "Config with string format should be valid")
260+
261+
// Test with array format
262+
configWithArrays := map[string]interface{}{
263+
"creation_rules": []map[string]interface{}{
264+
{
265+
"pgp": []string{"ABC123", "DEF456"},
266+
"age": []string{"age1xxx", "age2yyy"},
267+
},
268+
},
269+
}
270+
271+
jsonBytes, err = json.Marshal(configWithArrays)
272+
require.NoError(t, err)
273+
274+
documentLoader = gojsonschema.NewBytesLoader(jsonBytes)
275+
result, err = schema.Validate(documentLoader)
276+
require.NoError(t, err)
277+
assert.True(t, result.Valid(), "Config with array format should be valid")
278+
}
279+
280+
// TestSchemaRecreationRuleCompleteness tests that recreation_rule supports all creation_rule fields
281+
func TestSchemaRecreationRuleCompleteness(t *testing.T) {
282+
schema := loadJSONSchema(t)
283+
284+
// Test recreation_rule with various fields
285+
configWithRecreation := map[string]interface{}{
286+
"destination_rules": []map[string]interface{}{
287+
{
288+
"s3_bucket": "my-bucket",
289+
"recreation_rule": map[string]interface{}{
290+
"kms": "arn:aws:kms:us-east-1:123456789012:key/xxx",
291+
"pgp": "ABC123",
292+
"encrypted_regex": "^(password|secret)",
293+
"shamir_threshold": 2,
294+
"mac_only_encrypted": true,
295+
"unencrypted_suffix": "_public",
296+
"encrypted_comment_regex": "^encrypted:",
297+
"unencrypted_comment_regex": "^public:",
298+
},
299+
},
300+
},
301+
}
302+
303+
jsonBytes, err := json.Marshal(configWithRecreation)
304+
require.NoError(t, err)
305+
306+
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
307+
result, err := schema.Validate(documentLoader)
308+
require.NoError(t, err)
309+
310+
if !result.Valid() {
311+
for _, err := range result.Errors() {
312+
t.Logf("Validation error: %s", err)
313+
}
314+
}
315+
assert.True(t, result.Valid(), "Recreation rule with all fields should be valid")
316+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/sirupsen/logrus v1.9.3
3434
github.com/stretchr/testify v1.11.1
3535
github.com/urfave/cli v1.22.17
36+
github.com/xeipuuv/gojsonschema v1.2.0
3637
go.yaml.in/yaml/v3 v3.0.4
3738
golang.org/x/crypto v0.45.0
3839
golang.org/x/net v0.47.0
@@ -130,7 +131,6 @@ require (
130131
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
131132
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
132133
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
133-
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
134134
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
135135
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
136136
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect

0 commit comments

Comments
 (0)