Files
onix/pkg/plugin/implementation/schemav2validator/extended_schema_test.go

710 lines
16 KiB
Go

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