diff --git a/nrfcache/match_filters.go b/nrfcache/match_filters.go index 1006200a..f4d7a62f 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -34,6 +34,7 @@ var matchFilters = MatchFilters{ models.NFTYPE_PCF: MatchPcfProfile, models.NFTYPE_NSSF: MatchNssfProfile, models.NFTYPE_UDM: MatchUdmProfile, + models.NFTYPE_UDR: MatchUdrProfile, models.NFTYPE_AMF: MatchAmfProfile, } @@ -187,9 +188,10 @@ func extractSupiNumber(supi string) string { func MatchAusfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. if profile.AusfInfo == nil || len(profile.AusfInfo.SupiRanges) == 0 { - logger.NrfcacheLog.Debugf("ausf match failed: no SUPI ranges for %s", profile.NfInstanceId) - return false, nil + logger.NrfcacheLog.Debugf("ausf match successful (unrestricted: no SUPI ranges) for %s", profile.NfInstanceId) + return true, nil } matchFound := matchSupiRange(*supi, profile.AusfInfo.SupiRanges) @@ -279,9 +281,15 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A func MatchPcfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // A profile declaring no SUPI ranges is unrestricted and serves every + // SUPI. The NRF's own discovery filter encodes this as an $or over + // "a range contains the SUPI" / "supiRanges is null" / "supiRanges is + // absent" (nrf producer/nf_discovery.go, [Query-18] supi). Rationale: + // the cache must select the same profiles as the NRF it caches, + // otherwise a cached lookup and a live discovery disagree. if profile.PcfInfo == nil || len(profile.PcfInfo.SupiRanges) == 0 { - logger.NrfcacheLog.Infof("pcf match found = false (no SUPI ranges)") - return false, nil + logger.NrfcacheLog.Debugf("pcf match found = true (unrestricted: no SUPI ranges)") + return true, nil } matchFound := matchSupiRange(*supi, profile.PcfInfo.SupiRanges) @@ -297,9 +305,10 @@ func MatchPcfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A func MatchUdmProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { supi := opts.GetSupi() if supi != nil { + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. if profile.UdmInfo == nil || len(profile.UdmInfo.GetSupiRanges()) == 0 { - logger.NrfcacheLog.Infof("udm match found = false (no SUPI ranges)") - return false, nil + logger.NrfcacheLog.Debugf("udm match found = true (unrestricted: no SUPI ranges)") + return true, nil } matchFound := matchSupiRange(*supi, profile.UdmInfo.GetSupiRanges()) @@ -311,3 +320,27 @@ func MatchUdmProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A logger.NrfcacheLog.Infof("udm match found = true (no SUPI filter)") return true, nil } + +// MatchUdrProfile selects UDR profiles for a SUPI-filtered discovery. +// +// Without an entry in matchFilters a UDR profile is dropped unconditionally, +// so a cached UDR lookup returns nothing and every UDM/PCF data access falls +// through to a live NRF query. Rationale: UDR is resolved on every subscriber +// data access, which makes it the most frequently discovered NF in the core. +func MatchUdrProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { + supi := opts.GetSupi() + if supi == nil { + logger.NrfcacheLog.Debugf("udr match successful (no SUPI filter) for %s", profile.NfInstanceId) + return true, nil + } + + // Unrestricted when no SUPI ranges are declared; see MatchPcfProfile. + if profile.UdrInfo == nil || len(profile.UdrInfo.GetSupiRanges()) == 0 { + logger.NrfcacheLog.Debugf("udr match successful (unrestricted: no SUPI ranges) for %s", profile.NfInstanceId) + return true, nil + } + + matchFound := matchSupiRange(*supi, profile.UdrInfo.GetSupiRanges()) + logger.NrfcacheLog.Debugf("udr match found = %v for %s", matchFound, profile.NfInstanceId) + return matchFound, nil +} diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index 1e82a957..caa61d12 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1147,3 +1147,161 @@ func TestAmfProfileMatching(t *testing.T) { }) } } + +// TestMatchProfileWithoutSupiRangesIsUnrestricted covers profiles that declare +// no SUPI ranges. Such a profile serves every SUPI, so a SUPI-filtered +// discovery must select it. The NRF's own discovery filter encodes this as an +// $or over "a range contains the SUPI" / "supiRanges is null" / "supiRanges is +// absent"; a matcher that rejected these profiles would make cached lookups +// disagree with a live discovery against the same NRF. +func TestMatchProfileWithoutSupiRangesIsUnrestricted(t *testing.T) { + const supi = "imsi-208930100007500" + + testCases := []struct { + profile models.NFProfileDiscovery + matcher MatchFilter + name string + }{ + { + name: "udm_info_without_supi_ranges", + matcher: MatchUdmProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDM-no-ranges", + NfType: models.NFTYPE_UDM, + UdmInfo: &models.UdmInfo{}, + }, + }, + { + name: "udm_info_absent", + matcher: MatchUdmProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDM-no-info", + NfType: models.NFTYPE_UDM, + }, + }, + { + name: "pcf_info_without_supi_ranges", + matcher: MatchPcfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "PCF-no-ranges", + NfType: models.NFTYPE_PCF, + PcfInfo: &models.PcfInfo{}, + }, + }, + { + name: "pcf_info_absent", + matcher: MatchPcfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "PCF-no-info", + NfType: models.NFTYPE_PCF, + }, + }, + { + name: "ausf_info_without_supi_ranges", + matcher: MatchAusfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "AUSF-no-ranges", + NfType: models.NFTYPE_AUSF, + AusfInfo: &models.AusfInfo{}, + }, + }, + { + name: "ausf_info_absent", + matcher: MatchAusfProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "AUSF-no-info", + NfType: models.NFTYPE_AUSF, + }, + }, + { + name: "udr_info_without_supi_ranges", + matcher: MatchUdrProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDR-no-ranges", + NfType: models.NFTYPE_UDR, + UdrInfo: &models.UdrInfo{}, + }, + }, + { + name: "udr_info_absent", + matcher: MatchUdrProfile, + profile: models.NFProfileDiscovery{ + NfInstanceId: "UDR-no-info", + NfType: models.NFTYPE_UDR, + }, + }, + } + + param := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi(supi) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + match, err := tc.matcher(&tc.profile, param) + if err != nil { + t.Fatalf("matcher returned error: %v", err) + } + if !match { + t.Errorf("profile %s declares no SUPI ranges and must match SUPI %s, got no match", + tc.profile.NfInstanceId, supi) + } + }) + } +} + +// TestMatchProfileWithSupiRangesStillFiltersOut guards the complementary case: +// once a profile declares ranges, a SUPI outside them must not match. +func TestMatchProfileWithSupiRangesStillFiltersOut(t *testing.T) { + ranges := []models.SupiRange{{Start: openapi.PtrString("100000000000000"), End: openapi.PtrString("100000000000009")}} + param := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100007500") + + profile := models.NFProfileDiscovery{ + NfInstanceId: "UDM-ranged", + NfType: models.NFTYPE_UDM, + UdmInfo: &models.UdmInfo{SupiRanges: ranges}, + } + match, err := MatchUdmProfile(&profile, param) + if err != nil { + t.Fatalf("MatchUdmProfile returned error: %v", err) + } + if match { + t.Error("SUPI outside the declared ranges must not match") + } +} + +// TestUdrProfileIsSelectableFromCache guards the registration of UDR in +// matchFilters. A profile whose NfType has no registered filter is dropped by +// NrfCache.get, so an unregistered UDR made every cached UDR lookup return +// empty and forced a live NRF query on every subscriber data access. +func TestUdrProfileIsSelectableFromCache(t *testing.T) { + if _, ok := matchFilters[models.NFTYPE_UDR]; !ok { + t.Fatal("UDR has no entry in matchFilters, so cached UDR discovery can never return a profile") + } + + profile := models.NFProfileDiscovery{ + NfInstanceId: "UDR-1", + NfType: models.NFTYPE_UDR, + UdrInfo: &models.UdrInfo{ + SupiRanges: []models.SupiRange{ + {Start: openapi.PtrString("208930100007500"), End: openapi.PtrString("208930100007599")}, + }, + }, + } + + inRange := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100007550") + match, err := MatchUdrProfile(&profile, inRange) + if err != nil { + t.Fatalf("MatchUdrProfile returned error: %v", err) + } + if !match { + t.Error("SUPI inside the declared range must match") + } + + outOfRange := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Supi("imsi-208930100009999") + match, err = MatchUdrProfile(&profile, outOfRange) + if err != nil { + t.Fatalf("MatchUdrProfile returned error: %v", err) + } + if match { + t.Error("SUPI outside the declared range must not match") + } +}