diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 3e44c85..05fa0bf 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -76,29 +76,21 @@ runs once per language. Tag tampering and original-header-byte tests remain. |-------|----------|-------| | `nonce` | yes | value the endpoint MUST echo to prove decryption | | `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap | -| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged when the adapter uses message text. Do not extract, infer or guess a passcode | +| `message` | yes | fully rendered, localized text containing the passcode; text-message adapters forward it unchanged, while Soprano voice extracts the first six consecutive digits | | `extension` | no | office-voice contract field; not currently forwarded by the shared dispatch model | | `locale` | no | voice selection input where supported by the selected adapter | | `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here | -| `textToVoice` | for Soprano live voice | structured speech object supplied inside the encrypted context; see below | +| `textToVoice` | no | legacy structured speech input; current Soprano adapters ignore it | Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`. -For Soprano live voice, include `textToVoice` alongside the required delivery fields: - -```json -"textToVoice": { - "beforePasswordText": "Your verification code is", - "password": "001234", - "language": "en-US" -} -``` - -`beforePasswordText` must be a string (empty is allowed); `password` and `language` must be -nonblank strings. Supply the password explicitly to preserve leading zeros; it is never extracted -from `message`. These values are forwarded unchanged as `voice.text2voice`, without a top-level -`text` field. Missing or invalid speech returns `400` before credential lookup or provider HTTP. -SMS continues to use `message`, and evaluation continues to skip provider-specific validation and I/O. +For Soprano live voice, the adapter finds the first six consecutive digits in `message`, preserving +leading zeros, and splits the rendered text into `beforePasswordText`, `password`, and +`afterPasswordText`. It uses a nonblank SAS `locale` as `language`, falling back to `en-US`, and sends +fixed `gender: 1` and `loop: 2`. The resulting object is sent as `voice.text2voice` without a top-level +`text` field. A message without a six-digit sequence fails closed before provider HTTP. No additional +environment settings are required. Soprano SMS continues to forward `message` unchanged, and +evaluation continues to skip provider-specific validation and I/O. Soprano uses OAuth client-assertion exchange. The outbound user-assigned managed identity obtains an `api://AzureADTokenExchange/.default` assertion for the existing multitenant application, which then requests the configured provider scope. Existing platform caller authentication is unchanged. @@ -139,11 +131,10 @@ plus JWT do not validate the current Bearer-only configuration or production end See [Microsoft's managed-identity federation guidance](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity). -Use a speech language supported by the selected Soprano endpoint and account. On QA4, an API-key +Use a SAS locale supported by the selected Soprano endpoint and account. On QA4, an API-key voice request using `en` returned HTTP `400` with error code `400101`; the same request structure using `en-US` returned HTTP `201` with `ENROUTE` on September 15, 2026. This confirms acceptance, -not handset receipt or audio quality. The adapter preserves the supplied language and does not -guess a region for a language-only value. +not handset receipt or audio quality. When SAS omits the locale, the adapters use `en-US`. The supplied Soprano Connect Voice PDF describes a different API: `POST /voice/voice_orderApiCreate.do` with form-encoded fields, `subAction=20`, and numeric language IDs (`1` is default English). diff --git a/dotnet/README.md b/dotnet/README.md index f58fadb..eccb7d5 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -80,6 +80,11 @@ await acceptance before returning the nonce; failures omit it. Acceptance is not Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). +For Soprano voice, the adapter extracts the first six-digit passcode from the rendered message. It +uses a nonblank SAS request locale as the language, falling back to `en-US`, and sends fixed gender +`1` and loop `2`. These values require no additional environment settings. Soprano SMS continues to +forward the rendered message unchanged. + ## Source | Source | Purpose | diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index d1455d6..335ed1e 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -1,9 +1,14 @@ using System.Text.Json; +using System.Text.RegularExpressions; namespace Epp.Otp.Providers; public sealed class SopranoProvider : IProviderAdapter { + private const string DefaultVoiceLanguage = "en-US"; + private const int VoiceGender = 1; + private const int VoiceLoop = 2; + public ProviderManifest Manifest { get; } = new( Id: "soprano", Auth: new AuthConfig("oauth"), @@ -20,8 +25,7 @@ public sealed class SopranoProvider : IProviderAdapter ["FILTERED"] = Outcome.Fail, ["BLOCKED"] = Outcome.Block, ["default"] = Outcome.Fail, - }, - RequiresTextToVoice: true); + }); public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { @@ -40,9 +44,22 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc }; if (channel == "voice") { - var voice = dispatch.TextToVoice; - if (voice?.IsComplete != true) throw new InvalidOperationException("incomplete voice context"); - body["voice"] = new { text2voice = voice }; + var message = dispatch.Message ?? string.Empty; + var passcode = Regex.Match(message, "[0-9]{6}"); + if (!passcode.Success) + throw new InvalidOperationException("voice message does not contain a six-digit passcode"); + body["voice"] = new + { + text2voice = new + { + beforePasswordText = message[..passcode.Index], + password = passcode.Value, + afterPasswordText = message[(passcode.Index + passcode.Length)..], + language = string.IsNullOrWhiteSpace(dispatch.Locale) ? DefaultVoiceLanguage : dispatch.Locale, + gender = VoiceGender, + loop = VoiceLoop, + }, + }; } else { diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 53c5d73..7ae317a 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -15,7 +15,11 @@ private static DispatchRequest Request(string channel = "sms") => [InlineData("voice")] public void SopranoUsesSelectedEndpointAndOAuth(string channel) { - var dispatch = Request(channel) with { TextToVoice = new TextToVoice("Your code is", "001234", "en-US") }; + var dispatch = Request(channel) with + { + Locale = channel == "voice" ? "fr-FR" : "en-US", + TextToVoice = new TextToVoice("ignored", "001234", "override"), + }; var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/oauth/messages", dispatch, new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv()); Assert.Equal("https://provider.example/oauth/messages", request.Url); @@ -32,12 +36,47 @@ public void SopranoUsesSelectedEndpointAndOAuth(string channel) ["shutterMode"] = false, }; if (channel == "voice") - expected["voice"] = new { text2voice = new { beforePasswordText = "Your code is", password = "001234", language = "en-US" } }; + expected["voice"] = new + { + text2voice = new + { + beforePasswordText = " Your code is ", + password = "918273", + afterPasswordText = ".\nDo not share. ", + language = "fr-FR", + gender = 1, + loop = 2, + }, + }; else expected["text"] = Request().Message; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SopranoVoiceDefaultsLanguageWithoutLocale(string? locale) + { + var request = new SopranoProvider().BuildRequest("voice", "https://provider.example/oauth/messages", + Request("voice") with { Locale = locale }, new ProviderCredential("oauth", AccessToken: "provider-token"), + new TestEnv()); + using var body = JsonDocument.Parse(request.Body); + Assert.Equal("en-US", body.RootElement.GetProperty("voice").GetProperty("text2voice") + .GetProperty("language").GetString()); + } + + [Fact] + public void SopranoVoiceRequiresSixDigitPasscode() + { + var dispatch = Request("voice") with { Message = "Your code is unavailable." }; + var error = Assert.Throws(() => new SopranoProvider().BuildRequest( + "voice", "https://provider.example/oauth/messages", dispatch, + new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv())); + Assert.Contains("six-digit passcode", error.Message); + } + [Fact] public void ProviderStatusesMapToExpectedOutcomesAndHttpCodes() { diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 2871c38..4e488c6 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -70,12 +70,24 @@ public async Task SopranoOAuthUsesSetupIdentitiesScopeAndOneBoundedExchange() rig.Env["EPP_PROVIDER_CHANNEL"] = channel; AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: JsonSerializer.SerializeToElement(new { - providerJwt = "FORGED-PAYLOAD", textToVoice = new { beforePasswordText = "Code", password = "001234", language = "en-US" }, + providerJwt = "FORGED-PAYLOAD", locale = "fr-FR", + textToVoice = new { beforePasswordText = "ignored", password = "001234", language = "override" }, }))); Assert.Equal("Bearer private-provider-token", rig.Http.Headers["Authorization"]); Assert.DoesNotContain("X-MEMS-API-ID", rig.Http.Headers.Keys); Assert.DoesNotContain("X-MEMS-API-Key", rig.Http.Headers.Keys); Assert.DoesNotContain("FORGED", rig.Http.Body!); + if (channel == "voice") + { + using var body = JsonDocument.Parse(rig.Http.Body!); + var speech = body.RootElement.GetProperty("voice").GetProperty("text2voice"); + Assert.Equal(" Your code is ", speech.GetProperty("beforePasswordText").GetString()); + Assert.Equal("918273", speech.GetProperty("password").GetString()); + Assert.Equal(".\nDo not share. ", speech.GetProperty("afterPasswordText").GetString()); + Assert.Equal("fr-FR", speech.GetProperty("language").GetString()); + Assert.Equal(1, speech.GetProperty("gender").GetInt32()); + Assert.Equal(2, speech.GetProperty("loop").GetInt32()); + } } rig.Env["EPP_PROVIDER_CHANNEL"] = "sms"; rig.Env["EPP_PROVIDER_SCOPE"] = "api://second/.default"; @@ -181,23 +193,6 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() Assert.DoesNotContain(value, log); } - [Theory] - [InlineData("null")] - [InlineData("[]")] - [InlineData("{}")] - [InlineData("{\"beforePasswordText\":\"\",\"password\":123,\"language\":\"en\"}")] - [InlineData("{\"beforePasswordText\":null,\"password\":\"001234\",\"language\":\"en\"}")] - [InlineData("{\"beforePasswordText\":\"\",\"password\":\"001234\",\"language\":\" \"}")] - public async Task IncompleteVoiceFailsBeforeSecretsOrHttp(string speech) - { - using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "soprano"; - rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth"; - var overrides = JsonSerializer.SerializeToElement(new { textToVoice = JsonSerializer.Deserialize(speech) }); - AssertFailure(rig, await rig.Invoke(channel: "voice", deliveryOverrides: overrides), 400); - Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); - } - [Fact] public void VoiceAllowsEmptyIntroAndKeepsDebugOutputPrivate() { diff --git a/javascript/README.md b/javascript/README.md index 1b15a0d..5b56c75 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -65,8 +65,12 @@ configure the current shared engine. Use `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDP that are actually read, such as a service-plan ID or voice selection. Private integration helpers may load settings from another location or use test credential variables, but the Function itself does not. -The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. The Telesign -adapter uses the configured complete endpoint and API-key credentials from Key Vault. +The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. For voice, it +extracts the first six-digit passcode from the rendered message and sends fixed synthesis values: +gender `1` and loop `2`. It uses a nonblank SAS request locale as the language, falling back to +`en-US` when the locale is absent or invalid. These values require no additional environment +settings. Soprano SMS continues to forward the rendered message unchanged. The Telesign adapter +uses the configured complete endpoint and API-key credentials from Key Vault. For Azure, set these application variables on the Function App/slot's **Environment variables → App settings** page and use a Key Vault reference for the private PEM. The provider-secret resolver uses diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index db77475..a1ca4d6 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,11 +4,14 @@ 'use strict'; -const { ParsedResponse, TextToVoice } = require('../models'); +const { ParsedResponse } = require('../models'); + +const DEFAULT_VOICE_LANGUAGE = 'en-US'; +const VOICE_GENDER = 1; +const VOICE_LOOP = 2; const manifest = { id: 'soprano', - requiresTextToVoice: true, auth: { mode: 'oauth' }, responseMapping: { ENROUTE: 'Continue', @@ -25,6 +28,24 @@ const manifest = { }, }; +function buildTextToVoice(message, locale) { + const renderedMessage = String(message || ''); + const passcodeMatch = renderedMessage.match(/\d{6}/); + if (!passcodeMatch) { + throw new Error('voice message does not contain a six-digit passcode'); + } + + const passcodeIndex = passcodeMatch.index; + return { + beforePasswordText: renderedMessage.slice(0, passcodeIndex), + password: passcodeMatch[0], + afterPasswordText: renderedMessage.slice(passcodeIndex + passcodeMatch[0].length), + language: typeof locale === 'string' && locale.trim() ? locale : DEFAULT_VOICE_LANGUAGE, + gender: VOICE_GENDER, + loop: VOICE_LOOP, + }; +} + function buildRequest({ channel, endpoint, dispatch, credential }) { const headers = { 'Content-Type': 'application/json', @@ -40,9 +61,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { shutterMode: false, }; if (channel === 'voice') { - const voice = dispatch.textToVoice; - if (!(voice instanceof TextToVoice) || !voice.isComplete) throw new Error('incomplete voice context'); - body.voice = { text2voice: voice }; + body.voice = { text2voice: buildTextToVoice(dispatch.message, dispatch.locale) }; } else { body.text = dispatch.message; } diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 4bb2b53..81dfb4c 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { AppConfig, readConfig } = require('../src/functions/config'); -const { DeliveryContext, TextToVoice, ParsedResponse } = require('../src/functions/models'); +const { DeliveryContext, ParsedResponse } = require('../src/functions/models'); const fixtures = require('../../tests/fixtures/contract.json'); const { inspect } = require('node:util'); const { AzureLogger } = require('@azure/logger'); @@ -100,38 +100,35 @@ test('Soprano uses the selected endpoint and OAuth bearer token', () => { }); test('Soprano Voice sends structured speech with OAuth', () => { - const textToVoice = TextToVoice.fromPayload({ beforePasswordText: ' Your code is ', password: '001234', - language: 'en-US', unexpected: 'must-not-be-forwarded' }); const request = getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice', - endpoint: `${input.endpoint}/oauth/voice`, dispatch: { ...dispatch, textToVoice }, + endpoint: `${input.endpoint}/oauth/voice`, + dispatch: { ...dispatch, locale: 'fr-FR', textToVoice: { language: 'override', gender: 2, loop: 9 } }, credential: { mode: 'oauth', accessToken: 'provider-token' } }); assert.equal(request.url, `${input.endpoint}/oauth/voice`); assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bear' + 'er provider-token' }); assert.deepEqual(JSON.parse(request.body), { destination: '15551234567', messageTypes: ['voice'], correlationId: 'correlation-id', shutterMode: false, - voice: { text2voice: { beforePasswordText: ' Your code is ', password: '001234', language: 'en-US' } } }); - assert.equal(inspect(textToVoice), '[TextToVoice]'); - assert.throws(() => getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice' }), - /incomplete voice context/); + voice: { text2voice: { beforePasswordText: ' Your code is ', password: '918273', + afterPasswordText: '.\n', language: 'fr-FR', gender: 1, loop: 2 } } }); + for (const locale of [undefined, null, '', ' ', { untrusted: true }]) { + const fallbackRequest = getProvider('soprano').adapter.buildRequest({ + ...input, channel: 'voice', dispatch: { ...dispatch, locale }, + credential: { mode: 'oauth', accessToken: 'provider-token' }, + }); + assert.equal(JSON.parse(fallbackRequest.body).voice.text2voice.language, 'en-US'); + } + assert.throws(() => getProvider('soprano').adapter.buildRequest({ + ...input, channel: 'voice', dispatch: { ...dispatch, message: 'Your code is unavailable.' }, + }), /voice message does not contain a six-digit passcode/); }); -test('Soprano Voice validates decrypted speech before secret lookup or HTTP', async (t) => { - const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', () => assert.fail('unexpected secret lookup')); - const fetchMock = t.mock.method(global, 'fetch', () => assert.fail('unexpected HTTP')); - const config = readConfig({ EPP_PROVIDER_NAME: 'soprano', EPP_PROVIDER_ENDPOINT: input.endpoint }); - for (const textToVoice of [null, [], 'text', {}, { beforePasswordText: '', password: 123, language: 'en' }, - { beforePasswordText: '', password: '001234', language: '' }, - { beforePasswordText: null, password: '001234', language: 'en' }]) { - const context = DeliveryContext.fromPayload({ nonce: 'nonce', phoneNumber: dispatch.destination, - message: dispatch.message, textToVoice }); - const result = await dispatchOtp(contextToDispatch(context, { channel: 2 }, 'message-id'), { config }); - assert.deepEqual([result.httpStatus, result.body.reason], [400, 'incomplete voice context']); - } - const context = DeliveryContext.fromPayload({ textToVoice: { beforePasswordText: '', password: '001234', language: 'en-US' } }); - assert.ok(contextToDispatch(context, { channel: 2 }, 'message-id').textToVoice.isComplete); - assert.equal(getSecret.mock.callCount(), 0); - assert.equal(fetchMock.mock.callCount(), 0); +test('Soprano SMS remains independent from voice synthesis settings', () => { + const request = getProvider('soprano').adapter.buildRequest({ ...input, + dispatch: { ...dispatch, textToVoice: { language: 'override', gender: 2, loop: 9 } }, + credential: { mode: 'oauth', accessToken: 'provider-token' } }); + assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', + messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); }); test('App-auth SMS preserves its request and normalizes acceptance', () => { diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 74050d3..a8b7fdf 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -182,7 +182,6 @@ test('Soprano OAuth failures never fall back to keys or forward an inbound token test('SMS/voice preserve content and correlation without reflecting headers or logging PII', async () => { const correlationId = 'PRIVATE-CORRELATION'; - const textToVoice = { beforePasswordText: ' PRIVATE-PROMPT ', password: '001234', language: 'en-US' }; const forgedHeaders = { authorization: 'Bearer FORGED-BEARER', 'x-ms-client-principal': Buffer.from(JSON.stringify({ claims: [{ typ: 'appid', val: 'FORGED-CALLER' }], @@ -190,14 +189,21 @@ test('SMS/voice preserve content and correlation without reflecting headers or l for (const [channel, name] of [[1, 'sms'], [2, 'voice']]) { const headers = channel === 1 ? {} : forgedHeaders; const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }, - { ...delivery, textToVoice }), headers); + { ...delivery, textToVoice: { language: 'override', gender: 2, loop: 9 } }), headers); assert.equal(result.status, 200); assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); const init = fetchMock.mock.calls.at(-1).arguments[1]; const sent = JSON.parse(init.body); assert.deepEqual([sent.messageTypes, sent.correlationId], [[name], correlationId]); if (channel === 2) { - assert.deepEqual(sent.voice, { text2voice: textToVoice }); + assert.deepEqual(sent.voice, { text2voice: { + beforePasswordText: ' PRIVATE-MESSAGE ', + password: '918273', + afterPasswordText: '.\n', + language: delivery.locale, + gender: 1, + loop: 2, + } }); assert.equal(sent.text, undefined); } else { assert.equal(sent.text, delivery.message); diff --git a/python/README.md b/python/README.md index 329bd0c..27c3d4f 100644 --- a/python/README.md +++ b/python/README.md @@ -80,6 +80,11 @@ await acceptance before returning the nonce; failures omit it. Acceptance is not Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). +For Soprano voice, the adapter extracts the first six-digit passcode from the rendered message. It +uses a nonblank SAS request locale as the language, falling back to `en-US`, and sends fixed gender +`1` and loop `2`. These values require no additional environment settings. Soprano SMS continues to +forward the rendered message unchanged. + ## Source | Source | Purpose | diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 78d483e..5e874e6 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,12 +1,31 @@ import json +import re -from ..models import ParsedResponse, TextToVoice +from ..models import ParsedResponse + +DEFAULT_VOICE_LANGUAGE = "en-US" +VOICE_GENDER = 1 +VOICE_LOOP = 2 + + +def _build_text_to_voice(message, locale): + rendered_message = str(message or "") + passcode_match = re.search(r"[0-9]{6}", rendered_message) + if not passcode_match: + raise ValueError("voice message does not contain a six-digit passcode") + return { + "beforePasswordText": rendered_message[:passcode_match.start()], + "password": passcode_match.group(0), + "afterPasswordText": rendered_message[passcode_match.end():], + "language": locale if isinstance(locale, str) and locale.strip() else DEFAULT_VOICE_LANGUAGE, + "gender": VOICE_GENDER, + "loop": VOICE_LOOP, + } class SopranoProvider: manifest = { "id": "soprano", - "requires_text_to_voice": True, "auth": {"mode": "oauth"}, "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", @@ -29,14 +48,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "shutterMode": False, } if channel == "voice": - voice = dispatch.text_to_voice - if not isinstance(voice, TextToVoice) or not voice.is_complete: - raise ValueError("incomplete voice context") - body["voice"] = {"text2voice": { - "beforePasswordText": voice.before_password_text, - "password": voice.password, - "language": voice.language, - }} + body["voice"] = {"text2voice": _build_text_to_voice(dispatch.message, dispatch.locale)} else: body["text"] = dispatch.message return {"url": endpoint, "method": "POST", "headers": headers, "body": json.dumps(body)} diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index e819f9b..01471c5 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -23,7 +23,8 @@ def _dispatch(channel="sms"): def test_soprano_selected_endpoint_and_oauth_contract(channel): dispatch = _dispatch(channel) if channel == "voice": - dispatch.text_to_voice = TextToVoice("Your code is", "001234", "en-US") + dispatch.locale = "fr-FR" + dispatch.text_to_voice = TextToVoice("ignored", "001234", "override") request = ProviderRegistry([SopranoProvider()]).get("SOPRANO").build_request( channel, "https://qa4.example/oauth/messages", dispatch, {"mode": "oauth", "access_token": "provider-token"}, @@ -39,7 +40,14 @@ def test_soprano_selected_endpoint_and_oauth_contract(channel): "correlationId": "correlation-id", "shutterMode": False, } if channel == "voice": - expected["voice"] = {"text2voice": {"beforePasswordText": "Your code is", "password": "001234", "language": "en-US"}} + expected["voice"] = {"text2voice": { + "beforePasswordText": " Use ", + "password": "918273", + "afterPasswordText": "; then 1234.\nDo not rewrite + or café. ", + "language": "fr-FR", + "gender": 1, + "loop": 2, + }} else: expected["text"] = MESSAGE assert json.loads(request["body"]) == expected @@ -48,6 +56,27 @@ def test_soprano_selected_endpoint_and_oauth_contract(channel): assert "ENROUTE" not in repr(response) +@pytest.mark.parametrize("locale", [None, "", " ", {"untrusted": True}]) +def test_soprano_voice_defaults_language_without_valid_locale(locale): + dispatch = _dispatch("voice") + dispatch.locale = locale + request = SopranoProvider().build_request( + "voice", "https://qa4.example/oauth/messages", dispatch, + {"mode": "oauth", "access_token": "provider-token"}, {}, + ) + assert json.loads(request["body"])["voice"]["text2voice"]["language"] == "en-US" + + +def test_soprano_voice_requires_six_digit_passcode(): + dispatch = _dispatch("voice") + dispatch.message = "Your code is unavailable." + with pytest.raises(ValueError, match="six-digit passcode"): + SopranoProvider().build_request( + "voice", "https://qa4.example/oauth/messages", dispatch, + {"mode": "oauth", "access_token": "provider-token"}, {}, + ) + + def test_infobip_sms_request_and_response_contract(): request = InfobipProvider().build_request( "sms", "https://infobip.example", _dispatch(), diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 50da4aa..936c251 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -150,7 +150,8 @@ def test_soprano_voice_payload_uses_oauth(engine): engine.env["EPP_PROVIDER_CHANNEL"] = "voice" speech = {"beforePasswordText": "Your code is", "password": "001234", "language": "en-US"} context = DeliveryContext.from_payload({"nonce": "n", "phoneNumber": "+15551234567", - "message": "Your code is 001234", "textToVoice": speech}) + "message": "Your code is 001234", "locale": "fr-FR", + "textToVoice": speech}) envelope = Envelope("microsoft.mfa.otpDeliver.v1", "tenant", "correlation", 2, 1, None, "encrypted") request = dispatch_module.context_to_dispatch(context, envelope, "message") dispatch_module.requests.request.return_value = Mock(status_code=200, json=Mock(return_value={"status": "ACCEPTED"})) @@ -158,23 +159,26 @@ def test_soprano_voice_payload_uses_oauth(engine): assert status == 200 and body["outcome"] == "Continue" sent = dispatch_module.requests.request.call_args.kwargs payload = json.loads(sent["data"]) - assert payload["voice"] == {"text2voice": speech} + assert payload["voice"] == {"text2voice": { + "beforePasswordText": "Your code is ", + "password": "001234", + "afterPasswordText": "", + "language": "fr-FR", + "gender": 1, + "loop": 2, + }} assert payload["messageTypes"] == ["voice"] and payload["destination"] == "15551234567" assert "text" not in payload assert sent["headers"]["Authorization"] == "Bear" + "er provider-token" assert "001234" not in repr(request.text_to_voice) -@pytest.mark.parametrize("speech", [None, {}, [], "invalid", - {"beforePasswordText": "Code", "password": 1234, "language": "en"}, - {"beforePasswordText": "Code", "password": "1234", "language": " "}, - {"password": "1234", "language": "en"}]) -def test_incomplete_soprano_voice_never_sends(engine, speech): +def test_soprano_voice_without_six_digit_passcode_never_sends(engine): engine.env["EPP_PROVIDER_CHANNEL"] = "voice" request = _request("voice") - request.text_to_voice = TextToVoice.from_payload(speech) + request.message = "Your code is unavailable." status, body = engine.dispatch(request, "r") - assert status == 400 and body["reason"] == "incomplete voice context" + assert status == 502 and body["reason"] == "provider request failed" engine.secrets.resolve.assert_not_called() dispatch_module.requests.request.assert_not_called() diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 664cc54..5dc6301 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -180,7 +180,14 @@ def wait_for_acceptance(*args, **kwargs): send.assert_called_once() upstream.close.assert_called_once() wire = json.loads(send.call_args.kwargs["data"]) - assert wire["voice"] == {"text2voice": speech} + assert wire["voice"] == {"text2voice": { + "beforePasswordText": " Your code is ", + "password": "123456", + "afterPasswordText": "; keep 7890 unchanged.\nCafé. ", + "language": "en-US", + "gender": 1, + "loop": 2, + }} assert "text" not in wire and wire["messageTypes"] == ["voice"] and wire["correlationId"] == _CORRELATION summary = json.loads(caplog.records[-1].getMessage().removeprefix("[EPP] result ")) assert len(caplog.records) == 1