519 lines
16 KiB
Go
519 lines
16 KiB
Go
package telemetry
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Test projectPath
|
|
|
|
func TestProjectPath_EmptyParts(t *testing.T) {
|
|
root := map[string]interface{}{"a": "v"}
|
|
got, ok := projectPath(root, nil)
|
|
require.True(t, ok)
|
|
assert.Equal(t, root, got)
|
|
|
|
got, ok = projectPath(root, []string{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, root, got)
|
|
}
|
|
|
|
func TestProjectPath_MapSingleLevel(t *testing.T) {
|
|
root := map[string]interface{}{"context": map[string]interface{}{"action": "search"}}
|
|
got, ok := projectPath(root, []string{"context"})
|
|
require.True(t, ok)
|
|
assert.Equal(t, map[string]interface{}{"context": map[string]interface{}{"action": "search"}}, got)
|
|
}
|
|
|
|
func TestProjectPath_MapNested(t *testing.T) {
|
|
root := map[string]interface{}{
|
|
"context": map[string]interface{}{
|
|
"action": "select",
|
|
"transaction_id": "tx-1",
|
|
},
|
|
}
|
|
got, ok := projectPath(root, []string{"context", "action"})
|
|
require.True(t, ok)
|
|
assert.Equal(t, map[string]interface{}{"context": map[string]interface{}{"action": "select"}}, got)
|
|
}
|
|
|
|
func TestProjectPath_MissingKey(t *testing.T) {
|
|
root := map[string]interface{}{"context": map[string]interface{}{"action": "search"}}
|
|
got, ok := projectPath(root, []string{"context", "missing"})
|
|
require.False(t, ok)
|
|
assert.Nil(t, got)
|
|
}
|
|
|
|
func TestProjectPath_ArrayTraverseAndProject(t *testing.T) {
|
|
root := map[string]interface{}{
|
|
"message": map[string]interface{}{
|
|
"order": map[string]interface{}{
|
|
"beckn:orderItems": []interface{}{
|
|
map[string]interface{}{"beckn:orderedItem": "item-1"},
|
|
map[string]interface{}{"beckn:orderedItem": "item-2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
parts := []string{"message", "order", "beckn:orderItems", "beckn:orderedItem"}
|
|
got, ok := projectPath(root, parts)
|
|
require.True(t, ok)
|
|
|
|
expected := map[string]interface{}{
|
|
"message": map[string]interface{}{
|
|
"order": map[string]interface{}{
|
|
"beckn:orderItems": []interface{}{
|
|
map[string]interface{}{"beckn:orderedItem": "item-1"},
|
|
map[string]interface{}{"beckn:orderedItem": "item-2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
assert.Equal(t, expected, got)
|
|
}
|
|
|
|
func TestProjectPath_NonMapOrSlice(t *testing.T) {
|
|
_, ok := projectPath("string", []string{"a"})
|
|
require.False(t, ok)
|
|
|
|
_, ok = projectPath(42, []string{"a"})
|
|
require.False(t, ok)
|
|
}
|
|
|
|
func TestProjectPath_EmptyArray(t *testing.T) {
|
|
root := map[string]interface{}{"items": []interface{}{}}
|
|
got, ok := projectPath(root, []string{"items", "id"})
|
|
require.False(t, ok)
|
|
assert.Nil(t, got)
|
|
}
|
|
|
|
// Test deepMerge
|
|
|
|
func TestDeepMerge_NilDst(t *testing.T) {
|
|
src := map[string]interface{}{"a": 1}
|
|
got := deepMerge(nil, src)
|
|
assert.Equal(t, src, got)
|
|
}
|
|
|
|
func TestDeepMerge_MapIntoMap(t *testing.T) {
|
|
dst := map[string]interface{}{"a": 1, "b": 2}
|
|
src := map[string]interface{}{"b": 20, "c": 3}
|
|
got := deepMerge(dst, src)
|
|
assert.Equal(t, map[string]interface{}{"a": 1, "b": 20, "c": 3}, got)
|
|
}
|
|
|
|
func TestDeepMerge_MapNested(t *testing.T) {
|
|
dst := map[string]interface{}{
|
|
"context": map[string]interface{}{"action": "search", "domain": "retail"},
|
|
}
|
|
src := map[string]interface{}{
|
|
"context": map[string]interface{}{"action": "search", "transaction_id": "tx-1"},
|
|
}
|
|
got := deepMerge(dst, src)
|
|
ctx, ok := got.(map[string]interface{})["context"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "search", ctx["action"])
|
|
assert.Equal(t, "retail", ctx["domain"])
|
|
assert.Equal(t, "tx-1", ctx["transaction_id"])
|
|
}
|
|
|
|
func TestDeepMerge_ArrayIntoArray(t *testing.T) {
|
|
dst := []interface{}{
|
|
map[string]interface{}{"id": "a"},
|
|
map[string]interface{}{"id": "b"},
|
|
}
|
|
src := []interface{}{
|
|
map[string]interface{}{"id": "a", "name": "A"},
|
|
map[string]interface{}{"id": "b", "name": "B"},
|
|
}
|
|
got := deepMerge(dst, src)
|
|
sl, ok := got.([]interface{})
|
|
require.True(t, ok)
|
|
require.Len(t, sl, 2)
|
|
assert.Equal(t, map[string]interface{}{"id": "a", "name": "A"}, sl[0])
|
|
assert.Equal(t, map[string]interface{}{"id": "b", "name": "B"}, sl[1])
|
|
}
|
|
|
|
func TestDeepMerge_ArraySrcLonger(t *testing.T) {
|
|
dst := []interface{}{map[string]interface{}{"a": 1}}
|
|
src := []interface{}{
|
|
map[string]interface{}{"a": 1},
|
|
map[string]interface{}{"a": 2},
|
|
}
|
|
got := deepMerge(dst, src)
|
|
sl, ok := got.([]interface{})
|
|
require.True(t, ok)
|
|
require.Len(t, sl, 2)
|
|
}
|
|
|
|
func TestDeepMerge_ScalarSrc(t *testing.T) {
|
|
dst := map[string]interface{}{"a": 1}
|
|
src := "overwrite"
|
|
got := deepMerge(dst, src)
|
|
assert.Equal(t, "overwrite", got)
|
|
}
|
|
|
|
// Test getFieldForAction and selectAuditPayload (require loaded rules via temp file)
|
|
|
|
func writeAuditRulesFile(t *testing.T, content string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit-fields.yaml")
|
|
err := os.WriteFile(path, []byte(content), 0600)
|
|
require.NoError(t, err)
|
|
return path
|
|
}
|
|
|
|
func TestGetFieldForAction_ActionMatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.transaction_id
|
|
- context.action
|
|
search:
|
|
- context.action
|
|
- context.timestamp
|
|
select:
|
|
- context.action
|
|
- message.order
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
fields := getFieldForAction(ctx, "search")
|
|
assert.Equal(t, []string{"context.action", "context.timestamp"}, fields)
|
|
|
|
fields = getFieldForAction(ctx, "select")
|
|
assert.Equal(t, []string{"context.action", "message.order"}, fields)
|
|
}
|
|
|
|
func TestGetFieldForAction_FallbackToDefault(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.transaction_id
|
|
- context.message_id
|
|
search:
|
|
- context.action
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
fields := getFieldForAction(ctx, "unknown_action")
|
|
assert.Equal(t, []string{"context.transaction_id", "context.message_id"}, fields)
|
|
|
|
fields = getFieldForAction(ctx, "")
|
|
assert.Equal(t, []string{"context.transaction_id", "context.message_id"}, fields)
|
|
}
|
|
|
|
func TestGetFieldForAction_EmptyDefault(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default: []
|
|
search:
|
|
- context.action
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
fields := getFieldForAction(ctx, "other")
|
|
assert.Empty(t, fields)
|
|
}
|
|
|
|
func TestSelectAuditPayload_InvalidJSON(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.action
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
got := selectAuditPayload(ctx, []byte("not json"))
|
|
assert.Nil(t, got)
|
|
}
|
|
|
|
func TestSelectAuditPayload_NoRulesLoaded(t *testing.T) {
|
|
ctx := context.Background()
|
|
// use a fresh context without loading any rules; auditRules may be from previous test
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default: []
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
body := []byte(`{"context":{"action":"search"}}`)
|
|
got := selectAuditPayload(ctx, body)
|
|
assert.Nil(t, got)
|
|
}
|
|
|
|
func TestSelectAuditPayload_ContextAndActionOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.transaction_id
|
|
- context.message_id
|
|
- context.action
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
body := []byte(`{
|
|
"context": {
|
|
"action": "search",
|
|
"transaction_id": "tx-1",
|
|
"message_id": "msg-1",
|
|
"domain": "retail"
|
|
},
|
|
"message": {"intent": "buy"}
|
|
}`)
|
|
got := selectAuditPayload(ctx, body)
|
|
require.NotNil(t, got)
|
|
|
|
var out map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(got, &out))
|
|
ctxMap, ok := out["context"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "search", ctxMap["action"])
|
|
assert.Equal(t, "tx-1", ctxMap["transaction_id"])
|
|
assert.Equal(t, "msg-1", ctxMap["message_id"])
|
|
_, hasMessage := out["message"]
|
|
assert.False(t, hasMessage)
|
|
}
|
|
|
|
func TestSelectAuditPayload_ActionSpecificRules(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.action
|
|
search:
|
|
- context.action
|
|
- context.timestamp
|
|
- message.intent
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
body := []byte(`{
|
|
"context": {"action": "search", "timestamp": "2024-01-15T10:30:00Z", "domain": "retail"},
|
|
"message": {"intent": {"item": {"id": "x"}}}
|
|
}`)
|
|
got := selectAuditPayload(ctx, body)
|
|
require.NotNil(t, got)
|
|
|
|
var out map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(got, &out))
|
|
ctxMap := out["context"].(map[string]interface{})
|
|
assert.Equal(t, "search", ctxMap["action"])
|
|
assert.Equal(t, "2024-01-15T10:30:00Z", ctxMap["timestamp"])
|
|
msg := out["message"].(map[string]interface{})
|
|
assert.NotNil(t, msg["intent"])
|
|
}
|
|
|
|
func TestSelectAuditPayload_ArrayFieldProjection(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default:
|
|
- context.action
|
|
select:
|
|
- context.transaction_id
|
|
- context.action
|
|
- message.order.beckn:orderItems.beckn:orderedItem
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
body := []byte(`{
|
|
"context": {"action": "select", "transaction_id": "tx-2"},
|
|
"message": {
|
|
"order": {
|
|
"beckn:orderItems": [
|
|
{"beckn:orderedItem": "item-A", "other": "x"},
|
|
{"beckn:orderedItem": "item-B", "other": "y"}
|
|
]
|
|
}
|
|
}
|
|
}`)
|
|
got := selectAuditPayload(ctx, body)
|
|
require.NotNil(t, got)
|
|
|
|
var out map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(got, &out))
|
|
ctxMap := out["context"].(map[string]interface{})
|
|
assert.Equal(t, "select", ctxMap["action"])
|
|
assert.Equal(t, "tx-2", ctxMap["transaction_id"])
|
|
|
|
order := out["message"].(map[string]interface{})["order"].(map[string]interface{})
|
|
items := order["beckn:orderItems"].([]interface{})
|
|
require.Len(t, items, 2)
|
|
assert.Equal(t, map[string]interface{}{"beckn:orderedItem": "item-A"}, items[0])
|
|
assert.Equal(t, map[string]interface{}{"beckn:orderedItem": "item-B"}, items[1])
|
|
}
|
|
|
|
// TestSelectAuditPayload_SelectOrderExample uses a full select request payload and
|
|
// select audit rules to verify that only configured fields are projected into the
|
|
// audit log body. The request mirrors a real select with context, message.order,
|
|
// beckn:orderItems (array), beckn:acceptedOffer, and beckn:orderAttributes.
|
|
// Rules include array traversal (e.g. message.order.beckn:orderItems.beckn:orderedItem
|
|
// projects that field from each array element) and nested paths like
|
|
// message.order.beckn:orderItems.beckn:acceptedOffer.beckn:price.value.
|
|
func TestSelectAuditPayload_SelectOrderExample(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := writeAuditRulesFile(t, `
|
|
auditRules:
|
|
default: []
|
|
select:
|
|
- context.transaction_id
|
|
- context.message_id
|
|
- context.action
|
|
- context.timestamp
|
|
- message.order
|
|
- message.order.beckn:seller
|
|
- message.order.beckn:buyer
|
|
- message.order.beckn:buyer.beckn:id
|
|
- message.order.beckn:orderItems
|
|
- message.order.beckn:orderItems.beckn:orderedItem
|
|
- message.order.beckn:orderItems.beckn:acceptedOffer
|
|
- message.order.beckn:orderItems.beckn:acceptedOffer.beckn:id
|
|
- message.order.beckn:orderItems.beckn:acceptedOffer.beckn:price
|
|
- message.order.beckn:orderItems.beckn:acceptedOffer.beckn:price.value
|
|
- message.order.beckn:orderAttributes
|
|
- message.order.beckn:orderAttributes.preferences
|
|
- message.order.beckn:orderAttributes.preferences.startTime
|
|
`)
|
|
require.NoError(t, LoadAuditFieldRules(ctx, path))
|
|
|
|
// Full select request example: context (version, action, domain, timestamp, ids, URIs, ttl)
|
|
// and message.order with orderStatus, seller, buyer, orderItems array (orderedItem, quantity,
|
|
// acceptedOffer with id, descriptor, items, provider, price), orderAttributes (buyerFinderFee, preferences).
|
|
body := []byte(`{
|
|
"context": {
|
|
"version": "1.0.0",
|
|
"action": "select",
|
|
"domain": "ev_charging",
|
|
"timestamp": "2024-01-15T10:30:00Z",
|
|
"message_id": "bb9f86db-9a3d-4e9c-8c11-81c8f1a7b901",
|
|
"transaction_id": "2b4d69aa-22e4-4c78-9f56-5a7b9e2b2002",
|
|
"bap_id": "bap.example.com",
|
|
"bap_uri": "https://bap.example.com",
|
|
"ttl": "PT30S",
|
|
"bpp_id": "bpp.example.com",
|
|
"bpp_uri": "https://bpp.example.com"
|
|
},
|
|
"message": {
|
|
"order": {
|
|
"@context": "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/main/schema/core/v2/context.jsonld",
|
|
"@type": "beckn:Order",
|
|
"beckn:orderStatus": "CREATED",
|
|
"beckn:seller": "ecopower-charging",
|
|
"beckn:buyer": {
|
|
"@context": "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/main/schema/core/v2/context.jsonld",
|
|
"@type": "beckn:Buyer",
|
|
"beckn:id": "user-123",
|
|
"beckn:role": "BUYER",
|
|
"beckn:displayName": "Ravi Kumar",
|
|
"beckn:telephone": "+91-9876543210",
|
|
"beckn:email": "ravi.kumar@example.com",
|
|
"beckn:taxID": "GSTIN29ABCDE1234F1Z5"
|
|
},
|
|
"beckn:orderItems": [
|
|
{
|
|
"beckn:orderedItem": "IND*ecopower-charging*cs-01*IN*ECO*BTM*01*CCS2*A*CCS2-A",
|
|
"beckn:quantity": {
|
|
"unitText": "Kilowatt Hour",
|
|
"unitCode": "KWH",
|
|
"unitQuantity": 2.5
|
|
},
|
|
"beckn:acceptedOffer": {
|
|
"@context": "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/main/schema/core/v2/context.jsonld",
|
|
"@type": "beckn:Offer",
|
|
"beckn:id": "offer-ccs2-60kw-kwh",
|
|
"beckn:descriptor": {
|
|
"@type": "beckn:Descriptor",
|
|
"schema:name": "Per-kWh Tariff - CCS2 60kW"
|
|
},
|
|
"beckn:items": [
|
|
"IND*ecopower-charging*cs-01*IN*ECO*BTM*01*CCS2*A*CCS2-A"
|
|
],
|
|
"beckn:provider": "ecopower-charging",
|
|
"beckn:price": {
|
|
"currency": "INR",
|
|
"value": 45.0,
|
|
"applicableQuantity": {
|
|
"unitText": "Kilowatt Hour",
|
|
"unitCode": "KWH",
|
|
"unitQuantity": 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"beckn:orderAttributes": {
|
|
"@context": "https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/main/schema/EvChargingSession/v1/context.jsonld",
|
|
"@type": "ChargingSession",
|
|
"buyerFinderFee": {
|
|
"feeType": "PERCENTAGE",
|
|
"feeValue": 2.5
|
|
},
|
|
"preferences": {
|
|
"startTime": "2026-01-04T08:00:00+05:30",
|
|
"endTime": "2026-01-04T20:00:00+05:30"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
got := selectAuditPayload(ctx, body)
|
|
require.NotNil(t, got, "selectAuditPayload should return projected body for select action")
|
|
|
|
var out map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(got, &out))
|
|
|
|
// Context: only transaction_id, message_id, action, timestamp
|
|
ctxMap, ok := out["context"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "select", ctxMap["action"])
|
|
assert.Equal(t, "2b4d69aa-22e4-4c78-9f56-5a7b9e2b2002", ctxMap["transaction_id"])
|
|
assert.Equal(t, "bb9f86db-9a3d-4e9c-8c11-81c8f1a7b901", ctxMap["message_id"])
|
|
assert.Equal(t, "2024-01-15T10:30:00Z", ctxMap["timestamp"])
|
|
_, hasBapID := ctxMap["bap_id"]
|
|
assert.False(t, hasBapID, "context should not include bap_id when not in audit rules")
|
|
|
|
// message.order: full order merged with projected array fields
|
|
msg, ok := out["message"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
order, ok := msg["order"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "ecopower-charging", order["beckn:seller"])
|
|
buyer, ok := order["beckn:buyer"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "user-123", buyer["beckn:id"])
|
|
|
|
// beckn:orderItems: array with projected fields from each element (beckn:orderedItem, beckn:acceptedOffer with id, price, price.value)
|
|
items, ok := order["beckn:orderItems"].([]interface{})
|
|
require.True(t, ok)
|
|
require.Len(t, items, 1)
|
|
item0, ok := items[0].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "IND*ecopower-charging*cs-01*IN*ECO*BTM*01*CCS2*A*CCS2-A", item0["beckn:orderedItem"])
|
|
acceptedOffer, ok := item0["beckn:acceptedOffer"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "offer-ccs2-60kw-kwh", acceptedOffer["beckn:id"])
|
|
price, ok := acceptedOffer["beckn:price"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, 45.0, price["value"])
|
|
|
|
// beckn:orderAttributes: only preferences and preferences.startTime
|
|
orderAttrs, ok := order["beckn:orderAttributes"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
prefs, ok := orderAttrs["preferences"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "2026-01-04T08:00:00+05:30", prefs["startTime"])
|
|
}
|