710 lines
16 KiB
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")
|
|
}
|