diff --git a/internal/context/event_exposure_resolver.go b/internal/context/event_exposure_resolver.go new file mode 100644 index 00000000..176695f5 --- /dev/null +++ b/internal/context/event_exposure_resolver.go @@ -0,0 +1,165 @@ +package context + +import ( + "context" + "errors" + "net" + "strings" + + "github.com/free5gc/openapi/models" +) + +var ( + ErrEventExposureNoMatchingSession = errors.New("event exposure no active matching session") + ErrEventExposureMissingUEIP = errors.New("event exposure missing UE IP") + ErrEventExposureMissingUPF = errors.New("event exposure missing selected UPF") + ErrEventExposureMultipleSessions = errors.New("event exposure multiple matching sessions") + ErrEventExposureMissingEndpoint = errors.New("event exposure selected UPF missing endpoint") +) + +type EventExposureTargetResolver struct{} + +func NewEventExposureTargetResolver() *EventExposureTargetResolver { + return &EventExposureTargetResolver{} +} + +func (r *EventExposureTargetResolver) ResolveEventExposureTarget( + _ context.Context, + supi string, + selectors EventExposureSelectors, +) (EventExposureTarget, error) { + matches := make([]eventExposureSessionSnapshot, 0, 1) + var missingUEIP bool + var missingUPF bool + + smContextPool.Range(func(_, value interface{}) bool { + smContext, ok := value.(*SMContext) + if !ok { + return true + } + + snapshot, match := snapshotEventExposureSession(smContext, supi, selectors) + if !match { + return true + } + if snapshot.ueIP == nil { + missingUEIP = true + return true + } + if snapshot.selectedUPF == nil { + missingUPF = true + return true + } + matches = append(matches, snapshot) + return true + }) + + if len(matches) == 0 { + switch { + case missingUEIP: + return EventExposureTarget{}, ErrEventExposureMissingUEIP + case missingUPF: + return EventExposureTarget{}, ErrEventExposureMissingUPF + default: + return EventExposureTarget{}, ErrEventExposureNoMatchingSession + } + } + if len(matches) > 1 { + return EventExposureTarget{}, ErrEventExposureMultipleSessions + } + + return resolveEventExposureUPFSnapshot(matches[0]) +} + +type eventExposureSessionSnapshot struct { + selectedUPF *UPNode + ueIP net.IP + dnn string + snssai *modelsSnssai + pduSessionID int32 +} + +type modelsSnssai struct { + sst int32 + sd string +} + +func snapshotEventExposureSession( + smContext *SMContext, + supi string, + selectors EventExposureSelectors, +) (eventExposureSessionSnapshot, bool) { + smContext.SMLock.Lock() + defer smContext.SMLock.Unlock() + + if smContext.State() != Active || smContext.Supi != supi { + return eventExposureSessionSnapshot{}, false + } + if selectors.PDUSessionID != nil && smContext.PDUSessionID != *selectors.PDUSessionID { + return eventExposureSessionSnapshot{}, false + } + if selectors.Dnn != nil && smContext.Dnn != *selectors.Dnn { + return eventExposureSessionSnapshot{}, false + } + if selectors.Snssai != nil { + if !eventExposureSnssaiEqual(smContext.SNssai, selectors.Snssai) { + return eventExposureSessionSnapshot{}, false + } + } + + var snssai *modelsSnssai + if smContext.SNssai != nil { + snssai = &modelsSnssai{sst: smContext.SNssai.Sst, sd: smContext.SNssai.Sd} + } + return eventExposureSessionSnapshot{ + selectedUPF: smContext.SelectedUPF, + ueIP: append(net.IP(nil), smContext.PDUAddress...), + dnn: smContext.Dnn, + snssai: snssai, + pduSessionID: smContext.PDUSessionID, + }, true +} + +func eventExposureSnssaiEqual(a, b *models.Snssai) bool { + if a == nil || b == nil { + return a == b + } + return a.Sst == b.Sst && strings.EqualFold(a.Sd, b.Sd) +} + +func resolveEventExposureUPFSnapshot(snapshot eventExposureSessionSnapshot) (EventExposureTarget, error) { + upi := GetUserPlaneInformation() + if upi == nil { + return EventExposureTarget{}, ErrEventExposureMissingUPF + } + + upi.Mu.RLock() + defer upi.Mu.RUnlock() + + for name, upfNode := range upi.UPFs { + if upfNode != snapshot.selectedUPF { + continue + } + if upfNode.UPF == nil { + return EventExposureTarget{}, ErrEventExposureMissingUPF + } + if upfNode.NupfEeApiRoot == "" { + return EventExposureTarget{}, ErrEventExposureMissingEndpoint + } + target := EventExposureTarget{ + UPFName: name, + UPFID: upfNode.UPF.UUID(), + APIroot: upfNode.NupfEeApiRoot, + ServiceBaseURL: upfNode.NupfEeApiRoot + "/nupf-ee/v1", + UEIPAddress: append(net.IP(nil), snapshot.ueIP...), + Dnn: snapshot.dnn, + PDUSessionID: snapshot.pduSessionID, + } + if snapshot.snssai != nil { + target.Snssai = &models.Snssai{Sst: snapshot.snssai.sst, Sd: snapshot.snssai.sd} + } + return target, nil + } + + return EventExposureTarget{}, ErrEventExposureMissingUPF +} diff --git a/internal/context/event_exposure_resolver_internal_test.go b/internal/context/event_exposure_resolver_internal_test.go new file mode 100644 index 00000000..cc5bda6e --- /dev/null +++ b/internal/context/event_exposure_resolver_internal_test.go @@ -0,0 +1,251 @@ +package context + +import ( + stdctx "context" + "errors" + "fmt" + "net" + "testing" + + "github.com/free5gc/openapi/models" +) + +const mutatedSnssaiSd = "ffffff" + +func TestEventExposureResolverResolvesActiveSelectedUPFOnly(t *testing.T) { + resetEventExposureResolverState(t) + selectedUPF := testEventExposureUPF("http://selected.example.com/api") + otherUPF := testEventExposureUPF("http://other.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{ + UPFs: map[string]*UPNode{ + "selected": selectedUPF, + "other": otherUPF, + }, + } + smContext := newEventExposureSMContext(t, "imsi-001010000000001", 0, Active, selectedUPF) + + target, err := NewEventExposureTargetResolver().ResolveEventExposureTarget( + stdctx.Background(), + "imsi-001010000000001", + EventExposureSelectors{ + PDUSessionID: int32Ptr(0), + Dnn: stringPtr("internet"), + Snssai: &models.Snssai{Sst: 1, Sd: "010203"}, + }, + ) + if err != nil { + t.Fatalf("ResolveEventExposureTarget failed: %v", err) + } + if target.APIroot != selectedUPF.NupfEeApiRoot || + target.ServiceBaseURL != selectedUPF.NupfEeApiRoot+"/nupf-ee/v1" || + target.PDUSessionID != smContext.PDUSessionID || + target.Dnn != smContext.Dnn || + !target.UEIPAddress.Equal(smContext.PDUAddress) { + t.Fatalf("unexpected target snapshot: %+v", target) + } +} + +func TestEventExposureResolverRejectsInactiveAndMismatchedSelectors(t *testing.T) { + tests := []struct { + name string + state SMContextState + selectors EventExposureSelectors + }{ + { + name: "inactive", + state: InActive, + }, + { + name: "dnn mismatch", + state: Active, + selectors: EventExposureSelectors{ + Dnn: stringPtr("ims"), + }, + }, + { + name: "snssai mismatch", + state: Active, + selectors: EventExposureSelectors{ + Snssai: &models.Snssai{Sst: 1, Sd: mutatedSnssaiSd}, + }, + }, + { + name: "pdu session mismatch", + state: Active, + selectors: EventExposureSelectors{ + PDUSessionID: int32Ptr(1), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetEventExposureResolverState(t) + upf := testEventExposureUPF("http://upf.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"upf": upf}} + newEventExposureSMContext(t, "imsi-001010000000001", 0, tt.state, upf) + + _, err := NewEventExposureTargetResolver().ResolveEventExposureTarget( + stdctx.Background(), "imsi-001010000000001", tt.selectors) + if !errors.Is(err, ErrEventExposureNoMatchingSession) { + t.Fatalf("expected no matching session, got %v", err) + } + }) + } +} + +func TestEventExposureResolverAmbiguityAndMissingDependencies(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + wantErr error + }{ + { + name: "multiple matching sessions", + setup: func(t *testing.T) { + upf := testEventExposureUPF("http://upf.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"upf": upf}} + newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, upf) + newEventExposureSMContext(t, "imsi-001010000000001", 2, Active, upf) + }, + wantErr: ErrEventExposureMultipleSessions, + }, + { + name: "missing UE IP", + setup: func(t *testing.T) { + upf := testEventExposureUPF("http://upf.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"upf": upf}} + smContext := newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, upf) + smContext.PDUAddress = nil + }, + wantErr: ErrEventExposureMissingUEIP, + }, + { + name: "missing selected UPF", + setup: func(t *testing.T) { + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{}} + newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, nil) + }, + wantErr: ErrEventExposureMissingUPF, + }, + { + name: "missing endpoint", + setup: func(t *testing.T) { + upf := testEventExposureUPF("") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"upf": upf}} + newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, upf) + }, + wantErr: ErrEventExposureMissingEndpoint, + }, + { + name: "selected UPF pointer absent from topology", + setup: func(t *testing.T) { + selectedUPF := testEventExposureUPF("http://selected.example.com/api") + otherUPF := testEventExposureUPF("http://other.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"other": otherUPF}} + newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, selectedUPF) + }, + wantErr: ErrEventExposureMissingUPF, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetEventExposureResolverState(t) + tt.setup(t) + _, err := NewEventExposureTargetResolver().ResolveEventExposureTarget( + stdctx.Background(), "imsi-001010000000001", EventExposureSelectors{}) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error mismatch: got %v want %v", err, tt.wantErr) + } + }) + } +} + +func TestEventExposureResolverCopiesSnapshotValues(t *testing.T) { + resetEventExposureResolverState(t) + upf := testEventExposureUPF("http://upf.example.com/api") + smfContext.UserPlaneInformation = &UserPlaneInformation{UPFs: map[string]*UPNode{"upf": upf}} + smContext := newEventExposureSMContext(t, "imsi-001010000000001", 1, Active, upf) + + target, err := NewEventExposureTargetResolver().ResolveEventExposureTarget( + stdctx.Background(), "imsi-001010000000001", EventExposureSelectors{}) + if err != nil { + t.Fatalf("ResolveEventExposureTarget failed: %v", err) + } + + smContext.PDUAddress[0] = 203 + upf.NupfEeApiRoot = "http://mutated.example.com" + smContext.SNssai.Sd = mutatedSnssaiSd + if !target.UEIPAddress.Equal(net.ParseIP("192.0.2.1")) || + target.APIroot != "http://upf.example.com/api" || + target.Snssai.Sd != "010203" { + t.Fatalf("target aliases mutable state: %+v", target) + } +} + +func resetEventExposureResolverState(t *testing.T) { + t.Helper() + oldUPI := smfContext.UserPlaneInformation + smfContext.UserPlaneInformation = nil + smContextPool.Range(func(key, _ interface{}) bool { + smContextPool.Delete(key) + return true + }) + canonicalRef.Range(func(key, _ interface{}) bool { + canonicalRef.Delete(key) + return true + }) + t.Cleanup(func() { + smContextPool.Range(func(key, _ interface{}) bool { + smContextPool.Delete(key) + return true + }) + canonicalRef.Range(func(key, _ interface{}) bool { + canonicalRef.Delete(key) + return true + }) + smfContext.UserPlaneInformation = oldUPI + }) +} + +func newEventExposureSMContext( + t *testing.T, + supi string, + pduSessionID int32, + state SMContextState, + selectedUPF *UPNode, +) *SMContext { + t.Helper() + smContext := &SMContext{ + Ref: fmt.Sprintf("%s-%d", supi, pduSessionID), + Identifier: supi, + PDUSessionID: pduSessionID, + SmfPduSessionSmContextCreateData: &models.SmfPduSessionSmContextCreateData{ + Supi: supi, + Dnn: "internet", + SNssai: &models.Snssai{Sst: 1, Sd: "010203"}, + }, + PDUAddress: net.ParseIP("192.0.2.1").To4(), + SelectedUPF: selectedUPF, + state: state, + } + smContextPool.Store(smContext.Ref, smContext) + return smContext +} + +func testEventExposureUPF(apiRoot string) *UPNode { + return &UPNode{ + Type: UPNODE_UPF, + NupfEeApiRoot: apiRoot, + UPF: &UPF{}, + } +} + +func int32Ptr(value int32) *int32 { + return &value +} + +func stringPtr(value string) *string { + return &value +} diff --git a/internal/context/event_exposure_subscription.go b/internal/context/event_exposure_subscription.go new file mode 100644 index 00000000..5764f634 --- /dev/null +++ b/internal/context/event_exposure_subscription.go @@ -0,0 +1,137 @@ +package context + +import ( + "errors" + "net" + "sync" + "time" + + "github.com/free5gc/openapi/models" +) + +var ErrEventExposureSubscriptionIDCollision = errors.New("event exposure subscription id collision") + +type EventExposureSelectors struct { + PDUSessionID *int32 + Dnn *string + Snssai *models.Snssai +} + +type EventExposureTarget struct { + UPFName string + UPFID string + APIroot string + ServiceBaseURL string + UEIPAddress net.IP + Dnn string + Snssai *models.Snssai + PDUSessionID int32 +} + +type NupfCreateResult struct { + SubscriptionID string + ValidatedLocation string + CreateRequestURI string + Response models.UpfCreatedEventSubscription + StatusCode int +} + +type EventExposureSubscription struct { + ID string + Supi string + Selectors EventExposureSelectors + NotifID string + NotifURI string + BundledEventNotifyURI string + MeasurementTypes []models.UpfMeasurementType + ReportingPeriod int32 + Granularity models.UpfGranularityOfMeasurement + CreatedAt time.Time + + Target EventExposureTarget + NupfSubscriptionID string + NupfLocation string +} + +type EventExposureRepository struct { + mu sync.RWMutex + subscriptions map[string]EventExposureSubscription +} + +func NewEventExposureRepository() *EventExposureRepository { + return &EventExposureRepository{ + subscriptions: make(map[string]EventExposureSubscription), + } +} + +func (r *EventExposureRepository) Store(subscription EventExposureSubscription) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.subscriptions[subscription.ID]; ok { + return ErrEventExposureSubscriptionIDCollision + } + r.subscriptions[subscription.ID] = cloneEventExposureSubscription(subscription) + return nil +} + +func (r *EventExposureRepository) Get(id string) (EventExposureSubscription, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + subscription, ok := r.subscriptions[id] + if !ok { + return EventExposureSubscription{}, false + } + return cloneEventExposureSubscription(subscription), true +} + +func (r *EventExposureRepository) ClaimDelete(id string) (EventExposureSubscription, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + subscription, ok := r.subscriptions[id] + if !ok { + return EventExposureSubscription{}, false + } + delete(r.subscriptions, id) + return cloneEventExposureSubscription(subscription), true +} + +func cloneEventExposureSubscription(subscription EventExposureSubscription) EventExposureSubscription { + subscription.Selectors = cloneEventExposureSelectors(subscription.Selectors) + subscription.MeasurementTypes = append([]models.UpfMeasurementType(nil), subscription.MeasurementTypes...) + subscription.Target = cloneEventExposureTarget(subscription.Target) + return subscription +} + +func cloneEventExposureSelectors(selectors EventExposureSelectors) EventExposureSelectors { + if selectors.PDUSessionID != nil { + pduSessionID := *selectors.PDUSessionID + selectors.PDUSessionID = &pduSessionID + } + if selectors.Dnn != nil { + dnn := *selectors.Dnn + selectors.Dnn = &dnn + } + if selectors.Snssai != nil { + selectors.Snssai = cloneModelSnssai(selectors.Snssai) + } + return selectors +} + +func cloneEventExposureTarget(target EventExposureTarget) EventExposureTarget { + target.UEIPAddress = append(net.IP(nil), target.UEIPAddress...) + target.Snssai = cloneModelSnssai(target.Snssai) + return target +} + +func cloneModelSnssai(snssai *models.Snssai) *models.Snssai { + if snssai == nil { + return nil + } + return &models.Snssai{ + Sst: snssai.Sst, + Sd: snssai.Sd, + } +} diff --git a/internal/context/event_exposure_subscription_internal_test.go b/internal/context/event_exposure_subscription_internal_test.go new file mode 100644 index 00000000..fb67b702 --- /dev/null +++ b/internal/context/event_exposure_subscription_internal_test.go @@ -0,0 +1,79 @@ +package context + +import ( + "errors" + "net" + "testing" + + "github.com/free5gc/openapi/models" +) + +func TestEventExposureRepositoryCopiesStoredRecords(t *testing.T) { + repository := NewEventExposureRepository() + pduSessionID := int32(0) + dnn := "internet" + measurementTypes := []models.UpfMeasurementType{models.UpfMeasurementType_VOLUME_MEASUREMENT} + ip := net.ParseIP("192.0.2.1").To4() + + subscription := EventExposureSubscription{ + ID: "sub-1", + Selectors: EventExposureSelectors{ + PDUSessionID: &pduSessionID, + Dnn: &dnn, + Snssai: &models.Snssai{Sst: 1, Sd: "010203"}, + }, + MeasurementTypes: measurementTypes, + Target: EventExposureTarget{ + UEIPAddress: ip, + Snssai: &models.Snssai{Sst: 1, Sd: "010203"}, + }, + } + if err := repository.Store(subscription); err != nil { + t.Fatalf("Store failed: %v", err) + } + + pduSessionID = 9 + dnn = "mutated" + measurementTypes[0] = models.UpfMeasurementType_THROUGHPUT_MEASUREMENT + ip[0] = 203 + subscription.Selectors.Snssai.Sd = "ffffff" + subscription.Target.Snssai.Sd = "ffffff" + + stored, ok := repository.Get("sub-1") + if !ok { + t.Fatal("stored subscription not found") + } + if stored.Selectors.PDUSessionID == nil || *stored.Selectors.PDUSessionID != 0 { + t.Fatalf("PDUSessionID alias detected: %+v", stored.Selectors.PDUSessionID) + } + if stored.Selectors.Dnn == nil || *stored.Selectors.Dnn != "internet" { + t.Fatalf("Dnn alias detected: %+v", stored.Selectors.Dnn) + } + if stored.MeasurementTypes[0] != models.UpfMeasurementType_VOLUME_MEASUREMENT { + t.Fatalf("measurement alias detected: %v", stored.MeasurementTypes) + } + if !stored.Target.UEIPAddress.Equal(net.ParseIP("192.0.2.1")) { + t.Fatalf("IP alias detected: %v", stored.Target.UEIPAddress) + } + if stored.Selectors.Snssai.Sd != "010203" || stored.Target.Snssai.Sd != "010203" { + t.Fatalf("S-NSSAI alias detected: %+v %+v", stored.Selectors.Snssai, stored.Target.Snssai) + } +} + +func TestEventExposureRepositoryCollisionAndClaimDelete(t *testing.T) { + repository := NewEventExposureRepository() + subscription := EventExposureSubscription{ID: "sub-1"} + if err := repository.Store(subscription); err != nil { + t.Fatalf("Store failed: %v", err) + } + if err := repository.Store(subscription); !errors.Is(err, ErrEventExposureSubscriptionIDCollision) { + t.Fatalf("expected collision, got %v", err) + } + + if _, ok := repository.ClaimDelete("sub-1"); !ok { + t.Fatal("ClaimDelete failed") + } + if _, ok := repository.ClaimDelete("sub-1"); ok { + t.Fatal("ClaimDelete should own the record only once") + } +} diff --git a/internal/context/nf_profile_event_exposure_internal_test.go b/internal/context/nf_profile_event_exposure_internal_test.go new file mode 100644 index 00000000..02ec4b6f --- /dev/null +++ b/internal/context/nf_profile_event_exposure_internal_test.go @@ -0,0 +1,54 @@ +package context + +import ( + "testing" + + "github.com/free5gc/openapi/models" + "github.com/free5gc/smf/pkg/factory" +) + +func TestSetupNFProfileEventExposureServiceNameListGating(t *testing.T) { + tests := []struct { + name string + services []string + wantFound bool + }{ + { + name: "present advertises", + services: []string{"nsmf-pdusession", "nsmf-event-exposure"}, + wantFound: true, + }, + { + name: "absent disables advertisement", + services: []string{"nsmf-pdusession"}, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &SMFContext{ + NfInstanceID: "00000000-0000-4000-8000-000000000001", + URIScheme: models.UriScheme_HTTP, + RegisterIPv4: "127.0.0.1", + SBIPort: 8000, + } + ctx.SetupNFProfile(&factory.Config{ + Info: &factory.Info{Version: "1.0.7"}, + Configuration: &factory.Configuration{ + ServiceNameList: tt.services, + }, + }) + + found := false + for _, service := range *ctx.NfProfile.NFServices { + if service.ServiceName == models.ServiceName_NSMF_EVENT_EXPOSURE { + found = true + } + } + if found != tt.wantFound { + t.Fatalf("advertisement mismatch: got %v want %v", found, tt.wantFound) + } + }) + } +} diff --git a/internal/context/user_plane_information.go b/internal/context/user_plane_information.go index 81180f97..cd890978 100644 --- a/internal/context/user_plane_information.go +++ b/internal/context/user_plane_information.go @@ -37,13 +37,14 @@ const ( // UPNode represent the user plane node topology type UPNode struct { - Name string - Type UPNodeType - NodeID pfcpType.NodeID - ANIP net.IP - Dnn string - Links []*UPNode - UPF *UPF + Name string + Type UPNodeType + NodeID pfcpType.NodeID + ANIP net.IP + Dnn string + NupfEeApiRoot string + Links []*UPNode + UPF *UPF } func (u *UPNode) MatchedSelection(selection *UPFSelectionParams) bool { @@ -127,6 +128,9 @@ func NewUserPlaneInformation(upTopology *factory.UserPlaneInformation) (*UserPla upNode.UPF = NewUPF(&upNode.NodeID, node.InterfaceUpfInfoList) upNode.UPF.Addr = node.Addr + if node.NupfEeApiRoot != nil { + upNode.NupfEeApiRoot = *node.NupfEeApiRoot + } snssaiInfos := make([]*SnssaiUPFInfo, 0) for _, snssaiInfoConfig := range node.SNssaiInfos { snssaiInfo := SnssaiUPFInfo{ @@ -251,6 +255,10 @@ func (upi *UserPlaneInformation) UpNodesToConfiguration() map[string]*factory.UP u.NodeID = nodeIDtoIp.String() } if upNode.UPF != nil { + if upNode.NupfEeApiRoot != "" { + nupfEeApiRoot := upNode.NupfEeApiRoot + u.NupfEeApiRoot = &nupfEeApiRoot + } if upNode.UPF.SNssaiInfos != nil { FsNssaiInfoList := make([]*factory.SnssaiUpfInfoItem, 0) for _, sNssaiInfo := range upNode.UPF.SNssaiInfos { @@ -436,6 +444,10 @@ func (upi *UserPlaneInformation) UpNodesFromConfiguration(upTopology *factory.Us } upNode.UPF = NewUPF(&upNode.NodeID, node.InterfaceUpfInfoList) + upNode.UPF.Addr = node.Addr + if node.NupfEeApiRoot != nil { + upNode.NupfEeApiRoot = *node.NupfEeApiRoot + } createdUPFs = append(createdUPFs, upNode.UPF) snssaiInfos := make([]*SnssaiUPFInfo, 0) for _, snssaiInfoConfig := range node.SNssaiInfos { diff --git a/internal/sbi/api_eventexposure.go b/internal/sbi/api_eventexposure.go index a3d16dac..3d47d916 100644 --- a/internal/sbi/api_eventexposure.go +++ b/internal/sbi/api_eventexposure.go @@ -13,6 +13,11 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/pkg/factory" ) func (s *Server) getEventExposureRoutes() []Route { @@ -54,20 +59,63 @@ func (s *Server) getEventExposureRoutes() []Route { // SubscriptionsPost - func (s *Server) HTTPCreateIndividualSubcription(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{}) + request, problemDetails := decodeEventExposureCreateRequest(c.Request.Body) + if problemDetails != nil { + c.JSON(int(problemDetails.Status), problemDetails) + return + } + + result, problem := s.Processor().CreateEventExposureSubscription(c.Request.Context(), request) + if problem != nil { + c.JSON(problem.Status, problem.ProblemDetails) + return + } + + location := factory.SmfEventExposureResUriPrefix + "/subscriptions/" + result.Subscription.ID + c.Header("Location", location) + c.JSON(http.StatusCreated, eventExposureResponseBody(result.Subscription)) } // SubscriptionsSubIdDelete - func (s *Server) HTTPDeleteIndividualSubcription(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{}) + if problem := s.Processor().DeleteEventExposureSubscription(c.Request.Context(), c.Param("subId")); problem != nil { + c.JSON(problem.Status, problem.ProblemDetails) + return + } + c.Status(http.StatusNoContent) } // SubscriptionsSubIdGet - func (s *Server) HTTPGetIndividualSubcription(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{}) + problemDetails := openapi.ProblemDetailsOperationNotSupported() + c.JSON(int(problemDetails.Status), problemDetails) } // SubscriptionsSubIdPut - func (s *Server) HTTPReplaceIndividualSubcription(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{}) + problemDetails := openapi.ProblemDetailsOperationNotSupported() + c.JSON(int(problemDetails.Status), problemDetails) +} + +func eventExposureResponseBody(subscription smf_context.EventExposureSubscription) models.NsmfEventExposure { + eventSubscription := models.SmfEventExposureEventSubscription{ + Event: models.SmfEvent_UPF_EVENT, + UpfEvents: []models.UpfEvent{ + { + Type: models.UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: append([]models.UpfMeasurementType(nil), subscription.MeasurementTypes...), + GranularityOfMeasurement: models.UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + BundledEventNotifyUri: subscription.BundledEventNotifyURI, + } + return models.NsmfEventExposure{ + Supi: subscription.Supi, + SubId: subscription.ID, + NotifId: subscription.NotifID, + NotifUri: subscription.NotifURI, + EventSubs: []models.SmfEventExposureEventSubscription{eventSubscription}, + NotifMethod: models.SmfEventExposureNotificationMethod_PERIODIC, + RepPeriod: subscription.ReportingPeriod, + } } diff --git a/internal/sbi/api_eventexposure_internal_test.go b/internal/sbi/api_eventexposure_internal_test.go new file mode 100644 index 00000000..50af25ca --- /dev/null +++ b/internal/sbi/api_eventexposure_internal_test.go @@ -0,0 +1,147 @@ +package sbi + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/internal/sbi/consumer" + "github.com/free5gc/smf/internal/sbi/processor" + "github.com/free5gc/smf/pkg/factory" +) + +func TestHTTPCreateIndividualSubcriptionReturnsLocationAndBody(t *testing.T) { + gin.SetMode(gin.TestMode) + eventProcessor, err := processor.NewProcessorWithEventExposureDependencies( + &fakeEventExposureSMF{ + context: &smf_context.SMFContext{NfInstanceID: "smf-instance"}, + }, + processor.EventExposureDependencies{ + Repository: smf_context.NewEventExposureRepository(), + Resolver: fakeSBIEventExposureResolver{ + target: smf_context.EventExposureTarget{ + APIroot: "https://upf.example.com", + ServiceBaseURL: "https://upf.example.com/nupf-ee/v1", + UEIPAddress: net.ParseIP("192.0.2.1").To4(), + }, + }, + NupfConsumer: fakeSBIEventExposureConsumer{ + result: smf_context.NupfCreateResult{ + SubscriptionID: "upf-sub-1", + ValidatedLocation: "https://upf.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-1", + }, + }, + UUIDGenerator: fixedSBIUUIDGenerator{}, + }, + ) + if err != nil { + t.Fatalf("NewProcessorWithEventExposureDependencies failed: %v", err) + } + + server := &Server{ServerSmf: fakeServerSMF{processor: eventProcessor}} + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/nsmf-event-exposure/v1/subscriptions", + strings.NewReader(validEventExposureJSON("")), + ) + + server.HTTPCreateIndividualSubcription(ginContext) + if recorder.Code != http.StatusCreated { + t.Fatalf("status mismatch: %d body %s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Location") != "/nsmf-event-exposure/v1/subscriptions/sub-1" { + t.Fatalf("Location mismatch: %q", recorder.Header().Get("Location")) + } + + var response models.NsmfEventExposure + if unmarshalErr := json.Unmarshal(recorder.Body.Bytes(), &response); unmarshalErr != nil { + t.Fatalf("Unmarshal response failed: %v", unmarshalErr) + } + if response.SubId != "sub-1" || + response.Supi != "imsi-001010000000001" || + response.NotifId != "correlation-1" || + response.NotifUri != "https://nwdaf.example.com/nsmf" || + response.RepPeriod != 10 || + response.NotifMethod != models.SmfEventExposureNotificationMethod_PERIODIC || + len(response.EventSubs) != 1 || + response.EventSubs[0].BundledEventNotifyUri != "https://nwdaf.example.com/upf" { + t.Fatalf("unexpected response body: %+v", response) + } +} + +type fakeServerSMF struct { + processor *processor.Processor +} + +func (f fakeServerSMF) SetLogEnable(bool) {} +func (f fakeServerSMF) SetLogLevel(string) {} +func (f fakeServerSMF) SetReportCaller(bool) {} +func (f fakeServerSMF) Start() {} +func (f fakeServerSMF) Terminate() {} +func (f fakeServerSMF) Context() *smf_context.SMFContext { return nil } +func (f fakeServerSMF) Config() *factory.Config { return nil } +func (f fakeServerSMF) Consumer() *consumer.Consumer { return nil } +func (f fakeServerSMF) Processor() *processor.Processor { return f.processor } +func (f fakeServerSMF) CancelContext() context.Context { return context.Background() } + +type fakeEventExposureSMF struct { + context *smf_context.SMFContext +} + +func (f *fakeEventExposureSMF) SetLogEnable(bool) {} +func (f *fakeEventExposureSMF) SetLogLevel(string) {} +func (f *fakeEventExposureSMF) SetReportCaller(bool) {} +func (f *fakeEventExposureSMF) Start() {} +func (f *fakeEventExposureSMF) Terminate() {} +func (f *fakeEventExposureSMF) Context() *smf_context.SMFContext { return f.context } +func (f *fakeEventExposureSMF) Config() *factory.Config { return nil } +func (f *fakeEventExposureSMF) Consumer() *consumer.Consumer { return nil } + +type fakeSBIEventExposureResolver struct { + target smf_context.EventExposureTarget +} + +func (f fakeSBIEventExposureResolver) ResolveEventExposureTarget( + context.Context, + string, + smf_context.EventExposureSelectors, +) (smf_context.EventExposureTarget, error) { + return f.target, nil +} + +type fakeSBIEventExposureConsumer struct { + result smf_context.NupfCreateResult +} + +func (f fakeSBIEventExposureConsumer) CreateSubscription( + context.Context, + smf_context.EventExposureTarget, + models.UpfCreateEventSubscription, +) (smf_context.NupfCreateResult, error) { + return f.result, nil +} + +func (f fakeSBIEventExposureConsumer) DeleteSubscription( + context.Context, + smf_context.EventExposureTarget, + string, +) error { + return nil +} + +type fixedSBIUUIDGenerator struct{} + +func (f fixedSBIUUIDGenerator) NewString() string { + return "sub-1" +} diff --git a/internal/sbi/consumer/consumer.go b/internal/sbi/consumer/consumer.go index 6b9188b3..692835dc 100644 --- a/internal/sbi/consumer/consumer.go +++ b/internal/sbi/consumer/consumer.go @@ -9,6 +9,7 @@ import ( "github.com/free5gc/openapi/smf/PDUSession" "github.com/free5gc/openapi/udm/SubscriberDataManagement" "github.com/free5gc/openapi/udm/UEContextManagement" + NupfEventExposure "github.com/free5gc/openapi/upf/EventExposure" smf_context "github.com/free5gc/smf/internal/context" "github.com/free5gc/smf/pkg/app" ) @@ -24,6 +25,7 @@ type Consumer struct { *nudmService *nnrfService *nbsfService // BSF service for PCF binding discovery + *nupfEventExposureService } func NewConsumer(smf app.App) (*Consumer, error) { @@ -67,6 +69,12 @@ func NewConsumer(smf app.App) (*Consumer, error) { consumer: c, } + c.nupfEventExposureService = &nupfEventExposureService{ + consumer: c, + EventExposureClients: make(map[string]*NupfEventExposure.APIClient), + EventExposureCreateRequests: make(map[string]string), + } + return c, nil } diff --git a/internal/sbi/consumer/nupf_event_exposure.go b/internal/sbi/consumer/nupf_event_exposure.go new file mode 100644 index 00000000..8f2e30dd --- /dev/null +++ b/internal/sbi/consumer/nupf_event_exposure.go @@ -0,0 +1,296 @@ +package consumer + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" + NupfEventExposure "github.com/free5gc/openapi/upf/EventExposure" + smf_context "github.com/free5gc/smf/internal/context" + sbi_metrics "github.com/free5gc/util/metrics/sbi" +) + +type NupfEventExposureErrorKind string + +const ( + NupfEventExposureErrorToken NupfEventExposureErrorKind = "token" + NupfEventExposureErrorRedirect NupfEventExposureErrorKind = "redirect" + NupfEventExposureErrorTransport NupfEventExposureErrorKind = "transport" + NupfEventExposureErrorUpstreamProblem NupfEventExposureErrorKind = "upstream_problem" + NupfEventExposureErrorMalformedSuccess NupfEventExposureErrorKind = "malformed_success" +) + +type NupfEventExposureError struct { + Kind NupfEventExposureErrorKind + StatusCode int + ProblemDetails *models.ProblemDetails + Operation string +} + +func (e *NupfEventExposureError) Error() string { + return fmt.Sprintf("nupf event exposure %s failed: %s", e.Operation, e.Kind) +} + +type nupfEventExposureService struct { + consumer *Consumer + + EventExposureMu sync.RWMutex + EventExposureClients map[string]*NupfEventExposure.APIClient + EventExposureCreateRequests map[string]string +} + +func (s *nupfEventExposureService) getEventExposureClient(apiRoot string) *NupfEventExposure.APIClient { + if apiRoot == "" { + return nil + } + + s.EventExposureMu.RLock() + client, ok := s.EventExposureClients[apiRoot] + if ok { + s.EventExposureMu.RUnlock() + return client + } + + configuration := NupfEventExposure.NewConfiguration() + configuration.SetBasePath(apiRoot) + configuration.SetMetrics(sbi_metrics.SbiMetricHook) + configuration.SetRedirectPolicy(openapi.RejectRedirects) + client = NupfEventExposure.NewAPIClient(configuration) + requestURI := strings.TrimRight(configuration.BasePath(), "/") + "/ee-subscriptions" + + s.EventExposureMu.RUnlock() + s.EventExposureMu.Lock() + defer s.EventExposureMu.Unlock() + if existing, exists := s.EventExposureClients[apiRoot]; exists { + return existing + } + s.EventExposureClients[apiRoot] = client + s.EventExposureCreateRequests[apiRoot] = requestURI + return client +} + +func (s *nupfEventExposureService) CreateSubscription( + ctx context.Context, + target smf_context.EventExposureTarget, + request models.UpfCreateEventSubscription, +) (smf_context.NupfCreateResult, error) { + client := s.getEventExposureClient(target.APIroot) + if client == nil { + return smf_context.NupfCreateResult{}, &NupfEventExposureError{ + Kind: NupfEventExposureErrorTransport, + Operation: "create", + } + } + + callCtx, err := s.tokenContext(ctx) + if err != nil { + return smf_context.NupfCreateResult{}, &NupfEventExposureError{ + Kind: NupfEventExposureErrorToken, + Operation: "create", + } + } + + createRequest := &NupfEventExposure.CreateSubscriptionRequest{} + createRequest.SetUpfCreateEventSubscription(request) + response, err := client.SubscriptionsCollectionApi.CreateSubscription(callCtx, createRequest) + if err != nil { + return smf_context.NupfCreateResult{}, classifyNupfCreateError(err) + } + result, err := validateNupfCreateSuccess(target, s.createRequestURI(target.APIroot), response) + if err != nil { + return smf_context.NupfCreateResult{}, &NupfEventExposureError{ + Kind: NupfEventExposureErrorMalformedSuccess, + Operation: "create", + } + } + return result, nil +} + +func (s *nupfEventExposureService) DeleteSubscription( + ctx context.Context, + target smf_context.EventExposureTarget, + subscriptionID string, +) error { + client := s.getEventExposureClient(target.APIroot) + if client == nil { + return &NupfEventExposureError{Kind: NupfEventExposureErrorTransport, Operation: "delete"} + } + + callCtx, err := s.tokenContext(ctx) + if err != nil { + return &NupfEventExposureError{Kind: NupfEventExposureErrorToken, Operation: "delete"} + } + + deleteRequest := &NupfEventExposure.DeleteSubscriptionRequest{} + deleteRequest.SetSubscriptionId(subscriptionID) + _, err = client.IndividualSubscriptionDocumentApi.DeleteSubscription(callCtx, deleteRequest) + if err != nil { + return classifyNupfDeleteError(err) + } + return nil +} + +func (s *nupfEventExposureService) tokenContext(ctx context.Context) (context.Context, error) { + if !s.consumer.Context().OAuth2Required { + return ctx, nil + } + + tokenCtx, _, err := s.consumer.Context().GetTokenCtx( + models.ServiceName_NUPF_EE, models.NrfNfManagementNfType_UPF) + if err != nil { + return nil, err + } + return tokenCtx, nil +} + +func (s *nupfEventExposureService) createRequestURI(apiRoot string) string { + s.EventExposureMu.RLock() + defer s.EventExposureMu.RUnlock() + return s.EventExposureCreateRequests[apiRoot] +} + +func classifyNupfCreateError(err error) error { + return classifyNupfError("create", err) +} + +func classifyNupfDeleteError(err error) error { + return classifyNupfError("delete", err) +} + +func classifyNupfError(operation string, err error) error { + var apiError openapi.GenericOpenAPIError + if errors.As(err, &apiError) { + classified := &NupfEventExposureError{ + StatusCode: apiError.ErrorStatus, + Operation: operation, + } + switch model := apiError.Model().(type) { + case NupfEventExposure.CreateSubscriptionError: + classified.Kind = nupfErrorKindFromCreateStatus(apiError.ErrorStatus) + if model.ProblemDetails.Status != 0 { + classified.ProblemDetails = &model.ProblemDetails + } + case NupfEventExposure.DeleteSubscriptionError: + classified.Kind = nupfErrorKindFromDeleteStatus(apiError.ErrorStatus) + if model.ProblemDetails.Status != 0 { + classified.ProblemDetails = &model.ProblemDetails + } + default: + classified.Kind = NupfEventExposureErrorTransport + } + return classified + } + return &NupfEventExposureError{Kind: NupfEventExposureErrorTransport, Operation: operation} +} + +func nupfErrorKindFromCreateStatus(status int) NupfEventExposureErrorKind { + if status == http.StatusTemporaryRedirect || status == http.StatusPermanentRedirect { + return NupfEventExposureErrorRedirect + } + return NupfEventExposureErrorUpstreamProblem +} + +func nupfErrorKindFromDeleteStatus(status int) NupfEventExposureErrorKind { + if status == http.StatusTemporaryRedirect || status == http.StatusPermanentRedirect { + return NupfEventExposureErrorRedirect + } + return NupfEventExposureErrorUpstreamProblem +} + +func validateNupfCreateSuccess( + target smf_context.EventExposureTarget, + requestURI string, + response *NupfEventExposure.CreateSubscriptionResponse, +) (smf_context.NupfCreateResult, error) { + if response == nil { + return smf_context.NupfCreateResult{}, errors.New("missing response") + } + subscriptionID := response.UpfCreatedEventSubscription.SubscriptionId + if subscriptionID == "" || response.Location == "" { + return smf_context.NupfCreateResult{}, errors.New("missing subscription linkage") + } + + resolvedLocation, err := resolveNupfLocation(requestURI, response.Location) + if err != nil { + return smf_context.NupfCreateResult{}, err + } + if locationErr := validateNupfLocation(target, resolvedLocation, subscriptionID); locationErr != nil { + return smf_context.NupfCreateResult{}, locationErr + } + + return smf_context.NupfCreateResult{ + SubscriptionID: subscriptionID, + ValidatedLocation: resolvedLocation.String(), + CreateRequestURI: requestURI, + Response: response.UpfCreatedEventSubscription, + StatusCode: http.StatusCreated, + }, nil +} + +func resolveNupfLocation(requestURI, location string) (*url.URL, error) { + base, err := url.Parse(requestURI) + if err != nil { + return nil, err + } + ref, err := url.Parse(location) + if err != nil { + return nil, err + } + if hasNupfDotPathSegment(ref.Path) { + return nil, errors.New("invalid location path") + } + resolved := base.ResolveReference(ref) + if resolved.User != nil || resolved.RawQuery != "" || resolved.Fragment != "" { + return nil, errors.New("invalid location metadata") + } + return resolved, nil +} + +func validateNupfLocation( + target smf_context.EventExposureTarget, + location *url.URL, + subscriptionID string, +) error { + expectedBase, err := url.Parse(target.ServiceBaseURL) + if err != nil { + return err + } + if location.Scheme != expectedBase.Scheme || location.Host != expectedBase.Host { + return errors.New("location origin mismatch") + } + + if !validNupfSubscriptionIDSegment(subscriptionID) { + return errors.New("invalid subscription id") + } + + expectedPath := strings.TrimRight(expectedBase.EscapedPath(), "/") + + "/ee-subscriptions/" + subscriptionID + if location.EscapedPath() != expectedPath { + return errors.New("location path mismatch") + } + return nil +} + +func validNupfSubscriptionIDSegment(subscriptionID string) bool { + return subscriptionID != "" && + subscriptionID != "." && + subscriptionID != ".." && + !strings.Contains(subscriptionID, "/") && + !strings.Contains(subscriptionID, `\`) && + url.PathEscape(subscriptionID) == subscriptionID +} + +func hasNupfDotPathSegment(locationPath string) bool { + for _, segment := range strings.Split(locationPath, "/") { + if segment == "." || segment == ".." { + return true + } + } + return false +} diff --git a/internal/sbi/consumer/nupf_event_exposure_internal_test.go b/internal/sbi/consumer/nupf_event_exposure_internal_test.go new file mode 100644 index 00000000..8794ecdc --- /dev/null +++ b/internal/sbi/consumer/nupf_event_exposure_internal_test.go @@ -0,0 +1,405 @@ +package consumer + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" + NupfEventExposure "github.com/free5gc/openapi/upf/EventExposure" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/pkg/factory" +) + +func TestValidateNupfCreateSuccessLocationInvariants(t *testing.T) { + target := smf_context.EventExposureTarget{ + APIroot: "https://upf.example.com/api", + ServiceBaseURL: "https://upf.example.com/api/nupf-ee/v1", + } + requestURI := "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions" + + tests := []struct { + name string + location string + wantErr bool + }{ + { + name: "relative", + location: "ee-subscriptions/upf-sub-1", + }, + { + name: "absolute", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/upf-sub-1", + }, + { + name: "cross origin rejected", + location: "https://other.example.com/api/nupf-ee/v1/ee-subscriptions/upf-sub-1", + wantErr: true, + }, + { + name: "query rejected", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/upf-sub-1?x=1", + wantErr: true, + }, + { + name: "id mismatch rejected", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/other", + wantErr: true, + }, + { + name: "subscription id with parent segment rejected", + location: "https://upf.example.com/api/nupf-ee/v1/x", + wantErr: true, + }, + { + name: "subscription id with slash rejected", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/a/b", + wantErr: true, + }, + { + name: "subscription id with encoded slash rejected", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/a%2Fb", + wantErr: true, + }, + { + name: "path cleaning does not bypass validation", + location: "https://upf.example.com/api/nupf-ee/v1/ee-subscriptions/a/../upf-sub-1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subscriptionID := "upf-sub-1" + switch tt.name { + case "subscription id with parent segment rejected": + subscriptionID = "../x" + case "subscription id with slash rejected": + subscriptionID = "a/b" + case "subscription id with encoded slash rejected": + subscriptionID = "a%2Fb" + } + _, err := validateNupfCreateSuccess(target, requestURI, &NupfEventExposure.CreateSubscriptionResponse{ + Location: tt.location, + UpfCreatedEventSubscription: models.UpfCreatedEventSubscription{ + SubscriptionId: subscriptionID, + }, + }) + if (err != nil) != tt.wantErr { + t.Fatalf("error mismatch: got %v wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNupfEventExposureCreateSendsExactRequestBody(t *testing.T) { + var capturedPath string + var capturedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + if unmarshalErr := json.Unmarshal(body, &capturedBody); unmarshalErr != nil { + t.Fatalf("Unmarshal request body failed: %v", unmarshalErr) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", serverLocation(r, "upf-sub-1")) + w.WriteHeader(http.StatusCreated) + if _, writeErr := w.Write([]byte(`{"subscriptionId":"upf-sub-1"}`)); writeErr != nil { + t.Fatalf("Write failed: %v", writeErr) + } + })) + defer server.Close() + + service := newTestNupfEventExposureService(false, "") + installTestEventExposureClient(service, server.URL, server.Client()) + target := testEventExposureTarget(server.URL) + result, err := service.CreateSubscription(context.Background(), target, testNupfCreateRequest()) + if err != nil { + t.Fatalf("CreateSubscription failed: %v", err) + } + if result.SubscriptionID != "upf-sub-1" { + t.Fatalf("SubscriptionID mismatch: %q", result.SubscriptionID) + } + if capturedPath != "/nupf-ee/v1/ee-subscriptions" { + t.Fatalf("path mismatch: %q", capturedPath) + } + subscription, ok := capturedBody["subscription"].(map[string]interface{}) + if !ok { + t.Fatalf("missing subscription wrapper: %#v", capturedBody) + } + if subscription["eventNotifyUri"] != "https://nwdaf.example.com/upf" || + subscription["notifyCorrelationId"] != "correlation-1" || + subscription["nfId"] != "smf-instance" { + t.Fatalf("unexpected subscription body: %#v", subscription) + } + if _, ipOK := subscription["ueIpAddress"].(map[string]interface{}); !ipOK { + t.Fatalf("missing IpAddr object: %#v", subscription["ueIpAddress"]) + } + mode, ok := subscription["eventReportingMode"].(map[string]interface{}) + if !ok || mode["trigger"] != "PERIODIC" || mode["repPeriod"].(float64) != 10 { + t.Fatalf("unexpected reporting mode: %#v", subscription["eventReportingMode"]) + } +} + +func TestNupfEventExposureCreateAndDeleteRejectRedirectWithoutReplay(t *testing.T) { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + var targetRequests int + redirectTarget := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + targetRequests++ + })) + defer redirectTarget.Close() + + var createRequests int + var createBody []byte + createServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + createRequests++ + var err error + createBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", redirectTarget.URL+"/nupf-ee/v1/ee-subscriptions/upf-sub-1") + w.WriteHeader(status) + if _, writeErr := w.Write([]byte(`{"cause":"TEMPORARY_REDIRECT"}`)); writeErr != nil { + t.Fatalf("Write failed: %v", writeErr) + } + })) + defer createServer.Close() + + service := newTestNupfEventExposureService(false, "") + installTestEventExposureClient(service, createServer.URL, createServer.Client()) + _, err := service.CreateSubscription( + context.Background(), testEventExposureTarget(createServer.URL), testNupfCreateRequest()) + assertNupfError(t, err, NupfEventExposureErrorRedirect, "create", status) + if createRequests != 1 { + t.Fatalf("Create requests mismatch: %d", createRequests) + } + if len(createBody) == 0 { + t.Fatal("expected original Create request body") + } + if targetRequests != 0 { + t.Fatalf("redirect target received %d Create requests", targetRequests) + } + + var deleteRequests int + deleteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deleteRequests++ + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", redirectTarget.URL+"/nupf-ee/v1/ee-subscriptions/upf-sub-1") + w.WriteHeader(status) + if _, writeErr := w.Write([]byte(`{"cause":"TEMPORARY_REDIRECT"}`)); writeErr != nil { + t.Fatalf("Write failed: %v", writeErr) + } + })) + defer deleteServer.Close() + + service = newTestNupfEventExposureService(false, "") + installTestEventExposureClient(service, deleteServer.URL, deleteServer.Client()) + err = service.DeleteSubscription( + context.Background(), testEventExposureTarget(deleteServer.URL), "upf-sub-1") + assertNupfError(t, err, NupfEventExposureErrorRedirect, "delete", status) + if deleteRequests != 1 { + t.Fatalf("Delete requests mismatch: %d", deleteRequests) + } + if targetRequests != 0 { + t.Fatalf("redirect target received %d total requests", targetRequests) + } + }) + } +} + +func TestNupfEventExposureOAuthDisabledUsesCallerContext(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + service := newTestNupfEventExposureService(false, "") + installTestEventExposureClient(service, server.URL, server.Client()) + _, err := service.CreateSubscription(ctx, testEventExposureTarget(server.URL), testNupfCreateRequest()) + assertNupfError(t, err, NupfEventExposureErrorTransport, "create", 0) + if requests != 0 { + t.Fatalf("canceled caller context still sent %d requests", requests) + } +} + +func TestNupfEventExposureOAuthTokenFailureSendsNoNupfRequest(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer server.Close() + + service := newTestNupfEventExposureService(true, "://invalid-nrf") + _, err := service.CreateSubscription( + context.Background(), testEventExposureTarget(server.URL), testNupfCreateRequest()) + assertNupfError(t, err, NupfEventExposureErrorToken, "create", 0) + if requests != 0 { + t.Fatalf("token failure still sent %d Nupf requests", requests) + } + + err = service.DeleteSubscription(context.Background(), testEventExposureTarget(server.URL), "upf-sub-1") + assertNupfError(t, err, NupfEventExposureErrorToken, "delete", 0) + if requests != 0 { + t.Fatalf("token failure still sent %d Nupf requests after Delete", requests) + } +} + +func TestNupfEventExposureDefaultClientSetupUsesBasePathMetricsAndNoFollow(t *testing.T) { + var requestCount int + var capturedPath string + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", "https://redirect.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-1") + w.WriteHeader(http.StatusTemporaryRedirect) + if _, writeErr := w.Write([]byte(`{"cause":"TEMPORARY_REDIRECT"}`)); writeErr != nil { + t.Fatalf("Write failed: %v", writeErr) + } + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + service := newTestNupfEventExposureService(false, "") + _, err := service.CreateSubscription( + context.Background(), testEventExposureTarget(server.URL), testNupfCreateRequest()) + assertNupfError(t, err, NupfEventExposureErrorRedirect, "create", http.StatusTemporaryRedirect) + + if requestCount != 1 { + t.Fatalf("expected exactly one request without redirect replay, got %d", requestCount) + } + if capturedPath != "/nupf-ee/v1/ee-subscriptions" { + t.Fatalf("path mismatch: %q", capturedPath) + } + if service.createRequestURI(server.URL) != server.URL+"/nupf-ee/v1/ee-subscriptions" { + t.Fatalf("request URI cache mismatch: %q", service.createRequestURI(server.URL)) + } + + client := service.EventExposureClients[server.URL] + if client == nil { + t.Fatal("default setup did not cache generated client") + } + assertDefaultClientConfiguration(t, client) +} + +func newTestNupfEventExposureService(oauth bool, nrfURI string) *nupfEventExposureService { + consumer := &Consumer{App: fakeConsumerApp{ + context: &smf_context.SMFContext{ + NfInstanceID: "smf-instance", + OAuth2Required: oauth, + NrfUri: nrfURI, + }, + }} + return &nupfEventExposureService{ + consumer: consumer, + EventExposureClients: make(map[string]*NupfEventExposure.APIClient), + EventExposureCreateRequests: make(map[string]string), + } +} + +func installTestEventExposureClient( + service *nupfEventExposureService, + apiRoot string, + httpClient *http.Client, +) { + configuration := NupfEventExposure.NewConfiguration() + configuration.SetBasePath(apiRoot) + configuration.SetRedirectPolicy(openapi.RejectRedirects) + configuration.SetHTTPClient(httpClient) + service.EventExposureClients[apiRoot] = NupfEventExposure.NewAPIClient(configuration) + service.EventExposureCreateRequests[apiRoot] = strings.TrimRight( + configuration.BasePath(), "/") + "/ee-subscriptions" +} + +func assertDefaultClientConfiguration(t *testing.T, client *NupfEventExposure.APIClient) { + t.Helper() + cfg := reflect.ValueOf(client).Elem().FieldByName("cfg").Elem() + if cfg.FieldByName("MetricsHook").IsNil() { + t.Fatal("default setup did not configure metrics hook") + } + if cfg.FieldByName("redirectPolicy").IsNil() { + t.Fatal("default setup did not configure redirect policy") + } + if !cfg.FieldByName("httpClient").IsNil() { + t.Fatal("default setup unexpectedly configured an explicit HTTP client") + } +} + +func testEventExposureTarget(apiRoot string) smf_context.EventExposureTarget { + return smf_context.EventExposureTarget{ + APIroot: apiRoot, + ServiceBaseURL: apiRoot + "/nupf-ee/v1", + } +} + +func testNupfCreateRequest() models.UpfCreateEventSubscription { + return models.UpfCreateEventSubscription{ + Subscription: models.UpfEventSubscription{ + EventNotifyUri: "https://nwdaf.example.com/upf", + NotifyCorrelationId: "correlation-1", + NfId: "smf-instance", + UeIpAddress: &models.IpAddr{Ipv4Addr: "192.0.2.1"}, + EventList: []models.UpfEvent{ + { + Type: models.UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: []models.UpfMeasurementType{models.UpfMeasurementType_VOLUME_MEASUREMENT}, + GranularityOfMeasurement: models.UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + EventReportingMode: models.UpfEventMode{ + Trigger: models.UpfEventTrigger_PERIODIC, + RepPeriod: 10, + }, + }, + } +} + +func assertNupfError( + t *testing.T, + err error, + wantKind NupfEventExposureErrorKind, + wantOperation string, + wantStatus int, +) { + t.Helper() + var nupfErr *NupfEventExposureError + if !errors.As(err, &nupfErr) { + t.Fatalf("expected NupfEventExposureError, got %T %v", err, err) + } + if nupfErr.Kind != wantKind || nupfErr.Operation != wantOperation || nupfErr.StatusCode != wantStatus { + t.Fatalf("unexpected Nupf error: %+v", nupfErr) + } +} + +func serverLocation(r *http.Request, subscriptionID string) string { + return "http://" + r.Host + "/nupf-ee/v1/ee-subscriptions/" + subscriptionID +} + +type fakeConsumerApp struct { + context *smf_context.SMFContext +} + +func (f fakeConsumerApp) SetLogEnable(bool) {} +func (f fakeConsumerApp) SetLogLevel(string) {} +func (f fakeConsumerApp) SetReportCaller(bool) {} +func (f fakeConsumerApp) Start() {} +func (f fakeConsumerApp) Terminate() {} +func (f fakeConsumerApp) Context() *smf_context.SMFContext { return f.context } +func (f fakeConsumerApp) Config() *factory.Config { return nil } diff --git a/internal/sbi/event_exposure_decode.go b/internal/sbi/event_exposure_decode.go new file mode 100644 index 00000000..aec9c4e5 --- /dev/null +++ b/internal/sbi/event_exposure_decode.go @@ -0,0 +1,291 @@ +package sbi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/internal/sbi/processor" +) + +type presentString struct { + Set bool + Value string +} + +func (p *presentString) UnmarshalJSON(data []byte) error { + p.Set = true + return json.Unmarshal(data, &p.Value) +} + +type presentInt32 struct { + Set bool + Value int32 +} + +func (p *presentInt32) UnmarshalJSON(data []byte) error { + p.Set = true + return json.Unmarshal(data, &p.Value) +} + +type eventExposureCreateBody struct { + Supi presentString `json:"supi"` + Gpsi *json.RawMessage `json:"gpsi,omitempty"` + AnyUeInd *json.RawMessage `json:"anyUeInd,omitempty"` + GroupId *json.RawMessage `json:"groupId,omitempty"` + PduSeId presentInt32 `json:"pduSeId"` + Dnn presentString `json:"dnn"` + Snssai *models.Snssai `json:"snssai,omitempty"` + SubId *json.RawMessage `json:"subId,omitempty"` + NotifId presentString `json:"notifId"` + NotifUri presentString `json:"notifUri"` + AltNotifIpv4Addrs *json.RawMessage `json:"altNotifIpv4Addrs,omitempty"` + AltNotifIpv6Addrs *json.RawMessage `json:"altNotifIpv6Addrs,omitempty"` + AltNotifFqdns *json.RawMessage `json:"altNotifFqdns,omitempty"` + EventSubs []eventExposureEventSubscriptionBody `json:"eventSubs"` + EventNotifs *json.RawMessage `json:"eventNotifs,omitempty"` + ImmeRep *json.RawMessage `json:"ImmeRep,omitempty"` + NotifMethod *models.SmfEventExposureNotificationMethod `json:"notifMethod,omitempty"` + MaxReportNbr *json.RawMessage `json:"maxReportNbr,omitempty"` + Expiry *json.RawMessage `json:"expiry,omitempty"` + RepPeriod presentInt32 `json:"repPeriod"` + Guami *json.RawMessage `json:"guami,omitempty"` + ServiveName *json.RawMessage `json:"serviveName,omitempty"` + SupportedFeatures *json.RawMessage `json:"supportedFeatures,omitempty"` + SampRatio *json.RawMessage `json:"sampRatio,omitempty"` + PartitionCriteria *json.RawMessage `json:"partitionCriteria,omitempty"` + GrpRepTime *json.RawMessage `json:"grpRepTime,omitempty"` + NotifFlag *json.RawMessage `json:"notifFlag,omitempty"` +} + +type eventExposureEventSubscriptionBody struct { + Event models.SmfEvent `json:"event"` + DnaiChgType *json.RawMessage `json:"dnaiChgType,omitempty"` + DddTraDescriptors *json.RawMessage `json:"dddTraDescriptors,omitempty"` + DddStati *json.RawMessage `json:"dddStati,omitempty"` + AppIds *json.RawMessage `json:"appIds,omitempty"` + TargetPeriod *json.RawMessage `json:"targetPeriod,omitempty"` + TransacDispInd *json.RawMessage `json:"transacDispInd,omitempty"` + TransacMetrics *json.RawMessage `json:"transacMetrics,omitempty"` + UeIpAddr *json.RawMessage `json:"ueIpAddr,omitempty"` + UpfEvents []eventExposureUPFEventBody `json:"upfEvents"` + BundlingAllowed *json.RawMessage `json:"bundlingAllowed,omitempty"` + BundleId *json.RawMessage `json:"bundleId,omitempty"` + BundledEventNotifyUri presentString `json:"bundledEventNotifyUri"` +} + +type eventExposureUPFEventBody struct { + Type models.UpfEventType `json:"type"` + MeasurementTypes []models.UpfMeasurementType `json:"measurementTypes"` + GranularityOfMeasurement models.UpfGranularityOfMeasurement `json:"granularityOfMeasurement"` +} + +func decodeEventExposureCreateRequest(r io.Reader) (processor.EventExposureCreateRequest, *models.ProblemDetails) { + decoder := json.NewDecoder(r) + decoder.DisallowUnknownFields() + + var body eventExposureCreateBody + if err := decoder.Decode(&body); err != nil { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/", + "malformed JSON or unsupported field") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/", "trailing JSON is not allowed") + } + + return validateEventExposureCreateBody(body) +} + +func validateEventExposureCreateBody( + body eventExposureCreateBody, +) (processor.EventExposureCreateRequest, *models.ProblemDetails) { + if field := unsupportedTopLevelField(body); field != "" { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/"+field, "unsupported field") + } + if !body.Supi.Set || body.Supi.Value == "" { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/supi", "required") + } + if !body.NotifId.Set || body.NotifId.Value == "" { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/notifId", "required") + } + if !body.NotifUri.Set || !validEventExposureCallbackURI(body.NotifUri.Value) { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/notifUri", "invalid URI") + } + if !body.RepPeriod.Set || body.RepPeriod.Value <= 0 { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/repPeriod", "must be positive") + } + if body.PduSeId.Set && (body.PduSeId.Value < 0 || body.PduSeId.Value > 255) { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/pduSeId", "out of range") + } + if body.Dnn.Set && body.Dnn.Value == "" { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/dnn", "must not be empty") + } + if body.NotifMethod != nil && *body.NotifMethod != models.SmfEventExposureNotificationMethod_PERIODIC { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem("/notifMethod", "unsupported value") + } + if len(body.EventSubs) != 1 { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs", "must contain exactly one entry") + } + + eventSub := body.EventSubs[0] + if field := unsupportedEventField(eventSub); field != "" { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/"+field, "unsupported field") + } + if eventSub.Event != models.SmfEvent_UPF_EVENT { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/event", "unsupported value") + } + if !eventSub.BundledEventNotifyUri.Set || !validEventExposureCallbackURI(eventSub.BundledEventNotifyUri.Value) { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/bundledEventNotifyUri", "invalid URI") + } + if len(eventSub.UpfEvents) != 1 { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/upfEvents", "must contain exactly one entry") + } + + upfEvent := eventSub.UpfEvents[0] + if upfEvent.Type != models.UpfEventType_USER_DATA_USAGE_MEASURES { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/upfEvents/0/type", "unsupported value") + } + if len(upfEvent.MeasurementTypes) == 0 { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/upfEvents/0/measurementTypes", "required") + } + for i, measurementType := range upfEvent.MeasurementTypes { + if measurementType != models.UpfMeasurementType_VOLUME_MEASUREMENT && + measurementType != models.UpfMeasurementType_THROUGHPUT_MEASUREMENT { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + fmt.Sprintf("/eventSubs/0/upfEvents/0/measurementTypes/%d", i), "unsupported value") + } + } + if upfEvent.GranularityOfMeasurement != models.UpfGranularityOfMeasurement_PER_SESSION { + return processor.EventExposureCreateRequest{}, malformedEventExposureProblem( + "/eventSubs/0/upfEvents/0/granularityOfMeasurement", "unsupported value") + } + + selectors := smf_context.EventExposureSelectors{} + if body.PduSeId.Set { + pduSessionID := body.PduSeId.Value + selectors.PDUSessionID = &pduSessionID + } + if body.Dnn.Set { + dnn := body.Dnn.Value + selectors.Dnn = &dnn + } + if body.Snssai != nil { + selectors.Snssai = &models.Snssai{Sst: body.Snssai.Sst, Sd: body.Snssai.Sd} + } + + return processor.EventExposureCreateRequest{ + Supi: body.Supi.Value, + Selectors: selectors, + NotifID: body.NotifId.Value, + NotifURI: body.NotifUri.Value, + BundledEventNotifyURI: eventSub.BundledEventNotifyUri.Value, + MeasurementTypes: append([]models.UpfMeasurementType(nil), upfEvent.MeasurementTypes...), + ReportingPeriod: body.RepPeriod.Value, + }, nil +} + +func unsupportedTopLevelField(body eventExposureCreateBody) string { + switch { + case body.Gpsi != nil: + return "gpsi" + case body.AnyUeInd != nil: + return "anyUeInd" + case body.GroupId != nil: + return "groupId" + case body.SubId != nil: + return "subId" + case body.AltNotifIpv4Addrs != nil: + return "altNotifIpv4Addrs" + case body.AltNotifIpv6Addrs != nil: + return "altNotifIpv6Addrs" + case body.AltNotifFqdns != nil: + return "altNotifFqdns" + case body.EventNotifs != nil: + return "eventNotifs" + case body.ImmeRep != nil: + return "ImmeRep" + case body.MaxReportNbr != nil: + return "maxReportNbr" + case body.Expiry != nil: + return "expiry" + case body.Guami != nil: + return "guami" + case body.ServiveName != nil: + return "serviveName" + case body.SupportedFeatures != nil: + return "supportedFeatures" + case body.SampRatio != nil: + return "sampRatio" + case body.PartitionCriteria != nil: + return "partitionCriteria" + case body.GrpRepTime != nil: + return "grpRepTime" + case body.NotifFlag != nil: + return "notifFlag" + default: + return "" + } +} + +func unsupportedEventField(event eventExposureEventSubscriptionBody) string { + switch { + case event.DnaiChgType != nil: + return "dnaiChgType" + case event.DddTraDescriptors != nil: + return "dddTraDescriptors" + case event.DddStati != nil: + return "dddStati" + case event.AppIds != nil: + return "appIds" + case event.TargetPeriod != nil: + return "targetPeriod" + case event.TransacDispInd != nil: + return "transacDispInd" + case event.TransacMetrics != nil: + return "transacMetrics" + case event.UeIpAddr != nil: + return "ueIpAddr" + case event.BundlingAllowed != nil: + return "bundlingAllowed" + case event.BundleId != nil: + return "bundleId" + default: + return "" + } +} + +func validEventExposureCallbackURI(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && + u.Host != "" && + u.User == nil && + u.Fragment == "" +} + +func malformedEventExposureProblem(param, reason string) *models.ProblemDetails { + return &models.ProblemDetails{ + Title: "Bad Request", + Status: http.StatusBadRequest, + InvalidParams: []models.InvalidParam{ + { + Param: param, + Reason: reason, + }, + }, + } +} diff --git a/internal/sbi/event_exposure_decode_internal_test.go b/internal/sbi/event_exposure_decode_internal_test.go new file mode 100644 index 00000000..631b618b --- /dev/null +++ b/internal/sbi/event_exposure_decode_internal_test.go @@ -0,0 +1,338 @@ +package sbi + +import ( + "strings" + "testing" + + "github.com/gin-gonic/gin/binding" +) + +func TestDecodeEventExposureCreateRequestDnnPresence(t *testing.T) { + tests := []struct { + name string + patch string + wantDnnSet bool + wantDnn string + wantErr bool + }{ + { + name: "omitted dnn", + patch: "", + wantDnnSet: false, + }, + { + name: "empty dnn", + patch: `"dnn":"",`, + wantErr: true, + }, + { + name: "non-empty dnn", + patch: `"dnn":"internet",`, + wantDnnSet: true, + wantDnn: "internet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(validEventExposureJSON(tt.patch))) + if tt.wantErr { + if problemDetails == nil { + t.Fatal("expected ProblemDetails") + } + return + } + if problemDetails != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problemDetails) + } + if (request.Selectors.Dnn != nil) != tt.wantDnnSet { + t.Fatalf("Dnn presence mismatch: got %v", request.Selectors.Dnn != nil) + } + if request.Selectors.Dnn != nil && *request.Selectors.Dnn != tt.wantDnn { + t.Fatalf("Dnn mismatch: got %q want %q", *request.Selectors.Dnn, tt.wantDnn) + } + }) + } +} + +func TestDecodeEventExposureCreateRequestRejectsUnsupportedExactWireNames(t *testing.T) { + for _, field := range []string{ + `"ImmeRep":true,`, + `"serviveName":"nsmf-event-exposure",`, + } { + _, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(validEventExposureJSON(field))) + if problemDetails == nil { + t.Fatalf("expected ProblemDetails for field patch %s", field) + } + } + for _, field := range []string{ + `"bundlingAllowed":false,`, + `"bundlingAllowed":true,`, + `"bundleId":0,`, + `"bundleId":7,`, + } { + _, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(validEventExposureJSONWithEventPatch(field))) + if problemDetails == nil { + t.Fatalf("expected ProblemDetails for event field patch %s", field) + } + } +} + +func TestDecodeEventExposureCreateRequestPreservesExplicitZeroPDUSessionID(t *testing.T) { + request, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(validEventExposureJSON(`"pduSeId":0,`))) + if problemDetails != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problemDetails) + } + if request.Selectors.PDUSessionID == nil || *request.Selectors.PDUSessionID != 0 { + t.Fatalf("expected explicit pduSeId 0, got %+v", request.Selectors.PDUSessionID) + } +} + +func TestDecodeEventExposureCreateRequestRejectsMultiEntryProfile(t *testing.T) { + body := strings.Replace(validEventExposureJSON(""), `"eventSubs":[`, `"eventSubs":[{},`, 1) + _, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(body)) + if problemDetails == nil { + t.Fatal("expected ProblemDetails for multiple eventSubs") + } +} + +func TestDecodeEventExposureCreateRequestRejectsRequiredFieldFailures(t *testing.T) { + tests := []struct { + name string + body string + wantParam string + }{ + { + name: "missing supi", + body: strings.Replace(validEventExposureJSON(""), `"supi":"imsi-001010000000001",`, "", 1), + wantParam: "/supi", + }, + { + name: "missing notifId", + body: strings.Replace(validEventExposureJSON(""), `"notifId":"correlation-1",`, "", 1), + wantParam: "/notifId", + }, + { + name: "missing notifUri", + body: strings.Replace(validEventExposureJSON(""), `"notifUri":"https://nwdaf.example.com/nsmf",`, "", 1), + wantParam: "/notifUri", + }, + { + name: "missing eventSubs", + body: strings.Replace(validEventExposureJSON(""), `"eventSubs":[{`, `"eventSubsMissing":[{`, 1), + wantParam: "/", + }, + { + name: "missing upfEvents", + body: strings.Replace(validEventExposureJSON(""), `"upfEvents":[{`, `"upfEventsMissing":[{`, 1), + wantParam: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertDecodeProblemParam(t, tt.body, tt.wantParam) + }) + } +} + +func TestDecodeEventExposureCreateRequestRejectsInvalidURIAndReportingFields(t *testing.T) { + tests := []struct { + name string + body string + wantParam string + }{ + { + name: "relative notifUri", + body: validEventExposureJSONWithReplacement( + `"notifUri":"https://nwdaf.example.com/nsmf"`, `"notifUri":"/nsmf"`), + wantParam: "/notifUri", + }, + { + name: "userinfo notifUri", + body: validEventExposureJSONWithReplacement( + `"notifUri":"https://nwdaf.example.com/nsmf"`, + `"notifUri":"https://u@nwdaf.example.com/nsmf"`), + wantParam: "/notifUri", + }, + { + name: "fragment bundled uri", + body: validEventExposureJSONWithReplacement( + `"bundledEventNotifyUri":"https://nwdaf.example.com/upf"`, + `"bundledEventNotifyUri":"https://nwdaf.example.com/upf#x"`), + wantParam: "/eventSubs/0/bundledEventNotifyUri", + }, + { + name: "missing repPeriod", + body: strings.Replace(validEventExposureJSON(""), `"repPeriod":10,`, "", 1), + wantParam: "/repPeriod", + }, + { + name: "zero repPeriod", + body: validEventExposureJSONWithReplacement(`"repPeriod":10`, `"repPeriod":0`), + wantParam: "/repPeriod", + }, + { + name: "negative repPeriod", + body: validEventExposureJSONWithReplacement(`"repPeriod":10`, `"repPeriod":-1`), + wantParam: "/repPeriod", + }, + { + name: "negative pduSeId", + body: validEventExposureJSON(`"pduSeId":-1,`), + wantParam: "/pduSeId", + }, + { + name: "too large pduSeId", + body: validEventExposureJSON(`"pduSeId":256,`), + wantParam: "/pduSeId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertDecodeProblemParam(t, tt.body, tt.wantParam) + }) + } +} + +func TestDecodeEventExposureCreateRequestRejectsUnsupportedProfileValues(t *testing.T) { + tests := []struct { + name string + body string + wantParam string + }{ + { + name: "unsupported event", + body: validEventExposureJSONWithReplacement(`"event":"UPF_EVENT"`, `"event":"PDU_SES_EST"`), + wantParam: "/eventSubs/0/event", + }, + { + name: "unsupported measurement", + body: validEventExposureJSONWithReplacement(`"VOLUME_MEASUREMENT"`, `"DURATION_MEASUREMENT"`), + wantParam: "/eventSubs/0/upfEvents/0/measurementTypes/0", + }, + { + name: "missing measurement", + body: validEventExposureJSONWithReplacement( + `"measurementTypes":["VOLUME_MEASUREMENT"]`, `"measurementTypes":[]`), + wantParam: "/eventSubs/0/upfEvents/0/measurementTypes", + }, + { + name: "unsupported granularity", + body: validEventExposureJSONWithReplacement( + `"granularityOfMeasurement":"PER_SESSION"`, `"granularityOfMeasurement":"PER_FLOW"`), + wantParam: "/eventSubs/0/upfEvents/0/granularityOfMeasurement", + }, + { + name: "unsupported notifMethod", + body: validEventExposureJSON(`"notifMethod":"ONE_TIME",`), + wantParam: "/notifMethod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertDecodeProblemParam(t, tt.body, tt.wantParam) + }) + } +} + +func TestDecodeEventExposureCreateRequestRejectsUnknownTrailingAndNormalizedWrongNames(t *testing.T) { + tests := []struct { + name string + body string + wantParam string + }{ + { + name: "unknown field", + body: validEventExposureJSON(`"unexpected":true,`), + wantParam: "/", + }, + { + name: "trailing json", + body: validEventExposureJSON("") + `{}`, + wantParam: "/", + }, + { + name: "normalized ImmeRep rejected", + body: validEventExposureJSON(`"immeRep":true,`), + wantParam: "/ImmeRep", + }, + { + name: "normalized serviveName rejected", + body: validEventExposureJSON(`"serviceName":"nsmf-event-exposure",`), + wantParam: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertDecodeProblemParam(t, tt.body, tt.wantParam) + }) + } +} + +func TestDecodeEventExposureCreateRequestDoesNotChangeGinGlobalDecoderFlags(t *testing.T) { + originalDisallowUnknown := binding.EnableDecoderDisallowUnknownFields + originalUseNumber := binding.EnableDecoderUseNumber + t.Cleanup(func() { + binding.EnableDecoderDisallowUnknownFields = originalDisallowUnknown + binding.EnableDecoderUseNumber = originalUseNumber + }) + + _, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(validEventExposureJSON(""))) + if problemDetails != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problemDetails) + } + if binding.EnableDecoderDisallowUnknownFields != originalDisallowUnknown || + binding.EnableDecoderUseNumber != originalUseNumber { + t.Fatal("route-local decoder changed Gin global decoder flags") + } +} + +func assertDecodeProblemParam(t *testing.T, body, wantParam string) { + t.Helper() + _, problemDetails := decodeEventExposureCreateRequest(strings.NewReader(body)) + if problemDetails == nil { + t.Fatal("expected ProblemDetails") + } + if problemDetails.Status != 400 { + t.Fatalf("status mismatch: %d", problemDetails.Status) + } + if len(problemDetails.InvalidParams) == 0 || problemDetails.InvalidParams[0].Param != wantParam { + t.Fatalf("param mismatch: got %+v want %s", problemDetails.InvalidParams, wantParam) + } +} + +func validEventExposureJSONWithReplacement(old, replacement string) string { + return strings.Replace(validEventExposureJSON(""), old, replacement, 1) +} + +func validEventExposureJSON(extraTopLevel string) string { + return validEventExposureJSONWithPatches(extraTopLevel, "") +} + +func validEventExposureJSONWithEventPatch(extraEvent string) string { + return validEventExposureJSONWithPatches("", extraEvent) +} + +func validEventExposureJSONWithPatches(extraTopLevel, extraEvent string) string { + return `{ + ` + extraTopLevel + ` + "supi":"imsi-001010000000001", + "notifId":"correlation-1", + "notifUri":"https://nwdaf.example.com/nsmf", + "repPeriod":10, + "eventSubs":[{ + ` + extraEvent + ` + "event":"UPF_EVENT", + "bundledEventNotifyUri":"https://nwdaf.example.com/upf", + "upfEvents":[{ + "type":"USER_DATA_USAGE_MEASURES", + "measurementTypes":["VOLUME_MEASUREMENT"], + "granularityOfMeasurement":"PER_SESSION" + }] + }] + }` +} diff --git a/internal/sbi/processor/event_exposure.go b/internal/sbi/processor/event_exposure.go new file mode 100644 index 00000000..3deda099 --- /dev/null +++ b/internal/sbi/processor/event_exposure.go @@ -0,0 +1,189 @@ +package processor + +import ( + "context" + "errors" + "net" + "net/http" + "time" + + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/internal/logger" +) + +const ( + eventExposureGatewayFailureDetail = "Unable to complete the downstream service request." + maxEventExposureIDAttempts = 3 +) + +type EventExposureCreateRequest struct { + Supi string + Selectors smf_context.EventExposureSelectors + NotifID string + NotifURI string + BundledEventNotifyURI string + MeasurementTypes []models.UpfMeasurementType + ReportingPeriod int32 +} + +type EventExposureCreateResult struct { + Subscription smf_context.EventExposureSubscription +} + +type EventExposureProblem struct { + Status int + ProblemDetails models.ProblemDetails +} + +func (p *Processor) CreateEventExposureSubscription( + ctx context.Context, + request EventExposureCreateRequest, +) (EventExposureCreateResult, *EventExposureProblem) { + target, err := p.eventExposure.Resolver.ResolveEventExposureTarget(ctx, request.Supi, request.Selectors) + if err != nil { + return EventExposureCreateResult{}, problemFromResolverError(err) + } + + nupfRequest := buildNupfCreateEventSubscriptionRequest(p.Context().NfInstanceID, target, request) + nupfConsumer := p.nupfEventExposureConsumer() + if nupfConsumer == nil { + return EventExposureCreateResult{}, eventExposureInternalProblem() + } + nupfResult, err := nupfConsumer.CreateSubscription(ctx, target, nupfRequest) + if err != nil { + return EventExposureCreateResult{}, eventExposureBadGatewayProblem() + } + + subscription := smf_context.EventExposureSubscription{ + Supi: request.Supi, + Selectors: request.Selectors, + NotifID: request.NotifID, + NotifURI: request.NotifURI, + BundledEventNotifyURI: request.BundledEventNotifyURI, + MeasurementTypes: append([]models.UpfMeasurementType(nil), request.MeasurementTypes...), + ReportingPeriod: request.ReportingPeriod, + Granularity: models.UpfGranularityOfMeasurement_PER_SESSION, + CreatedAt: time.Now(), + Target: target, + NupfSubscriptionID: nupfResult.SubscriptionID, + NupfLocation: nupfResult.ValidatedLocation, + } + + for attempt := 0; attempt < maxEventExposureIDAttempts; attempt++ { + subscription.ID = p.eventExposure.UUIDGenerator.NewString() + err = p.eventExposure.Repository.Store(subscription) + if err == nil { + stored, _ := p.eventExposure.Repository.Get(subscription.ID) + return EventExposureCreateResult{Subscription: stored}, nil + } + if !errors.Is(err, smf_context.ErrEventExposureSubscriptionIDCollision) { + logger.SBILog.Warn("Event Exposure downstream subscription may be orphaned after local store failure") + return EventExposureCreateResult{}, eventExposureInternalProblem() + } + } + + logger.SBILog.Warn("Event Exposure downstream subscription may be orphaned after subscription ID collision") + return EventExposureCreateResult{}, eventExposureInternalProblem() +} + +func (p *Processor) DeleteEventExposureSubscription(ctx context.Context, subscriptionID string) *EventExposureProblem { + subscription, ok := p.eventExposure.Repository.ClaimDelete(subscriptionID) + if !ok { + return eventExposureNotFoundProblem() + } + + nupfConsumer := p.nupfEventExposureConsumer() + if nupfConsumer == nil { + logger.SBILog.Warn("Event Exposure downstream delete did not start; local cleanup completed") + return nil + } + if err := nupfConsumer.DeleteSubscription(ctx, subscription.Target, subscription.NupfSubscriptionID); err != nil { + logger.SBILog.Warn("Event Exposure downstream delete did not complete; local cleanup completed") + } + return nil +} + +func (p *Processor) nupfEventExposureConsumer() NupfEventExposureConsumer { + if p.eventExposure.NupfConsumer != nil { + return p.eventExposure.NupfConsumer + } + return p.Consumer() +} + +func buildNupfCreateEventSubscriptionRequest( + nfID string, + target smf_context.EventExposureTarget, + request EventExposureCreateRequest, +) models.UpfCreateEventSubscription { + return models.UpfCreateEventSubscription{ + Subscription: models.UpfEventSubscription{ + EventList: []models.UpfEvent{ + { + Type: models.UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: append([]models.UpfMeasurementType(nil), request.MeasurementTypes...), + GranularityOfMeasurement: models.UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + EventNotifyUri: request.BundledEventNotifyURI, + NotifyCorrelationId: request.NotifID, + EventReportingMode: models.UpfEventMode{ + Trigger: models.UpfEventTrigger_PERIODIC, + RepPeriod: request.ReportingPeriod, + }, + NfId: nfID, + UeIpAddress: ipAddrFromNetIP(target.UEIPAddress), + }, + } +} + +func ipAddrFromNetIP(ip net.IP) *models.IpAddr { + if ip == nil { + return nil + } + if ipv4 := ip.To4(); ipv4 != nil { + return &models.IpAddr{Ipv4Addr: ipv4.String()} + } + return &models.IpAddr{Ipv6Addr: ip.String()} +} + +func problemFromResolverError(err error) *EventExposureProblem { + switch { + case errors.Is(err, smf_context.ErrEventExposureNoMatchingSession), + errors.Is(err, smf_context.ErrEventExposureMissingUEIP), + errors.Is(err, smf_context.ErrEventExposureMissingUPF): + return eventExposureProblem(http.StatusNotFound, "Not Found", "Matching active session was not found.", "") + case errors.Is(err, smf_context.ErrEventExposureMultipleSessions): + return eventExposureProblem(http.StatusConflict, "Conflict", "Multiple matching active sessions were found.", "") + case errors.Is(err, smf_context.ErrEventExposureMissingEndpoint): + return eventExposureProblem(http.StatusServiceUnavailable, "Service Unavailable", + "Selected UPF does not expose Event Exposure.", "") + default: + return eventExposureInternalProblem() + } +} + +func eventExposureBadGatewayProblem() *EventExposureProblem { + return eventExposureProblem(http.StatusBadGateway, "Bad Gateway", eventExposureGatewayFailureDetail, "") +} + +func eventExposureInternalProblem() *EventExposureProblem { + return eventExposureProblem(http.StatusInternalServerError, "Internal Server Error", + "Unable to persist the Event Exposure subscription.", "SYSTEM_FAILURE") +} + +func eventExposureNotFoundProblem() *EventExposureProblem { + return eventExposureProblem(http.StatusNotFound, "Not Found", "Event Exposure subscription was not found.", "") +} + +func eventExposureProblem(status int, title, detail, cause string) *EventExposureProblem { + return &EventExposureProblem{ + Status: status, + ProblemDetails: models.ProblemDetails{ + Status: int32(status), + Title: title, + Detail: detail, + Cause: cause, + }, + } +} diff --git a/internal/sbi/processor/event_exposure_internal_test.go b/internal/sbi/processor/event_exposure_internal_test.go new file mode 100644 index 00000000..358ee086 --- /dev/null +++ b/internal/sbi/processor/event_exposure_internal_test.go @@ -0,0 +1,474 @@ +package processor + +import ( + "bytes" + "context" + "errors" + "net" + "testing" + + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" + "github.com/free5gc/smf/internal/logger" + "github.com/free5gc/smf/internal/sbi/consumer" + "github.com/free5gc/smf/pkg/factory" +) + +func TestEventExposureCreateSuccessStoresStateAndMapsNupfRequest(t *testing.T) { + repository := smf_context.NewEventExposureRepository() + nupf := &fakeEventExposureConsumer{ + createResult: smf_context.NupfCreateResult{ + SubscriptionID: "upf-sub-1", + ValidatedLocation: "https://upf.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-1", + }, + } + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: fakeEventExposureResolver{target: validEventExposureTarget()}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"sub-1"}}, + }) + + result, problem := processor.CreateEventExposureSubscription(context.Background(), validEventExposureCreateRequest()) + if problem != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problem) + } + if result.Subscription.ID != "sub-1" || + result.Subscription.NupfSubscriptionID != "upf-sub-1" || + result.Subscription.NupfLocation != nupf.createResult.ValidatedLocation { + t.Fatalf("unexpected subscription: %+v", result.Subscription) + } + if _, ok := repository.Get("sub-1"); !ok { + t.Fatal("subscription was not stored") + } + if nupf.createCalls != 1 { + t.Fatalf("expected one Nupf Create, got %d", nupf.createCalls) + } + subscription := nupf.lastCreateRequest.Subscription + if subscription.EventNotifyUri != "https://nwdaf.example.com/upf" || + subscription.NotifyCorrelationId != "correlation-1" || + subscription.NfId != "smf-instance" || + subscription.EventReportingMode.Trigger != models.UpfEventTrigger_PERIODIC || + subscription.EventReportingMode.RepPeriod != 10 || + subscription.UeIpAddress == nil || + subscription.UeIpAddress.Ipv4Addr != "192.0.2.1" { + t.Fatalf("unexpected Nupf mapping: %+v", subscription) + } + if len(subscription.EventList) != 1 || + subscription.EventList[0].Type != models.UpfEventType_USER_DATA_USAGE_MEASURES || + subscription.EventList[0].GranularityOfMeasurement != models.UpfGranularityOfMeasurement_PER_SESSION || + subscription.EventList[0].MeasurementTypes[0] != models.UpfMeasurementType_VOLUME_MEASUREMENT { + t.Fatalf("unexpected Nupf event list: %+v", subscription.EventList) + } +} + +func TestEventExposureStoreFailureDoesNotCompensateOrRetryCreate(t *testing.T) { + nupf := &fakeEventExposureConsumer{ + createResult: smf_context.NupfCreateResult{ + SubscriptionID: "upf-sub-1", + ValidatedLocation: "https://upf.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-1", + }, + } + repository := &failingEventExposureRepository{err: errors.New("store failed")} + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: fakeEventExposureResolver{target: validEventExposureTarget()}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"sub-1"}}, + }) + + _, problem := processor.CreateEventExposureSubscription(context.Background(), validEventExposureCreateRequest()) + if problem == nil || problem.Status != 500 { + t.Fatalf("expected 500 ProblemDetails, got %+v", problem) + } + if nupf.createCalls != 1 { + t.Fatalf("expected one Nupf Create, got %d", nupf.createCalls) + } + if nupf.deleteCalls != 0 { + t.Fatalf("expected no compensating Delete, got %d", nupf.deleteCalls) + } + if repository.storeCalls != 1 { + t.Fatalf("expected one Store attempt, got %d", repository.storeCalls) + } + if _, ok := repository.Get("sub-1"); ok { + t.Fatal("store failure must not leave local record") + } +} + +func TestEventExposureUUIDCollisionRetryAndMaxAttempts(t *testing.T) { + repository := smf_context.NewEventExposureRepository() + if err := repository.Store(smf_context.EventExposureSubscription{ID: "collision"}); err != nil { + t.Fatalf("Store collision seed failed: %v", err) + } + nupf := &fakeEventExposureConsumer{ + createResult: smf_context.NupfCreateResult{ + SubscriptionID: "upf-sub-1", + ValidatedLocation: "https://upf.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-1", + }, + } + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: fakeEventExposureResolver{target: validEventExposureTarget()}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"collision", "sub-2"}}, + }) + + result, problem := processor.CreateEventExposureSubscription(context.Background(), validEventExposureCreateRequest()) + if problem != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problem) + } + if result.Subscription.ID != "sub-2" { + t.Fatalf("expected retry ID sub-2, got %q", result.Subscription.ID) + } + + processor = newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: fakeEventExposureResolver{target: validEventExposureTarget()}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"collision", "collision", "collision"}}, + }) + _, problem = processor.CreateEventExposureSubscription(context.Background(), validEventExposureCreateRequest()) + if problem == nil || problem.Status != 500 { + t.Fatalf("expected max collision 500, got %+v", problem) + } +} + +func TestEventExposureDeleteUsesStoredTargetSnapshot(t *testing.T) { + repository := smf_context.NewEventExposureRepository() + target := validEventExposureTarget() + err := repository.Store(smf_context.EventExposureSubscription{ + ID: "sub-1", + Target: target, + NupfSubscriptionID: "upf-sub-1", + NupfLocation: "https://malicious.example.com/arbitrary", + }) + if err != nil { + t.Fatalf("Store failed: %v", err) + } + resolver := &countingEventExposureResolver{} + nupf := &fakeEventExposureConsumer{} + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: resolver, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{}, + }) + + problem := processor.DeleteEventExposureSubscription(context.Background(), "sub-1") + if problem != nil { + t.Fatalf("unexpected ProblemDetails: %+v", problem) + } + if resolver.calls != 0 { + t.Fatalf("Delete must not rerun resolver, got %d calls", resolver.calls) + } + if nupf.deleteCalls != 1 { + t.Fatalf("expected one Nupf Delete, got %d", nupf.deleteCalls) + } + if nupf.lastDeleteTarget.APIroot != target.APIroot || + nupf.lastDeleteSubscriptionID != "upf-sub-1" { + t.Fatalf("Delete did not use stored target/subscription ID: %+v %q", + nupf.lastDeleteTarget, nupf.lastDeleteSubscriptionID) + } +} + +func TestEventExposureResolverErrorMapping(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + {name: "no matching", err: smf_context.ErrEventExposureNoMatchingSession, wantStatus: 404}, + {name: "missing ue ip", err: smf_context.ErrEventExposureMissingUEIP, wantStatus: 404}, + {name: "missing upf", err: smf_context.ErrEventExposureMissingUPF, wantStatus: 404}, + {name: "multiple", err: smf_context.ErrEventExposureMultipleSessions, wantStatus: 409}, + {name: "missing endpoint", err: smf_context.ErrEventExposureMissingEndpoint, wantStatus: 503}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nupf := &fakeEventExposureConsumer{} + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: smf_context.NewEventExposureRepository(), + Resolver: fakeEventExposureResolver{err: tt.err}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{}, + }) + + _, problem := processor.CreateEventExposureSubscription( + context.Background(), validEventExposureCreateRequest()) + if problem == nil || problem.Status != tt.wantStatus || + problem.ProblemDetails.Status != int32(tt.wantStatus) { + t.Fatalf("ProblemDetails mismatch: %+v", problem) + } + if nupf.createCalls != 0 { + t.Fatalf("resolver failure sent %d Nupf requests", nupf.createCalls) + } + }) + } +} + +func TestEventExposureDownstreamCreateFailuresAreSanitized502(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "token", err: &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorToken}}, + {name: "redirect", err: &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorRedirect}}, + {name: "transport", err: &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorTransport}}, + {name: "upstream", err: &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorUpstreamProblem}}, + {name: "malformed", err: &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorMalformedSuccess}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repository := smf_context.NewEventExposureRepository() + nupf := &fakeEventExposureConsumer{createErr: tt.err} + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: fakeEventExposureResolver{target: validEventExposureTarget()}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"sub-1"}}, + }) + + _, problem := processor.CreateEventExposureSubscription( + context.Background(), validEventExposureCreateRequest()) + if problem == nil || + problem.Status != 502 || + problem.ProblemDetails.Status != 502 || + problem.ProblemDetails.Title != "Bad Gateway" || + problem.ProblemDetails.Cause != "" || + problem.ProblemDetails.Detail != eventExposureGatewayFailureDetail { + t.Fatalf("expected sanitized 502, got %+v", problem) + } + if _, ok := repository.Get("sub-1"); ok { + t.Fatal("downstream failure must not leave local state") + } + }) + } +} + +func TestEventExposureDeleteUnknownAndDownstreamFailureCleanup(t *testing.T) { + repository := smf_context.NewEventExposureRepository() + nupf := &fakeEventExposureConsumer{} + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: repository, + Resolver: &countingEventExposureResolver{}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{}, + }) + + problem := processor.DeleteEventExposureSubscription(context.Background(), "missing") + if problem == nil || problem.Status != 404 { + t.Fatalf("expected 404 for unknown Delete, got %+v", problem) + } + if nupf.deleteCalls != 0 { + t.Fatalf("unknown Delete sent %d downstream requests", nupf.deleteCalls) + } + + if err := repository.Store(smf_context.EventExposureSubscription{ + ID: "sub-1", + Target: validEventExposureTarget(), + NupfSubscriptionID: "upf-sub-1", + }); err != nil { + t.Fatalf("Store failed: %v", err) + } + nupf.deleteErr = &consumer.NupfEventExposureError{Kind: consumer.NupfEventExposureErrorToken} + problem = processor.DeleteEventExposureSubscription(context.Background(), "sub-1") + if problem != nil { + t.Fatalf("downstream Delete failure should still return 204/nil problem, got %+v", problem) + } + if _, ok := repository.Get("sub-1"); ok { + t.Fatal("downstream Delete failure must still remove local state") + } +} + +func TestEventExposureWarningsDoNotLogSensitiveValues(t *testing.T) { + var logOutput bytes.Buffer + oldOutput := logger.Log.Out + logger.Log.SetOutput(&logOutput) + t.Cleanup(func() { + logger.Log.SetOutput(oldOutput) + }) + + nupf := &fakeEventExposureConsumer{ + createResult: smf_context.NupfCreateResult{ + SubscriptionID: "upf-sub-sensitive", + ValidatedLocation: "https://upf.example.com/nupf-ee/v1/ee-subscriptions/upf-sub-sensitive", + }, + } + processor := newTestEventExposureProcessor(t, EventExposureDependencies{ + Repository: &failingEventExposureRepository{err: errors.New("store failed")}, + Resolver: fakeEventExposureResolver{target: smf_context.EventExposureTarget{ + APIroot: "https://apiroot-sensitive.example.com", + ServiceBaseURL: "https://apiroot-sensitive.example.com/nupf-ee/v1", + UEIPAddress: net.ParseIP("192.0.2.55").To4(), + }}, + NupfConsumer: nupf, + UUIDGenerator: &fixedUUIDGenerator{ids: []string{"sub-1"}}, + }) + request := validEventExposureCreateRequest() + request.Supi = "imsi-001010999999999" + request.NotifID = "notif-sensitive" + request.NotifURI = "https://nwdaf.example.com/nsmf-sensitive" + request.BundledEventNotifyURI = "https://nwdaf.example.com/upf-sensitive" + + _, _ = processor.CreateEventExposureSubscription(context.Background(), request) + for _, sentinel := range []string{ + request.Supi, + request.NotifID, + request.NotifURI, + request.BundledEventNotifyURI, + "192.0.2.55", + "apiroot-sensitive", + "upf-sub-sensitive", + } { + if bytes.Contains(logOutput.Bytes(), []byte(sentinel)) { + t.Fatalf("log output leaked sensitive sentinel %q: %s", sentinel, logOutput.String()) + } + } +} + +func newTestEventExposureProcessor(t *testing.T, deps EventExposureDependencies) *Processor { + t.Helper() + processor, err := NewProcessorWithEventExposureDependencies(&fakeProcessorSMF{ + context: &smf_context.SMFContext{NfInstanceID: "smf-instance"}, + }, deps) + if err != nil { + t.Fatalf("NewProcessorWithEventExposureDependencies failed: %v", err) + } + return processor +} + +func validEventExposureCreateRequest() EventExposureCreateRequest { + return EventExposureCreateRequest{ + Supi: "imsi-001010000000001", + NotifID: "correlation-1", + NotifURI: "https://nwdaf.example.com/nsmf", + BundledEventNotifyURI: "https://nwdaf.example.com/upf", + MeasurementTypes: []models.UpfMeasurementType{models.UpfMeasurementType_VOLUME_MEASUREMENT}, + ReportingPeriod: 10, + } +} + +func validEventExposureTarget() smf_context.EventExposureTarget { + return smf_context.EventExposureTarget{ + UPFName: "upf-1", + UPFID: "upf-id-1", + APIroot: "https://upf.example.com", + ServiceBaseURL: "https://upf.example.com/nupf-ee/v1", + UEIPAddress: net.ParseIP("192.0.2.1").To4(), + Dnn: "internet", + PDUSessionID: 1, + } +} + +type fakeProcessorSMF struct { + context *smf_context.SMFContext +} + +func (f *fakeProcessorSMF) SetLogEnable(bool) {} +func (f *fakeProcessorSMF) SetLogLevel(string) {} +func (f *fakeProcessorSMF) SetReportCaller(bool) {} +func (f *fakeProcessorSMF) Start() {} +func (f *fakeProcessorSMF) Terminate() {} +func (f *fakeProcessorSMF) Context() *smf_context.SMFContext { return f.context } +func (f *fakeProcessorSMF) Config() *factory.Config { return nil } +func (f *fakeProcessorSMF) Consumer() *consumer.Consumer { return nil } + +type fakeEventExposureResolver struct { + target smf_context.EventExposureTarget + err error +} + +func (f fakeEventExposureResolver) ResolveEventExposureTarget( + context.Context, + string, + smf_context.EventExposureSelectors, +) (smf_context.EventExposureTarget, error) { + return f.target, f.err +} + +type countingEventExposureResolver struct { + calls int +} + +func (f *countingEventExposureResolver) ResolveEventExposureTarget( + context.Context, + string, + smf_context.EventExposureSelectors, +) (smf_context.EventExposureTarget, error) { + f.calls++ + return smf_context.EventExposureTarget{}, errors.New("unexpected resolver call") +} + +type fakeEventExposureConsumer struct { + createCalls int + deleteCalls int + createResult smf_context.NupfCreateResult + createErr error + deleteErr error + lastCreateRequest models.UpfCreateEventSubscription + lastCreateTarget smf_context.EventExposureTarget + lastDeleteTarget smf_context.EventExposureTarget + lastDeleteSubscriptionID string +} + +func (f *fakeEventExposureConsumer) CreateSubscription( + _ context.Context, + target smf_context.EventExposureTarget, + request models.UpfCreateEventSubscription, +) (smf_context.NupfCreateResult, error) { + f.createCalls++ + f.lastCreateTarget = target + f.lastCreateRequest = request + if f.createErr != nil { + return smf_context.NupfCreateResult{}, f.createErr + } + return f.createResult, nil +} + +func (f *fakeEventExposureConsumer) DeleteSubscription( + _ context.Context, + target smf_context.EventExposureTarget, + subscriptionID string, +) error { + f.deleteCalls++ + f.lastDeleteTarget = target + f.lastDeleteSubscriptionID = subscriptionID + return f.deleteErr +} + +type failingEventExposureRepository struct { + err error + storeCalls int +} + +func (f *failingEventExposureRepository) Store(smf_context.EventExposureSubscription) error { + f.storeCalls++ + return f.err +} + +func (f *failingEventExposureRepository) Get(string) (smf_context.EventExposureSubscription, bool) { + return smf_context.EventExposureSubscription{}, false +} + +func (f *failingEventExposureRepository) ClaimDelete(string) (smf_context.EventExposureSubscription, bool) { + return smf_context.EventExposureSubscription{}, false +} + +type fixedUUIDGenerator struct { + ids []string + next int +} + +func (f *fixedUUIDGenerator) NewString() string { + if len(f.ids) == 0 { + return "sub-id" + } + if f.next >= len(f.ids) { + return f.ids[len(f.ids)-1] + } + id := f.ids[f.next] + f.next++ + return id +} diff --git a/internal/sbi/processor/processor.go b/internal/sbi/processor/processor.go index 5e67ff1c..4eda6bb6 100644 --- a/internal/sbi/processor/processor.go +++ b/internal/sbi/processor/processor.go @@ -1,6 +1,12 @@ package processor import ( + "context" + + "github.com/google/uuid" + + "github.com/free5gc/openapi/models" + smf_context "github.com/free5gc/smf/internal/context" "github.com/free5gc/smf/internal/sbi/consumer" "github.com/free5gc/smf/pkg/app" ) @@ -17,11 +23,74 @@ type ProcessorSmf interface { type Processor struct { ProcessorSmf + + eventExposure EventExposureDependencies } func NewProcessor(smf ProcessorSmf) (*Processor, error) { - p := &Processor{ - ProcessorSmf: smf, + return NewProcessorWithEventExposureDependencies(smf, EventExposureDependencies{}) +} + +func NewProcessorWithEventExposureDependencies( + smf ProcessorSmf, + deps EventExposureDependencies, +) (*Processor, error) { + if deps.Repository == nil { + deps.Repository = smf_context.NewEventExposureRepository() + } + if deps.Resolver == nil { + deps.Resolver = smf_context.NewEventExposureTargetResolver() } - return p, nil + if deps.UUIDGenerator == nil { + deps.UUIDGenerator = uuidGenerator{} + } + + return &Processor{ + ProcessorSmf: smf, + eventExposure: deps, + }, nil +} + +type EventExposureRepository interface { + Store(smf_context.EventExposureSubscription) error + Get(id string) (smf_context.EventExposureSubscription, bool) + ClaimDelete(id string) (smf_context.EventExposureSubscription, bool) +} + +type EventExposureResolver interface { + ResolveEventExposureTarget( + ctx context.Context, + supi string, + selectors smf_context.EventExposureSelectors, + ) (smf_context.EventExposureTarget, error) +} + +type NupfEventExposureConsumer interface { + CreateSubscription( + ctx context.Context, + target smf_context.EventExposureTarget, + request models.UpfCreateEventSubscription, + ) (smf_context.NupfCreateResult, error) + DeleteSubscription( + ctx context.Context, + target smf_context.EventExposureTarget, + subscriptionID string, + ) error +} + +type UUIDGenerator interface { + NewString() string +} + +type EventExposureDependencies struct { + Repository EventExposureRepository + Resolver EventExposureResolver + NupfConsumer NupfEventExposureConsumer + UUIDGenerator UUIDGenerator +} + +type uuidGenerator struct{} + +func (uuidGenerator) NewString() string { + return uuid.NewString() } diff --git a/internal/sbi/server_eventexposure_internal_test.go b/internal/sbi/server_eventexposure_internal_test.go new file mode 100644 index 00000000..36e4b6e4 --- /dev/null +++ b/internal/sbi/server_eventexposure_internal_test.go @@ -0,0 +1,63 @@ +package sbi + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/free5gc/smf/pkg/factory" +) + +func TestEventExposureRouteRegistrationFollowsServiceNameList(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + services []string + wantRoute bool + }{ + { + name: "present registers route", + services: []string{"nsmf-pdusession", "nsmf-event-exposure"}, + wantRoute: true, + }, + { + name: "absent leaves route unregistered", + services: []string{"nsmf-pdusession"}, + wantRoute: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldConfig := factory.SmfConfig + factory.SmfConfig = &factory.Config{ + Configuration: &factory.Configuration{ + ServiceNameList: tt.services, + }, + } + t.Cleanup(func() { + factory.SmfConfig = oldConfig + }) + + router := newRouter(&Server{ServerSmf: fakeServerSMF{}}) + found := hasRoute( + router.Routes(), + http.MethodPost, + factory.SmfEventExposureResUriPrefix+"/subscriptions", + ) + if found != tt.wantRoute { + t.Fatalf("route presence mismatch: got %v want %v", found, tt.wantRoute) + } + }) + } +} + +func hasRoute(routes gin.RoutesInfo, method, path string) bool { + for _, route := range routes { + if route.Method == method && route.Path == path { + return true + } + } + return false +} diff --git a/pkg/factory/config.go b/pkg/factory/config.go index 20e9be33..2384e1a2 100644 --- a/pkg/factory/config.go +++ b/pkg/factory/config.go @@ -7,8 +7,11 @@ package factory import ( "errors" "fmt" + "net/url" "os" + "path" "strconv" + "strings" "sync" "time" @@ -35,7 +38,7 @@ const ( SmfMetricsDefaultScheme = "https" SmfMetricsDefaultNamespace = "free5gc" SmfDefaultNrfUri = "https://127.0.0.10:8000" - SmfEventExposureResUriPrefix = "/nsmf_event-exposure/v1" + SmfEventExposureResUriPrefix = "/nsmf-event-exposure/v1" SmfPdusessionResUriPrefix = "/nsmf-pdusession/v1" SmfOamUriPrefix = "/nsmf-oam/v1" SmfCallbackUriPrefix = "/nsmf-callback/v1" @@ -54,7 +57,7 @@ type Config struct { func (c *Config) Validate() (bool, error) { govalidator.TagMap["scheme"] = func(str string) bool { - return str == "https" || str == "http" + return str == SmfSbiDefaultScheme || str == "http" } if configuration := c.Configuration; configuration != nil { @@ -310,7 +313,7 @@ type Sbi struct { func (s *Sbi) validate() (bool, error) { govalidator.TagMap["scheme"] = govalidator.Validator(func(str string) bool { - return str == "https" || str == "http" + return str == SmfSbiDefaultScheme || str == "http" }) if tls := s.Tls; tls != nil { @@ -566,6 +569,7 @@ type UPNode struct { Addr string `json:"addr" yaml:"addr" valid:"host,optional"` ANIP string `json:"anIP" yaml:"anIP" valid:"host,optional"` Dnn string `json:"dnn" yaml:"dnn" valid:"type(string),minstringlength(1),optional"` + NupfEeApiRoot *string `json:"nupfEeApiRoot" yaml:"nupfEeApiRoot" valid:"optional"` SNssaiInfos []*SnssaiUpfInfoItem `json:"sNssaiUpfInfos" yaml:"sNssaiUpfInfos,omitempty" valid:"optional"` InterfaceUpfInfoList []*InterfaceUpfInfoItem `json:"interfaces" yaml:"interfaces,omitempty" valid:"optional"` } @@ -575,6 +579,17 @@ func (u *UPNode) validate() (bool, error) { return str == "AN" || str == "UPF" }) + if u.NupfEeApiRoot != nil { + if u.Type != "UPF" { + return false, errors.New("nupfEeApiRoot is only valid on UPF nodes") + } + normalized, err := normalizeNupfEeApiRoot(*u.NupfEeApiRoot) + if err != nil { + return false, err + } + u.NupfEeApiRoot = &normalized + } + for _, snssaiInfo := range u.SNssaiInfos { if result, err := snssaiInfo.Validate(); err != nil { return result, err @@ -605,6 +620,41 @@ func (u *UPNode) validate() (bool, error) { return result, appendInvalid(err) } +func normalizeNupfEeApiRoot(raw string) (string, error) { + if raw == "" { + return "", errors.New("nupfEeApiRoot must not be empty") + } + + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid nupfEeApiRoot: %w", err) + } + if u.Scheme != "http" && u.Scheme != SmfSbiDefaultScheme { + return "", errors.New("nupfEeApiRoot must use http or https scheme") + } + if u.Host == "" { + return "", errors.New("nupfEeApiRoot must include a host") + } + if u.User != nil { + return "", errors.New("nupfEeApiRoot must not include userinfo") + } + if u.RawQuery != "" { + return "", errors.New("nupfEeApiRoot must not include query") + } + if u.Fragment != "" { + return "", errors.New("nupfEeApiRoot must not include fragment") + } + + cleanPath := path.Clean(u.Path) + if cleanPath == "." || cleanPath == "/" { + u.Path = "" + } else { + u.Path = "/" + strings.Trim(cleanPath, "/") + } + u.RawPath = "" + return strings.TrimRight(u.String(), "/"), nil +} + type InterfaceUpfInfoItem struct { InterfaceType models.UpInterfaceType `json:"interfaceType" yaml:"interfaceType" valid:"required"` Endpoints []string `json:"endpoints" yaml:"endpoints" valid:"required"` diff --git a/pkg/factory/event_exposure_config_internal_test.go b/pkg/factory/event_exposure_config_internal_test.go new file mode 100644 index 00000000..cdf45857 --- /dev/null +++ b/pkg/factory/event_exposure_config_internal_test.go @@ -0,0 +1,73 @@ +package factory + +import "testing" + +func TestUPNodeNupfEeApiRootValidation(t *testing.T) { + tests := []struct { + name string + node UPNode + wantRoot string + wantError bool + }{ + { + name: "omitted", + node: UPNode{Type: "UPF"}, + }, + { + name: "normalizes path and trailing slash", + node: UPNode{ + Type: "UPF", + NupfEeApiRoot: stringPtr("https://upf.example.com/api//"), + }, + wantRoot: "https://upf.example.com/api", + }, + { + name: "empty rejected", + node: UPNode{ + Type: "UPF", + NupfEeApiRoot: stringPtr(""), + }, + wantError: true, + }, + { + name: "AN rejected", + node: UPNode{ + Type: "AN", + NupfEeApiRoot: stringPtr("https://upf.example.com"), + }, + wantError: true, + }, + { + name: "query rejected", + node: UPNode{ + Type: "UPF", + NupfEeApiRoot: stringPtr("https://upf.example.com?x=1"), + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.node.validate() + if tt.wantError { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantRoot != "" { + if tt.node.NupfEeApiRoot == nil || *tt.node.NupfEeApiRoot != tt.wantRoot { + t.Fatalf("root mismatch: got %v want %q", tt.node.NupfEeApiRoot, tt.wantRoot) + } + } + }) + } +} + +func stringPtr(s string) *string { + return &s +}