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