Loading README.md +9 −6 Changes for README.md: 9 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -263,8 +263,8 @@ See `tests/contract/bootstrap_example.yaml` for an explicit file-mode example. ## OAI QoD provider profiles Configure the profile identifiers accepted by each OAI NEF in that control path's runtime infrastructure. For example, when the NEF offers these names: Optionally restrict profile identifiers in an OAI control path's runtime infrastructure. For example, when the NEF offers these names: ```yaml control_paths: Loading @@ -285,10 +285,13 @@ are opaque provider names: they reach the NEF unchanged, with no inferred aliase or QoS policy mapping. Configure the actual names and policies offered by that NEF; the example does not assert a universal OAI default. Omitting qos_profiles preserves qos-e/qos-s/qos-m/qos-l. An explicit null, empty, duplicate or malformed list fails adapter construction with ValidationError. Requests outside the configured list fail before HTTP. Other control paths keep their own lists, and backend refusals remain typed execution failures. The Omitting qos_profiles applies no local allowlist: syntactically valid profile identifiers reach the NEF unchanged. The NEF decides whether a profile exists and can be applied. There are no built-in provider profile names. An explicit null, empty, duplicate or malformed list fails adapter construction with ValidationError. Requests outside a configured list fail before HTTP. Each control path keeps its own optional restriction. Backend refusals remain typed execution failures even for profiles allowed locally. The existing notification destination requirement still applies; configuring profiles does not implement a receiver or establish data-plane QoS guarantees. Loading src/openop_tfsdk/network/adapters/oai/client.py +7 −5 Changes for src/openop_tfsdk/network/adapters/oai/client.py: 7 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -28,7 +28,6 @@ from openop_tfsdk.network.core.schemas import ( ) log = logger.get_logger(__name__) supportedQos = ("qos-e", "qos-s", "qos-m", "qos-l") class NetworkManager(BaseNetworkClient): Loading @@ -46,7 +45,7 @@ class NetworkManager(BaseNetworkClient): super().__init__() self.base_url = base_url self.scs_as_id = scs_as_id self._qos_profiles = tuple(supportedQos if qos_profiles is None else qos_profiles) self._qos_profiles = None if qos_profiles is None else tuple(qos_profiles) self.identity_service_url = f"{base_url.rstrip('/')}/3gpp-ueid/v1" log.info( f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}" Loading @@ -67,10 +66,13 @@ class NetworkManager(BaseNetworkClient): raises: ValidationError: If the session information does not meet core-specific requirements. """ if session_info.qosProfile.root not in self._qos_profiles: if ( self._qos_profiles is not None and session_info.qosProfile.root not in self._qos_profiles ): raise OaiValidationError( f"QoS profile {session_info.qosProfile} not supported by OAI, " f"supported profiles are {list(self._qos_profiles)}" f"QoS profile {session_info.qosProfile} not allowed by configured qos_profiles, " f"allowed profiles are {list(self._qos_profiles)}" ) if session_info.device is None or session_info.device.ipv4Address is None: Loading tests/contract/test_network_qod.py +1 −1 Changes for tests/contract/test_network_qod.py: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -110,7 +110,7 @@ def test_qod_behind_nat_targets_the_private_address(nef_bodies): @pytest.mark.parametrize( "kind, request_kwargs, reason", [ ("oai", {"profile": "QOS_PREMIUM"}, "not supported by OAI"), ("oai", {"profile": "qos e"}, "qosProfile string_pattern_mismatch"), ("oai", {"ipv4": None, "ipv6": "2001:db8::1"}, "OAI requires UE IPv4 Address"), ("open5gs", {"profile": "qos-xx"}, "Open5Gs only supports"), ("open5gs", {"profile": "qos e"}, "qosProfile string_pattern_mismatch"), Loading tests/contract/test_oai_qod_profiles.py +35 −11 Changes for tests/contract/test_oai_qod_profiles.py: 35 added lines, 11 removed lines. Original line number Diff line number Diff line """Provider profile names are runtime configuration of one OAI control path.""" """Provider profile names pass through unless a control path explicitly restricts them.""" import json from concurrent.futures import ThreadPoolExecutor Loading Loading @@ -64,8 +64,21 @@ def request(profile, ref="nef"): ) @pytest.mark.parametrize("profile", ["qos-e", "qos-s", "qos-m", "qos-l"]) def test_omitted_profiles_keep_existing_release_support(nef, profile): @pytest.mark.parametrize( "profile", [ "qos-e", "qos-s", "qos-m", "qos-l", "qos2", "qos3", "qos4", "provider.premium_5", "18dcdddc-98d5-44d8-a5ca-4ea96ef1d706", ], ) def test_omitted_profiles_forward_valid_provider_names_unchanged(nef, profile): assert adapter().activate(request(profile)).state is LifecycleState.ACTIVE assert nef.calls[0][1]["qosReference"] == profile Loading @@ -78,16 +91,16 @@ def test_configured_provider_names_reach_nef_without_aliases(nef, profile): assert nef.calls[0][1]["notificationDestination"] == NOTIFY["notification_url"] @pytest.mark.parametrize("profile", ["qos2", "qos3", "qos4"]) def test_unconfigured_names_still_fail_before_http(nef, profile): with pytest.raises(ValidationError, match="not supported by OAI"): @pytest.mark.parametrize("profile", ["ab", "qos 2", "q" * 257]) def test_omitted_profiles_still_reject_malformed_names_before_http(nef, profile): with pytest.raises(ValidationError): adapter().activate(request(profile)) assert not nef.calls @pytest.mark.parametrize("profile", ["qos-s", "qos-m", "qos-l", "unknown-profile"]) def test_explicit_configuration_replaces_default_support(nef, profile): with pytest.raises(ValidationError, match="not supported by OAI"): def test_explicit_configuration_restricts_profiles_before_http(nef, profile): with pytest.raises(ValidationError, match="configured qos_profiles"): adapter(qos_profiles=["qos-e", "qos2"]).activate(request(profile)) assert not nef.calls Loading Loading @@ -121,15 +134,15 @@ def test_invalid_profile_configuration_fails_before_client_construction(monkeypa assert clients == [] def test_profile_configuration_is_snapshotted_and_does_not_change_defaults(nef): def test_profile_configuration_is_snapshotted_without_restricting_other_clients(nef): profiles = ["qos2"] configured = adapter(qos_profiles=profiles) profiles[:] = ["qos-injected"] assert configured.activate(request("qos2")).state is LifecycleState.ACTIVE with pytest.raises(ValidationError): configured.activate(request("qos-injected")) assert adapter().activate(request("qos-s")).state is LifecycleState.ACTIVE assert [body["qosReference"] for _, body in nef.calls] == ["qos2", "qos-s"] assert adapter().activate(request("provider.other")).state is LifecycleState.ACTIVE assert [body["qosReference"] for _, body in nef.calls] == ["qos2", "provider.other"] def test_different_control_paths_validate_and_execute_concurrently_without_cross_talk(nef): Loading Loading @@ -188,3 +201,14 @@ def test_configured_profile_can_be_refused_by_nef_with_safe_details(nef): assert len(nef.calls) == 1 assert caught.value.details == {"backend_status": 400, "reason": "bad_request"} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details) @pytest.mark.parametrize("status, reason", [(400, "bad_request"), (503, "service_unavailable")]) def test_unrestricted_profile_refusal_is_reported_from_nef(nef, status, reason): nef.status = status with pytest.raises(DomainExecutionFailed) as caught: adapter().activate(request("provider.unknown")) assert len(nef.calls) == 1 assert nef.calls[0][1]["qosReference"] == "provider.unknown" assert caught.value.details == {"backend_status": status, "reason": reason} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details) Loading
README.md +9 −6 Changes for README.md: 9 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -263,8 +263,8 @@ See `tests/contract/bootstrap_example.yaml` for an explicit file-mode example. ## OAI QoD provider profiles Configure the profile identifiers accepted by each OAI NEF in that control path's runtime infrastructure. For example, when the NEF offers these names: Optionally restrict profile identifiers in an OAI control path's runtime infrastructure. For example, when the NEF offers these names: ```yaml control_paths: Loading @@ -285,10 +285,13 @@ are opaque provider names: they reach the NEF unchanged, with no inferred aliase or QoS policy mapping. Configure the actual names and policies offered by that NEF; the example does not assert a universal OAI default. Omitting qos_profiles preserves qos-e/qos-s/qos-m/qos-l. An explicit null, empty, duplicate or malformed list fails adapter construction with ValidationError. Requests outside the configured list fail before HTTP. Other control paths keep their own lists, and backend refusals remain typed execution failures. The Omitting qos_profiles applies no local allowlist: syntactically valid profile identifiers reach the NEF unchanged. The NEF decides whether a profile exists and can be applied. There are no built-in provider profile names. An explicit null, empty, duplicate or malformed list fails adapter construction with ValidationError. Requests outside a configured list fail before HTTP. Each control path keeps its own optional restriction. Backend refusals remain typed execution failures even for profiles allowed locally. The existing notification destination requirement still applies; configuring profiles does not implement a receiver or establish data-plane QoS guarantees. Loading
src/openop_tfsdk/network/adapters/oai/client.py +7 −5 Changes for src/openop_tfsdk/network/adapters/oai/client.py: 7 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -28,7 +28,6 @@ from openop_tfsdk.network.core.schemas import ( ) log = logger.get_logger(__name__) supportedQos = ("qos-e", "qos-s", "qos-m", "qos-l") class NetworkManager(BaseNetworkClient): Loading @@ -46,7 +45,7 @@ class NetworkManager(BaseNetworkClient): super().__init__() self.base_url = base_url self.scs_as_id = scs_as_id self._qos_profiles = tuple(supportedQos if qos_profiles is None else qos_profiles) self._qos_profiles = None if qos_profiles is None else tuple(qos_profiles) self.identity_service_url = f"{base_url.rstrip('/')}/3gpp-ueid/v1" log.info( f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}" Loading @@ -67,10 +66,13 @@ class NetworkManager(BaseNetworkClient): raises: ValidationError: If the session information does not meet core-specific requirements. """ if session_info.qosProfile.root not in self._qos_profiles: if ( self._qos_profiles is not None and session_info.qosProfile.root not in self._qos_profiles ): raise OaiValidationError( f"QoS profile {session_info.qosProfile} not supported by OAI, " f"supported profiles are {list(self._qos_profiles)}" f"QoS profile {session_info.qosProfile} not allowed by configured qos_profiles, " f"allowed profiles are {list(self._qos_profiles)}" ) if session_info.device is None or session_info.device.ipv4Address is None: Loading
tests/contract/test_network_qod.py +1 −1 Changes for tests/contract/test_network_qod.py: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -110,7 +110,7 @@ def test_qod_behind_nat_targets_the_private_address(nef_bodies): @pytest.mark.parametrize( "kind, request_kwargs, reason", [ ("oai", {"profile": "QOS_PREMIUM"}, "not supported by OAI"), ("oai", {"profile": "qos e"}, "qosProfile string_pattern_mismatch"), ("oai", {"ipv4": None, "ipv6": "2001:db8::1"}, "OAI requires UE IPv4 Address"), ("open5gs", {"profile": "qos-xx"}, "Open5Gs only supports"), ("open5gs", {"profile": "qos e"}, "qosProfile string_pattern_mismatch"), Loading
tests/contract/test_oai_qod_profiles.py +35 −11 Changes for tests/contract/test_oai_qod_profiles.py: 35 added lines, 11 removed lines. Original line number Diff line number Diff line """Provider profile names are runtime configuration of one OAI control path.""" """Provider profile names pass through unless a control path explicitly restricts them.""" import json from concurrent.futures import ThreadPoolExecutor Loading Loading @@ -64,8 +64,21 @@ def request(profile, ref="nef"): ) @pytest.mark.parametrize("profile", ["qos-e", "qos-s", "qos-m", "qos-l"]) def test_omitted_profiles_keep_existing_release_support(nef, profile): @pytest.mark.parametrize( "profile", [ "qos-e", "qos-s", "qos-m", "qos-l", "qos2", "qos3", "qos4", "provider.premium_5", "18dcdddc-98d5-44d8-a5ca-4ea96ef1d706", ], ) def test_omitted_profiles_forward_valid_provider_names_unchanged(nef, profile): assert adapter().activate(request(profile)).state is LifecycleState.ACTIVE assert nef.calls[0][1]["qosReference"] == profile Loading @@ -78,16 +91,16 @@ def test_configured_provider_names_reach_nef_without_aliases(nef, profile): assert nef.calls[0][1]["notificationDestination"] == NOTIFY["notification_url"] @pytest.mark.parametrize("profile", ["qos2", "qos3", "qos4"]) def test_unconfigured_names_still_fail_before_http(nef, profile): with pytest.raises(ValidationError, match="not supported by OAI"): @pytest.mark.parametrize("profile", ["ab", "qos 2", "q" * 257]) def test_omitted_profiles_still_reject_malformed_names_before_http(nef, profile): with pytest.raises(ValidationError): adapter().activate(request(profile)) assert not nef.calls @pytest.mark.parametrize("profile", ["qos-s", "qos-m", "qos-l", "unknown-profile"]) def test_explicit_configuration_replaces_default_support(nef, profile): with pytest.raises(ValidationError, match="not supported by OAI"): def test_explicit_configuration_restricts_profiles_before_http(nef, profile): with pytest.raises(ValidationError, match="configured qos_profiles"): adapter(qos_profiles=["qos-e", "qos2"]).activate(request(profile)) assert not nef.calls Loading Loading @@ -121,15 +134,15 @@ def test_invalid_profile_configuration_fails_before_client_construction(monkeypa assert clients == [] def test_profile_configuration_is_snapshotted_and_does_not_change_defaults(nef): def test_profile_configuration_is_snapshotted_without_restricting_other_clients(nef): profiles = ["qos2"] configured = adapter(qos_profiles=profiles) profiles[:] = ["qos-injected"] assert configured.activate(request("qos2")).state is LifecycleState.ACTIVE with pytest.raises(ValidationError): configured.activate(request("qos-injected")) assert adapter().activate(request("qos-s")).state is LifecycleState.ACTIVE assert [body["qosReference"] for _, body in nef.calls] == ["qos2", "qos-s"] assert adapter().activate(request("provider.other")).state is LifecycleState.ACTIVE assert [body["qosReference"] for _, body in nef.calls] == ["qos2", "provider.other"] def test_different_control_paths_validate_and_execute_concurrently_without_cross_talk(nef): Loading Loading @@ -188,3 +201,14 @@ def test_configured_profile_can_be_refused_by_nef_with_safe_details(nef): assert len(nef.calls) == 1 assert caught.value.details == {"backend_status": 400, "reason": "bad_request"} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details) @pytest.mark.parametrize("status, reason", [(400, "bad_request"), (503, "service_unavailable")]) def test_unrestricted_profile_refusal_is_reported_from_nef(nef, status, reason): nef.status = status with pytest.raises(DomainExecutionFailed) as caught: adapter().activate(request("provider.unknown")) assert len(nef.calls) == 1 assert nef.calls[0][1]["qosReference"] == "provider.unknown" assert caught.value.details == {"backend_status": status, "reason": reason} assert "PRIVATE_NEF_BODY" not in str(caught.value) + str(caught.value.details)