diff --git a/docs/rsr.rst b/docs/rsr.rst
index f6f65a288..451c83f4d 100644
--- a/docs/rsr.rst
+++ b/docs/rsr.rst
@@ -128,6 +128,16 @@ Converters transform the extracted value. Chain them with ``&``::
* ``*e164Domain`` - extract domain from NAPTR record
* ``*ip2hex`` - IP to hex
* ``*sipuri_host``, ``*sipuri_user``, ``*sipuri_method`` - parse SIP URIs
+* ``*3gpp_uli`` - decode 3GPP-User-Location-Info hex to ULI object
+* ``*3gpp_uli:path`` - extract specific field from ULI
+
+ULI component paths: ``CGI``, ``SAI``, ``RAI``, ``TAI``, ``ECGI``, ``TAI5GS``, ``NCGI``
+
+Field paths: ``TAI.MCC``, ``TAI.MNC``, ``TAI.TAC``, ``ECGI.MCC``, ``ECGI.MNC``, ``ECGI.ECI``, ``NCGI.NCI``, etc.
+
+Example::
+
+ ~*req.3GPP-User-Location-Info{*3gpp_uli:TAI.MCC}
**Time**
diff --git a/general_tests/diam_uli_it_test.go b/general_tests/diam_uli_it_test.go
new file mode 100644
index 000000000..26dd2cc64
--- /dev/null
+++ b/general_tests/diam_uli_it_test.go
@@ -0,0 +1,164 @@
+//go:build integration
+
+/*
+Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments
+Copyright (C) ITsysCOM GmbH
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
+*/
+
+package general_tests
+
+import (
+ "bytes"
+ "testing"
+ "time"
+
+ "github.com/cgrates/cgrates/agents"
+ "github.com/cgrates/cgrates/engine"
+ "github.com/cgrates/cgrates/utils"
+ "github.com/cgrates/go-diameter/diam"
+ "github.com/cgrates/go-diameter/diam/avp"
+ "github.com/cgrates/go-diameter/diam/datatype"
+)
+
+func TestDiamULI(t *testing.T) {
+ // t.Skip("configuration reference for *3gpp_uli request_fields; does not assert values")
+ switch *utils.DBType {
+ case utils.MetaInternal:
+ case utils.MetaMySQL, utils.MetaMongo, utils.MetaPostgres:
+ t.SkipNow()
+ default:
+ t.Fatal("unsupported dbtype value")
+ }
+
+ cfgJSON := `{
+ "sessions": {
+ "enabled": true
+ },
+ "diameter_agent": {
+ "enabled": true,
+ "request_processors": [
+ {
+ "id": "tgpp_loc_info",
+ "flags": [
+ "*dryrun"
+ ],
+ "request_fields": [
+ {
+ "tag": "ULI",
+ "path": "*cgreq.ULI",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info"
+ },
+ {
+ "tag": "DecodedULI",
+ "path": "*cgreq.DecodedULI",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli}"
+ },
+ {
+ "tag": "TAI",
+ "path": "*cgreq.TAI",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:TAI}"
+ },
+ {
+ "tag": "TAI-MCC",
+ "path": "*cgreq.TAI-MCC",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:TAI.MCC}"
+ },
+ {
+ "tag": "TAI-MNC",
+ "path": "*cgreq.TAI-MNC",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:TAI.MNC}"
+ },
+ {
+ "tag": "TAI-TAC",
+ "path": "*cgreq.TAI-TAC",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:TAI.TAC}"
+ },
+ {
+ "tag": "ECGI",
+ "path": "*cgreq.ECGI",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:ECGI}"
+ },
+ {
+ "tag": "ECGI-MCC",
+ "path": "*cgreq.ECGI-MCC",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:ECGI.MCC}"
+ },
+ {
+ "tag": "ECGI-MNC",
+ "path": "*cgreq.ECGI-MNC",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:ECGI.MNC}"
+ },
+ {
+ "tag": "ECGI-ECI",
+ "path": "*cgreq.ECGI-ECI",
+ "type": "*variable",
+ "value": "~*req.Service-Information.PS-Information.3GPP-User-Location-Info{*3gpp_uli:ECGI.ECI}"
+ }
+ ],
+ "reply_fields": []
+ }
+ ]
+ }
+}`
+
+ ng := engine.TestEngine{
+ ConfigJSON: cfgJSON,
+ DBCfg: engine.InternalDBCfg,
+ LogBuffer: &bytes.Buffer{},
+ }
+ t.Cleanup(func() {
+ t.Log(ng.LogBuffer)
+ })
+ _, cfg := ng.Run(t)
+
+ diamClient, err := agents.NewDiameterClient(cfg.DiameterAgentCfg().Listeners[0].Address, "localhost",
+ cfg.DiameterAgentCfg().OriginRealm, cfg.DiameterAgentCfg().VendorID,
+ cfg.DiameterAgentCfg().ProductName, utils.DiameterFirmwareRevision,
+ cfg.DiameterAgentCfg().DictionariesPath, cfg.DiameterAgentCfg().Listeners[0].Network)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ userLocInfoRaw := "8245f750000145f75000000101"
+ ccr := diam.NewRequest(diam.CreditControl, 4, nil)
+ ccr.NewAVP(avp.ServiceInformation, avp.Mbit, 10415,
+ &diam.GroupedAVP{
+ AVP: []*diam.AVP{
+ diam.NewAVP(avp.PSInformation, avp.Mbit, 10415,
+ &diam.GroupedAVP{
+ AVP: []*diam.AVP{
+ diam.NewAVP(avp.TGPPUserLocationInfo, avp.Mbit, 10415, datatype.OctetString(userLocInfoRaw)),
+ },
+ },
+ ),
+ },
+ },
+ )
+
+ if err := diamClient.SendMessage(ccr); err != nil {
+ t.Errorf("failed to send diameter message: %v", err)
+ }
+ _ = diamClient.ReceivedMessage(2 * time.Second)
+}
diff --git a/utils/consts.go b/utils/consts.go
index 3aedd544d..7561e1f6c 100644
--- a/utils/consts.go
+++ b/utils/consts.go
@@ -1331,6 +1331,7 @@ const (
MetaDurationNanoseconds = "*duration_nanoseconds"
MetaDurationMinutes = "*duration_minutes"
MetaGigawords = "*gigawords"
+ Meta3GPPULI = "*3gpp_uli"
CapAttributes = "Attributes"
CapResourceAllocation = "ResourceAllocation"
CapAllocatedIP = "AllocatedIP"
diff --git a/utils/dataconverter.go b/utils/dataconverter.go
index 135b92c6f..5882ef093 100644
--- a/utils/dataconverter.go
+++ b/utils/dataconverter.go
@@ -136,6 +136,8 @@ func NewDataConverter(params string) (conv DataConverter, err error) {
return ConnStatusConverter{}, nil
case strings.HasPrefix(params, MetaGigawords):
return new(GigawordsConverter), nil
+ case strings.HasPrefix(params, Meta3GPPULI):
+ return NewULIConverter(params)
default:
return nil, fmt.Errorf("unsupported converter definition: <%s>", params)
}
@@ -859,3 +861,40 @@ func (c ConnStatusConverter) Convert(in any) (any, error) {
}
return 0, fmt.Errorf("unsupported connection status: %q", status)
}
+
+// ULIConverter decodes 3GPP-User-Location-Info and extracts fields by path.
+type ULIConverter struct {
+ path string
+}
+
+// NewULIConverter creates a ULI converter. The path after the colon specifies
+// which field to extract (e.g. "*3gpp_uli:TAI.MCC"). Empty path returns the
+// full ULI object.
+func NewULIConverter(params string) (*ULIConverter, error) {
+ _, path, _ := strings.Cut(params, InInFieldSep)
+ return &ULIConverter{path: path}, nil
+}
+
+// Convert implements DataConverter interface.
+func (c *ULIConverter) Convert(in any) (any, error) {
+ raw := IfaceAsString(in)
+ if raw == "" {
+ return nil, errors.New("empty ULI input")
+ }
+
+ data, err := hex.DecodeString(strings.TrimPrefix(raw, "0x"))
+ if err != nil {
+ return nil, fmt.Errorf("invalid ULI hex: %w", err)
+ }
+
+ uli, err := DecodeULI(data)
+ if err != nil {
+ return nil, err
+ }
+
+ if c.path == "" {
+ return uli, nil
+ }
+
+ return uli.GetField(c.path)
+}
diff --git a/utils/uli.go b/utils/uli.go
new file mode 100644
index 000000000..018ac362b
--- /dev/null
+++ b/utils/uli.go
@@ -0,0 +1,386 @@
+/*
+Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments
+Copyright (C) ITsysCOM GmbH
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
+*/
+
+package utils
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// Geographic location types per 3GPP TS 29.061 section 16.4.7.2
+const (
+ ULITypeCGI = 0 // Cell Global Identity (2G)
+ ULITypeSAI = 1 // Service Area Identity (3G)
+ ULITypeRAI = 2 // Routing Area Identity (2G/3G)
+ ULITypeTAI = 128 // Tracking Area Identity (4G)
+ ULITypeECGI = 129 // E-UTRAN Cell Global Identifier (4G)
+ ULITypeTAIECGI = 130 // TAI and ECGI (4G)
+ ULITypeNCGI = 135 // NR Cell Global Identifier (5G)
+ ULIType5GSTAI = 136 // 5G Tracking Area Identity
+ ULIType5GSTAINCGI = 137 // 5GS TAI and NCGI (5G)
+)
+
+// ULI holds decoded 3GPP-User-Location-Info.
+type ULI struct {
+ CGI *CGI `json:"CGI,omitempty"`
+ SAI *SAI `json:"SAI,omitempty"`
+ RAI *RAI `json:"RAI,omitempty"`
+ TAI *TAI `json:"TAI,omitempty"`
+ ECGI *ECGI `json:"ECGI,omitempty"`
+ TAI5GS *TAI5GS `json:"TAI5GS,omitempty"`
+ NCGI *NCGI `json:"NCGI,omitempty"`
+}
+
+// CGI is Cell Global Identity (2G GSM).
+type CGI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ LAC uint16 `json:"LAC"`
+ CI uint16 `json:"CI"`
+}
+
+// SAI is Service Area Identity (3G UMTS).
+type SAI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ LAC uint16 `json:"LAC"`
+ SAC uint16 `json:"SAC"`
+}
+
+// RAI is Routing Area Identity (2G/3G).
+type RAI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ LAC uint16 `json:"LAC"`
+ RAC uint8 `json:"RAC"`
+}
+
+// TAI is Tracking Area Identity (4G LTE).
+type TAI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ TAC uint16 `json:"TAC"`
+}
+
+// ECGI is E-UTRAN Cell Global Identifier (4G LTE).
+type ECGI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ ECI uint32 `json:"ECI"`
+}
+
+// TAI5GS is 5G Tracking Area Identity.
+type TAI5GS struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ TAC uint32 `json:"TAC"` // 24-bit (vs 16-bit in 4G TAI)
+}
+
+// NCGI is NR Cell Global Identifier (5G).
+type NCGI struct {
+ MCC string `json:"MCC"`
+ MNC string `json:"MNC"`
+ NCI uint64 `json:"NCI"` // 36-bit NR Cell Identity
+}
+
+// DecodeULI parses 3GPP-User-Location-Info from bytes per 3GPP TS 29.061 section 16.4.7.2.
+func DecodeULI(data []byte) (*ULI, error) {
+ if len(data) < 2 {
+ return nil, errors.New("ULI data too short")
+ }
+
+ uli := &ULI{}
+ locType := data[0]
+ pos := 1
+
+ switch locType {
+ case ULITypeCGI:
+ if len(data) < 8 { // 1 type + 3 PLMN + 2 LAC + 2 CI
+ return nil, errors.New("insufficient data for CGI")
+ }
+ uli.CGI = decodeCGI(data[pos:])
+
+ case ULITypeSAI:
+ if len(data) < 8 { // 1 type + 3 PLMN + 2 LAC + 2 SAC
+ return nil, errors.New("insufficient data for SAI")
+ }
+ uli.SAI = decodeSAI(data[pos:])
+
+ case ULITypeRAI:
+ if len(data) < 7 { // 1 type + 3 PLMN + 2 LAC + 1 RAC
+ return nil, errors.New("insufficient data for RAI")
+ }
+ uli.RAI = decodeRAI(data[pos:])
+
+ case ULITypeTAI:
+ if len(data) < 6 { // 1 type + 3 PLMN + 2 TAC
+ return nil, errors.New("insufficient data for TAI")
+ }
+ uli.TAI = decodeTAI(data[pos:])
+
+ case ULITypeECGI:
+ if len(data) < 8 { // 1 type + 3 PLMN + 4 ECI
+ return nil, errors.New("insufficient data for ECGI")
+ }
+ uli.ECGI = decodeECGI(data[pos:])
+
+ case ULITypeTAIECGI:
+ if len(data) < 13 { // 1 type + 5 TAI + 7 ECGI
+ return nil, errors.New("insufficient data for TAI+ECGI")
+ }
+ uli.TAI = decodeTAI(data[pos:])
+ uli.ECGI = decodeECGI(data[pos+5:])
+
+ case ULITypeNCGI:
+ if len(data) < 9 { // 1 type + 8 NCGI
+ return nil, errors.New("insufficient data for NCGI")
+ }
+ uli.NCGI = decodeNCGI(data[pos:])
+
+ case ULIType5GSTAI:
+ if len(data) < 7 { // 1 type + 6 5GS TAI
+ return nil, errors.New("insufficient data for 5GS TAI")
+ }
+ uli.TAI5GS = decodeTAI5GS(data[pos:])
+
+ case ULIType5GSTAINCGI:
+ if len(data) < 15 { // 1 type + 6 5GS TAI + 8 NCGI
+ return nil, errors.New("insufficient data for 5GS TAI+NCGI")
+ }
+ uli.TAI5GS = decodeTAI5GS(data[pos:])
+ uli.NCGI = decodeNCGI(data[pos+6:])
+
+ default:
+ return nil, fmt.Errorf("unsupported ULI location type: %d", locType)
+ }
+
+ return uli, nil
+}
+
+// decodePLMN extracts MCC and MNC from 3 bytes (TS 24.008 section 10.5.1.13).
+// Each digit is one nibble: [MCC2|MCC1] [MNC3|MCC3] [MNC2|MNC1].
+// MNC3=0xF means the MNC is only 2 digits.
+func decodePLMN(data []byte) (mcc, mnc string) {
+ mcc1 := data[0] & 0x0F
+ mcc2 := data[0] >> 4
+ mcc3 := data[1] & 0x0F
+ mnc3 := data[1] >> 4
+ mnc1 := data[2] & 0x0F
+ mnc2 := data[2] >> 4
+
+ mcc = fmt.Sprintf("%d%d%d", mcc1, mcc2, mcc3)
+
+ if mnc3 == 0x0F {
+ mnc = fmt.Sprintf("%d%d", mnc1, mnc2)
+ } else {
+ mnc = fmt.Sprintf("%d%d%d", mnc1, mnc2, mnc3)
+ }
+ return
+}
+
+func decodeCGI(data []byte) *CGI {
+ // PLMN + LAC + CI (TS 29.274 section 8.21.1)
+ mcc, mnc := decodePLMN(data)
+ return &CGI{
+ MCC: mcc,
+ MNC: mnc,
+ LAC: binary.BigEndian.Uint16(data[3:5]),
+ CI: binary.BigEndian.Uint16(data[5:7]),
+ }
+}
+
+func decodeSAI(data []byte) *SAI {
+ // PLMN + LAC + SAC (TS 29.274 section 8.21.2)
+ mcc, mnc := decodePLMN(data)
+ return &SAI{
+ MCC: mcc,
+ MNC: mnc,
+ LAC: binary.BigEndian.Uint16(data[3:5]),
+ SAC: binary.BigEndian.Uint16(data[5:7]),
+ }
+}
+
+func decodeRAI(data []byte) *RAI {
+ // PLMN + LAC + RAC (TS 29.274 section 8.21.3)
+ mcc, mnc := decodePLMN(data)
+ return &RAI{
+ MCC: mcc,
+ MNC: mnc,
+ LAC: binary.BigEndian.Uint16(data[3:5]),
+ RAC: data[5],
+ }
+}
+
+func decodeTAI(data []byte) *TAI {
+ // PLMN + TAC (TS 29.274 section 8.21.4)
+ mcc, mnc := decodePLMN(data)
+ return &TAI{
+ MCC: mcc,
+ MNC: mnc,
+ TAC: binary.BigEndian.Uint16(data[3:5]),
+ }
+}
+
+func decodeECGI(data []byte) *ECGI {
+ mcc, mnc := decodePLMN(data)
+ // the leading 4 bits are spare (TS 29.274 section 8.21.5)
+ eci := binary.BigEndian.Uint32(data[3:7]) & 0x0FFFFFFF
+ return &ECGI{
+ MCC: mcc,
+ MNC: mnc,
+ ECI: eci,
+ }
+}
+
+func decodeTAI5GS(data []byte) *TAI5GS {
+ mcc, mnc := decodePLMN(data)
+ // TAC is 24 bits in 5GS (TS 38.413 section 9.3.3.11), unlike 16 bits in 4G TAI
+ tac := uint32(data[3])<<16 | uint32(data[4])<<8 | uint32(data[5])
+ return &TAI5GS{
+ MCC: mcc,
+ MNC: mnc,
+ TAC: tac,
+ }
+}
+
+func decodeNCGI(data []byte) *NCGI {
+ mcc, mnc := decodePLMN(data)
+ // the leading 4 bits are spare (TS 38.413 section 9.3.1.7)
+ // TODO: check why Wireshark's ULI dissector uses trail-spare for NCGI
+ nci := uint64(data[3]&0x0F)<<32 |
+ uint64(data[4])<<24 |
+ uint64(data[5])<<16 |
+ uint64(data[6])<<8 |
+ uint64(data[7])
+ return &NCGI{
+ MCC: mcc,
+ MNC: mnc,
+ NCI: nci,
+ }
+}
+
+// GetField retrieves a value found at the specified path (e.g. "TAI.MCC" or "ECGI.ECI").
+func (uli *ULI) GetField(path string) (any, error) {
+ parts := strings.SplitN(path, ".", 2)
+ if len(parts) == 0 || parts[0] == "" {
+ return nil, errors.New("empty path")
+ }
+
+ var loc any
+ var mcc, mnc string
+
+ switch parts[0] {
+ case "CGI":
+ if uli.CGI == nil {
+ return nil, errors.New("CGI not present in ULI")
+ }
+ loc, mcc, mnc = uli.CGI, uli.CGI.MCC, uli.CGI.MNC
+ case "SAI":
+ if uli.SAI == nil {
+ return nil, errors.New("SAI not present in ULI")
+ }
+ loc, mcc, mnc = uli.SAI, uli.SAI.MCC, uli.SAI.MNC
+ case "RAI":
+ if uli.RAI == nil {
+ return nil, errors.New("RAI not present in ULI")
+ }
+ loc, mcc, mnc = uli.RAI, uli.RAI.MCC, uli.RAI.MNC
+ case "TAI":
+ if uli.TAI == nil {
+ return nil, errors.New("TAI not present in ULI")
+ }
+ loc, mcc, mnc = uli.TAI, uli.TAI.MCC, uli.TAI.MNC
+ case "ECGI":
+ if uli.ECGI == nil {
+ return nil, errors.New("ECGI not present in ULI")
+ }
+ loc, mcc, mnc = uli.ECGI, uli.ECGI.MCC, uli.ECGI.MNC
+ case "TAI5GS":
+ if uli.TAI5GS == nil {
+ return nil, errors.New("TAI5GS not present in ULI")
+ }
+ loc, mcc, mnc = uli.TAI5GS, uli.TAI5GS.MCC, uli.TAI5GS.MNC
+ case "NCGI":
+ if uli.NCGI == nil {
+ return nil, errors.New("NCGI not present in ULI")
+ }
+ loc, mcc, mnc = uli.NCGI, uli.NCGI.MCC, uli.NCGI.MNC
+ default:
+ return nil, fmt.Errorf("unknown ULI component: %s", parts[0])
+ }
+
+ if len(parts) == 1 {
+ return loc, nil
+ }
+
+ return uliFieldValue(loc, parts[1], mcc, mnc)
+}
+
+func uliFieldValue(loc any, field, mcc, mnc string) (any, error) {
+ switch field {
+ case "MCC":
+ return mcc, nil
+ case "MNC":
+ return mnc, nil
+ }
+
+ switch l := loc.(type) {
+ case *CGI:
+ switch field {
+ case "LAC":
+ return l.LAC, nil
+ case "CI":
+ return l.CI, nil
+ }
+ case *SAI:
+ switch field {
+ case "LAC":
+ return l.LAC, nil
+ case "SAC":
+ return l.SAC, nil
+ }
+ case *RAI:
+ switch field {
+ case "LAC":
+ return l.LAC, nil
+ case "RAC":
+ return l.RAC, nil
+ }
+ case *TAI:
+ if field == "TAC" {
+ return l.TAC, nil
+ }
+ case *ECGI:
+ if field == "ECI" {
+ return l.ECI, nil
+ }
+ case *TAI5GS:
+ if field == "TAC" {
+ return l.TAC, nil
+ }
+ case *NCGI:
+ if field == "NCI" {
+ return l.NCI, nil
+ }
+ }
+
+ return nil, fmt.Errorf("unknown field: %s", field)
+}
diff --git a/utils/uli_test.go b/utils/uli_test.go
new file mode 100644
index 000000000..ec8e21806
--- /dev/null
+++ b/utils/uli_test.go
@@ -0,0 +1,429 @@
+/*
+Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments
+Copyright (C) ITsysCOM GmbH
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
+*/
+
+package utils
+
+import (
+ "encoding/hex"
+ "reflect"
+ "testing"
+)
+
+func TestDecodePLMN(t *testing.T) {
+ tests := []struct {
+ name string
+ hex string
+ expectedMCC string
+ expectedMNC string
+ }{
+ {
+ name: "2-digit MNC (547/05)",
+ hex: "45f750",
+ expectedMCC: "547",
+ expectedMNC: "05",
+ },
+ {
+ name: "3-digit MNC (310/260)",
+ hex: "130062",
+ expectedMCC: "310",
+ expectedMNC: "260",
+ },
+ {
+ name: "2-digit MNC (262/01)",
+ hex: "62f210",
+ expectedMCC: "262",
+ expectedMNC: "01",
+ },
+ {
+ name: "3GPP test PLMN 2-digit MNC (001/01)",
+ hex: "00f110",
+ expectedMCC: "001",
+ expectedMNC: "01",
+ },
+ {
+ name: "3GPP test PLMN 3-digit MNC (001/001)",
+ hex: "001100",
+ expectedMCC: "001",
+ expectedMNC: "001",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ data, err := hex.DecodeString(tt.hex)
+ if err != nil {
+ t.Fatalf("invalid test hex: %v", err)
+ }
+ mcc, mnc := decodePLMN(data)
+ if mcc != tt.expectedMCC {
+ t.Errorf("MCC: got %q, want %q", mcc, tt.expectedMCC)
+ }
+ if mnc != tt.expectedMNC {
+ t.Errorf("MNC: got %q, want %q", mnc, tt.expectedMNC)
+ }
+ })
+ }
+}
+
+func TestDecodeULI(t *testing.T) {
+ tests := []struct {
+ name string
+ hex string
+ expected *ULI
+ }{
+ {
+ name: "CGI",
+ hex: "0062f21012345678",
+ expected: &ULI{
+ CGI: &CGI{MCC: "262", MNC: "01", LAC: 0x1234, CI: 0x5678},
+ },
+ },
+ {
+ name: "SAI",
+ hex: "0162f2101234abcd",
+ expected: &ULI{
+ SAI: &SAI{MCC: "262", MNC: "01", LAC: 0x1234, SAC: 0xABCD},
+ },
+ },
+ {
+ name: "RAI",
+ hex: "0262f210123456",
+ expected: &ULI{
+ RAI: &RAI{MCC: "262", MNC: "01", LAC: 0x1234, RAC: 0x56},
+ },
+ },
+ {
+ name: "TAI",
+ hex: "8013006204d2",
+ expected: &ULI{
+ TAI: &TAI{MCC: "310", MNC: "260", TAC: 1234},
+ },
+ },
+ {
+ name: "ECGI",
+ hex: "8145f75000000101",
+ expected: &ULI{
+ ECGI: &ECGI{MCC: "547", MNC: "05", ECI: 257},
+ },
+ },
+ {
+ name: "TAI+ECGI",
+ hex: "8245f750000145f75000000101",
+ expected: &ULI{
+ TAI: &TAI{MCC: "547", MNC: "05", TAC: 1},
+ ECGI: &ECGI{MCC: "547", MNC: "05", ECI: 257},
+ },
+ },
+ {
+ name: "NCGI",
+ hex: "871300620123456789",
+ expected: &ULI{
+ NCGI: &NCGI{MCC: "310", MNC: "260", NCI: 0x123456789},
+ },
+ },
+ {
+ name: "5GS TAI",
+ hex: "88130062123456",
+ expected: &ULI{
+ TAI5GS: &TAI5GS{MCC: "310", MNC: "260", TAC: 0x123456},
+ },
+ },
+ {
+ name: "5GS TAI+NCGI",
+ hex: "891300620000011300620000000101",
+ expected: &ULI{
+ TAI5GS: &TAI5GS{MCC: "310", MNC: "260", TAC: 1},
+ NCGI: &NCGI{MCC: "310", MNC: "260", NCI: 257},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ data, err := hex.DecodeString(tt.hex)
+ if err != nil {
+ t.Fatalf("invalid test hex: %v", err)
+ }
+ got, err := DecodeULI(data)
+ if err != nil {
+ t.Fatalf("DecodeULI failed: %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("got %s, want %s", ToJSON(got), ToJSON(tt.expected))
+ }
+ })
+ }
+}
+
+func TestULI_GetField(t *testing.T) {
+ uli := &ULI{
+ TAI: &TAI{
+ MCC: "547",
+ MNC: "05",
+ TAC: 1,
+ },
+ ECGI: &ECGI{
+ MCC: "547",
+ MNC: "05",
+ ECI: 257,
+ },
+ TAI5GS: &TAI5GS{
+ MCC: "310",
+ MNC: "260",
+ TAC: 0x123456,
+ },
+ NCGI: &NCGI{
+ MCC: "310",
+ MNC: "260",
+ NCI: 0x123456789,
+ },
+ }
+
+ tests := []struct {
+ path string
+ expected any
+ }{
+ {"TAI", uli.TAI},
+ {"TAI.MCC", "547"},
+ {"TAI.MNC", "05"},
+ {"TAI.TAC", uint16(1)},
+ {"ECGI", uli.ECGI},
+ {"ECGI.MCC", "547"},
+ {"ECGI.MNC", "05"},
+ {"ECGI.ECI", uint32(257)},
+ {"TAI5GS", uli.TAI5GS},
+ {"TAI5GS.MCC", "310"},
+ {"TAI5GS.MNC", "260"},
+ {"TAI5GS.TAC", uint32(0x123456)},
+ {"NCGI", uli.NCGI},
+ {"NCGI.MCC", "310"},
+ {"NCGI.MNC", "260"},
+ {"NCGI.NCI", uint64(0x123456789)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ got, err := uli.GetField(tt.path)
+ if err != nil {
+ t.Fatalf("GetField(%q) error: %v", tt.path, err)
+ }
+ if !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("GetField(%q): got %v (%T), want %v (%T)",
+ tt.path, got, got, tt.expected, tt.expected)
+ }
+ })
+ }
+}
+
+func TestULI_GetField_Errors(t *testing.T) {
+ uli := &ULI{
+ TAI: &TAI{MCC: "547", MNC: "05", TAC: 1},
+ }
+
+ tests := []struct {
+ name string
+ path string
+ }{
+ {"missing ECGI", "ECGI"},
+ {"missing CGI", "CGI"},
+ {"missing TAI5GS", "TAI5GS"},
+ {"missing NCGI", "NCGI"},
+ {"invalid field", "TAI.INVALID"},
+ {"invalid component", "INVALID"},
+ {"empty path", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := uli.GetField(tt.path)
+ if err == nil {
+ t.Errorf("GetField(%q) should have returned error", tt.path)
+ }
+ })
+ }
+}
+
+func TestULIConverter(t *testing.T) {
+ tests := []struct {
+ name string
+ params string
+ input string
+ expected any
+ }{
+ {
+ name: "Extract TAI.MCC",
+ params: "*3gpp_uli:TAI.MCC",
+ input: "8245f750000145f75000000101",
+ expected: "547",
+ },
+ {
+ name: "Extract TAI.MNC",
+ params: "*3gpp_uli:TAI.MNC",
+ input: "8245f750000145f75000000101",
+ expected: "05",
+ },
+ {
+ name: "Extract TAI.TAC",
+ params: "*3gpp_uli:TAI.TAC",
+ input: "8245f750000145f75000000101",
+ expected: uint16(1),
+ },
+ {
+ name: "Extract ECGI.ECI",
+ params: "*3gpp_uli:ECGI.ECI",
+ input: "8245f750000145f75000000101",
+ expected: uint32(257),
+ },
+ {
+ name: "Extract TAI5GS.MCC from 5GS TAI",
+ params: "*3gpp_uli:TAI5GS.MCC",
+ input: "88130062123456",
+ expected: "310",
+ },
+ {
+ name: "Extract TAI5GS.TAC from 5GS TAI",
+ params: "*3gpp_uli:TAI5GS.TAC",
+ input: "88130062123456",
+ expected: uint32(0x123456),
+ },
+ {
+ name: "Extract NCGI.NCI from NCGI",
+ params: "*3gpp_uli:NCGI.NCI",
+ input: "871300620123456789",
+ expected: uint64(0x123456789),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv, err := NewULIConverter(tt.params)
+ if err != nil {
+ t.Fatalf("NewULIConverter failed: %v", err)
+ }
+ got, err := conv.Convert(tt.input)
+ if err != nil {
+ t.Fatalf("Convert failed: %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("got %v (%T), want %v (%T)",
+ got, got, tt.expected, tt.expected)
+ }
+ })
+ }
+}
+
+func TestDecodeULI_InsufficientData(t *testing.T) {
+ tests := []struct {
+ name string
+ hex string
+ }{
+ {"CGI too short", "0062f210123456"},
+ {"SAI too short", "0162f210123456"},
+ {"RAI too short", "0262f2101234"},
+ {"TAI too short", "8062f21012"},
+ {"ECGI too short", "8162f210123456"},
+ {"TAI+ECGI too short", "8262f2101234567890"},
+ {"5GS TAI too short", "881300621234"},
+ {"NCGI too short", "8713006201234567"},
+ {"5GS TAI+NCGI too short", "8913006200000113006200000001"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ data, err := hex.DecodeString(tt.hex)
+ if err != nil {
+ t.Fatalf("invalid test hex: %v", err)
+ }
+ _, err = DecodeULI(data)
+ if err == nil {
+ t.Error("DecodeULI should have returned error for insufficient data")
+ }
+ })
+ }
+}
+
+func TestDecodeULI_UnsupportedType(t *testing.T) {
+ data, err := hex.DecodeString("0362f21012345678")
+ if err != nil {
+ t.Fatalf("invalid test hex: %v", err)
+ }
+
+ _, err = DecodeULI(data)
+ if err == nil {
+ t.Error("DecodeULI should have returned error for unsupported type")
+ }
+}
+
+func TestULIConverter_0xPrefix(t *testing.T) {
+ conv, err := NewULIConverter("*3gpp_uli:TAI.MCC")
+ if err != nil {
+ t.Fatalf("NewULIConverter failed: %v", err)
+ }
+ got, err := conv.Convert("0x8245f750000145f75000000101")
+ if err != nil {
+ t.Fatalf("Convert failed: %v", err)
+ }
+ if got != "547" {
+ t.Errorf("got %q, want %q", got, "547")
+ }
+}
+
+func TestULIConverter_EmptyPath(t *testing.T) {
+ conv, err := NewULIConverter("*3gpp_uli")
+ if err != nil {
+ t.Fatalf("NewULIConverter failed: %v", err)
+ }
+ got, err := conv.Convert("8013006204d2")
+ if err != nil {
+ t.Fatalf("Convert failed: %v", err)
+ }
+ uli, ok := got.(*ULI)
+ if !ok {
+ t.Fatalf("expected *ULI, got %T", got)
+ }
+ if uli.TAI == nil {
+ t.Fatal("TAI should not be nil")
+ }
+ if uli.TAI.MCC != "310" {
+ t.Errorf("TAI.MCC: got %q, want %q", uli.TAI.MCC, "310")
+ }
+}
+
+func TestULIConverter_Errors(t *testing.T) {
+ conv, err := NewULIConverter("*3gpp_uli:TAI.MCC")
+ if err != nil {
+ t.Fatalf("NewULIConverter failed: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ input any
+ }{
+ {"empty string", ""},
+ {"invalid hex", "zzzz"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := conv.Convert(tt.input)
+ if err == nil {
+ t.Error("Convert should have returned error")
+ }
+ })
+ }
+}