package validate import ( "context" "encoding/json" "net/url" "os" "path/filepath" "reflect" "runtime" "strconv" "strings" "testing" "testing/fstest" "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) func TestStandardValidatorNoneSkipped(t *testing.T) { v := NewStandardValidator("") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte("ignored")}, domain.OutputContract{ ValidationMode: domain.ValidationNone, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationSkipped { t.Fatalf("expected skipped, got %q", res.Status) } if !res.IsValid { t.Fatal("expected valid=true for skipped") } } func TestStandardValidatorBasicSuccess(t *testing.T) { v := NewStandardValidator("") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte("hello")}, domain.OutputContract{ ValidationMode: domain.ValidationBasic, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } } func TestStandardValidatorBasicFailureEmpty(t *testing.T) { v := NewStandardValidator("") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(" \n\t ")}, domain.OutputContract{ ValidationMode: domain.ValidationBasic, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationFailed || res.IsValid { t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) } if len(res.Errors) == 0 { t.Fatal("expected validation errors") } } func TestStandardValidatorJSONSuccess(t *testing.T) { v := NewStandardValidator("") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"ok":true}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSON, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } } func TestStandardValidatorJSONFailure(t *testing.T) { v := NewStandardValidator("") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"ok":`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSON, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationFailed || res.IsValid { t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) } if len(res.Errors) == 0 { t.Fatal("expected parse errors") } } func TestJSONValidationChecksCompleteSyntaxWithoutChangingArtifact(t *testing.T) { tests := []struct { name string body string wantValid bool }{ {name: "ordinary object", body: `{"count":2,"ok":true}`, wantValid: true}, {name: "integer at exact float boundary", body: `9007199254740992`, wantValid: true}, {name: "integer beyond exact float boundary", body: `9007199254740993`, wantValid: true}, {name: "large exponent", body: `1e400`, wantValid: true}, {name: "precise decimal", body: `0.123456789012345678901234567890`, wantValid: true}, {name: "surrounding whitespace", body: " \n [1,2,3] \t", wantValid: true}, {name: "malformed document", body: `{"count":`, wantValid: false}, {name: "trailing value", body: `1 2`, wantValid: false}, } validator := NewStandardValidator("") for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { body := []byte(tc.body) before := append([]byte(nil), body...) artifact := &domain.Artifact{Body: body} result, err := validator.Validate(context.Background(), artifact, domain.OutputContract{ ValidationMode: domain.ValidationJSON, }) if err != nil { t.Fatalf("validate JSON: %v", err) } if result.IsValid != tc.wantValid { t.Fatalf("valid = %v, want %v; result=%+v", result.IsValid, tc.wantValid, result) } if tc.wantValid && result.Status != domain.ValidationPassed { t.Fatalf("status = %q, want %q", result.Status, domain.ValidationPassed) } if !tc.wantValid && result.Status != domain.ValidationFailed { t.Fatalf("status = %q, want %q", result.Status, domain.ValidationFailed) } if !reflect.DeepEqual(artifact.Body, before) { t.Fatalf("artifact body changed: got %q, want %q", artifact.Body, before) } }) } } func TestStandardValidatorJSONSchemaSuccess(t *testing.T) { tmp := t.TempDir() schemaPath := filepath.Join(tmp, "schema.json") if err := os.WriteFile(schemaPath, []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["name"], "properties": { "name": {"type": "string"} } }`), 0644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } } func TestStandardValidatorPreparedSchemaSurvivesSourceRemoval(t *testing.T) { tmp := t.TempDir() rootSchema := []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "original root", "type": "object", "required": ["value"], "properties": { "value": {"$ref": "value.json"} } }`) rootPath := filepath.Join(tmp, "schema.json") referencePath := filepath.Join(tmp, "value.json") if err := os.WriteFile(rootPath, rootSchema, 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(referencePath, []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "integer", "minimum": 2 }`), 0o644); err != nil { t.Fatal(err) } validator := NewStandardValidator(tmp) preparer, ok := validator.(ValidationPreparer) if !ok { t.Fatal("standard validator does not support validation preparation") } prepared, err := preparer.PrepareValidation(context.Background(), domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("prepare validation: %v", err) } assertSchemaDocument(t, prepared.SchemaDocument(), rootSchema) if err := os.Remove(rootPath); err != nil { t.Fatal(err) } if err := os.Remove(referencePath); err != nil { t.Fatal(err) } valid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":3}`)}) if err != nil { t.Fatalf("validate prepared artifact: %v", err) } if valid.Status != domain.ValidationPassed || !valid.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v errors=%v", valid.Status, valid.IsValid, valid.Errors) } invalid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":"changed"}`)}) if err != nil { t.Fatalf("validate prepared artifact: %v", err) } if invalid.Status != domain.ValidationFailed || invalid.IsValid { t.Fatalf("expected failed/invalid, got status=%q valid=%v", invalid.Status, invalid.IsValid) } } func TestStandardValidatorJSONSchemaNestedSchemaPathSuccess(t *testing.T) { tmp := t.TempDir() nestedDir := filepath.Join(tmp, "dnd") if err := os.MkdirAll(nestedDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(nestedDir, "schema.json"), []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["name"], "properties": { "name": {"type": "string"} } }`), 0644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: filepath.Join("dnd", "schema.json"), }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } } func TestStandardValidatorJSONSchemaNestedSchemaPathMissing(t *testing.T) { v := NewStandardValidator(t.TempDir()) _, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: filepath.Join("dnd", "missing.json"), }) if err == nil { t.Fatal("expected nested schema load error") } } func TestStandardValidatorJSONSchemaFailure(t *testing.T) { tmp := t.TempDir() schemaPath := filepath.Join(tmp, "schema.json") if err := os.WriteFile(schemaPath, []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["name"], "properties": { "name": {"type": "string"} } }`), 0644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"count":1}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationFailed || res.IsValid { t.Fatalf("expected failed/invalid, got status=%q valid=%v", res.Status, res.IsValid) } if len(res.Errors) == 0 { t.Fatal("expected schema errors") } } func TestStandardValidatorJSONSchemaSchemaLoadError(t *testing.T) { v := NewStandardValidator(t.TempDir()) _, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "missing.json", }) if err == nil { t.Fatal("expected schema load error") } } func TestStandardValidatorJSONSchemaCompilationError(t *testing.T) { tmp := t.TempDir() if err := os.WriteFile(filepath.Join(tmp, "schema.json"), []byte(`{"type":42}`), 0o644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err == nil || !strings.Contains(err.Error(), "failed to compile JSON schema") { t.Fatalf("expected schema compilation error, got result=%#v error=%v", res, err) } } func TestFSValidatorJSONSchemaSuccess(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/events.schema.json": &fstest.MapFile{Data: []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["events"], "properties": { "events": {"type": "array"} } }`)}, }, "schemas") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "events.schema.json", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } } func TestFSValidatorPreparedSchemaSurvivesSourceMutation(t *testing.T) { rootSchema := []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "original root", "type": "object", "required": ["value"], "properties": { "value": {"$ref": "value.json"} } }`) fsys := fstest.MapFS{ "schema.json": &fstest.MapFile{Data: rootSchema}, "value.json": &fstest.MapFile{Data: []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "integer", "minimum": 2 }`)}, } validator := NewFSValidator(fsys, ".") preparer, ok := validator.(ValidationPreparer) if !ok { t.Fatal("filesystem validator does not support validation preparation") } prepared, err := preparer.PrepareValidation(context.Background(), domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("prepare validation: %v", err) } assertSchemaDocument(t, prepared.SchemaDocument(), rootSchema) fsys["schema.json"] = &fstest.MapFile{Data: []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string" }`)} fsys["value.json"] = &fstest.MapFile{Data: []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string" }`)} valid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":3}`)}) if err != nil { t.Fatalf("validate prepared artifact: %v", err) } if valid.Status != domain.ValidationPassed || !valid.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v errors=%v", valid.Status, valid.IsValid, valid.Errors) } invalid, err := prepared.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"value":"changed"}`)}) if err != nil { t.Fatalf("validate prepared artifact: %v", err) } if invalid.Status != domain.ValidationFailed || invalid.IsValid { t.Fatalf("expected failed/invalid, got status=%q valid=%v", invalid.Status, invalid.IsValid) } } func TestFSValidatorEscapesSchemaResourcePath(t *testing.T) { for _, schemaName := range []string{"%zz.json", "space name.json", "hash#.json", "query?.json", "rún.json"} { t.Run(schemaName, func(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/" + schemaName: &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, }, "schemas") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: schemaName, }) if err != nil || !res.IsValid { t.Fatalf("validate schema %q: result=%#v error=%v", schemaName, res, err) } }) } } func TestSchemaReferencesPreserveEscapedFilenames(t *testing.T) { for _, name := range []string{"%2F.json", "space name.json", "hash#.json", "query?.json", "rún.json"} { t.Run(name, func(t *testing.T) { rootName := "root-" + name childName := "child-" + name childPath := "nested/" + childName reference := (&url.URL{Path: childPath}).EscapedPath() rootSchema := []byte(`{"$ref":` + strconv.Quote(reference) + `}`) childSchema := []byte(`{"type":"integer","minimum":2}`) t.Run("fs.FS", func(t *testing.T) { validator := NewFSValidator(fstest.MapFS{ "schemas/" + rootName: &fstest.MapFile{Data: rootSchema}, "schemas/" + childPath: &fstest.MapFile{Data: childSchema}, }, "schemas") assertSchemaValidation(t, validator, rootName) }) t.Run("operating system files", func(t *testing.T) { if runtime.GOOS == "windows" && strings.ContainsAny(rootName+childName, `<>:"/\|?*`) { t.Skip("filename is not legal on Windows") } root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, rootName), rootSchema, 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(childPath)), childSchema, 0o644); err != nil { t.Fatal(err) } assertSchemaValidation(t, NewStandardValidator(root), rootName) }) }) } } func assertSchemaValidation(t *testing.T, validator Validator, schemaPath string) { t.Helper() result, err := validator.Validate(context.Background(), &domain.Artifact{Body: []byte(`2`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: schemaPath, }) if err != nil || !result.IsValid { t.Fatalf("validate schema %q: result=%#v error=%v", schemaPath, result, err) } } func TestFSValidatorJSONSchemaPathContainment(t *testing.T) { t.Run("nested schema inside root succeeds", func(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/nested/events.schema.json": &fstest.MapFile{Data: []byte(`{ "type": "object", "required": ["events"], "properties": { "events": {"type": "array"} } }`)}, }, "schemas") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "nested/events.schema.json", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } }) tests := []struct { name string schemaPath string wantErr string }{ {name: "parent escape rejected", schemaPath: "../outside.schema.json", wantErr: "escapes source root"}, {name: "absolute path rejected", schemaPath: "/outside.schema.json", wantErr: "must be relative"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/events.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, "outside.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, "schemas/outside.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, }, "schemas") _, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: tc.schemaPath, }) if err == nil { t.Fatal("expected schema path error") } if !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err) } }) } } func TestFSValidatorSingleSchemaFileUsesBaseName(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "events.schema.json": &fstest.MapFile{Data: []byte(`{ "type": "object", "required": ["events"], "properties": { "events": {"type": "array"} } }`)}, }, "events.schema.json") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "events.schema.json", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if res.Status != domain.ValidationPassed || !res.IsValid { t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) } _, err = v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"events":[]}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "other.schema.json", }) if err == nil { t.Fatal("expected schema path mismatch error") } } func TestStandardValidatorJSONSchemaReferenceBoundaries(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "child.json"), []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string" }`), 0o644); err != nil { t.Fatal(err) } tests := []struct { name string reference string wantError string writeOuter bool }{ {name: "contained relative reference", reference: "child.json"}, {name: "remote reference", reference: "https://example.test/schema.json", wantError: "not a contained file reference"}, {name: "escaping reference", reference: "../outside.json", wantError: "escapes source root", writeOuter: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.writeOuter { if err := os.WriteFile(filepath.Join(filepath.Dir(root), "outside.json"), []byte(`{"type":"string"}`), 0o644); err != nil { t.Fatal(err) } } schema := `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$ref": ` + strconv.Quote(tc.reference) + ` }` if err := os.WriteFile(filepath.Join(root, "root.json"), []byte(schema), 0o644); err != nil { t.Fatal(err) } v := NewStandardValidator(root) result, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`"value"`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "root.json", }) if tc.wantError == "" { if err != nil || !result.IsValid { t.Fatalf("expected contained reference to validate, got result=%#v error=%v", result, err) } return } if err == nil || !strings.Contains(err.Error(), tc.wantError) { t.Fatalf("expected error containing %q, got %v", tc.wantError, err) } }) } } func TestFSValidatorJSONSchemaReferenceBoundaries(t *testing.T) { tests := []struct { name string reference string wantError string }{ {name: "same document fragment", reference: "#/$defs/value"}, {name: "contained relative reference", reference: "child.json"}, {name: "remote reference", reference: "https://example.test/schema.json", wantError: "is not allowed"}, {name: "escaping reference", reference: "../outside.json", wantError: "escapes source root"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rootSchema := `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": {"value": {"type": "string"}}, "$ref": ` + strconv.Quote(tc.reference) + ` }` v := NewFSValidator(fstest.MapFS{ "schemas/root.json": &fstest.MapFile{Data: []byte(rootSchema)}, "schemas/child.json": &fstest.MapFile{Data: []byte(`{"type":"string"}`)}, "outside.json": &fstest.MapFile{Data: []byte(`{"type":"string"}`)}, }, "schemas") result, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`"value"`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "root.json", }) if tc.wantError == "" { if err != nil || !result.IsValid { t.Fatalf("expected supported reference to validate, got result=%#v error=%v", result, err) } return } if err == nil || !strings.Contains(err.Error(), tc.wantError) { t.Fatalf("expected error containing %q, got %v", tc.wantError, err) } }) } } func TestJSONSchemaNumericConstraintsRetainJSONPrecision(t *testing.T) { tests := []struct { name string schema string instance string wantValid bool }{ { name: "const distinguishes adjacent large integers", schema: `{"const":9007199254740993}`, instance: `9007199254740993`, wantValid: true, }, { name: "const rejects adjacent large integer", schema: `{"const":9007199254740993}`, instance: `9007199254740992`, wantValid: false, }, { name: "const accepts exponent beyond float range", schema: `{"const":1e400}`, instance: `1e400`, wantValid: true, }, { name: "minimum accepts precise decimal boundary", schema: `{"type":"number","minimum":0.123456789012345678901234567890}`, instance: `0.123456789012345678901234567890`, wantValid: true, }, { name: "minimum rejects lower precise decimal", schema: `{"type":"number","minimum":0.123456789012345678901234567890}`, instance: `0.123456789012345678901234567889`, wantValid: false, }, { name: "maximum distinguishes adjacent large integers", schema: `{"type":"number","maximum":9007199254740992}`, instance: `9007199254740993`, wantValid: false, }, { name: "multiple of accepts exact decimal multiple", schema: `{"type":"number","multipleOf":0.0000000000000000001}`, instance: `0.0000000000000000003`, wantValid: true, }, { name: "multiple of rejects inexact decimal multiple", schema: `{"type":"number","multipleOf":0.0000000000000000001}`, instance: `0.00000000000000000031`, wantValid: false, }, { name: "ordinary number remains supported", schema: `{"type":"number","minimum":1,"maximum":3}`, instance: `2`, wantValid: true, }, } for _, source := range jsonSchemaValidatorSources() { t.Run(source.name, func(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { validator := source.new(t, []byte(tc.schema)) result, err := validator.Validate(context.Background(), &domain.Artifact{Body: []byte(tc.instance)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("validate JSON Schema instance: %v", err) } if result.IsValid != tc.wantValid { t.Fatalf("valid = %v, want %v; result=%+v", result.IsValid, tc.wantValid, result) } }) } }) } } func TestJSONSchemaDecodingRequiresOneCompleteDocument(t *testing.T) { for _, source := range jsonSchemaValidatorSources() { t.Run(source.name, func(t *testing.T) { for _, schema := range []string{`{"type":`, `{} {}`} { validator := source.new(t, []byte(schema)) _, err := validator.Validate(context.Background(), &domain.Artifact{Body: []byte(`1`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err == nil { t.Fatalf("schema %q: expected decoding error", schema) } } validator := source.new(t, []byte(`{}`)) for _, instance := range []string{`{"value":`, `1 2`} { result, err := validator.Validate(context.Background(), &domain.Artifact{Body: []byte(instance)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("instance %q: expected completed validation, got %v", instance, err) } if result.Status != domain.ValidationFailed || result.IsValid || len(result.Errors) == 0 { t.Fatalf("instance %q: expected failed validation, got %+v", instance, result) } } }) } } func TestPreparedSchemaDocumentsRetainExactNumbers(t *testing.T) { const schema = `{ "const": 9007199254740993, "minimum": 0.123456789012345678901234567890, "maximum": 1e400, "multipleOf": 0.0000000000000000001 }` want := map[string]string{ "const": "9007199254740993", "minimum": "0.123456789012345678901234567890", "maximum": "1e400", "multipleOf": "0.0000000000000000001", } for _, source := range jsonSchemaValidatorSources() { t.Run(source.name, func(t *testing.T) { validator := source.new(t, []byte(schema)) preparer, ok := validator.(ValidationPreparer) if !ok { t.Fatal("validator does not support validation preparation") } prepared, err := preparer.PrepareValidation(context.Background(), domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if err != nil { t.Fatalf("prepare validation: %v", err) } document, ok := prepared.SchemaDocument().(map[string]any) if !ok { t.Fatalf("schema document = %#v, want object", prepared.SchemaDocument()) } for name, wantNumber := range want { got, ok := document[name].(json.Number) if !ok { t.Fatalf("schema field %q = %#v, want json.Number", name, document[name]) } if got.String() != wantNumber { t.Fatalf("schema field %q = %q, want %q", name, got, wantNumber) } } }) } } type jsonSchemaValidatorSource struct { name string new func(*testing.T, []byte) Validator } func jsonSchemaValidatorSources() []jsonSchemaValidatorSource { return []jsonSchemaValidatorSource{ { name: "operating system files", new: func(t *testing.T, schema []byte) Validator { t.Helper() root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "schema.json"), schema, 0o644); err != nil { t.Fatalf("write schema: %v", err) } return NewStandardValidator(root) }, }, { name: "fs.FS", new: func(t *testing.T, schema []byte) Validator { t.Helper() return NewFSValidator(fstest.MapFS{ "schema.json": &fstest.MapFile{Data: schema}, }, ".") }, }, } } func BenchmarkJSONValidation(b *testing.B) { largeArray := []byte(`[` + strings.Repeat(`12345678901234567890,`, 32*1024) + `0]`) benchmarks := []struct { name string body []byte }{ {name: "scalar", body: []byte(`1e400`)}, {name: "object", body: []byte(`{"name":"eris","count":9007199254740993,"enabled":true}`)}, {name: "large array", body: largeArray}, } validator := NewStandardValidator("") contract := domain.OutputContract{ValidationMode: domain.ValidationJSON} for _, benchmark := range benchmarks { b.Run(benchmark.name, func(b *testing.B) { artifact := &domain.Artifact{Body: benchmark.body} b.ReportAllocs() b.SetBytes(int64(len(benchmark.body))) b.ResetTimer() for range b.N { result, err := validator.Validate(context.Background(), artifact, contract) if err != nil || !result.IsValid { b.Fatalf("validate JSON: result=%+v error=%v", result, err) } } }) } } func TestJSONSchemaDialectIsDraft2020(t *testing.T) { tests := []struct { name string dialect string wantError bool }{ {name: "omitted uses supported default"}, {name: "draft 2020-12", dialect: "https://json-schema.org/draft/2020-12/schema"}, {name: "draft 7 rejected", dialect: "http://json-schema.org/draft-07/schema#", wantError: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { schema := map[string]any{"type": "object"} if tc.dialect != "" { schema["$schema"] = tc.dialect } data, err := json.Marshal(schema) if err != nil { t.Fatal(err) } v := NewFSValidator(fstest.MapFS{ "schema.json": &fstest.MapFile{Data: data}, }, ".") _, err = v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json", }) if tc.wantError { if err == nil || !strings.Contains(err.Error(), "unsupported JSON Schema dialect") { t.Fatalf("expected unsupported-dialect error, got %v", err) } return } if err != nil { t.Fatalf("expected supported dialect, got %v", err) } }) } } func assertSchemaDocument(t *testing.T, got any, expectedJSON []byte) { t.Helper() expected, err := decodeJSONValue(context.Background(), expectedJSON) if err != nil { t.Fatalf("decode expected schema document: %v", err) } if !reflect.DeepEqual(got, expected) { t.Fatalf("schema document mismatch:\n got: %#v\nwant: %#v", got, expected) } }