package validate import ( "context" "encoding/json" "os" "path/filepath" "reflect" "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 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 TestStandardValidatorLoadSchemaDocumentSuccess(t *testing.T) { tmp := t.TempDir() if err := os.WriteFile(filepath.Join(tmp, "schema.json"), []byte(`{ "type": "object", "properties": { "name": {"type": "string"} } }`), 0644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) loader, ok := v.(SchemaDocumentLoader) if !ok { t.Fatal("standard validator must implement SchemaDocumentLoader") } doc, err := loader.LoadSchemaDocument(context.Background(), "schema.json") if err != nil { t.Fatalf("expected no error, got %v", err) } obj, ok := doc.(map[string]any) if !ok { t.Fatalf("expected object document, got %#v", doc) } if obj["type"] != "object" { t.Fatalf("expected schema type=object, got %#v", obj["type"]) } } func TestStandardValidatorLoadSchemaDocumentInvalidJSON(t *testing.T) { tmp := t.TempDir() if err := os.WriteFile(filepath.Join(tmp, "schema.json"), []byte(`{`), 0644); err != nil { t.Fatal(err) } v := NewStandardValidator(tmp) loader, ok := v.(SchemaDocumentLoader) if !ok { t.Fatal("standard validator must implement SchemaDocumentLoader") } _, err := loader.LoadSchemaDocument(context.Background(), "schema.json") if err == nil { t.Fatal("expected decode error") } } 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 TestFSValidatorJSONSchemaRegistrationError(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/%zz.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, }, "schemas") res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{}`)}, domain.OutputContract{ ValidationMode: domain.ValidationJSONSchema, SchemaPath: "%zz.json", }) if err == nil || !strings.Contains(err.Error(), "failed to register JSON schema") { t.Fatalf("expected schema registration error, got result=%#v error=%v", res, 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 TestFSValidatorLoadSchemaDocument(t *testing.T) { v := NewFSValidator(fstest.MapFS{ "schemas/schema.json": &fstest.MapFile{Data: []byte(`{"type":"object"}`)}, }, "schemas") loader, ok := v.(SchemaDocumentLoader) if !ok { t.Fatal("fs validator must implement SchemaDocumentLoader") } doc, err := loader.LoadSchemaDocument(context.Background(), "schema.json") if err != nil { t.Fatalf("expected no error, got %v", err) } obj, ok := doc.(map[string]any) if !ok || obj["type"] != "object" { t.Fatalf("unexpected schema document: %#v", doc) } } 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 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() var expected any if err := json.Unmarshal(expectedJSON, &expected); 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) } }