Files
promptkit/internal/validate/standard_validator_test.go

935 lines
30 KiB
Go

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)
}
}