From bc0fd1bfad661f4bb31db0b09c65486bef6799e8 Mon Sep 17 00:00:00 2001 From: haha39 <60254357+haha39@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:51:59 +0800 Subject: [PATCH 1/2] feat: add UPF event to SMF event exposure --- models/model_smf_evt_expos_smf_event.go | 1 + models/model_smf_evt_expos_smf_event_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 models/model_smf_evt_expos_smf_event_test.go diff --git a/models/model_smf_evt_expos_smf_event.go b/models/model_smf_evt_expos_smf_event.go index dacd849b..abefe8bb 100644 --- a/models/model_smf_evt_expos_smf_event.go +++ b/models/model_smf_evt_expos_smf_event.go @@ -35,4 +35,5 @@ const ( Smf_EvtExpos_SmfEvent_UP_STATUS_INFO Smf_EvtExpos_SmfEvent = "UP_STATUS_INFO" // #nosec G101 Smf_EvtExpos_SmfEvent_SATB_CH Smf_EvtExpos_SmfEvent = "SATB_CH" // #nosec G101 Smf_EvtExpos_SmfEvent_TRAFFIC_CORRELATION Smf_EvtExpos_SmfEvent = "TRAFFIC_CORRELATION" // #nosec G101 + Smf_EvtExpos_SmfEvent_UPF_EVENT Smf_EvtExpos_SmfEvent = "UPF_EVENT" // #nosec G101 ) diff --git a/models/model_smf_evt_expos_smf_event_test.go b/models/model_smf_evt_expos_smf_event_test.go new file mode 100644 index 00000000..139a5ce9 --- /dev/null +++ b/models/model_smf_evt_expos_smf_event_test.go @@ -0,0 +1,16 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmfEvtExposSmfEventUPFEventWireValue(t *testing.T) { + require.Equal(t, Smf_EvtExpos_SmfEvent("UPF_EVENT"), Smf_EvtExpos_SmfEvent_UPF_EVENT) + + wireValue, err := json.Marshal(Smf_EvtExpos_SmfEvent_UPF_EVENT) + require.NoError(t, err) + require.Equal(t, `"UPF_EVENT"`, string(wireValue)) +} From aa182235b5d3b392dfe0f83d352c39f332582a4d Mon Sep 17 00:00:00 2001 From: haha39 <60254357+haha39@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:52:26 +0800 Subject: [PATCH 2/2] feat: add opt-in redirect rejection policy --- client.go | 69 ++- client_test.go | 405 ++++++++++++++++++ upf/EvtExpos/api_default_test.go | 47 ++ .../api_subscriptions_collection_test.go | 146 +++++++ upf/EvtExpos/configuration.go | 23 +- 5 files changed, 661 insertions(+), 29 deletions(-) create mode 100644 upf/EvtExpos/api_default_test.go create mode 100644 upf/EvtExpos/api_subscriptions_collection_test.go diff --git a/client.go b/client.go index b5cb4111..382d2b9c 100644 --- a/client.go +++ b/client.go @@ -64,6 +64,19 @@ type Configuration interface { Metrics() RequestMetricsHook } +// RedirectPolicy controls how an HTTP client handles a redirect response. +type RedirectPolicy func(req *http.Request, via []*http.Request) error + +// RedirectPolicyProvider optionally supplies a redirect policy for one configuration. +type RedirectPolicyProvider interface { + RedirectPolicy() RedirectPolicy +} + +// RejectRedirects returns the original redirect response without following it. +func RejectRedirects(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + // SelectHeaderAccept join all accept types and return func SelectHeaderAccept(accepts []string) string { if len(accepts) == 0 { @@ -194,32 +207,44 @@ func CallAPI(cfg Configuration, request *http.Request) (*http.Response, error) { start := time.Now() metricHook := cfg.Metrics() - if cfg.HTTPClient() != nil { - resp, err := cfg.HTTPClient().Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) - } - return resp, err + client, err := selectHTTPClient(cfg, request) + if err != nil { + return nil, err } - switch request.URL.Scheme { - case "https": - resp, err := innerHTTP2Client.Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) - } - return resp, err - case "http": - resp, err := innerHTTP2CleartextClient.Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) + + resp, err := client.Do(request) + if metricHook != nil { + metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), + time.Since(start).Seconds()) + } + return resp, err +} + +func selectHTTPClient(cfg Configuration, request *http.Request) (*http.Client, error) { + client := cfg.HTTPClient() + if client == nil { + switch request.URL.Scheme { + case "https": + client = innerHTTP2Client + case "http": + client = innerHTTP2CleartextClient + default: + return nil, fmt.Errorf("unsupported scheme[%s]", request.URL.Scheme) } - return resp, err } - return nil, fmt.Errorf("unsupported scheme[%s]", request.URL.Scheme) + provider, ok := cfg.(RedirectPolicyProvider) + if !ok { + return client, nil + } + policy := provider.RedirectPolicy() + if policy == nil { + return client, nil + } + + requestClient := *client + requestClient.CheckRedirect = policy + return &requestClient, nil } // // Change base path to allow switching to mocks diff --git a/client_test.go b/client_test.go index 7539e8e4..7a76bc01 100644 --- a/client_test.go +++ b/client_test.go @@ -1,9 +1,15 @@ package openapi import ( + "errors" "fmt" + "io" "net/http" + "net/http/cookiejar" "net/url" + "strings" + "sync" + "sync/atomic" "testing" "time" @@ -13,6 +19,41 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) +type clientTestRoundTripper func(*http.Request) (*http.Response, error) + +func (f clientTestRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type clientTestTransport struct { + marker string +} + +func (t *clientTestTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return clientTestResponse(request, http.StatusNoContent, nil), nil +} + +type clientTestConfiguration struct { + httpClient *http.Client + metrics RequestMetricsHook +} + +func (c *clientTestConfiguration) BasePath() string { return "" } +func (c *clientTestConfiguration) Host() string { return "" } +func (c *clientTestConfiguration) UserAgent() string { return "" } +func (c *clientTestConfiguration) DefaultHeader() map[string]string { return nil } +func (c *clientTestConfiguration) HTTPClient() *http.Client { return c.httpClient } +func (c *clientTestConfiguration) Metrics() RequestMetricsHook { return c.metrics } + +type redirectClientTestConfiguration struct { + *clientTestConfiguration + policy RedirectPolicy +} + +func (c *redirectClientTestConfiguration) RedirectPolicy() RedirectPolicy { + return c.policy +} + // Regression test: all HTTP clients must wrap their transport with otelhttp.NewTransport // so that trace IDs are propagated in outgoing requests. If this breaks, // downstream services will not receive the traceparent header and cross-service tracing fails. @@ -265,3 +306,367 @@ func TestMultipartDeserialize_LargerThanBuffer(t *testing.T) { require.NotNil(t, notify.JsonData.RegistrationCtxtContainer) require.NotNil(t, notify.JsonData.N1MessageContainer) } + +func TestCallAPIPreservesDefaultRedirectBehaviorWithoutPolicy(t *testing.T) { + tests := []struct { + name string + newConfig func(*http.Client) Configuration + }{ + { + name: "provider absent", + newConfig: func(client *http.Client) Configuration { + return &clientTestConfiguration{httpClient: client} + }, + }, + { + name: "nil policy", + newConfig: func(client *http.Client) Configuration { + return &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: client}, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + requests.Add(1) + if request.URL.Host == "origin.example.com" { + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": {"https://target.example.com/resource"}, + }), nil + } + return clientTestResponse(request, http.StatusNoContent, nil), nil + }), + } + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + require.NoError(t, err) + + response, err := CallAPI(test.newConfig(client), request) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + require.NoError(t, response.Body.Close()) + require.Equal(t, int32(2), requests.Load()) + require.Nil(t, client.CheckRedirect) + }) + } +} + +func TestCallAPIPreservesExistingRedirectPolicyWithoutNewPolicy(t *testing.T) { + tests := []struct { + name string + newConfig func(*http.Client) Configuration + }{ + { + name: "provider absent", + newConfig: func(client *http.Client) Configuration { + return &clientTestConfiguration{httpClient: client} + }, + }, + { + name: "nil policy", + newConfig: func(client *http.Client) Configuration { + return &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: client}, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var redirectCalls atomic.Int32 + var targetRequests atomic.Int32 + existingPolicy := func(_ *http.Request, _ []*http.Request) error { + redirectCalls.Add(1) + return http.ErrUseLastResponse + } + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "target.example.com" { + targetRequests.Add(1) + return clientTestResponse(request, http.StatusNoContent, nil), nil + } + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": {"https://target.example.com/resource"}, + }), nil + }), + CheckRedirect: existingPolicy, + } + configuration := test.newConfig(client) + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + require.NoError(t, err) + + selected, err := selectHTTPClient(configuration, request) + require.NoError(t, err) + require.Same(t, client, selected) + + response, err := CallAPI(configuration, request) + require.NoError(t, err) + require.Equal(t, http.StatusTemporaryRedirect, response.StatusCode) + require.NoError(t, response.Body.Close()) + require.Equal(t, int32(1), redirectCalls.Load()) + require.Zero(t, targetRequests.Load()) + require.NotNil(t, client.CheckRedirect) + }) + } +} + +func TestSelectHTTPClientPreservesSelectedClient(t *testing.T) { + t.Run("explicit client takes precedence", func(t *testing.T) { + transport := &clientTestTransport{marker: "unchanged"} + jar, err := cookiejar.New(nil) + require.NoError(t, err) + originalRedirectError := errors.New("original redirect policy") + originalRedirectPolicy := func(_ *http.Request, _ []*http.Request) error { + return originalRedirectError + } + original := &http.Client{ + Transport: transport, + CheckRedirect: originalRedirectPolicy, + Jar: jar, + Timeout: 23 * time.Second, + } + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: original}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, "custom://origin.example.com/resource", nil) + require.NoError(t, err) + + selected, err := selectHTTPClient(configuration, request) + + require.NoError(t, err) + require.NotSame(t, original, selected) + requireSameHTTPClientSettings(t, original, selected) + require.ErrorIs(t, selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) + require.ErrorIs(t, original.CheckRedirect(nil, nil), originalRedirectError) + require.Equal(t, "unchanged", transport.marker) + }) + + for _, test := range []struct { + name string + scheme string + original *http.Client + }{ + {name: "shared HTTPS client", scheme: "https", original: innerHTTP2Client}, + {name: "shared cleartext client", scheme: "http", original: innerHTTP2CleartextClient}, + } { + t.Run(test.name, func(t *testing.T) { + require.Nil(t, test.original.CheckRedirect) + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, test.scheme+"://origin.example.com/resource", nil) + require.NoError(t, err) + + selected, err := selectHTTPClient(configuration, request) + + require.NoError(t, err) + require.NotSame(t, test.original, selected) + requireSameHTTPClientSettings(t, test.original, selected) + require.ErrorIs(t, selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) + require.Nil(t, test.original.CheckRedirect) + }) + } +} + +func TestCallAPIRedirectPolicyPreservesMetrics(t *testing.T) { + for _, redirectStatus := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(fmt.Sprintf("status_%d", redirectStatus), func(t *testing.T) { + var metricCalls atomic.Int32 + var metricStatus atomic.Int32 + var metricMethod string + var metricService string + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + return clientTestResponse(request, redirectStatus, http.Header{ + "Location": {"https://target.example.com/resource"}, + }), nil + }), + } + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{ + httpClient: client, + metrics: func(method string, service string, status int, duration float64) { + metricCalls.Add(1) + metricStatus.Store(int32(status)) + metricMethod = method + metricService = service + require.GreaterOrEqual(t, duration, float64(0)) + }, + }, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodPost, "https://origin.example.com/nupf-ee/v1/resource", nil) + require.NoError(t, err) + + response, err := CallAPI(configuration, request) + + require.NoError(t, err) + require.Equal(t, redirectStatus, response.StatusCode) + require.NoError(t, response.Body.Close()) + require.Equal(t, int32(1), metricCalls.Load()) + require.Equal(t, int32(redirectStatus), metricStatus.Load()) + require.Equal(t, http.MethodPost, metricMethod) + require.Equal(t, "nupf-ee", metricService) + require.Nil(t, client.CheckRedirect) + }) + } +} + +func TestCallAPIRedirectPolicyConcurrentUse(t *testing.T) { + transport := clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "origin.example.com" { + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": {"https://target.example.com/resource"}, + }), nil + } + return clientTestResponse(request, http.StatusNoContent, nil), nil + }) + followClient := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + var existingPolicyCalls atomic.Int32 + existingPolicyClient := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + existingPolicyCalls.Add(1) + return http.ErrUseLastResponse + }, + } + followConfiguration := &clientTestConfiguration{httpClient: followClient} + existingPolicyConfiguration := &clientTestConfiguration{httpClient: existingPolicyClient} + rejectConfiguration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: existingPolicyClient}, + policy: RejectRedirects, + } + + const iterations = 50 + errorsCh := make(chan error, iterations*3) + var waitGroup sync.WaitGroup + for range iterations { + waitGroup.Add(3) + go func() { + defer waitGroup.Done() + errorsCh <- callAPIExpectStatus(followConfiguration, http.StatusNoContent) + }() + go func() { + defer waitGroup.Done() + errorsCh <- callAPIExpectStatus(existingPolicyConfiguration, http.StatusTemporaryRedirect) + }() + go func() { + defer waitGroup.Done() + errorsCh <- callAPIExpectStatus(rejectConfiguration, http.StatusTemporaryRedirect) + }() + } + waitGroup.Wait() + close(errorsCh) + + for err := range errorsCh { + require.NoError(t, err) + } + require.Nil(t, followClient.CheckRedirect) + require.NotNil(t, existingPolicyClient.CheckRedirect) + require.Equal(t, int32(iterations), existingPolicyCalls.Load()) +} + +func TestSelectHTTPClientRedirectPolicyConcurrentSharedUse(t *testing.T) { + followConfiguration := &clientTestConfiguration{} + rejectConfiguration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + require.NoError(t, err) + + const iterations = 100 + errorsCh := make(chan error, iterations*2) + var waitGroup sync.WaitGroup + for range iterations { + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + selected, selectErr := selectHTTPClient(followConfiguration, request) + if selectErr != nil { + errorsCh <- selectErr + return + } + if selected != innerHTTP2Client || selected.CheckRedirect != nil { + errorsCh <- errors.New("follow selection changed shared HTTPS client") + return + } + errorsCh <- nil + }() + go func() { + defer waitGroup.Done() + selected, selectErr := selectHTTPClient(rejectConfiguration, request) + if selectErr != nil { + errorsCh <- selectErr + return + } + if selected == innerHTTP2Client { + errorsCh <- errors.New("reject selection returned shared HTTPS client") + return + } + if !errors.Is(selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) { + errorsCh <- errors.New("reject selection did not install redirect policy") + return + } + errorsCh <- nil + }() + } + waitGroup.Wait() + close(errorsCh) + + for err := range errorsCh { + require.NoError(t, err) + } + require.Nil(t, innerHTTP2Client.CheckRedirect) +} + +func callAPIExpectStatus(configuration Configuration, expectedStatus int) error { + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + if err != nil { + return err + } + response, err := CallAPI(configuration, request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != expectedStatus { + return fmt.Errorf("status = %d, want %d", response.StatusCode, expectedStatus) + } + return nil +} + +func clientTestResponse(request *http.Request, status int, header http.Header) *http.Response { + if header == nil { + header = make(http.Header) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(strings.NewReader("")), + Request: request, + } +} + +func requireSameHTTPClientSettings(t *testing.T, expected *http.Client, actual *http.Client) { + t.Helper() + require.Same(t, expected.Transport, actual.Transport) + require.Equal(t, expected.Timeout, actual.Timeout) + if expected.Jar == nil { + require.Nil(t, actual.Jar) + } else { + require.Same(t, expected.Jar, actual.Jar) + } +} diff --git a/upf/EvtExpos/api_default_test.go b/upf/EvtExpos/api_default_test.go new file mode 100644 index 00000000..cb1f2e3f --- /dev/null +++ b/upf/EvtExpos/api_default_test.go @@ -0,0 +1,47 @@ +package EvtExpos + +import ( + "context" + "net/http" + "strconv" + "testing" + + "github.com/free5gc/openapi" + "github.com/stretchr/testify/require" +) + +func TestDeleteSubscriptionRejectsRedirectWithoutReplay(t *testing.T) { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + client, capture, location := newRedirectTestAPIClient( + t, + status, + "/nupf-ee/v1/ee-subscriptions/sub-1", + ) + request := &DeleteSubscriptionRequest{} + request.SetSubscriptionId("sub-1") + + response, err := client.DefaultApi.DeleteSubscription(context.Background(), request) + + require.Nil(t, response) + require.Error(t, err) + var apiError openapi.GenericOpenAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, status, apiError.ErrorStatus) + errorModel, ok := apiError.Model().(DeleteSubscriptionError) + require.True(t, ok) + require.Equal(t, location, errorModel.Location) + require.Equal(t, redirectTargetNFID, errorModel.Var3gpp_Sbi_Target_Nf_Id) + require.NotNil(t, errorModel.RedirectResponse) + require.Equal(t, "TEMPORARY_REDIRECTION", errorModel.RedirectResponse.Cause) + + captured := capture.snapshot() + require.Equal(t, int32(1), captured.originRequests) + require.Zero(t, captured.targetRequests) + require.Zero(t, captured.originBodies) + require.Equal(t, http.MethodDelete, captured.originMethod) + require.Equal(t, "/nupf-ee/v1/ee-subscriptions/sub-1", captured.originPath) + require.Empty(t, captured.originBody) + }) + } +} diff --git a/upf/EvtExpos/api_subscriptions_collection_test.go b/upf/EvtExpos/api_subscriptions_collection_test.go new file mode 100644 index 00000000..c5a2210b --- /dev/null +++ b/upf/EvtExpos/api_subscriptions_collection_test.go @@ -0,0 +1,146 @@ +package EvtExpos + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "sync/atomic" + "testing" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" + "github.com/stretchr/testify/require" +) + +const redirectTargetNFID = "target-nf-id" + +type redirectTestCapture struct { + originRequests atomic.Int32 + targetRequests atomic.Int32 + originBodies atomic.Int32 + + mu sync.Mutex + originMethod string + originPath string + originBody string +} + +type redirectTestSnapshot struct { + originRequests int32 + targetRequests int32 + originBodies int32 + originMethod string + originPath string + originBody string +} + +func (c *redirectTestCapture) recordOrigin(request *http.Request, body []byte) { + c.originRequests.Add(1) + if len(body) > 0 { + c.originBodies.Add(1) + } + c.mu.Lock() + defer c.mu.Unlock() + c.originMethod = request.Method + c.originPath = request.URL.Path + c.originBody = string(body) +} + +func (c *redirectTestCapture) snapshot() redirectTestSnapshot { + c.mu.Lock() + defer c.mu.Unlock() + return redirectTestSnapshot{ + originRequests: c.originRequests.Load(), + targetRequests: c.targetRequests.Load(), + originBodies: c.originBodies.Load(), + originMethod: c.originMethod, + originPath: c.originPath, + originBody: c.originBody, + } +} + +func newRedirectTestAPIClient( + t *testing.T, + status int, + locationPath string, +) (*APIClient, *redirectTestCapture, string) { + t.Helper() + + capture := &redirectTestCapture{} + targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + capture.targetRequests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(targetServer.Close) + + location := targetServer.URL + locationPath + originServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + capture.recordOrigin(request, body) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", location) + w.Header().Set("3gpp-Sbi-Target-Nf-Id", redirectTargetNFID) + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"cause":"TEMPORARY_REDIRECTION"}`) + })) + t.Cleanup(originServer.Close) + + httpClient := &http.Client{} + require.Nil(t, httpClient.Transport, "redirect opt-in must not require a custom transport") + configuration := NewConfiguration() + configuration.SetBasePath(originServer.URL) + configuration.SetHTTPClient(httpClient) + require.Nil(t, configuration.RedirectPolicy()) + configuration.SetRedirectPolicy(openapi.RejectRedirects) + require.ErrorIs(t, configuration.RedirectPolicy()(nil, nil), http.ErrUseLastResponse) + + return NewAPIClient(configuration), capture, location +} + +func TestCreateSubscriptionRejectsRedirectWithoutReplay(t *testing.T) { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + client, capture, location := newRedirectTestAPIClient( + t, + status, + "/nupf-ee/v1/ee-subscriptions", + ) + request := &CreateSubscriptionRequest{} + request.SetRequestBody(models.Upf_EvtExpos_CreateEventSubscription{ + Subscription: &models.Upf_EvtExpos_UpfEventSubscription{ + EventNotifyUri: "https://consumer.example.com/notify", + NotifyCorrelationId: "correlation-id", + }, + }) + + response, err := client.SubscriptionsCollectionApi.CreateSubscription(context.Background(), request) + + require.Nil(t, response) + require.Error(t, err) + var apiError openapi.GenericOpenAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, status, apiError.ErrorStatus) + errorModel, ok := apiError.Model().(CreateSubscriptionError) + require.True(t, ok) + require.Equal(t, location, errorModel.Location) + require.Equal(t, redirectTargetNFID, errorModel.Var3gpp_Sbi_Target_Nf_Id) + require.NotNil(t, errorModel.RedirectResponse) + require.Equal(t, "TEMPORARY_REDIRECTION", errorModel.RedirectResponse.Cause) + + captured := capture.snapshot() + require.Equal(t, int32(1), captured.originRequests) + require.Zero(t, captured.targetRequests) + require.Equal(t, int32(1), captured.originBodies) + require.Equal(t, http.MethodPost, captured.originMethod) + require.Equal(t, "/nupf-ee/v1/ee-subscriptions", captured.originPath) + require.Contains(t, captured.originBody, `"subscription"`) + }) + } +} diff --git a/upf/EvtExpos/configuration.go b/upf/EvtExpos/configuration.go index f02c5359..509d06c0 100644 --- a/upf/EvtExpos/configuration.go +++ b/upf/EvtExpos/configuration.go @@ -20,13 +20,14 @@ import ( ) type Configuration struct { - url string - basePath string - host string - defaultHeader map[string]string - userAgent string - httpClient *http.Client - MetricsHook openapi.RequestMetricsHook + url string + basePath string + host string + defaultHeader map[string]string + userAgent string + httpClient *http.Client + MetricsHook openapi.RequestMetricsHook + redirectPolicy openapi.RedirectPolicy } func NewConfiguration() *Configuration { @@ -91,3 +92,11 @@ func (c *Configuration) Metrics() openapi.RequestMetricsHook { func (c *Configuration) SetMetrics(h openapi.RequestMetricsHook) { c.MetricsHook = h } + +func (c *Configuration) RedirectPolicy() openapi.RedirectPolicy { + return c.redirectPolicy +} + +func (c *Configuration) SetRedirectPolicy(policy openapi.RedirectPolicy) { + c.redirectPolicy = policy +}