package schemav2validator import ( "context" "os" "testing" "time" "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/assert" ) func TestIsCoreSchema(t *testing.T) { tests := []struct { name string contextURL string want bool }{ { name: "core schema URL", contextURL: "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/draft/schema/core/v2/context.jsonld", want: true, }, { name: "domain schema URL", contextURL: "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/draft/schema/EvChargingOffer/v1/context.jsonld", want: false, }, { name: "empty URL", contextURL: "", want: false, }, { name: "URL without schema/core", contextURL: "https://example.com/some/path/context.jsonld", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isCoreSchema(tt.contextURL) assert.Equal(t, tt.want, got) }) } } func TestFindReferencedObjects(t *testing.T) { tests := []struct { name string data interface{} path string want int // number of objects found }{ { name: "single domain object", data: map[string]interface{}{ "@context": "https://example.com/schema/DomainType/v1/context.jsonld", "@type": "DomainType", "field": "value", }, path: "message", want: 1, }, { name: "core schema object - should be skipped", data: map[string]interface{}{ "@context": "https://example.com/schema/core/v2/context.jsonld", "@type": "beckn:Order", "field": "value", }, path: "message", want: 0, }, { name: "nested domain objects", data: map[string]interface{}{ "order": map[string]interface{}{ "@context": "https://example.com/schema/core/v2/context.jsonld", "@type": "beckn:Order", "orderAttributes": map[string]interface{}{ "@context": "https://example.com/schema/ChargingSession/v1/context.jsonld", "@type": "ChargingSession", "field": "value", }, }, }, path: "message", want: 1, // Only domain object, core skipped }, { name: "array with domain objects", data: map[string]interface{}{ "items": []interface{}{ map[string]interface{}{ "@context": "https://example.com/schema/DomainType/v1/context.jsonld", "@type": "DomainType", }, map[string]interface{}{ "@context": "https://example.com/schema/AnotherType/v1/context.jsonld", "@type": "AnotherType", }, }, }, path: "message", want: 2, }, { name: "object without @context", data: map[string]interface{}{ "field": "value", }, path: "message", want: 0, }, { name: "object with @context but no @type", data: map[string]interface{}{ "@context": "https://example.com/schema/DomainType/v1/context.jsonld", "field": "value", }, path: "message", want: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := findReferencedObjects(tt.data, tt.path) assert.Equal(t, tt.want, len(got)) }) } } func TestTransformContextToSchemaURL(t *testing.T) { tests := []struct { name string contextURL string want string }{ { name: "standard transformation", contextURL: "https://example.com/schema/EvChargingOffer/v1/context.jsonld", want: "https://example.com/schema/EvChargingOffer/v1/attributes.yaml", }, { name: "already attributes.yaml", contextURL: "https://example.com/schema/EvChargingOffer/v1/attributes.yaml", want: "https://example.com/schema/EvChargingOffer/v1/attributes.yaml", }, { name: "no context.jsonld in URL", contextURL: "https://example.com/schema/EvChargingOffer/v1/", want: "https://example.com/schema/EvChargingOffer/v1/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := transformContextToSchemaURL(tt.contextURL) assert.Equal(t, tt.want, got) }) } } func TestHashURL(t *testing.T) { tests := []struct { name string url string }{ { name: "consistent hashing", url: "https://example.com/schema.yaml", }, { name: "empty string", url: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hash1 := hashURL(tt.url) hash2 := hashURL(tt.url) // Same URL should produce same hash assert.Equal(t, hash1, hash2) // Hash should be 64 characters (SHA256 hex) assert.Equal(t, 64, len(hash1)) }) } } func TestIsValidSchemaPath(t *testing.T) { tests := []struct { name string schemaPath string want bool }{ { name: "http URL", schemaPath: "http://example.com/schema.yaml", want: true, }, { name: "https URL", schemaPath: "https://example.com/schema.yaml", want: true, }, { name: "file URL", schemaPath: "file:///path/to/schema.yaml", want: true, }, { name: "local path", schemaPath: "/path/to/schema.yaml", want: true, }, { name: "relative path", schemaPath: "./schema.yaml", want: true, }, { name: "empty path", schemaPath: "", want: true, // url.Parse("") succeeds, returns empty scheme }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isValidSchemaPath(tt.schemaPath) assert.Equal(t, tt.want, got) }) } } func TestNewSchemaCache(t *testing.T) { tests := []struct { name string maxSize int }{ { name: "default size", maxSize: 100, }, { name: "custom size", maxSize: 50, }, { name: "zero size", maxSize: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cache := newSchemaCache(tt.maxSize) assert.NotNil(t, cache) assert.Equal(t, tt.maxSize, cache.maxSize) assert.NotNil(t, cache.schemas) assert.Equal(t, 0, len(cache.schemas)) }) } } func TestSchemaCache_GetSet(t *testing.T) { cache := newSchemaCache(10) // Create a simple schema doc doc := &openapi3.T{ OpenAPI: "3.1.0", } urlHash := hashURL("https://example.com/schema.yaml") ttl := 1 * time.Hour // Test Set cache.set(urlHash, doc, ttl) // Test Get - should find it retrieved, found := cache.get(urlHash) assert.True(t, found) assert.Equal(t, doc, retrieved) // Test Get - non-existent key _, found = cache.get("non-existent-hash") assert.False(t, found) } func TestSchemaCache_LRUEviction(t *testing.T) { cache := newSchemaCache(2) // Small cache for testing doc1 := &openapi3.T{OpenAPI: "3.1.0"} doc2 := &openapi3.T{OpenAPI: "3.1.1"} doc3 := &openapi3.T{OpenAPI: "3.1.2"} ttl := 1 * time.Hour // Add first two items cache.set("hash1", doc1, ttl) cache.set("hash2", doc2, ttl) // Access first item to make it more recent cache.get("hash1") // Add third item - should evict hash2 (least recently used) cache.set("hash3", doc3, ttl) // Verify hash1 and hash3 exist, hash2 was evicted _, found1 := cache.get("hash1") _, found2 := cache.get("hash2") _, found3 := cache.get("hash3") assert.True(t, found1, "hash1 should exist (recently accessed)") assert.False(t, found2, "hash2 should be evicted (LRU)") assert.True(t, found3, "hash3 should exist (just added)") } func TestSchemaCache_TTLExpiry(t *testing.T) { cache := newSchemaCache(10) doc := &openapi3.T{OpenAPI: "3.1.0"} urlHash := "test-hash" // Set with very short TTL cache.set(urlHash, doc, 1*time.Millisecond) // Should be found immediately _, found := cache.get(urlHash) assert.True(t, found) // Wait for expiry time.Sleep(10 * time.Millisecond) // Should not be found after expiry _, found = cache.get(urlHash) assert.False(t, found) } func TestSchemaCache_CleanupExpired(t *testing.T) { cache := newSchemaCache(10) doc := &openapi3.T{OpenAPI: "3.1.0"} // Add items with short TTL cache.set("hash1", doc, 1*time.Millisecond) cache.set("hash2", doc, 1*time.Millisecond) cache.set("hash3", doc, 1*time.Hour) // This one won't expire // Wait for expiry time.Sleep(10 * time.Millisecond) // Cleanup expired count := cache.cleanupExpired() // Should have cleaned up 2 expired items assert.Equal(t, 2, count) // Verify only hash3 remains cache.mu.RLock() assert.Equal(t, 1, len(cache.schemas)) _, exists := cache.schemas["hash3"] assert.True(t, exists) cache.mu.RUnlock() } func TestIsAllowedDomain(t *testing.T) { tests := []struct { name string schemaURL string allowedDomains []string want bool }{ { name: "empty whitelist - all allowed", schemaURL: "https://example.com/schema.yaml", allowedDomains: []string{}, want: true, }, { name: "nil whitelist - all allowed", schemaURL: "https://example.com/schema.yaml", allowedDomains: nil, want: true, }, { name: "domain in whitelist", schemaURL: "https://raw.githubusercontent.com/beckn/schema.yaml", allowedDomains: []string{"raw.githubusercontent.com", "schemas.beckn.org"}, want: true, }, { name: "domain not in whitelist", schemaURL: "https://malicious.com/schema.yaml", allowedDomains: []string{"raw.githubusercontent.com", "schemas.beckn.org"}, want: false, }, { name: "partial domain match", schemaURL: "https://raw.githubusercontent.com/beckn/schema.yaml", allowedDomains: []string{"githubusercontent.com"}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isAllowedDomain(tt.schemaURL, tt.allowedDomains) assert.Equal(t, tt.want, got) }) } } func TestFindReferencedObjects_PathBuilding(t *testing.T) { data := map[string]interface{}{ "order": map[string]interface{}{ "beckn:orderItems": []interface{}{ map[string]interface{}{ "beckn:acceptedOffer": map[string]interface{}{ "beckn:offerAttributes": map[string]interface{}{ "@context": "https://example.com/schema/ChargingOffer/v1/context.jsonld", "@type": "ChargingOffer", }, }, }, }, }, } objects := findReferencedObjects(data, "message") assert.Equal(t, 1, len(objects)) assert.Equal(t, "message.order.beckn:orderItems[0].beckn:acceptedOffer.beckn:offerAttributes", objects[0].Path) assert.Equal(t, "ChargingOffer", objects[0].Type) } // Integration tests for the 4 remaining functions func TestLoadSchemaFromPath_LocalFile(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 components: schemas: TestType: type: object properties: field1: type: string` _, err = tmpFile.Write([]byte(schemaContent)) assert.NoError(t, err) tmpFile.Close() doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second) assert.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "3.1.0", doc.OpenAPI) } func TestLoadSchemaFromPath_CacheHit(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0` tmpFile.Write([]byte(schemaContent)) tmpFile.Close() doc1, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second) assert.NoError(t, err) doc2, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second) assert.NoError(t, err) assert.Equal(t, doc1, doc2) } func TestLoadSchemaFromPath_InvalidPath(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() _, err := cache.loadSchemaFromPath(ctx, "/nonexistent/schema.yaml", 1*time.Hour, 30*time.Second) assert.Error(t, err) } func TestFindSchemaByType_DirectMatch(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 components: schemas: TestType: type: object properties: field1: type: string` tmpFile.Write([]byte(schemaContent)) tmpFile.Close() doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second) assert.NoError(t, err) schema, err := findSchemaByType(ctx, doc, "TestType") assert.NoError(t, err) assert.NotNil(t, schema) } func TestFindSchemaByType_NotFound(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 components: schemas: TestType: type: object` tmpFile.Write([]byte(schemaContent)) tmpFile.Close() doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second) assert.NoError(t, err) _, err = findSchemaByType(ctx, doc, "NonExistentType") assert.Error(t, err) assert.Contains(t, err.Error(), "no schema found") } func TestValidateReferencedObject_Valid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 components: schemas: TestType: type: object additionalProperties: false x-jsonld: "@context": ./context.jsonld "@type": TestType properties: field1: type: string required: - field1` tmpFile.Write([]byte(schemaContent)) tmpFile.Close() obj := referencedObject{ Path: "message.test", Context: tmpFile.Name(), Type: "TestType", Data: map[string]interface{}{ "@context": tmpFile.Name(), "@type": "TestType", "field1": "value1", }, } err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil) assert.NoError(t, err) } func TestValidateReferencedObject_Invalid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 components: schemas: TestType: type: object additionalProperties: false x-jsonld: "@context": ./context.jsonld "@type": TestType properties: field1: type: string required: - field1` tmpFile.Write([]byte(schemaContent)) tmpFile.Close() obj := referencedObject{ Path: "message.test", Context: tmpFile.Name(), Type: "TestType", Data: map[string]interface{}{ "@context": tmpFile.Name(), "@type": "TestType", }, } err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil) assert.Error(t, err) } func TestValidateReferencedObject_DomainNotAllowed(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() obj := referencedObject{ Path: "message.test", Context: "https://malicious.com/schema.yaml", Type: "TestType", Data: map[string]interface{}{}, } allowedDomains := []string{"trusted.com"} err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, allowedDomains) assert.Error(t, err) assert.Contains(t, err.Error(), "domain not allowed") } func TestValidateExtendedSchemas_NoObjects(t *testing.T) { v := &schemav2Validator{ config: &Config{ EnableExtendedSchema: true, ExtendedSchemaConfig: ExtendedSchemaConfig{}, }, schemaCache: newSchemaCache(10), } ctx := context.Background() body := map[string]interface{}{ "message": map[string]interface{}{ "field": "value", }, } err := v.validateExtendedSchemas(ctx, body) assert.NoError(t, err) } func TestValidateExtendedSchemas_MissingMessage(t *testing.T) { v := &schemav2Validator{ config: &Config{ EnableExtendedSchema: true, }, schemaCache: newSchemaCache(10), } ctx := context.Background() body := map[string]interface{}{ "context": map[string]interface{}{}, } err := v.validateExtendedSchemas(ctx, body) assert.Error(t, err) assert.Contains(t, err.Error(), "missing 'message' field") }