diff --git a/CHANGELOG.md b/CHANGELOG.md index e92cde46..ef2e2d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## Unreleased +## [4.1.0] - 2026-05-04 + +### Added +- Complete the API 2026-04-16 `SipConfiguration` attribute set: `enabledSipRegistration`, `useDidInRuri`, `cnamLookup`, `networkProtocolPriority`, `diversionInjectMode`, plus the server-generated read-only `incomingAuthUsername` / `incomingAuthPassword`. +- `SipConfiguration#toString` and `CredentialsAndIpAuthenticationMethod#toString` redact credential fields with `[FILTERED]` so default logging / debugger / unhandled exception traces never expose plaintext credentials. Wire payload is unaffected. +- `SipConfiguration` auto-cascades server-enforced field dependencies on assignment: `setEnabledSipRegistration(true)` clears `host` / `port`, `setEnabledSipRegistration(false)` forces `useDidInRuri = false`, and `setHost()` flips both. Jackson populates the private fields via reflection during deserialization, so server responses are not clobbered. ### Breaking Changes - Renamed `Order#isCancelled` to `Order#isCanceled` for wire-format consistency diff --git a/README.md b/README.md index c7f1ad3a..9702f962 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,49 @@ client.voiceInTrunks().update(created); client.voiceInTrunks().delete(created.getId()); ``` +#### 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: + +* `setEnabledSipRegistration(true)` clears any previously-set `host` / + `port` (the server rejects them with 422 otherwise); +* `setHost()` flips `enabledSipRegistration` to `false` and + forces `useDidInRuri = false` so the server accepts the disable PATCH. + +The server generates `incomingAuthUsername` and `incomingAuthPassword` and +surfaces them in the response when SIP registration is enabled (read-only +— the SDK exposes only getters). + +```java +SipConfiguration sip = new SipConfiguration(); +sip.setEnabledSipRegistration(true); +sip.setUseDidInRuri(true); +sip.setCnamLookup(true); + +VoiceInTrunk trunk = new VoiceInTrunk(); +trunk.setName("Office (registered)"); +trunk.setConfiguration(sip); +SipConfiguration created = (SipConfiguration) + client.voiceInTrunks().create(trunk).getData().getConfiguration(); +// created.getIncomingAuthUsername() — server-generated +// created.getIncomingAuthPassword() — server-generated +``` + +To disable SIP registration on an existing trunk, just set `host` — the +cascade flips `enabledSipRegistration` and `useDidInRuri` to `false` +automatically: + +```java +SipConfiguration disable = new SipConfiguration(); +disable.setHost("sip.example.com"); + +VoiceInTrunk update = new VoiceInTrunk().withId("trunk-uuid"); +update.setConfiguration(disable); +client.voiceInTrunks().update(update); +``` + ### Voice In Trunk Groups ```java diff --git a/build.gradle.kts b/build.gradle.kts index be487e28..aa91b82e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.didww" -version = "4.0.0" +version = "4.1.0" java { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/examples/README.md b/examples/README.md index 647fa27f..d8ab71da 100644 --- a/examples/README.md +++ b/examples/README.md @@ -42,6 +42,7 @@ DIDWW_API_KEY=your_api_key ./gradlew runExample -PexampleClass=com.didww.example | [`com.didww.examples.UploadFileExample`](src/main/java/com/didww/examples/UploadFileExample.java) | Creates (or reads) a file, encrypts it, and uploads to `encrypted_files`. | | [`com.didww.examples.IdentityAddressProofsExample`](src/main/java/com/didww/examples/IdentityAddressProofsExample.java) | Creates identity and address, encrypts and uploads files, attaches proofs to both. | | [`com.didww.examples.VoiceInTrunkGroupsExample`](src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java) | CRUD for trunk groups with trunk relationships. | +| [`com.didww.examples.VoiceInTrunkSipRegistrationExample`](src/main/java/com/didww/examples/VoiceInTrunkSipRegistrationExample.java) | End-to-end SIP registration flow: create with `enabledSipRegistration=true`, rename, disable by setting `host`, re-enable by toggling the flag. The SDK keeps the dependent fields (`host`, `port`, `useDidInRuri`) aligned with the server's validation rules automatically. | | [`com.didww.examples.VoiceOutTrunksExample`](src/main/java/com/didww/examples/VoiceOutTrunksExample.java) | CRUD for voice out trunks (requires account config). | | [`com.didww.examples.DidTrunkAssignmentExample`](src/main/java/com/didww/examples/DidTrunkAssignmentExample.java) | Demonstrates exclusive trunk/trunk group assignment on DIDs. | | [`com.didww.examples.DidReservationsExample`](src/main/java/com/didww/examples/DidReservationsExample.java) | Creates, lists, finds and deletes DID reservations. | diff --git a/examples/src/main/java/com/didww/examples/VoiceInTrunkSipRegistrationExample.java b/examples/src/main/java/com/didww/examples/VoiceInTrunkSipRegistrationExample.java new file mode 100644 index 00000000..63677562 --- /dev/null +++ b/examples/src/main/java/com/didww/examples/VoiceInTrunkSipRegistrationExample.java @@ -0,0 +1,92 @@ +package com.didww.examples; + +import com.didww.sdk.DidwwClient; +import com.didww.sdk.resource.VoiceInTrunk; +import com.didww.sdk.resource.configuration.SipConfiguration; +import com.didww.sdk.resource.enums.CliFormat; +import com.didww.sdk.resource.enums.Codec; +import com.didww.sdk.resource.enums.TransportProtocol; + +import java.util.Arrays; + +/** + * 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, useDidInRuri) aligned with the server's + * validation rules. The sandbox trunk is left in place after the script + * completes. + * + * Usage: DIDWW_API_KEY=xxx ./gradlew runExample -PexampleClass=com.didww.examples.VoiceInTrunkSipRegistrationExample + */ +public class VoiceInTrunkSipRegistrationExample { + + public static void main(String[] args) { + DidwwClient client = ExampleClientFactory.fromEnv(); + System.out.println("=== Java SDK — SIP registration flow ==="); + + // 1) Create with sip_registration enabled. + System.out.println("\n[1/4] Create with sip_registration enabled..."); + SipConfiguration sip = new SipConfiguration(); + sip.setEnabledSipRegistration(true); + sip.setUseDidInRuri(true); + sip.setCnamLookup(false); + sip.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA)); + sip.setTransportProtocolId(TransportProtocol.UDP); + + VoiceInTrunk trunk = new VoiceInTrunk(); + trunk.setName("java-sip-registration-" + System.currentTimeMillis()); + trunk.setPriority(1); + trunk.setWeight(100); + trunk.setCliFormat(CliFormat.E164); + trunk.setRingingTimeout(30); + trunk.setConfiguration(sip); + + VoiceInTrunk created = client.voiceInTrunks().create(trunk).getData(); + String trunkId = created.getId(); + SipConfiguration cfg1 = (SipConfiguration) created.getConfiguration(); + System.out.println(" id=" + trunkId); + System.out.println(" incomingAuthUsername=" + cfg1.getIncomingAuthUsername()); + System.out.println(" incomingAuthPassword=" + cfg1.getIncomingAuthPassword()); + + // 2) Rename — single-field PATCH. + System.out.println("\n[2/4] Rename trunk..."); + VoiceInTrunk rename = new VoiceInTrunk().withId(trunkId); + rename.setName("java-renamed-" + System.currentTimeMillis()); + client.voiceInTrunks().update(rename); + System.out.println(" name=" + rename.getName()); + + // 3) Disable sip_registration by setting host. + System.out.println("\n[3/4] Disable by setting host..."); + SipConfiguration disable = new SipConfiguration(); + disable.setHost("203.0.113.10"); + VoiceInTrunk update = new VoiceInTrunk().withId(trunkId); + update.setConfiguration(disable); + client.voiceInTrunks().update(update); + SipConfiguration cfg3 = (SipConfiguration) client.voiceInTrunks().find(trunkId).getData().getConfiguration(); + System.out.println(" enabledSipRegistration=" + cfg3.getEnabledSipRegistration()); + System.out.println(" useDidInRuri=" + cfg3.getUseDidInRuri()); + System.out.println(" host=" + cfg3.getHost()); + System.out.println(" incomingAuthUsername=" + cfg3.getIncomingAuthUsername()); + + // 4) Re-enable sip_registration. The SDK should send host=null / port=null + // on the wire so the server clears the values it had persisted. + System.out.println("\n[4/4] Re-enable by toggling enabledSipRegistration..."); + SipConfiguration reEnable = new SipConfiguration(); + reEnable.setEnabledSipRegistration(true); + reEnable.setUseDidInRuri(true); + VoiceInTrunk update4 = new VoiceInTrunk().withId(trunkId); + update4.setConfiguration(reEnable); + try { + client.voiceInTrunks().update(update4); + SipConfiguration cfg4 = (SipConfiguration) client.voiceInTrunks().find(trunkId).getData().getConfiguration(); + System.out.println(" enabledSipRegistration=" + cfg4.getEnabledSipRegistration()); + System.out.println(" host=" + cfg4.getHost()); + System.out.println(" incomingAuthUsername=" + cfg4.getIncomingAuthUsername()); + System.out.println("\n=== PASS — trunk " + trunkId + " left in sandbox ==="); + } catch (Exception e) { + System.out.println(" ✗ FAIL: " + e.getMessage()); + System.out.println("\n=== FAIL at re-enable — trunk " + trunkId + " left in sandbox ==="); + } + } +} diff --git a/src/main/java/com/didww/sdk/internal/Redact.java b/src/main/java/com/didww/sdk/internal/Redact.java new file mode 100644 index 00000000..77c4cb2d --- /dev/null +++ b/src/main/java/com/didww/sdk/internal/Redact.java @@ -0,0 +1,20 @@ +package com.didww.sdk.internal; + +/** + * Shared helpers for masking sensitive credential values in default + * {@code toString} / debugger / log output. The wire format is never + * affected — only Stringer-style methods route through these helpers. + */ +public final class Redact { + + private Redact() {} + + /** + * Returns {@code "[FILTERED]"} for any non-null input and {@code "null"} + * otherwise. The {@code "null"} passthrough avoids leaking + * "this field was set" information when the value is genuinely unset. + */ + public static String mask(String value) { + return value == null ? "null" : "[FILTERED]"; + } +} diff --git a/src/main/java/com/didww/sdk/resource/authenticationmethod/CredentialsAndIpAuthenticationMethod.java b/src/main/java/com/didww/sdk/resource/authenticationmethod/CredentialsAndIpAuthenticationMethod.java index 27732cf6..a871fbdd 100644 --- a/src/main/java/com/didww/sdk/resource/authenticationmethod/CredentialsAndIpAuthenticationMethod.java +++ b/src/main/java/com/didww/sdk/resource/authenticationmethod/CredentialsAndIpAuthenticationMethod.java @@ -1,5 +1,6 @@ package com.didww.sdk.resource.authenticationmethod; +import com.didww.sdk.internal.Redact; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; @@ -28,4 +29,20 @@ public class CredentialsAndIpAuthenticationMethod extends AuthenticationMethod { public String getType() { return "credentials_and_ip"; } + + /** + * Override toString() so default logging / debugger inspection / unhandled + * exception traces never leak server-generated credentials. The wire + * payload is unaffected — Jackson serializes the real values (or strips + * them via the WRITE_ONLY access modifier on incoming requests). + */ + @Override + public String toString() { + return "CredentialsAndIpAuthenticationMethod(" + + "allowedSipIps=" + allowedSipIps + + ", techPrefix=" + techPrefix + + ", username=" + Redact.mask(username) + + ", password=" + Redact.mask(password) + + ")"; + } } diff --git a/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java b/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java index 78a1526e..75e0bb71 100644 --- a/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java +++ b/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java @@ -1,8 +1,11 @@ package com.didww.sdk.resource.configuration; +import com.didww.sdk.internal.Redact; import com.didww.sdk.resource.enums.*; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; @@ -15,10 +18,21 @@ public class SipConfiguration extends TrunkConfiguration { @JsonProperty("username") private String username; + // host / port / enabledSipRegistration use manual setters that propagate + // server-enforced field dependencies (API 2026-04-16). Lombok would + // otherwise generate plain setters from the class-level @Setter, which + // would skip the cascade. Jackson deserializes into the private fields + // directly via reflection (no setter present), so server responses + // bypass the cascade on the way in — exactly what we want, since + // server data is already internally consistent. + @Setter(AccessLevel.NONE) @JsonProperty("host") + @JsonInclude(JsonInclude.Include.ALWAYS) private String host; + @Setter(AccessLevel.NONE) @JsonProperty("port") + @JsonInclude(JsonInclude.Include.ALWAYS) private Integer port; @JsonProperty("codec_ids") @@ -102,9 +116,112 @@ public class SipConfiguration extends TrunkConfiguration { @JsonProperty("diversion_relay_policy") private DiversionRelayPolicy diversionRelayPolicy; + @JsonProperty("diversion_inject_mode") + private DiversionInjectMode diversionInjectMode; + + @JsonProperty("network_protocol_priority") + private NetworkProtocolPriority networkProtocolPriority; + + /** + * Whether SIP registration is enabled. When {@code true} the server + * generates {@code incoming_auth_username} / {@code incoming_auth_password} + * and the trunk's {@code host} and {@code port} must be left blank. When + * disabling sip registration on an existing trunk, the same PATCH must + * also set {@code host} to a non-blank value and {@code use_did_in_ruri} + * to {@code false}, or the server returns 422. (API 2026-04-16) + */ + @Setter(AccessLevel.NONE) + @JsonProperty("enabled_sip_registration") + private Boolean enabledSipRegistration; + + @JsonProperty("use_did_in_ruri") + private Boolean useDidInRuri; + + @JsonProperty("cnam_lookup") + private Boolean cnamLookup; + + /** + * Server-generated SIP authentication username, returned in responses + * when {@code enabledSipRegistration} is {@code true}. Read-only; the API + * rejects any write attempt with HTTP 400 "Param not allowed". (API 2026-04-16) + */ + @JsonProperty(value = "incoming_auth_username", access = JsonProperty.Access.WRITE_ONLY) + @Setter(AccessLevel.PRIVATE) + private String incomingAuthUsername; + + /** + * Server-generated SIP authentication password, returned in responses + * when {@code enabledSipRegistration} is {@code true}. Read-only; the API + * rejects any write attempt with HTTP 400 "Param not allowed". (API 2026-04-16) + */ + @JsonProperty(value = "incoming_auth_password", access = JsonProperty.Access.WRITE_ONLY) + @Setter(AccessLevel.PRIVATE) + private String incomingAuthPassword; + + /** + * Setting host to a non-null value cascades enabledSipRegistration and + * useDidInRuri to false because the server requires those states + * whenever host is present (API 2026-04-16). + */ + public void setHost(String host) { + if (host != null) { + this.enabledSipRegistration = false; + this.useDidInRuri = false; + } + this.host = host; + } + + public void setPort(Integer port) { + this.port = port; + } + + /** + * Setting enabledSipRegistration cascades dependent fields: + * + */ + public void setEnabledSipRegistration(Boolean enabledSipRegistration) { + if (Boolean.TRUE.equals(enabledSipRegistration)) { + // Always emit host: null and port: null on the wire so a PATCH + // against an existing trunk that already has them persisted on + // the server side is told to clear them. + this.host = null; + this.port = null; + } else if (Boolean.FALSE.equals(enabledSipRegistration)) { + this.useDidInRuri = false; + } + this.enabledSipRegistration = enabledSipRegistration; + } + @Override @JsonIgnore public String getType() { return "sip_configurations"; } + + /** + * Override toString() so default logging / debugger inspection / unhandled + * exception traces never leak SIP credentials. The wire payload is + * unaffected — Jackson serializes the real values (or strips read-only + * ones via @JsonProperty WRITE_ONLY). + */ + @Override + public String toString() { + return "SipConfiguration(" + + "username=" + username + + ", host=" + host + + ", port=" + port + + ", authPassword=" + Redact.mask(authPassword) + + ", enabledSipRegistration=" + enabledSipRegistration + + ", useDidInRuri=" + useDidInRuri + + ", incomingAuthUsername=" + Redact.mask(incomingAuthUsername) + + ", incomingAuthPassword=" + Redact.mask(incomingAuthPassword) + + ")"; + } } diff --git a/src/main/java/com/didww/sdk/resource/enums/DiversionInjectMode.java b/src/main/java/com/didww/sdk/resource/enums/DiversionInjectMode.java new file mode 100644 index 00000000..cf452394 --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/DiversionInjectMode.java @@ -0,0 +1,8 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum DiversionInjectMode { + @JsonProperty("none") NONE, + @JsonProperty("did_number") DID_NUMBER; +} diff --git a/src/main/java/com/didww/sdk/resource/enums/NetworkProtocolPriority.java b/src/main/java/com/didww/sdk/resource/enums/NetworkProtocolPriority.java new file mode 100644 index 00000000..025497e9 --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/NetworkProtocolPriority.java @@ -0,0 +1,11 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum NetworkProtocolPriority { + @JsonProperty("force_ipv4") FORCE_IPV4, + @JsonProperty("force_ipv6") FORCE_IPV6, + @JsonProperty("any") ANY, + @JsonProperty("prefer_ipv4") PREFER_IPV4, + @JsonProperty("prefer_ipv6") PREFER_IPV6; +} diff --git a/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java b/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java index a51f6d30..3ef563bc 100644 --- a/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java +++ b/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java @@ -5,8 +5,11 @@ import com.didww.sdk.repository.ApiResponse; import com.didww.sdk.resource.configuration.PstnConfiguration; import com.didww.sdk.resource.enums.CliFormat; +import com.didww.sdk.resource.enums.DiversionInjectMode; import com.didww.sdk.resource.enums.DiversionRelayPolicy; +import com.didww.sdk.resource.enums.NetworkProtocolPriority; import com.didww.sdk.resource.enums.Codec; +import com.fasterxml.jackson.databind.ObjectMapper; import com.didww.sdk.resource.enums.ReroutingDisconnectCode; import com.didww.sdk.resource.enums.RxDtmfFormat; import com.didww.sdk.resource.enums.SstRefreshMethod; @@ -162,6 +165,15 @@ void testCreateSipTrunkWithReroutingDisconnectCodes() { sipConfig.setMediaEncryptionMode(MediaEncryptionMode.ZRTP); sipConfig.setStirShakenMode(StirShakenMode.PAI); sipConfig.setAllowedRtpIps(Arrays.asList("203.0.113.1")); + // API 2026-04-16 writable attributes + sipConfig.setDiversionRelayPolicy(DiversionRelayPolicy.AS_IS); + sipConfig.setDiversionInjectMode(DiversionInjectMode.DID_NUMBER); + sipConfig.setNetworkProtocolPriority(NetworkProtocolPriority.FORCE_IPV4); + sipConfig.setCnamLookup(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 server default but documents the field. + sipConfig.setUseDidInRuri(false); VoiceInTrunk trunk = new VoiceInTrunk(); trunk.setName("hello, test sip trunk"); @@ -177,7 +189,10 @@ void testCreateSipTrunkWithReroutingDisconnectCodes() { assertThat(codes.get(0)).isEqualTo(ReroutingDisconnectCode.SIP_400_BAD_REQUEST); assertThat(codes.get(codes.size() - 1)).isEqualTo(ReroutingDisconnectCode.RINGING_TIMEOUT); assertThat(codes).contains(ReroutingDisconnectCode.SIP_480_TEMPORARILY_UNAVAILABLE); - assertThat(config.getDiversionRelayPolicy()).isEqualTo(DiversionRelayPolicy.SIP); + assertThat(config.getDiversionRelayPolicy()).isEqualTo(DiversionRelayPolicy.AS_IS); + assertThat(config.getDiversionInjectMode()).isEqualTo(DiversionInjectMode.DID_NUMBER); + assertThat(config.getNetworkProtocolPriority()).isEqualTo(NetworkProtocolPriority.FORCE_IPV4); + assertThat(config.getCnamLookup()).isTrue(); } @Test @@ -278,4 +293,251 @@ void testDeleteVoiceInTrunk() { wireMock.verify(deleteRequestedFor(urlPathEqualTo("/v3/voice_in_trunks/" + id))); } + + // 2026-04-16 SIP-registration attributes (API 2026-04-16). + // + // Real wire shape captured from sandbox: when sip_registration is enabled + // the API returns host/port/username as null (and rejects writes that set + // them). The fixtures below mirror that shape. + @Test + void testSipConfigurationDeserializesRegistrationAttributesIncludingReadOnlyCredentials() throws Exception { + String json = "{\"type\":\"sip_configurations\",\"username\":null,\"host\":null,\"port\":null," + + "\"enabled_sip_registration\":true,\"use_did_in_ruri\":true,\"cnam_lookup\":true," + + "\"diversion_inject_mode\":\"did_number\",\"network_protocol_priority\":\"prefer_ipv4\"," + + "\"incoming_auth_username\":\"sipreg-user-1\"," + + "\"incoming_auth_password\":\"s3cret-Pa55\"}"; + + ObjectMapper mapper = new ObjectMapper(); + SipConfiguration config = mapper.readValue(json, SipConfiguration.class); + + assertThat(config.getEnabledSipRegistration()).isTrue(); + assertThat(config.getUseDidInRuri()).isTrue(); + assertThat(config.getCnamLookup()).isTrue(); + assertThat(config.getDiversionInjectMode()).isEqualTo(DiversionInjectMode.DID_NUMBER); + assertThat(config.getNetworkProtocolPriority()).isEqualTo(NetworkProtocolPriority.PREFER_IPV4); + assertThat(config.getIncomingAuthUsername()).isEqualTo("sipreg-user-1"); + assertThat(config.getIncomingAuthPassword()).isEqualTo("s3cret-Pa55"); + } + + /** + * 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 null). + */ + @Test + void testCreateWithEnabledSipRegistrationReturnsPopulatedIncomingAuthCredentials() { + wireMock.stubFor(post(urlPathEqualTo("/v3/voice_in_trunks")) + .withRequestBody(equalToJson(loadFixture("voice_in_trunks/create_with_sip_registration_request.json"), true, false)) + .willReturn(aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/vnd.api+json") + .withBody(loadFixture("voice_in_trunks/create_with_sip_registration.json")))); + + SipConfiguration sipConfig = new SipConfiguration(); + sipConfig.setEnabledSipRegistration(true); + sipConfig.setUseDidInRuri(true); + sipConfig.setCnamLookup(true); + sipConfig.setDiversionRelayPolicy(DiversionRelayPolicy.AS_IS); + sipConfig.setDiversionInjectMode(DiversionInjectMode.DID_NUMBER); + sipConfig.setNetworkProtocolPriority(NetworkProtocolPriority.PREFER_IPV4); + + VoiceInTrunk trunk = new VoiceInTrunk(); + trunk.setName("sip-registration"); + trunk.setPriority(1); + trunk.setWeight(100); + trunk.setCliFormat(CliFormat.E164); + trunk.setRingingTimeout(30); + trunk.setConfiguration(sipConfig); + + ApiResponse response = client.voiceInTrunks().create(trunk); + SipConfiguration created = (SipConfiguration) response.getData().getConfiguration(); + assertThat(created.getEnabledSipRegistration()).isTrue(); + // Server-generated credentials are populated, not null. + assertThat(created.getIncomingAuthUsername()).isNotNull().isNotEmpty(); + assertThat(created.getIncomingAuthPassword()).isNotNull().isNotEmpty(); + } + + @Test + void testSipConfigurationStripsReadOnlyCredentialsOnSerialization() throws Exception { + // Simulate a caller that loaded a configuration from the server + // (with incoming_auth_* populated) and is about to write it back. + // The server returns 400 Param not allowed if these credentials are + // echoed in the request, so the SDK MUST omit them from serialized + // output. Jackson's @JsonProperty(access = WRITE_ONLY) handles this. + // + // Note: host/port are intentionally left null because the API + // requires them to be blank when sip_registration is enabled. The + // load-shape JSON below mirrors a real GET response — the SDK + // exposes incoming_auth_* via Lombok-generated getters but locks + // their setters to private so callers cannot mutate them. + ObjectMapper mapper = new ObjectMapper(); + SipConfiguration config = mapper.readValue( + loadFixture("voice_in_trunks/sip_registration_load_shape.json"), + SipConfiguration.class); + assertThat(config.getIncomingAuthUsername()).isEqualTo("sipreg-user-1"); + assertThat(config.getIncomingAuthPassword()).isEqualTo("s3cret-Pa55"); + + String json = mapper.writeValueAsString(config); + + assertThat(json).contains("\"enabled_sip_registration\":true"); + assertThat(json).contains("\"use_did_in_ruri\":true"); + assertThat(json).contains("\"cnam_lookup\":true"); + assertThat(json).contains("\"diversion_inject_mode\":\"did_number\""); + assertThat(json).contains("\"network_protocol_priority\":\"prefer_ipv4\""); + assertThat(json).doesNotContain("incoming_auth_username"); + assertThat(json).doesNotContain("incoming_auth_password"); + } + + /** + * 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 via + * {@code equalToJson(..., true, false)} (lenient ordering, strict + * field set) — a regression that drops one of them fails the request + * match and so the test fails. + */ + @Test + void testDisableSipRegistrationPatchSerializesAllThreeFields() { + String id = "57a939dd-1600-41a6-80b1-f624e22a1f4c"; + wireMock.stubFor(patch(urlPathEqualTo("/v3/voice_in_trunks/" + id)) + .withRequestBody(equalToJson(loadFixture("voice_in_trunks/disable_sip_registration_request.json"), true, false)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/vnd.api+json") + .withBody(loadFixture("voice_in_trunks/disable_sip_registration.json")))); + + SipConfiguration sipConfig = new SipConfiguration(); + sipConfig.setEnabledSipRegistration(false); + sipConfig.setUseDidInRuri(false); + sipConfig.setHost("203.0.113.10"); + + VoiceInTrunk trunk = new VoiceInTrunk().withId(id); + trunk.setConfiguration(sipConfig); + + ApiResponse response = client.voiceInTrunks().update(trunk); + SipConfiguration updated = (SipConfiguration) response.getData().getConfiguration(); + assertThat(updated.getEnabledSipRegistration()).isFalse(); + assertThat(updated.getUseDidInRuri()).isFalse(); + assertThat(updated.getHost()).isEqualTo("203.0.113.10"); + assertThat(updated.getIncomingAuthUsername()).isNull(); + assertThat(updated.getIncomingAuthPassword()).isNull(); + } + + @Test + void testEnablingSipRegistrationClearsHostAndPort() { + SipConfiguration cfg = new SipConfiguration(); + cfg.setHost("sip.example.com"); + cfg.setPort(5060); + cfg.setEnabledSipRegistration(true); + assertThat(cfg.getHost()).isNull(); + assertThat(cfg.getPort()).isNull(); + assertThat(cfg.getEnabledSipRegistration()).isTrue(); + } + + @Test + void testDisablingSipRegistrationForcesUseDidInRuriToFalse() { + SipConfiguration cfg = new SipConfiguration(); + cfg.setEnabledSipRegistration(true); + cfg.setUseDidInRuri(true); + cfg.setEnabledSipRegistration(false); + assertThat(cfg.getEnabledSipRegistration()).isFalse(); + assertThat(cfg.getUseDidInRuri()).isFalse(); + } + + @Test + void testSettingHostDisablesSipRegistrationAndForcesUseDidInRuriToFalse() { + SipConfiguration cfg = new SipConfiguration(); + cfg.setEnabledSipRegistration(true); + cfg.setUseDidInRuri(true); + cfg.setHost("sip.example.com"); + assertThat(cfg.getHost()).isEqualTo("sip.example.com"); + assertThat(cfg.getEnabledSipRegistration()).isFalse(); + assertThat(cfg.getUseDidInRuri()).isFalse(); + } + + /** + * Mirror dimension: after the cascade fires from a setter, the + * on-the-wire payload (Jackson output) must contain the cascaded + * field values — not just the in-memory state. + */ + @Test + void testSipConfigurationWirePayloadReflectsCascadedState() throws Exception { + SipConfiguration cfg = new SipConfiguration(); + cfg.setEnabledSipRegistration(true); + cfg.setUseDidInRuri(true); + cfg.setHost("sip.example.com"); // triggers cascade + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(cfg); + assertThat(json).contains("\"host\":\"sip.example.com\""); + assertThat(json).contains("\"enabled_sip_registration\":false"); + assertThat(json).contains("\"use_did_in_ruri\":false"); + } + + @Test + void testEnablingSipRegistrationLeavesUseDidInRuriUntouched() { + SipConfiguration cfg = new SipConfiguration(); + cfg.setEnabledSipRegistration(true); + cfg.setUseDidInRuri(true); + cfg.setEnabledSipRegistration(true); + assertThat(cfg.getUseDidInRuri()).isTrue(); + } + + /** + * Server may return a regular SIP trunk shape (host: present together + * with use_did_in_ruri: true) — Jackson must populate the private fields + * directly via reflection rather than calling the cascading setters. + * The private fields skip the cascade, so the loaded configuration + * matches the server response byte-for-byte. + */ + @Test + void testDeserializingServerResponseDoesNotTriggerCascade() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + SipConfiguration config = mapper.readValue( + loadFixture("voice_in_trunks/sip_regular_load_shape.json"), + SipConfiguration.class); + assertThat(config.getHost()).isEqualTo("sip.example.com"); + assertThat(config.getPort()).isEqualTo(5060); + assertThat(config.getEnabledSipRegistration()).isFalse(); + assertThat(config.getUseDidInRuri()).as("deserialization must not cascade UseDidInRuri to false").isTrue(); + } + + /** + * Regression: PATCH against an existing trunk that already has a + * host/port persisted server-side. The local SipConfiguration starts + * empty (host/port never assigned), so the cascade must still emit + * "host": null and "port": null on the wire — otherwise the server + * merges the new enabled_sip_registration=true with the persisted host + * and rejects with 422. + */ + @Test + void testEnablingSipRegistrationOnFreshConfigEmitsHostAndPortAsNullOnWire() throws Exception { + SipConfiguration cfg = new SipConfiguration(); + cfg.setEnabledSipRegistration(true); + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(cfg); + assertThat(json).contains("\"host\":null"); + assertThat(json).contains("\"port\":null"); + assertThat(json).contains("\"enabled_sip_registration\":true"); + } + + /** + * Default toString output is what shows up in default logging / debugger + * inspection / unhandled exception traces — none of those contexts should + * ever expose SIP credentials in plaintext. + */ + @Test + void testSipConfigurationToStringRedactsCredentials() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + SipConfiguration config = mapper.readValue( + loadFixture("voice_in_trunks/sip_registration_load_shape.json"), + SipConfiguration.class); + + String output = config.toString(); + assertThat(output).contains("[FILTERED]"); + assertThat(output).doesNotContain("sipreg-user-1"); + assertThat(output).doesNotContain("s3cret-Pa55"); + } } diff --git a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json index 02f9d8ea..7e71045b 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json @@ -14,6 +14,14 @@ "configuration": { "type": "sip_configurations", "attributes": { + "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, "username": "username", "host": "203.0.113.110", "port": 5060, @@ -98,8 +106,7 @@ "stir_shaken_mode": "pai", "allowed_rtp_ips": [ "203.0.113.1" - ], - "diversion_relay_policy": "sip" + ] } }, "created_at": "2018-12-28T17:37:48.010Z" diff --git a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json index 47dd0a57..1ca82f1d 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json @@ -1 +1 @@ -{"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"}}} +{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"username":"username","host":"203.0.113.110","enabled_sip_registration":false,"use_did_in_ruri":false,"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"}}} \ No newline at end of file diff --git a/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration.json b/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration.json new file mode 100644 index 00000000..f9d86aca --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } + } +} diff --git a/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration_request.json b/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration_request.json new file mode 100644 index 00000000..36d1081e --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/create_with_sip_registration_request.json @@ -0,0 +1,25 @@ +{ + "data": { + "type": "voice_in_trunks", + "attributes": { + "name": "sip-registration", + "priority": 1, + "weight": 100, + "cli_format": "e164", + "ringing_timeout": 30, + "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" + } + } + } + } +} diff --git a/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration.json b/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration.json new file mode 100644 index 00000000..1ad54a86 --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration.json @@ -0,0 +1,28 @@ +{ + "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 + } + } + } + } +} diff --git a/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration_request.json b/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration_request.json new file mode 100644 index 00000000..ec8bf084 --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/disable_sip_registration_request.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": "57a939dd-1600-41a6-80b1-f624e22a1f4c", + "type": "voice_in_trunks", + "attributes": { + "configuration": { + "type": "sip_configurations", + "attributes": { + "host": "203.0.113.10", + "port": null, + "enabled_sip_registration": false, + "use_did_in_ruri": false + } + } + } + } +} diff --git a/src/test/resources/fixtures/voice_in_trunks/index.json b/src/test/resources/fixtures/voice_in_trunks/index.json index 069ab696..45bf0448 100644 --- a/src/test/resources/fixtures/voice_in_trunks/index.json +++ b/src/test/resources/fixtures/voice_in_trunks/index.json @@ -52,6 +52,14 @@ "configuration": { "type": "sip_configurations", "attributes": { + "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, "username": "username", "host": "203.0.113.78", "port": 8060, @@ -149,4 +157,4 @@ "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" } -} \ No newline at end of file +} diff --git a/src/test/resources/fixtures/voice_in_trunks/sip_registration_load_shape.json b/src/test/resources/fixtures/voice_in_trunks/sip_registration_load_shape.json new file mode 100644 index 00000000..4ccf6494 --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/sip_registration_load_shape.json @@ -0,0 +1,9 @@ +{ + "enabled_sip_registration": true, + "use_did_in_ruri": true, + "cnam_lookup": true, + "diversion_inject_mode": "did_number", + "network_protocol_priority": "prefer_ipv4", + "incoming_auth_username": "sipreg-user-1", + "incoming_auth_password": "s3cret-Pa55" +} diff --git a/src/test/resources/fixtures/voice_in_trunks/sip_regular_load_shape.json b/src/test/resources/fixtures/voice_in_trunks/sip_regular_load_shape.json new file mode 100644 index 00000000..5973207c --- /dev/null +++ b/src/test/resources/fixtures/voice_in_trunks/sip_regular_load_shape.json @@ -0,0 +1,7 @@ +{ + "username": "alice", + "host": "sip.example.com", + "port": 5060, + "enabled_sip_registration": false, + "use_did_in_ruri": true +} diff --git a/src/test/resources/fixtures/voice_in_trunks/update_sip.json b/src/test/resources/fixtures/voice_in_trunks/update_sip.json index 3d6125f6..6a68d609 100644 --- a/src/test/resources/fixtures/voice_in_trunks/update_sip.json +++ b/src/test/resources/fixtures/voice_in_trunks/update_sip.json @@ -14,6 +14,14 @@ "configuration": { "type": "sip_configurations", "attributes": { + "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, "username": "new-username", "host": "203.0.113.110", "port": 5060, @@ -118,4 +126,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json b/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json index 938611f3..cb78defb 100644 --- a/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json +++ b/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json @@ -10,6 +10,8 @@ "attributes": { "username": "new-username", "host": "203.0.113.110", + "enabled_sip_registration": false, + "use_did_in_ruri": false, "port": 5060, "codec_ids": [9, 10, 8, 7, 6], "sst_refresh_method_id": 1,