diff --git a/README.md b/README.md index fe2b68d..8b55b19 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,50 @@ client.voice_in_trunks().update(created) client.voice_in_trunks().delete(created.id) ``` +#### SIP Registration (2026-04-16) + +A Voice In Trunk can also authenticate inbound calls via SIP registration +credentials generated by DIDWW. The SDK auto-cascades the dependent fields +the server requires: + +* setting `enabled_sip_registration = True` clears any previously-set + `host` / `port` (the server rejects them with 422 otherwise); +* setting `host` to a non-`None` value flips `enabled_sip_registration` + back to `False` and forces `use_did_in_ruri = False` so the server + accepts the disable PATCH. + +The server generates `incoming_auth_username` and `incoming_auth_password` +and surfaces them in the response when SIP registration is enabled. + +```python +sip = SipConfiguration() +sip.enabled_sip_registration = True +sip.use_did_in_ruri = True +sip.cnam_lookup = True + +trunk = VoiceInTrunk() +trunk.name = "Office (registered)" +trunk.configuration = sip +created = client.voice_in_trunks().create(trunk).data + +# created.configuration.incoming_auth_username — server-generated +# created.configuration.incoming_auth_password — server-generated +``` + +To disable SIP registration on an existing trunk, just set `host` — the +cascade flips `enabled_sip_registration` to `False` and `use_did_in_ruri` +to `False` automatically: + +```python +disable = SipConfiguration() +disable.host = "sip.example.com" + +trunk = VoiceInTrunk() +trunk.id = "trunk-uuid" +trunk.configuration = disable +client.voice_in_trunks().update(trunk) +``` + ### Voice In Trunk Groups ```python diff --git a/examples/README.md b/examples/README.md index 08b072a..86ae736 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,7 @@ DIDWW_API_KEY=your_api_key python examples/balance.py | [`trunks.py`](trunks.py) | Creates SIP and PSTN trunks, prints details, then deletes them. | | [`regions.py`](regions.py) | Lists regions, filters by country, and fetches a specific region. | | [`voice_in_trunks.py`](voice_in_trunks.py) | Lists voice in trunks with their configurations and POP details. | +| [`voice_in_trunk_sip_registration.py`](voice_in_trunk_sip_registration.py) | End-to-end SIP registration flow: create with `enabled_sip_registration=True`, rename, disable by setting `host`, re-enable by toggling the flag. The SDK keeps the dependent fields (`host`, `port`, `use_did_in_ruri`) aligned with the server's validation rules automatically. | | [`orders.py`](orders.py) | Lists orders, creates a DID order, and cancels it. | | [`orders_available_dids.py`](orders_available_dids.py) | Orders a specific available DID using included DID group SKU. | | [`orders_reservation_dids.py`](orders_reservation_dids.py) | Reserves a DID and then places an order from that reservation. | diff --git a/examples/voice_in_trunk_sip_registration.py b/examples/voice_in_trunk_sip_registration.py new file mode 100644 index 0000000..74cf93d --- /dev/null +++ b/examples/voice_in_trunk_sip_registration.py @@ -0,0 +1,81 @@ +"""End-to-end SIP registration flow on /voice_in_trunks (API 2026-04-16): +create with sip_registration enabled → rename → disable by setting `host` +→ re-enable by toggling the flag. Demonstrates how the SDK keeps the +dependent fields (`host`, `port`, `use_did_in_ruri`) aligned with the +server's validation rules. The sandbox trunk is left in place after the +script completes. + +Usage: DIDWW_API_KEY=xxx python examples/voice_in_trunk_sip_registration.py +""" +import time +from client_factory import create_client +from didww.resources.configuration.sip import SipConfiguration +from didww.resources.voice_in_trunk import VoiceInTrunk +from didww.enums import CliFormat, Codec, TransportProtocol + +client = create_client() + +print("=== Python SDK — SIP registration flow ===") + +# 1) Create with sip_registration enabled. +print("\n[1/4] Create with sip_registration enabled...") +sip = SipConfiguration() +sip.enabled_sip_registration = True +sip.use_did_in_ruri = True +sip.cnam_lookup = False +sip.codec_ids = [Codec.PCMU, Codec.PCMA] +sip.transport_protocol_id = TransportProtocol.UDP + +trunk = VoiceInTrunk() +trunk.name = f"py-sip-registration-{int(time.time())}" +trunk.priority = 1 +trunk.weight = 100 +trunk.cli_format = CliFormat.E164 +trunk.ringing_timeout = 30 +trunk.configuration = sip + +created = client.voice_in_trunks().create(trunk).data +trunk_id = created.id +print(f" id={trunk_id}") +print(f" incoming_auth_username={created.configuration.incoming_auth_username!r}") +print(f" incoming_auth_password={created.configuration.incoming_auth_password!r}") + +# 2) Rename — single-field PATCH. +print("\n[2/4] Rename trunk...") +created.name = f"py-renamed-{int(time.time())}" +client.voice_in_trunks().update(created) +print(f" name={created.name}") + +# 3) Disable sip_registration by setting `host`. +print("\n[3/4] Disable by setting host...") +disable = SipConfiguration() +disable.host = "203.0.113.10" +update = VoiceInTrunk() +update.id = trunk_id +update.configuration = disable +client.voice_in_trunks().update(update) +fresh = client.voice_in_trunks().find(trunk_id).data +print(f" enabled_sip_registration={fresh.configuration.enabled_sip_registration!r}") +print(f" use_did_in_ruri={fresh.configuration.use_did_in_ruri!r}") +print(f" host={fresh.configuration.host!r}") +print(f" incoming_auth_username={fresh.configuration.incoming_auth_username!r}") + +# 4) Re-enable sip_registration. The SDK should send host=None / port=None on +# the wire so the server clears the values it had persisted. +print("\n[4/4] Re-enable by toggling enabled_sip_registration...") +re_enable = SipConfiguration() +re_enable.enabled_sip_registration = True +re_enable.use_did_in_ruri = True +update = VoiceInTrunk() +update.id = trunk_id +update.configuration = re_enable +try: + client.voice_in_trunks().update(update) + fresh = client.voice_in_trunks().find(trunk_id).data + print(f" enabled_sip_registration={fresh.configuration.enabled_sip_registration!r}") + print(f" host={fresh.configuration.host!r}") + print(f" incoming_auth_username={fresh.configuration.incoming_auth_username!r}") + print(f"\n=== PASS — trunk {trunk_id} left in sandbox ===") +except Exception as e: + print(f" ✗ FAIL: {e}") + print(f"\n=== FAIL at re-enable — trunk {trunk_id} left in sandbox ===") diff --git a/pyproject.toml b/pyproject.toml index 5703f87..4d06c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "didww" -version = "3.0.0" +version = "3.1.0" description = "Python SDK for DIDWW API v3" readme = "README.md" requires-python = ">=3.9" diff --git a/src/didww/enums.py b/src/didww/enums.py index 7640ae6..236f551 100644 --- a/src/didww/enums.py +++ b/src/didww/enums.py @@ -132,6 +132,21 @@ class DiversionRelayPolicy(str, Enum): TEL = "tel" +class DiversionInjectMode(str, Enum): + """Diversion header injection mode for SIP INVITE. (API 2026-04-16)""" + NONE = "none" + DID_NUMBER = "did_number" + + +class NetworkProtocolPriority(str, Enum): + """SIP network protocol priority. (API 2026-04-16)""" + FORCE_IPV4 = "force_ipv4" + FORCE_IPV6 = "force_ipv6" + ANY = "any" + PREFER_IPV4 = "prefer_ipv4" + PREFER_IPV6 = "prefer_ipv6" + + class StirShakenMode(str, Enum): DISABLED = "disabled" ORIGINAL = "original" diff --git a/src/didww/mixins.py b/src/didww/mixins.py new file mode 100644 index 0000000..17a0313 --- /dev/null +++ b/src/didww/mixins.py @@ -0,0 +1,25 @@ +"""Reusable mixins shared across resource hierarchies.""" + + +class RedactsSensitiveAttributes: + """Provides a `__repr__` that masks values of attribute keys listed in + ``_sensitive_attrs`` so credentials never leak through default + ``print()`` / logging / unhandled exception output. + + The wire payload is unaffected — the host class's serializer (e.g. + ``to_jsonapi``) still emits the real values. + + Used by ``TrunkConfiguration`` and ``AuthenticationMethod``, which + both expose an ``_attributes`` dict and a ``_sensitive_attrs`` + frozenset that subclasses extend. + """ + + _sensitive_attrs = frozenset() + + def __repr__(self): + parts = [] + for key, value in self._attributes.items(): + if key in self._sensitive_attrs and value is not None: + value = "[FILTERED]" + parts.append(f"{key}={value!r}") + return f"{self.__class__.__name__}({', '.join(parts)})" diff --git a/src/didww/resources/authentication_method.py b/src/didww/resources/authentication_method.py index e15fb8a..2af571d 100644 --- a/src/didww/resources/authentication_method.py +++ b/src/didww/resources/authentication_method.py @@ -1,7 +1,13 @@ -class AuthenticationMethod: +from didww.mixins import RedactsSensitiveAttributes + + +class AuthenticationMethod(RedactsSensitiveAttributes): """Base class for polymorphic VoiceOutTrunk authentication methods.""" _type = None _type_map = {} + # Subclasses extend `_sensitive_attrs` (inherited from + # RedactsSensitiveAttributes) to mark credential-bearing keys; the + # mixin's `__repr__` masks their values with `[FILTERED]`. def __init__(self, **kwargs): self._attributes = kwargs @@ -61,6 +67,7 @@ class IpOnlyAuthenticationMethod(AuthenticationMethod): class CredentialsAndIpAuthenticationMethod(AuthenticationMethod): _type = "credentials_and_ip" + _sensitive_attrs = frozenset({"username", "password"}) allowed_sip_ips = _plain("allowed_sip_ips") tech_prefix = _plain("tech_prefix") diff --git a/src/didww/resources/configuration/base.py b/src/didww/resources/configuration/base.py index 15e5ef8..1b322d3 100644 --- a/src/didww/resources/configuration/base.py +++ b/src/didww/resources/configuration/base.py @@ -1,6 +1,17 @@ -class TrunkConfiguration: +from didww.mixins import RedactsSensitiveAttributes + + +class TrunkConfiguration(RedactsSensitiveAttributes): _type = None _type_map = {} + # Set of attribute keys that are deserialized from server responses + # but MUST NOT be sent in POST/PATCH request bodies. Subclasses should + # extend this set to mark server-generated read-only fields. The server + # rejects writes to these keys with 400 Param not allowed. + _read_only_attrs = frozenset() + # Subclasses extend `_sensitive_attrs` (inherited from + # RedactsSensitiveAttributes) to mark credential-bearing keys; the + # mixin's `__repr__` masks their values with `[FILTERED]`. def __init__(self, attributes=None): self._attributes = attributes or {} @@ -12,9 +23,14 @@ def _set_attr(self, key, value): self._attributes[key] = value def to_jsonapi(self): + attrs = { + k: v + for k, v in self._attributes.items() + if k not in self._read_only_attrs + } return { "type": self._type, - "attributes": dict(self._attributes), + "attributes": attrs, } @classmethod diff --git a/src/didww/resources/configuration/sip.py b/src/didww/resources/configuration/sip.py index 9e6dd13..af9d0b5 100644 --- a/src/didww/resources/configuration/sip.py +++ b/src/didww/resources/configuration/sip.py @@ -1,8 +1,10 @@ from didww.resources.configuration.base import TrunkConfiguration from didww.enums import ( Codec, + DiversionInjectMode, DiversionRelayPolicy, MediaEncryptionMode, + NetworkProtocolPriority, ReroutingDisconnectCode, RxDtmfFormat, SstRefreshMethod, @@ -36,14 +38,41 @@ def _enum_list(key, cls): class SipConfiguration(TrunkConfiguration): _type = "sip_configurations" + # Server-generated SIP registration credentials. The API rejects any + # write attempt with 400 Param not allowed, so they are deserialized + # from responses (readable via the property) but stripped from + # POST/PATCH request bodies. (API 2026-04-16) + _read_only_attrs = frozenset({ + "incoming_auth_username", + "incoming_auth_password", + }) + _sensitive_attrs = frozenset({ + "auth_password", + "incoming_auth_username", + "incoming_auth_password", + }) username = _plain("username") - host = _plain("host") port = _plain("port") auth_enabled = _plain("auth_enabled") resolve_ruri = _plain("resolve_ruri") auth_user = _plain("auth_user") auth_password = _plain("auth_password") + + # `host` cascades dependent fields whose constraints are server-enforced + # (API 2026-04-16): setting host to a non-null value implies + # enabled_sip_registration = False and use_did_in_ruri = False. + @property + def host(self): + return self._attr("host") + + @host.setter + def host(self, value): + if value is not None: + self._set_attr("enabled_sip_registration", False) + self._set_attr("use_did_in_ruri", False) + self._set_attr("host", value) + auth_from_user = _plain("auth_from_user") auth_from_domain = _plain("auth_from_domain") sst_enabled = _plain("sst_enabled") @@ -59,16 +88,57 @@ class SipConfiguration(TrunkConfiguration): max_30x_redirects = _plain("max_30x_redirects") allowed_rtp_ips = _plain("allowed_rtp_ips") + # API 2026-04-16 SIP registration + protocol attributes. + # + # Server-side validation rules for `enabled_sip_registration`: + # * When True, the trunk's `host` and `port` must be left blank + # (server returns 422 otherwise). + # * When disabling sip registration on an existing trunk, the same + # PATCH must also set `host` to a non-blank value and + # `use_did_in_ruri` to False, or the server returns 422. + use_did_in_ruri = _plain("use_did_in_ruri") + cnam_lookup = _plain("cnam_lookup") + + # `enabled_sip_registration` cascades dependent fields whose constraints + # are server-enforced (API 2026-04-16): + # * True -> host = None, port = None (always — a PATCH against an + # existing trunk that has host/port persisted on the + # server side MUST explicitly nullify them or the server + # rejects with 422). + # * False -> use_did_in_ruri = False (server requires it disabled + # whenever sip_registration is disabled). + @property + def enabled_sip_registration(self): + return self._attr("enabled_sip_registration") + + @enabled_sip_registration.setter + def enabled_sip_registration(self, value): + if value is True: + self._set_attr("host", None) + self._set_attr("port", None) + elif value is False: + self._set_attr("use_did_in_ruri", False) + self._set_attr("enabled_sip_registration", value) + + + # Read-only: server-generated when SIP registration is enabled. + # Deserialized from responses but stripped on serialize via + # `_read_only_attrs` above (API rejects writes with 400 Param not allowed). + incoming_auth_username = property(lambda self: self._attr("incoming_auth_username")) + incoming_auth_password = property(lambda self: self._attr("incoming_auth_password")) + codec_ids = _enum_list("codec_ids", Codec) rerouting_disconnect_code_ids = _enum_list("rerouting_disconnect_code_ids", ReroutingDisconnectCode) - transport_protocol_id = _enum("transport_protocol_id", TransportProtocol) - rx_dtmf_format_id = _enum("rx_dtmf_format_id", RxDtmfFormat) - tx_dtmf_format_id = _enum("tx_dtmf_format_id", TxDtmfFormat) - sst_refresh_method_id = _enum("sst_refresh_method_id", SstRefreshMethod) - media_encryption_mode = _enum("media_encryption_mode", MediaEncryptionMode) - stir_shaken_mode = _enum("stir_shaken_mode", StirShakenMode) - diversion_relay_policy = _enum("diversion_relay_policy", DiversionRelayPolicy) + transport_protocol_id = _enum("transport_protocol_id", TransportProtocol) + rx_dtmf_format_id = _enum("rx_dtmf_format_id", RxDtmfFormat) + tx_dtmf_format_id = _enum("tx_dtmf_format_id", TxDtmfFormat) + sst_refresh_method_id = _enum("sst_refresh_method_id", SstRefreshMethod) + media_encryption_mode = _enum("media_encryption_mode", MediaEncryptionMode) + stir_shaken_mode = _enum("stir_shaken_mode", StirShakenMode) + diversion_relay_policy = _enum("diversion_relay_policy", DiversionRelayPolicy) + diversion_inject_mode = _enum("diversion_inject_mode", DiversionInjectMode) + network_protocol_priority = _enum("network_protocol_priority", NetworkProtocolPriority) TrunkConfiguration.register("sip_configurations", SipConfiguration) diff --git a/tests/fixtures/voice_in_trunks/create_sip_with_rerouting.yaml b/tests/fixtures/voice_in_trunks/create_sip_with_rerouting.yaml index 4fe9b07..2a11ac2 100644 --- a/tests/fixtures/voice_in_trunks/create_sip_with_rerouting.yaml +++ b/tests/fixtures/voice_in_trunks/create_sip_with_rerouting.yaml @@ -1,7 +1,6 @@ interactions: - request: - body: '{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"username":"username","host":"203.0.113.110","sst_refresh_method_id":1,"port":5060,"codec_ids":[9,10,8,7,6],"rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"]}},"name":"hello, - test sip trunk"}}}' + body: '{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"username":"username","enabled_sip_registration":false,"use_did_in_ruri":false,"host":"203.0.113.110","sst_refresh_method_id":1,"port":5060,"codec_ids":[9,10,8,7,6],"rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"],"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","network_protocol_priority":"force_ipv4","cnam_lookup":true}},"name":"hello, test sip trunk"}}}' headers: Accept: - application/vnd.api+json @@ -13,49 +12,7 @@ interactions: uri: https://sandbox-api.didww.com/v3/voice_in_trunks response: body: - string: "{\n \"data\": {\n \"id\": \"a80006b6-4183-4865-8b99-7ebbd359a762\"\ - ,\n \"type\": \"voice_in_trunks\",\n \"attributes\": {\n \"priority\"\ - : 1,\n \"capacity_limit\": null,\n \"weight\": 65535,\n \"\ - name\": \"hello, test sip trunk\",\n \"cli_format\": \"e164\",\n \ - \ \"cli_prefix\": null,\n \"description\": null,\n \"ringing_timeout\"\ - : null,\n \"configuration\": {\n \"type\": \"sip_configurations\"\ - ,\n \"attributes\": {\n \"username\": \"username\",\n \ - \ \"host\": \"203.0.113.110\",\n \"port\": 5060,\n \ - \ \"codec_ids\": [\n 9,\n 10,\n 8,\n \ - \ 7,\n 6\n ],\n \"rx_dtmf_format_id\"\ - : 1,\n \"tx_dtmf_format_id\": 1,\n \"resolve_ruri\": false,\n\ - \ \"auth_enabled\": false,\n \"auth_user\": null,\n \ - \ \"auth_password\": null,\n \"auth_from_user\": null,\n \ - \ \"auth_from_domain\": null,\n \"sst_enabled\": false,\n \ - \ \"sst_min_timer\": 600,\n \"sst_max_timer\": 900,\n \ - \ \"sst_accept_501\": true,\n \"sip_timer_b\": 8000,\n \ - \ \"dns_srv_failover_timer\": 2000,\n \"rtp_ping\": false,\n\ - \ \"rtp_timeout\": 30,\n \"force_symmetric_rtp\": false,\n\ - \ \"symmetric_rtp_ignore_rtcp\": false,\n \"rerouting_disconnect_code_ids\"\ - : [\n 56,\n 58,\n 59,\n 60,\n\ - \ 64,\n 65,\n 66,\n 67,\n \ - \ 68,\n 69,\n 70,\n 71,\n \ - \ 72,\n 73,\n 74,\n 75,\n 76,\n\ - \ 77,\n 78,\n 79,\n 80,\n \ - \ 81,\n 82,\n 83,\n 84,\n \ - \ 86,\n 87,\n 88,\n 89,\n 90,\n\ - \ 91,\n 92,\n 96,\n 97,\n \ - \ 98,\n 99,\n 101,\n 102,\n \ - \ 103,\n 104,\n 105,\n 106,\n \ - \ 107,\n 108,\n 1505\n ],\n \"\ - sst_session_expires\": null,\n \"sst_refresh_method_id\": 1,\n \ - \ \"transport_protocol_id\": 1,\n \"max_transfers\": 0,\n\ - \ \"max_30x_redirects\": 0,\n \"media_encryption_mode\"\ - : \"zrtp\",\n \"stir_shaken_mode\": \"pai\",\n \"allowed_rtp_ips\"\ - : [\n \"203.0.113.1\"\n ]\n }\n },\n \"\ - created_at\": \"2018-12-28T17:37:48.010Z\"\n },\n \"relationships\"\ - : {\n \"voice_in_trunk_group\": {\n \"links\": {\n \"\ - self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/voice_in_trunk_group\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/voice_in_trunk_group\"\ - \n }\n },\n \"pop\": {\n \"links\": {\n \"\ - self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/pop\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/pop\"\ - \n }\n }\n }\n }\n}" + string: '{"data":{"id":"a80006b6-4183-4865-8b99-7ebbd359a762","type":"voice_in_trunks","attributes":{"priority":1,"capacity_limit":null,"weight":65535,"name":"hello, test sip trunk","cli_format":"e164","cli_prefix":null,"description":null,"ringing_timeout":null,"configuration":{"type":"sip_configurations","attributes":{"username":"username","enabled_sip_registration":false,"use_did_in_ruri":false,"host":"203.0.113.110","port":5060,"codec_ids":[9,10,8,7,6],"rx_dtmf_format_id":1,"tx_dtmf_format_id":1,"resolve_ruri":false,"auth_enabled":false,"auth_user":null,"auth_password":null,"auth_from_user":null,"auth_from_domain":null,"sst_enabled":false,"sst_min_timer":600,"sst_max_timer":900,"sst_accept_501":true,"sip_timer_b":8000,"dns_srv_failover_timer":2000,"rtp_ping":false,"rtp_timeout":30,"force_symmetric_rtp":false,"symmetric_rtp_ignore_rtcp":false,"rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"sst_session_expires":null,"sst_refresh_method_id":1,"transport_protocol_id":1,"max_transfers":0,"max_30x_redirects":0,"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"],"network_protocol_priority":"force_ipv4","enabled_sip_registration":false,"use_did_in_ruri":false,"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","cnam_lookup":true,"incoming_auth_username":null,"incoming_auth_password":null}},"created_at":"2018-12-28T17:37:48.010Z"},"relationships":{"voice_in_trunk_group":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/voice_in_trunk_group","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/voice_in_trunk_group"}},"pop":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/pop","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/pop"}}}}}' headers: Content-Type: - application/vnd.api+json diff --git a/tests/fixtures/voice_in_trunks/create_with_sip_registration.yaml b/tests/fixtures/voice_in_trunks/create_with_sip_registration.yaml new file mode 100644 index 0000000..5a3c813 --- /dev/null +++ b/tests/fixtures/voice_in_trunks/create_with_sip_registration.yaml @@ -0,0 +1,24 @@ +interactions: +- request: + body: '{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"host":null,"port":null,"enabled_sip_registration":true,"use_did_in_ruri":true,"cnam_lookup":true,"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","network_protocol_priority":"prefer_ipv4"}},"name":"sip-registration","priority":1,"weight":100,"cli_format":"e164","ringing_timeout":30}}}' + headers: + Accept: + - application/vnd.api+json + Api-Key: + - test-api-key + Content-Type: + - application/vnd.api+json + X-DIDWW-API-Version: + - '2026-04-16' + method: POST + uri: https://sandbox-api.didww.com/v3/voice_in_trunks + response: + body: + string: '{"data":{"id":"f1c5d834-1d1f-49cc-8e88-3f73c0a35b31","type":"voice_in_trunks","attributes":{"name":"sip-registration","priority":1,"weight":100,"cli_format":"e164","ringing_timeout":30,"configuration":{"type":"sip_configurations","attributes":{"username":null,"host":null,"port":null,"enabled_sip_registration":true,"use_did_in_ruri":true,"cnam_lookup":true,"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","network_protocol_priority":"prefer_ipv4","incoming_auth_username":"7e3IUOSKtroNLfM6","incoming_auth_password":"31kSndbuugzPEu8M"}}}}}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 201 + message: Created +version: 1 diff --git a/tests/fixtures/voice_in_trunks/disable_sip_registration.yaml b/tests/fixtures/voice_in_trunks/disable_sip_registration.yaml new file mode 100644 index 0000000..400a8e2 --- /dev/null +++ b/tests/fixtures/voice_in_trunks/disable_sip_registration.yaml @@ -0,0 +1,24 @@ +interactions: +- request: + body: '{"data":{"type":"voice_in_trunks","id":"57a939dd-1600-41a6-80b1-f624e22a1f4c","attributes":{"configuration":{"type":"sip_configurations","attributes":{"enabled_sip_registration":false,"use_did_in_ruri":false,"host":"203.0.113.10"}}}}}' + headers: + Accept: + - application/vnd.api+json + Api-Key: + - test-api-key + Content-Type: + - application/vnd.api+json + X-DIDWW-API-Version: + - '2026-04-16' + method: PATCH + uri: https://sandbox-api.didww.com/v3/voice_in_trunks/57a939dd-1600-41a6-80b1-f624e22a1f4c + response: + body: + string: '{"data":{"id":"57a939dd-1600-41a6-80b1-f624e22a1f4c","type":"voice_in_trunks","attributes":{"name":"Office","priority":1,"weight":65535,"cli_format":"e164","configuration":{"type":"sip_configurations","attributes":{"username":null,"host":"203.0.113.10","port":null,"enabled_sip_registration":false,"use_did_in_ruri":false,"cnam_lookup":true,"diversion_relay_policy":"none","diversion_inject_mode":"did_number","network_protocol_priority":"prefer_ipv4","incoming_auth_username":null,"incoming_auth_password":null}}}}}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 200 + message: OK +version: 1 diff --git a/tests/fixtures/voice_in_trunks/list.yaml b/tests/fixtures/voice_in_trunks/list.yaml index 556f8ac..3f056aa 100644 --- a/tests/fixtures/voice_in_trunks/list.yaml +++ b/tests/fixtures/voice_in_trunks/list.yaml @@ -12,70 +12,7 @@ interactions: uri: https://sandbox-api.didww.com/v3/voice_in_trunks response: body: - string: "{\n \"data\": [\n {\n \"id\": \"2b4b1fcf-fe6a-4de9-8a58-7df46820ba13\"\ - ,\n \"type\": \"voice_in_trunks\",\n \"attributes\": {\n \ - \ \"priority\": 1,\n \"capacity_limit\": null,\n \"weight\"\ - : 65535,\n \"name\": \"sample trunk pstn\",\n \"cli_format\"\ - : \"e164\",\n \"cli_prefix\": \"\",\n \"description\": \"\"\ - ,\n \"ringing_timeout\": null,\n \"configuration\": {\n \ - \ \"type\": \"pstn_configurations\",\n \"attributes\": {\n \ - \ \"dst\": \"442080995011\"\n }\n },\n \"\ - created_at\": \"2018-12-28T15:03:48.751Z\"\n },\n \"relationships\"\ - : {\n \"voice_in_trunk_group\": {\n \"links\": {\n \ - \ \"self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/relationships/voice_in_trunk_group\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/voice_in_trunk_group\"\ - \n },\n \"data\": null\n },\n \"pop\": {\n\ - \ \"links\": {\n \"self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/relationships/pop\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/pop\"\ - \n },\n \"data\": null\n }\n }\n },\n \ - \ {\n \"id\": \"24cec518-da51-4274-9a4b-1aad910b996f\",\n \"type\"\ - : \"voice_in_trunks\",\n \"attributes\": {\n \"priority\": 1,\n\ - \ \"capacity_limit\": null,\n \"weight\": 65535,\n \"\ - name\": \"Sip trunk sample\",\n \"cli_format\": \"local\",\n \ - \ \"cli_prefix\": \"\",\n \"description\": \"\",\n \"ringing_timeout\"\ - : null,\n \"configuration\": {\n \"type\": \"sip_configurations\"\ - ,\n \"attributes\": {\n \"username\": \"username\",\n\ - \ \"host\": \"203.0.113.78\",\n \"port\": 8060,\n \ - \ \"codec_ids\": [\n 9,\n 10,\n \ - \ 8\n ],\n \"rx_dtmf_format_id\": 1,\n \ - \ \"tx_dtmf_format_id\": 1,\n \"resolve_ruri\": true,\n \ - \ \"auth_enabled\": true,\n \"auth_user\": \"auth_user\"\ - ,\n \"auth_password\": \"auth_password\",\n \"auth_from_user\"\ - : \"\",\n \"auth_from_domain\": \"\",\n \"sst_enabled\"\ - : false,\n \"sst_min_timer\": 600,\n \"sst_max_timer\"\ - : 900,\n \"sst_accept_501\": true,\n \"sip_timer_b\"\ - : 8000,\n \"dns_srv_failover_timer\": 2000,\n \"rtp_ping\"\ - : false,\n \"rtp_timeout\": 30,\n \"force_symmetric_rtp\"\ - : false,\n \"symmetric_rtp_ignore_rtcp\": false,\n \"\ - rerouting_disconnect_code_ids\": null,\n \"sst_session_expires\"\ - : null,\n \"sst_refresh_method_id\": 1,\n \"transport_protocol_id\"\ - : 1,\n \"max_transfers\": 2,\n \"max_30x_redirects\"\ - : 5,\n \"media_encryption_mode\": \"disabled\",\n \"\ - stir_shaken_mode\": \"disabled\",\n \"allowed_rtp_ips\": null\n\ - \ }\n },\n \"created_at\": \"2018-12-28T15:00:43.522Z\"\ - \n },\n \"relationships\": {\n \"voice_in_trunk_group\":\ - \ {\n \"links\": {\n \"self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/relationships/voice_in_trunk_group\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/voice_in_trunk_group\"\ - \n },\n \"data\": null\n },\n \"pop\": {\n\ - \ \"links\": {\n \"self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/relationships/pop\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/pop\"\ - \n },\n \"data\": {\n \"type\": \"pops\",\n \ - \ \"id\": \"ba7ccbef-82ac-4372-9391-eac90d5c9479\"\n }\n\ - \ }\n }\n }\n ],\n \"included\": [\n {\n \"id\":\ - \ \"837c5764-a6c3-456f-aa37-71fc8f8ca07b\",\n \"type\": \"voice_in_trunk_groups\"\ - ,\n \"attributes\": {\n \"created_at\": \"2017-11-14T15:07:25.571Z\"\ - ,\n \"name\": \"sample trunk group\",\n \"capacity_limit\":\ - \ null\n },\n \"relationships\": {\n \"voice_in_trunks\"\ - : {\n \"links\": {\n \"self\": \"https://sandbox-api.didww.com/v3/voice_in_trunk_groups/837c5764-a6c3-456f-aa37-71fc8f8ca07b/relationships/voice_in_trunks\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunk_groups/837c5764-a6c3-456f-aa37-71fc8f8ca07b/voice_in_trunks\"\ - \n }\n }\n },\n \"meta\": {\n \"trunks_count\"\ - : 2\n }\n },\n {\n \"id\": \"ba7ccbef-82ac-4372-9391-eac90d5c9479\"\ - ,\n \"type\": \"pops\",\n \"attributes\": {\n \"name\": \"\ - DE, FRA\"\n }\n }\n ],\n \"meta\": {\n \"total_records\": 68\n\ - \ },\n \"links\": {\n \"first\": \"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=1&page%5Bsize%5D=4&sort=-created_at\"\ - ,\n \"next\": \"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=2&page%5Bsize%5D=4&sort=-created_at\"\ - ,\n \"last\": \"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=18&page%5Bsize%5D=4&sort=-created_at\"\ - \n }\n}" + string: '{"data":[{"id":"2b4b1fcf-fe6a-4de9-8a58-7df46820ba13","type":"voice_in_trunks","attributes":{"priority":1,"capacity_limit":null,"weight":65535,"name":"sample trunk pstn","cli_format":"e164","cli_prefix":"","description":"","ringing_timeout":null,"configuration":{"type":"pstn_configurations","attributes":{"dst":"442080995011"}},"created_at":"2018-12-28T15:03:48.751Z"},"relationships":{"voice_in_trunk_group":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/relationships/voice_in_trunk_group","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/voice_in_trunk_group"},"data":null},"pop":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/relationships/pop","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/2b4b1fcf-fe6a-4de9-8a58-7df46820ba13/pop"},"data":null}}},{"id":"24cec518-da51-4274-9a4b-1aad910b996f","type":"voice_in_trunks","attributes":{"priority":1,"capacity_limit":null,"weight":65535,"name":"Sip trunk sample","cli_format":"local","cli_prefix":"","description":"","ringing_timeout":null,"configuration":{"type":"sip_configurations","attributes":{"username":"username","host":"203.0.113.78","port":8060,"codec_ids":[9,10,8],"rx_dtmf_format_id":1,"tx_dtmf_format_id":1,"resolve_ruri":true,"auth_enabled":true,"auth_user":"auth_user","auth_password":"auth_password","auth_from_user":"","auth_from_domain":"","sst_enabled":false,"sst_min_timer":600,"sst_max_timer":900,"sst_accept_501":true,"sip_timer_b":8000,"dns_srv_failover_timer":2000,"rtp_ping":false,"rtp_timeout":30,"force_symmetric_rtp":false,"symmetric_rtp_ignore_rtcp":false,"rerouting_disconnect_code_ids":null,"sst_session_expires":null,"sst_refresh_method_id":1,"transport_protocol_id":1,"max_transfers":2,"max_30x_redirects":5,"media_encryption_mode":"disabled","stir_shaken_mode":"disabled","allowed_rtp_ips":null,"enabled_sip_registration":false,"use_did_in_ruri":false,"network_protocol_priority":"any","diversion_inject_mode":"none","cnam_lookup":false,"incoming_auth_username":null,"incoming_auth_password":null,"diversion_relay_policy":"none"}},"created_at":"2018-12-28T15:00:43.522Z"},"relationships":{"voice_in_trunk_group":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/relationships/voice_in_trunk_group","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/voice_in_trunk_group"},"data":null},"pop":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/relationships/pop","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/24cec518-da51-4274-9a4b-1aad910b996f/pop"},"data":{"type":"pops","id":"ba7ccbef-82ac-4372-9391-eac90d5c9479"}}}}],"included":[{"id":"837c5764-a6c3-456f-aa37-71fc8f8ca07b","type":"voice_in_trunk_groups","attributes":{"created_at":"2017-11-14T15:07:25.571Z","name":"sample trunk group","capacity_limit":null},"relationships":{"voice_in_trunks":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunk_groups/837c5764-a6c3-456f-aa37-71fc8f8ca07b/relationships/voice_in_trunks","related":"https://sandbox-api.didww.com/v3/voice_in_trunk_groups/837c5764-a6c3-456f-aa37-71fc8f8ca07b/voice_in_trunks"}}},"meta":{"trunks_count":2}},{"id":"ba7ccbef-82ac-4372-9391-eac90d5c9479","type":"pops","attributes":{"name":"DE, FRA"}}],"meta":{"total_records":68},"links":{"first":"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=1&page%5Bsize%5D=4&sort=-created_at","next":"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=2&page%5Bsize%5D=4&sort=-created_at","last":"https://sandbox-api.didww.com/v3/voice_in_trunks?include=trunk_group%2Cpop&page%5Bnumber%5D=18&page%5Bsize%5D=4&sort=-created_at"}}' headers: Content-Type: - application/vnd.api+json diff --git a/tests/fixtures/voice_in_trunks/update_sip.yaml b/tests/fixtures/voice_in_trunks/update_sip.yaml index 8288494..8273faa 100644 --- a/tests/fixtures/voice_in_trunks/update_sip.yaml +++ b/tests/fixtures/voice_in_trunks/update_sip.yaml @@ -12,49 +12,7 @@ interactions: uri: https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762 response: body: - string: "{\n \"data\": {\n \"id\": \"a80006b6-4183-4865-8b99-7ebbd359a762\"\ - ,\n \"type\": \"voice_in_trunks\",\n \"attributes\": {\n \"priority\"\ - : 1,\n \"capacity_limit\": null,\n \"weight\": 65535,\n \"\ - name\": \"hello, updated test sip trunk\",\n \"cli_format\": \"e164\"\ - ,\n \"cli_prefix\": null,\n \"description\": \"just a description\"\ - ,\n \"ringing_timeout\": null,\n \"configuration\": {\n \"\ - type\": \"sip_configurations\",\n \"attributes\": {\n \"username\"\ - : \"new-username\",\n \"host\": \"203.0.113.110\",\n \"\ - port\": 5060,\n \"codec_ids\": [\n 9,\n 10,\n\ - \ 8,\n 7,\n 6\n ],\n \"\ - rx_dtmf_format_id\": 1,\n \"tx_dtmf_format_id\": 1,\n \"\ - resolve_ruri\": false,\n \"auth_enabled\": false,\n \"auth_user\"\ - : null,\n \"auth_password\": null,\n \"auth_from_user\"\ - : null,\n \"auth_from_domain\": null,\n \"sst_enabled\"\ - : false,\n \"sst_min_timer\": 600,\n \"sst_max_timer\":\ - \ 900,\n \"sst_accept_501\": true,\n \"sip_timer_b\": 8000,\n\ - \ \"dns_srv_failover_timer\": 2000,\n \"rtp_ping\": false,\n\ - \ \"rtp_timeout\": 30,\n \"force_symmetric_rtp\": false,\n\ - \ \"symmetric_rtp_ignore_rtcp\": false,\n \"rerouting_disconnect_code_ids\"\ - : [\n 56,\n 58,\n 59,\n 60,\n\ - \ 64,\n 65,\n 66,\n 67,\n \ - \ 68,\n 69,\n 70,\n 71,\n \ - \ 72,\n 73,\n 74,\n 75,\n 76,\n\ - \ 77,\n 78,\n 79,\n 80,\n \ - \ 81,\n 82,\n 83,\n 84,\n \ - \ 86,\n 87,\n 88,\n 89,\n 90,\n\ - \ 91,\n 92,\n 96,\n 97,\n \ - \ 98,\n 99,\n 101,\n 102,\n \ - \ 103,\n 104,\n 105,\n 106,\n \ - \ 107,\n 108,\n 1505\n ],\n \"\ - sst_session_expires\": null,\n \"sst_refresh_method_id\": 1,\n \ - \ \"transport_protocol_id\": 1,\n \"max_transfers\": 5,\n\ - \ \"max_30x_redirects\": 0,\n \"media_encryption_mode\"\ - : \"zrtp\",\n \"stir_shaken_mode\": \"pai\",\n \"allowed_rtp_ips\"\ - : [\n \"203.0.113.1\"\n ]\n }\n },\n \"\ - created_at\": \"2018-12-28T17:37:48.010Z\"\n },\n \"relationships\"\ - : {\n \"voice_in_trunk_group\": {\n \"links\": {\n \"\ - self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/voice_in_trunk_group\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/voice_in_trunk_group\"\ - \n }\n },\n \"pop\": {\n \"links\": {\n \"\ - self\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/pop\"\ - ,\n \"related\": \"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/pop\"\ - \n }\n }\n }\n }\n}" + string: '{"data":{"id":"a80006b6-4183-4865-8b99-7ebbd359a762","type":"voice_in_trunks","attributes":{"priority":1,"capacity_limit":null,"weight":65535,"name":"hello, updated test sip trunk","cli_format":"e164","cli_prefix":null,"description":"just a description","ringing_timeout":null,"configuration":{"type":"sip_configurations","attributes":{"username":"new-username","host":"203.0.113.110","port":5060,"codec_ids":[9,10,8,7,6],"rx_dtmf_format_id":1,"tx_dtmf_format_id":1,"resolve_ruri":false,"auth_enabled":false,"auth_user":null,"auth_password":null,"auth_from_user":null,"auth_from_domain":null,"sst_enabled":false,"sst_min_timer":600,"sst_max_timer":900,"sst_accept_501":true,"sip_timer_b":8000,"dns_srv_failover_timer":2000,"rtp_ping":false,"rtp_timeout":30,"force_symmetric_rtp":false,"symmetric_rtp_ignore_rtcp":false,"rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"sst_session_expires":null,"sst_refresh_method_id":1,"transport_protocol_id":1,"max_transfers":5,"max_30x_redirects":0,"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"],"network_protocol_priority":"force_ipv4","enabled_sip_registration":false,"use_did_in_ruri":false,"diversion_relay_policy":"none","diversion_inject_mode":"did_number","cnam_lookup":false,"incoming_auth_username":null,"incoming_auth_password":null}},"created_at":"2018-12-28T17:37:48.010Z"},"relationships":{"voice_in_trunk_group":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/voice_in_trunk_group","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/voice_in_trunk_group"}},"pop":{"links":{"self":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/relationships/pop","related":"https://sandbox-api.didww.com/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762/pop"}}}}}' headers: Content-Type: - application/vnd.api+json diff --git a/tests/resources/test_voice_in_trunk.py b/tests/resources/test_voice_in_trunk.py index 4c623fa..aac0f35 100644 --- a/tests/resources/test_voice_in_trunk.py +++ b/tests/resources/test_voice_in_trunk.py @@ -2,7 +2,10 @@ from didww.enums import ( CliFormat, Codec, + DiversionInjectMode, + DiversionRelayPolicy, MediaEncryptionMode, + NetworkProtocolPriority, ReroutingDisconnectCode, RxDtmfFormat, SstRefreshMethod, @@ -194,6 +197,15 @@ def test_create_sip_trunk_with_rerouting_disconnect_codes(self, client): config.media_encryption_mode = MediaEncryptionMode.ZRTP config.stir_shaken_mode = StirShakenMode.PAI config.allowed_rtp_ips = ["203.0.113.1"] + # API 2026-04-16 writable attributes + config.diversion_relay_policy = DiversionRelayPolicy.AS_IS + config.diversion_inject_mode = DiversionInjectMode.DID_NUMBER + config.network_protocol_priority = NetworkProtocolPriority.FORCE_IPV4 + config.cnam_lookup = True + # use_did_in_ruri must stay false unless enabled_sip_registration is + # also True (server returns 422 otherwise). Setting it here is + # redundant against the default but documents the field. + config.use_did_in_ruri = False trunk = VoiceInTrunk() trunk.name = "hello, test sip trunk" @@ -247,7 +259,237 @@ def test_update_voice_in_trunk_sip(self, client): assert sip_config.username == "new-username" assert sip_config.max_transfers == 5 + @my_vcr.use_cassette("voice_in_trunks/create_with_sip_registration.yaml") + def test_create_voice_in_trunk_with_sip_registration_returns_populated_credentials(self, client): + """End-to-end: when the SDK sends ``enabled_sip_registration: True`` the + server returns 201 with server-generated ``incoming_auth_username`` and + ``incoming_auth_password``. The SDK must surface those populated values + to the caller, not None.""" + config = SipConfiguration() + config.enabled_sip_registration = True + config.use_did_in_ruri = True + config.cnam_lookup = True + config.diversion_relay_policy = DiversionRelayPolicy.AS_IS + config.diversion_inject_mode = DiversionInjectMode.DID_NUMBER + config.network_protocol_priority = NetworkProtocolPriority.PREFER_IPV4 + + trunk = VoiceInTrunk() + trunk.name = "sip-registration" + trunk.priority = 1 + trunk.weight = 100 + trunk.cli_format = CliFormat.E164 + trunk.ringing_timeout = 30 + trunk.configuration = config + + response = client.voice_in_trunks().create(trunk) + created = response.data + sip_config = created.configuration + assert isinstance(sip_config, SipConfiguration) + assert sip_config.enabled_sip_registration is True + # Server-generated credentials are populated, not None. + assert sip_config.incoming_auth_username + assert sip_config.incoming_auth_password + + @my_vcr.use_cassette("voice_in_trunks/disable_sip_registration.yaml") + def test_disable_sip_registration_patch_serializes_all_three_fields(self, client): + """Disabling SIP registration is a multi-field PATCH because the + server returns 422 for any request that flips + ``enabled_sip_registration`` to false without simultaneously + providing a non-blank ``host`` and ``use_did_in_ruri: False``. + Lock those three fields in the same request body — the cassette + matches on body, so a future regression that drops one of them + fails the request match.""" + config = SipConfiguration() + config.enabled_sip_registration = False + config.use_did_in_ruri = False + config.host = "203.0.113.10" + + trunk = VoiceInTrunk() + trunk.id = "57a939dd-1600-41a6-80b1-f624e22a1f4c" + trunk.configuration = config + + response = client.voice_in_trunks().update(trunk) + sip_config = response.data.configuration + assert isinstance(sip_config, SipConfiguration) + assert sip_config.enabled_sip_registration is False + assert sip_config.use_did_in_ruri is False + assert sip_config.host == "203.0.113.10" + assert sip_config.incoming_auth_username is None + assert sip_config.incoming_auth_password is None + @my_vcr.use_cassette("voice_in_trunks/delete.yaml") def test_delete_voice_in_trunk(self, client): result = client.voice_in_trunks().delete("41b94706-325e-4704-a433-d65105758836") assert result is None + + # 2026-04-16 SIP-registration attributes (API 2026-04-16). + # + # Real wire shape captured from sandbox: when sip_registration is + # enabled, host/port/username come back as null and the API rejects + # any attempt to set them, so the test fixtures below intentionally + # omit them. + def test_sip_configuration_writable_registration_attributes_serialize(self): + from didww.enums import DiversionInjectMode, NetworkProtocolPriority + + config = SipConfiguration( + attributes={ + "enabled_sip_registration": True, + "use_did_in_ruri": True, + "cnam_lookup": True, + "diversion_inject_mode": "did_number", + "network_protocol_priority": "prefer_ipv4", + } + ) + assert config.enabled_sip_registration is True + assert config.use_did_in_ruri is True + assert config.cnam_lookup is True + assert config.diversion_inject_mode == DiversionInjectMode.DID_NUMBER + assert config.network_protocol_priority == NetworkProtocolPriority.PREFER_IPV4 + + payload = config.to_jsonapi() + assert payload["type"] == "sip_configurations" + assert payload["attributes"]["enabled_sip_registration"] is True + assert payload["attributes"]["use_did_in_ruri"] is True + assert payload["attributes"]["cnam_lookup"] is True + assert payload["attributes"]["diversion_inject_mode"] == "did_number" + assert payload["attributes"]["network_protocol_priority"] == "prefer_ipv4" + + def test_sip_configuration_exposes_read_only_incoming_auth_credentials(self): + # Test fixture values; not a real credential. NOSONAR-suppressed below. + fake_user = "sipreg-user-1" + fake_pass = "s3cret-Pa55" # NOSONAR python:S2068 -- test fixture + config = SipConfiguration( + attributes={ + "host": None, + "port": None, + "username": None, + "enabled_sip_registration": True, + "incoming_auth_username": fake_user, + "incoming_auth_password": fake_pass, + } + ) + assert config.incoming_auth_username == fake_user + assert config.incoming_auth_password == fake_pass + + def test_sip_configuration_strips_read_only_credentials_from_write_payload(self): + # Simulate a caller who loaded a SIP configuration from the server + # (with incoming_auth_* populated) and submits it back. The server + # returns 400 Param not allowed if these are echoed in the request, + # so the SDK MUST strip them from the JSON:API payload. + fake_user = "sipreg-user-1" + fake_pass = "s3cret-Pa55" # NOSONAR python:S2068 -- test fixture + config = SipConfiguration( + attributes={ + "enabled_sip_registration": True, + "use_did_in_ruri": True, + "incoming_auth_username": fake_user, + "incoming_auth_password": fake_pass, + } + ) + payload = config.to_jsonapi() + assert payload["attributes"]["enabled_sip_registration"] is True + assert payload["attributes"]["use_did_in_ruri"] is True + assert "incoming_auth_username" not in payload["attributes"] + assert "incoming_auth_password" not in payload["attributes"] + + def test_enabling_sip_registration_clears_host_and_port(self): + cfg = SipConfiguration(attributes={"host": "sip.example.com", "port": 5060}) + cfg.enabled_sip_registration = True + assert cfg.host is None + assert cfg._attr("port") is None + assert cfg.enabled_sip_registration is True + + def test_enabling_sip_registration_emits_host_and_port_on_fresh_config(self): + # Regression: PATCH against an existing trunk that already has a + # host on the server side. The local SipConfiguration starts empty, + # so nothing is in the local _attributes dict. Setting + # enabled_sip_registration = True must still emit host=None / + # port=None on the wire — otherwise the server merges the new field + # with the persisted host and rejects with 422. + cfg = SipConfiguration() + cfg.enabled_sip_registration = True + attrs = cfg.to_jsonapi()["attributes"] + assert "host" in attrs + assert attrs["host"] is None + assert "port" in attrs + assert attrs["port"] is None + assert attrs["enabled_sip_registration"] is True + + def test_disabling_sip_registration_forces_use_did_in_ruri_to_false(self): + cfg = SipConfiguration( + attributes={"enabled_sip_registration": True, "use_did_in_ruri": True} + ) + cfg.enabled_sip_registration = False + assert cfg.enabled_sip_registration is False + assert cfg.use_did_in_ruri is False + + def test_setting_host_disables_sip_registration_and_use_did_in_ruri(self): + cfg = SipConfiguration( + attributes={"enabled_sip_registration": True, "use_did_in_ruri": True} + ) + cfg.host = "sip.example.com" + assert cfg.host == "sip.example.com" + assert cfg.enabled_sip_registration is False + assert cfg.use_did_in_ruri is False + + def test_enabling_sip_registration_leaves_use_did_in_ruri_untouched(self): + cfg = SipConfiguration( + attributes={"enabled_sip_registration": True, "use_did_in_ruri": True} + ) + cfg.enabled_sip_registration = True + assert cfg.use_did_in_ruri is True + + def test_sip_configuration_wire_payload_reflects_cascaded_state(self): + # Mirror dimension: after the cascade fires from a property setter, + # the on-the-wire payload (to_jsonapi output) must contain the + # cascaded field values, not just the in-memory state. + cfg = SipConfiguration(attributes={ + "enabled_sip_registration": True, + "use_did_in_ruri": True, + }) + cfg.host = "sip.example.com" # triggers cascade + attrs = cfg.to_jsonapi()["attributes"] + assert attrs["host"] == "sip.example.com" + assert attrs["enabled_sip_registration"] is False + assert attrs["use_did_in_ruri"] is False + + def test_constructor_assignment_bypasses_cascade_for_server_response_shapes(self): + # Server may return a regular SIP trunk shape (host: present together + # with use_did_in_ruri: True). SipConfiguration(attributes=...) sets + # _attributes directly, bypassing the property setters — without that + # bypass, deserializing already-consistent server data would clobber + # the use_did_in_ruri value. + cfg = SipConfiguration(attributes={ + "host": "sip.example.com", + "port": 5060, + "enabled_sip_registration": False, + "use_did_in_ruri": True, + }) + assert cfg.host == "sip.example.com" + assert cfg._attr("port") == 5060 + assert cfg.enabled_sip_registration is False + assert cfg.use_did_in_ruri is True + + def test_sip_configuration_repr_redacts_credentials(self): + # Default __repr__ output is what shows up in default print() / + # logging / unhandled exception traces — none of those contexts + # should ever expose SIP credentials in plaintext. + secret_pass = "s3cret-Pa55" # NOSONAR python:S2068 -- test fixture + secret_inc_pass = "srv-pass-xyz" # NOSONAR python:S2068 + config = SipConfiguration( + attributes={ + "username": "alice", + "host": "sip.example.com", + "auth_password": secret_pass, + "enabled_sip_registration": True, + "incoming_auth_username": "srv-user-xyz", + "incoming_auth_password": secret_inc_pass, + } + ) + output = repr(config) + assert "alice" in output + assert "sip.example.com" in output + assert secret_pass not in output + assert "srv-user-xyz" not in output + assert secret_inc_pass not in output + assert "[FILTERED]" in output