1 package jsonschemax
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io/ioutil"
8 "math/big"
9 "regexp"
10 "testing"
11
12 "github.com/pkg/errors"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "github.com/ory/jsonschema/v3"
18 )
19
20 const recursiveSchema = `{
21 "$schema": "http://json-schema.org/draft-07/schema#",
22 "$id": "test.json",
23 "definitions": {
24 "foo": {
25 "type": "object",
26 "properties": {
27 "bars": {
28 "type": "string",
29 "format": "email",
30 "pattern": ".*"
31 },
32 "bar": {
33 "$ref": "#/definitions/bar"
34 }
35 }
36 },
37 "bar": {
38 "type": "object",
39 "properties": {
40 "foos": {
41 "type": "string",
42 "minLength": 1,
43 "maxLength": 10
44 },
45 "foo": {
46 "$ref": "#/definitions/foo"
47 }
48 }
49 }
50 },
51 "type": "object",
52 "properties": {
53 "bar": {
54 "$ref": "#/definitions/bar"
55 }
56 }
57 }`
58
59 func readFile(t *testing.T, path string) string {
60 schema, err := ioutil.ReadFile(path)
61 require.NoError(t, err)
62 return string(schema)
63 }
64
65 func assertEqualPaths(t *testing.T, expected byName, actual byName) {
66 for i := range expected {
67 t.Run("path="+expected[i].Name, func(t *testing.T) {
68 e := expected[i]
69
70 if e.MinLength == 0 {
71 e.MinLength = -1
72 }
73 if e.MaxLength == 0 {
74 e.MaxLength = -1
75 }
76
77 a := actual[i]
78 assert.Equal(t, e.Pattern, a.Pattern, fmt.Sprintf("path: %s\n", e.Name))
79
80 e.Pattern = nil
81 a.Pattern = nil
82
83 if e.Minimum != nil {
84 assert.NotNil(t, a.Minimum)
85 assert.Equal(t, e.Minimum.String(), e.Minimum.String(), fmt.Sprintf("path: %s\n", e.Name))
86 } else {
87 assert.Nil(t, a.Minimum)
88 }
89 if e.Maximum != nil {
90 assert.NotNil(t, a.Maximum)
91 assert.Equal(t, e.Maximum.String(), e.Maximum.String(), fmt.Sprintf("path: %s\n", e.Name))
92 } else {
93 assert.Nil(t, a.Maximum)
94 }
95
96 e.Minimum = nil
97 a.Minimum = nil
98 e.Maximum = nil
99 a.Maximum = nil
100
101 assert.Equal(t, e, a)
102 })
103 }
104 }
105
106 const fooExtensionName = "fooExtension"
107
108 type (
109 extensionConfig struct {
110 NotAJSONSchemaKey string `json:"not-a-json-schema-key"`
111 }
112 )
113
114 func fooExtensionCompile(_ jsonschema.CompilerContext, m map[string]interface{}) (interface{}, error) {
115 if raw, ok := m[fooExtensionName]; ok {
116 var b bytes.Buffer
117 if err := json.NewEncoder(&b).Encode(raw); err != nil {
118 return nil, errors.WithStack(err)
119 }
120
121 var e extensionConfig
122 if err := json.NewDecoder(&b).Decode(&e); err != nil {
123 return nil, errors.WithStack(err)
124 }
125
126 return &e, nil
127 }
128 return nil, nil
129 }
130
131 func fooExtensionValidate(_ jsonschema.ValidationContext, _, _ interface{}) error {
132 return nil
133 }
134
135 func (ec *extensionConfig) EnhancePath(p Path) map[string]interface{} {
136 if ec.NotAJSONSchemaKey != "" {
137 fmt.Printf("enhancing path: %s with custom property %s\n", p.Name, ec.NotAJSONSchemaKey)
138 return map[string]interface{}{
139 ec.NotAJSONSchemaKey: p.Name,
140 }
141 }
142 return nil
143 }
144
145 func TestListPathsWithRecursion(t *testing.T) {
146 for k, tc := range []struct {
147 recursion uint8
148 expected byName
149 }{
150 {
151 recursion: 5,
152 expected: byName{
153 Path{
154 Name: "bar.foo.bar.foo.bar.foos",
155 Default: interface{}(nil),
156 Type: "",
157 TypeHint: String,
158 MaxLength: 10,
159 MinLength: 1,
160 },
161 Path{
162 Name: "bar.foo.bar.foo.bars",
163 Default: interface{}(nil),
164 Type: "",
165 Format: "email",
166 TypeHint: String,
167 Pattern: regexp.MustCompile(".*"),
168 MaxLength: -1,
169 MinLength: -1,
170 },
171 Path{
172 Name: "bar.foo.bar.foos",
173 Default: interface{}(nil),
174 Type: "",
175 TypeHint: String,
176 MaxLength: 10,
177 MinLength: 1,
178 },
179 Path{
180 Name: "bar.foo.bars",
181 Default: interface{}(nil),
182 Type: "",
183 TypeHint: String,
184 Format: "email",
185 Pattern: regexp.MustCompile(".*"),
186 MaxLength: -1,
187 MinLength: -1,
188 },
189 Path{
190 Name: "bar.foos",
191 Default: interface{}(nil),
192 Type: "",
193 TypeHint: String,
194 MaxLength: 10,
195 MinLength: 1,
196 },
197 },
198 },
199 } {
200 t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
201 c := jsonschema.NewCompiler()
202 require.NoError(t, c.AddResource("test.json", bytes.NewBufferString(recursiveSchema)))
203 actual, err := ListPathsWithRecursion("test.json", c, tc.recursion)
204 require.NoError(t, err)
205 assertEqualPaths(t, tc.expected, actual)
206 })
207 }
208 }
209
210 func TestListPaths(t *testing.T) {
211 for k, tc := range []struct {
212 schema string
213 expectErr bool
214 expected byName
215 extension *jsonschema.Extension
216 }{
217 {
218 schema: readFile(t, "./stub/.oathkeeper.schema.json"),
219 expected: byName{
220 Path{Name: "access_rules.repositories", Type: []string{}, TypeHint: StringSlice},
221 Path{Name: "authenticators.anonymous.config.subject", Default: "anonymous", Type: "", TypeHint: String},
222 Path{Name: "authenticators.anonymous.enabled", Default: false, Type: false, TypeHint: Bool},
223 Path{Name: "authenticators.cookie_session.config.check_session_url", Type: "", TypeHint: String, Format: "uri"},
224 Path{Name: "authenticators.cookie_session.config.only", Type: []string{}, TypeHint: StringSlice},
225 Path{Name: "authenticators.cookie_session.enabled", Default: false, Type: false, TypeHint: Bool},
226 Path{Name: "authenticators.jwt.config.allowed_algorithms", Type: []string{}, TypeHint: StringSlice},
227 Path{Name: "authenticators.jwt.config.jwks_urls", Type: []string{}, TypeHint: StringSlice},
228 Path{Name: "authenticators.jwt.config.required_scope", Type: []string{}, TypeHint: StringSlice},
229 Path{Name: "authenticators.jwt.config.scope_strategy", Default: "none", Type: "", TypeHint: String, Enum: []interface{}{"hierarchic", "exact", "wildcard", "none"}},
230 Path{Name: "authenticators.jwt.config.target_audience", Type: []string{}, TypeHint: StringSlice},
231 Path{Name: "authenticators.jwt.config.token_from.header", Type: "", TypeHint: String},
232 Path{Name: "authenticators.jwt.config.token_from.query_parameter", Type: "", TypeHint: String},
233 Path{Name: "authenticators.jwt.config.trusted_issuers", Type: []string{}, TypeHint: StringSlice},
234 Path{Name: "authenticators.jwt.enabled", Default: false, Type: false, TypeHint: Bool},
235 Path{Name: "authenticators.noop.enabled", Default: false, Type: false, TypeHint: Bool},
236 Path{Name: "authenticators.oauth2_client_credentials.config.required_scope", Type: []string{}, TypeHint: StringSlice},
237 Path{Name: "authenticators.oauth2_client_credentials.config.token_url", Type: "", TypeHint: String, Format: "uri"},
238 Path{Name: "authenticators.oauth2_client_credentials.enabled", Default: false, Type: false, TypeHint: Bool},
239 Path{Name: "authenticators.oauth2_introspection.config.introspection_url", Type: "", TypeHint: String, Format: "uri"},
240 Path{Name: "authenticators.oauth2_introspection.config.pre_authorization.client_id", Type: "", TypeHint: String},
241 Path{Name: "authenticators.oauth2_introspection.config.pre_authorization.client_secret", Type: "", TypeHint: String},
242 Path{Name: "authenticators.oauth2_introspection.config.pre_authorization.enabled", Default: false, Type: false, TypeHint: Bool},
243 Path{Name: "authenticators.oauth2_introspection.config.pre_authorization.scope", Type: []string{}, TypeHint: StringSlice},
244 Path{Name: "authenticators.oauth2_introspection.config.pre_authorization.token_url", Type: "", TypeHint: String, Format: "uri"},
245 Path{Name: "authenticators.oauth2_introspection.config.required_scope", Type: []string{}, TypeHint: StringSlice},
246 Path{Name: "authenticators.oauth2_introspection.config.scope_strategy", Default: "none", Type: "", TypeHint: String, Enum: []interface{}{"hierarchic", "exact", "wildcard", "none"}},
247 Path{Name: "authenticators.oauth2_introspection.config.target_audience", Type: []string{}, TypeHint: StringSlice},
248 Path{Name: "authenticators.oauth2_introspection.config.token_from", Type: map[string]interface{}{}, TypeHint: JSON},
249 Path{Name: "authenticators.oauth2_introspection.config.token_from.header", Type: "", TypeHint: String},
250 Path{Name: "authenticators.oauth2_introspection.config.token_from.query_parameter", Type: "", TypeHint: String},
251 Path{Name: "authenticators.oauth2_introspection.config.trusted_issuers", Type: []string{}, TypeHint: StringSlice},
252 Path{Name: "authenticators.oauth2_introspection.enabled", Default: false, Type: false, TypeHint: Bool},
253 Path{Name: "authenticators.unauthorized.enabled", Default: false, Type: false, TypeHint: Bool},
254 Path{Name: "authorizers.allow.enabled", Default: false, Type: false, TypeHint: Bool},
255 Path{Name: "authorizers.deny.enabled", Default: false, Type: false, TypeHint: Bool},
256 Path{Name: "authorizers.keto_engine_acp_ory.config.base_url", Type: "", TypeHint: String, Format: "uri"},
257 Path{Name: "authorizers.keto_engine_acp_ory.config.flavor", Type: "", TypeHint: String},
258 Path{Name: "authorizers.keto_engine_acp_ory.config.required_action", Default: "unset", Type: "", TypeHint: String},
259 Path{Name: "authorizers.keto_engine_acp_ory.config.required_resource", Default: "unset", Type: "", TypeHint: String},
260 Path{Name: "authorizers.keto_engine_acp_ory.config.subject", Type: "", TypeHint: String},
261 Path{Name: "authorizers.keto_engine_acp_ory.enabled", Default: false, Type: false, TypeHint: Bool},
262 Path{Name: "log.format", Default: "text", Type: "", TypeHint: String, Enum: []interface{}{"text", "json"}},
263 Path{Name: "log.level", Default: "info", Type: "", TypeHint: String, Enum: []interface{}{"panic", "fatal", "error", "warn", "info", "debug"}},
264 Path{Name: "mutators.cookie.config.cookies", Type: map[string]interface{}{}, TypeHint: JSON},
265 Path{Name: "mutators.cookie.enabled", Default: false, Type: false, TypeHint: Bool},
266 Path{Name: "mutators.header.config.headers", Type: map[string]interface{}{}, TypeHint: JSON},
267 Path{Name: "mutators.header.enabled", Default: false, Type: false, TypeHint: Bool},
268 Path{Name: "mutators.hydrator.config.api.auth.basic.password", Type: "", TypeHint: String},
269 Path{Name: "mutators.hydrator.config.api.auth.basic.username", Type: "", TypeHint: String},
270 Path{Name: "mutators.hydrator.config.api.retry.delay_in_milliseconds", Default: float64(3), Type: float64(0), TypeHint: Int, Minimum: big.NewFloat(0)},
271 Path{Name: "mutators.hydrator.config.api.retry.number_of_retries", Default: float64(100), Type: float64(0), TypeHint: Float, Minimum: big.NewFloat(0)},
272 Path{Name: "mutators.hydrator.config.api.url", Type: "", TypeHint: String, Format: "uri"},
273 Path{Name: "mutators.hydrator.enabled", Default: false, Type: false, TypeHint: Bool},
274 Path{Name: "mutators.id_token.config.claims", Type: "", TypeHint: String},
275 Path{Name: "mutators.id_token.config.issuer_url", Type: "", TypeHint: String},
276 Path{Name: "mutators.id_token.config.jwks_url", Type: "", TypeHint: String, Format: "uri"},
277 Path{Name: "mutators.id_token.config.ttl", Default: "1m", Type: "", TypeHint: String, Pattern: regexp.MustCompile("^[0-9]+(ns|us|ms|s|m|h)$")},
278 Path{Name: "mutators.id_token.enabled", Default: false, Type: false, TypeHint: Bool},
279 Path{Name: "mutators.noop.enabled", Default: false, Type: false, TypeHint: Bool},
280 Path{Name: "profiling", Type: "", TypeHint: String, Enum: []interface{}{"cpu", "mem"}},
281 Path{Name: "serve.api.cors.allow_credentials", Default: false, Type: false, TypeHint: Bool},
282 Path{Name: "serve.api.cors.allowed_headers", Default: []interface{}{"Authorization", "Content-Type"}, MinLength: 1,
283 Type: []string{}, TypeHint: StringSlice},
284 Path{Name: "serve.api.cors.allowed_methods", Default: []interface{}{"GET", "POST", "PUT", "PATCH", "DELETE"},
285 Type: []string{}, TypeHint: StringSlice},
286 Path{Name: "serve.api.cors.allowed_origins", Default: []interface{}{"*"},
287 Type: []string{}, TypeHint: StringSlice},
288 Path{Name: "serve.api.cors.debug", Default: false, Type: false, TypeHint: Bool},
289 Path{Name: "serve.api.cors.enabled", Default: false, Type: false, TypeHint: Bool},
290 Path{Name: "serve.api.cors.exposed_headers", Default: []interface{}{"Content-Type"}, MinLength: 1,
291 Type: []string{}, TypeHint: StringSlice},
292 Path{Name: "serve.api.cors.max_age", Default: float64(0), Type: float64(0), TypeHint: Float},
293 Path{Name: "serve.api.host", Default: "", Type: "", TypeHint: String},
294 Path{Name: "serve.api.port", Default: float64(4456), Type: float64(0), TypeHint: Float},
295 Path{Name: "serve.api.tls.cert.base64", Type: "", TypeHint: String},
296 Path{Name: "serve.api.tls.cert.path", Type: "", TypeHint: String},
297 Path{Name: "serve.api.tls.key.base64", Type: "", TypeHint: String},
298 Path{Name: "serve.api.tls.key.path", Type: "", TypeHint: String},
299 Path{Name: "serve.proxy.cors.allow_credentials", Default: false, Type: false, TypeHint: Bool},
300 Path{Name: "serve.proxy.cors.allowed_headers", Default: []interface{}{"Authorization", "Content-Type"}, MinLength: 1,
301 Type: []string{}, TypeHint: StringSlice},
302 Path{Name: "serve.proxy.cors.allowed_methods", Default: []interface{}{"GET", "POST", "PUT", "PATCH", "DELETE"},
303 Type: []string{}, TypeHint: StringSlice},
304 Path{Name: "serve.proxy.cors.allowed_origins", Default: []interface{}{"*"},
305 Type: []string{}, TypeHint: StringSlice},
306 Path{Name: "serve.proxy.cors.debug", Default: false, Type: false, TypeHint: Bool},
307 Path{Name: "serve.proxy.cors.enabled", Default: false, Type: false, TypeHint: Bool},
308 Path{Name: "serve.proxy.cors.exposed_headers", Default: []interface{}{"Content-Type"}, MinLength: 1,
309 Type: []string{}, TypeHint: StringSlice},
310 Path{Name: "serve.proxy.cors.max_age", Default: float64(0), Type: float64(0), TypeHint: Float},
311 Path{Name: "serve.proxy.host", Default: "", Type: "", TypeHint: String},
312 Path{Name: "serve.proxy.port", Default: float64(4455), Type: float64(0), TypeHint: Float},
313 Path{Name: "serve.proxy.timeout.idle", Default: "120s", Type: "", TypeHint: String, Pattern: regexp.MustCompile("^[0-9]+(ns|us|ms|s|m|h)$")},
314 Path{Name: "serve.proxy.timeout.read", Default: "5s", Type: "", TypeHint: String, Pattern: regexp.MustCompile("^[0-9]+(ns|us|ms|s|m|h)$")},
315 Path{Name: "serve.proxy.timeout.write", Default: "120s", Type: "", TypeHint: String, Pattern: regexp.MustCompile("^[0-9]+(ns|us|ms|s|m|h)$")},
316 Path{Name: "serve.proxy.tls.cert.base64", Type: "", TypeHint: String},
317 Path{Name: "serve.proxy.tls.cert.path", Type: "", TypeHint: String},
318 Path{Name: "serve.proxy.tls.key.base64", Type: "", TypeHint: String},
319 Path{Name: "serve.proxy.tls.key.path", Type: "", TypeHint: String},
320 },
321 },
322 {
323 schema: readFile(t, "./stub/config.schema.json"),
324 expected: []Path{
325 {
326 Name: "dsn",
327 Default: nil,
328 TypeHint: String,
329 Type: "",
330 },
331 },
332 },
333 {
334
335 schema: recursiveSchema,
336 expectErr: true,
337 },
338 {
339 schema: `{
340 "$schema": "http://json-schema.org/draft-07/schema#",
341 "$id": "test.json",
342 "oneOf": [
343 {
344 "type": "object",
345 "properties": {
346 "list": {
347 "type": "array",
348 "items": {
349 "type": "string"
350 }
351 },
352 "foo": {
353 "default": false,
354 "type": "boolean"
355 },
356 "bar": {
357 "type": "boolean",
358 "default": "asdf",
359 "readOnly": true
360 }
361 }
362 },
363 {
364 "type": "object",
365 "properties": {
366 "foo": {
367 "type": "boolean"
368 }
369 }
370 }
371 ]
372 }`,
373 expected: byName{
374 {
375 Name: "bar",
376 Default: "asdf",
377 Type: false,
378 TypeHint: Bool,
379 ReadOnly: true,
380 },
381 {
382 Name: "foo",
383 Default: false,
384 Type: false,
385 TypeHint: Bool,
386 },
387 {
388 Name: "list",
389 Type: []string{},
390 TypeHint: StringSlice,
391 },
392 },
393 },
394 {
395 schema: `{
396 "$schema": "http://json-schema.org/draft-07/schema#",
397 "$id": "test.json",
398 "type": "object",
399 "properties": {
400 "foo": {
401 "type": "boolean"
402 },
403 "bar": {
404 "type": "string",
405 "fooExtension": {
406 "not-a-json-schema-key": "foobar"
407 }
408 }
409 }
410 }`,
411 extension: &jsonschema.Extension{
412 Meta: nil,
413 Compile: fooExtensionCompile,
414 Validate: fooExtensionValidate,
415 },
416 expected: byName{
417 {
418 Name: "bar",
419 Type: "",
420 TypeHint: String,
421 CustomProperties: map[string]interface{}{
422 "foobar": "bar",
423 },
424 },
425 {
426 Name: "foo",
427 Type: false,
428 TypeHint: Bool,
429 },
430 },
431 },
432 } {
433 t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
434 c := jsonschema.NewCompiler()
435 if tc.extension != nil {
436 c.Extensions[fooExtensionName] = *tc.extension
437 }
438
439 require.NoError(t, c.AddResource("test.json", bytes.NewBufferString(tc.schema)))
440 actual, err := ListPaths("test.json", c)
441 if tc.expectErr {
442 require.Error(t, err, "%+v", actual)
443 return
444 }
445 require.NoError(t, err)
446 assertEqualPaths(t, tc.expected, actual)
447 })
448 }
449 }
450
View as plain text