diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 01ab72d..26d67f7 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -76,13 +76,33 @@ 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, including caller-supplied voice digit spacing. Do not extract, infer or guess a passcode | +| `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 | | `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 | 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" +} +``` + +`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. +Soprano authentication remains API-key-only (`X-MEMS-API-ID` and `X-MEMS-API-Key`, resolved from +`soprano-api-id` and `soprano-api-key` in Key Vault). No provider JWT, OAuth flow, token endpoint, +or bearer-token forwarding is added. Existing platform caller authentication is unchanged. + JWE provides payload confidentiality and integrity, **not SAS caller authentication**. Anyone with the public key can encrypt a request. The nonce acknowledges decryption; it is not an authentication credential or replay protection, and a fixed nonce cannot substitute for Easy Auth. diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index 735a639..4c3bc4e 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -84,7 +84,8 @@ ObjectResult Reply(int status, object body) Channel: channel, MessageId: clientRequestId, CorrelationId: correlationId, - Locale: context.Locale); + Locale: context.Locale, + TextToVoice: context.TextToVoice); // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. var result = await _engine.DispatchAsync(dispatch, requestId); diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 1163547..fdf5cec 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -103,6 +103,7 @@ public sealed class DeliveryContext [JsonPropertyName("locale")] public string? Locale { get; set; } [JsonPropertyName("message")] public string? Message { get; set; } [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } + [JsonPropertyName("textToVoice")] public TextToVoice? TextToVoice { get; set; } [JsonIgnore] public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce) @@ -114,6 +115,13 @@ public static DeliveryContext FromPayload(JsonElement payload) if (payload.ValueKind != JsonValueKind.Object) return new(); string? ReadString(string name) => payload.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + TextToVoice? voice = null; + if (payload.TryGetProperty("textToVoice", out var speech) && speech.ValueKind == JsonValueKind.Object) + { + string? ReadVoiceString(string name) => speech.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + voice = new TextToVoice(ReadVoiceString("beforePasswordText"), ReadVoiceString("password"), ReadVoiceString("language")); + } return new() { Nonce = ReadString("nonce"), @@ -122,6 +130,7 @@ public static DeliveryContext FromPayload(JsonElement payload) Extension = ReadString("extension"), Locale = ReadString("locale"), RiskContext = payload.TryGetProperty("riskContext", out var risk) ? risk.Clone() : null, + TextToVoice = voice, }; } } @@ -230,6 +239,9 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!OutcomeMapper.DefaultChannels.Contains(channel)) return new DispatchResult(400, new { status = "error", provider = providerId, reason = "unsupported channel", requestId }); + if (channel == "voice" && manifest.RequiresTextToVoice && dispatch.TextToVoice?.IsComplete != true) + return new DispatchResult(400, FailBody(providerId, channel, "incomplete voice context", dispatch, requestId)); + if (manifest.Auth.Mode != "apiKey") return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId)); diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index e8f14c6..e4beeb6 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -24,7 +24,20 @@ public sealed record DispatchRequest( string Channel, string MessageId, string? CorrelationId, - string? Locale); + string? Locale, + TextToVoice? TextToVoice = null); + +public sealed record TextToVoice( + [property: JsonPropertyName("beforePasswordText")] string? BeforePasswordText, + [property: JsonPropertyName("password")] string? Password, + [property: JsonPropertyName("language")] string? Language) +{ + [JsonIgnore] + public bool IsComplete => BeforePasswordText is not null + && !string.IsNullOrWhiteSpace(Password) && !string.IsNullOrWhiteSpace(Language); + + public override string ToString() => nameof(TextToVoice); +} public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); @@ -43,7 +56,8 @@ public sealed record ParsedResponse( public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null); -public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping); +public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping, + bool RequiresTextToVoice = false); public sealed record DispatchResult(int HttpStatus, object Body); diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 7ffb5df..a65fdea 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -20,7 +20,8 @@ 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) { @@ -31,14 +32,23 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc ["X-MEMS-API-ID"] = credential.Identity ?? string.Empty, ["X-MEMS-API-Key"] = credential.Secret ?? string.Empty, }; - var body = new + var body = new Dictionary { - text = dispatch.Message, - destination = dispatch.Destination.TrimStart('+'), - messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, - correlationId = dispatch.CorrelationId ?? dispatch.MessageId, - shutterMode = false, + ["destination"] = dispatch.Destination.TrimStart('+'), + ["messageTypes"] = new[] { channel == "voice" ? "voice" : "sms" }, + ["correlationId"] = dispatch.CorrelationId ?? dispatch.MessageId, + ["shutterMode"] = false, }; + if (channel == "voice") + { + var voice = dispatch.TextToVoice; + if (voice?.IsComplete != true) throw new InvalidOperationException("incomplete voice context"); + body["voice"] = new { text2voice = voice }; + } + else + { + body["text"] = dispatch.Message; + } return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); } diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index e78a06b..4673630 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -15,7 +15,8 @@ private static DispatchRequest Request(string channel = "sms") => [InlineData("voice")] public void SopranoUsesExactOmnimsgContract(string channel) { - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel), + var dispatch = Request(channel) with { TextToVoice = new TextToVoice("Your code is", "001234", "en") }; + var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", dispatch, new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url); Assert.Equal("POST", request.Method); @@ -24,14 +25,17 @@ public void SopranoUsesExactOmnimsgContract(string channel) Assert.Equal("test-key", request.Headers["X-MEMS-API-Key"]); Assert.Equal("application/json", request.Headers["Accept"]); Assert.Equal("application/json", request.Headers["Content-Type"]); - var expected = new + var expected = new Dictionary { - text = Request().Message, - destination = "15551234567", - messageTypes = new[] { channel }, - correlationId = "correlation-id", - shutterMode = false, + ["destination"] = "15551234567", + ["messageTypes"] = new[] { channel }, + ["correlationId"] = "correlation-id", + ["shutterMode"] = false, }; + if (channel == "voice") + expected["voice"] = new { text2voice = new { beforePasswordText = "Your code is", password = "001234", language = "en" } }; + else + expected["text"] = Request().Message; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 6324883..f297f26 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -31,7 +31,8 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() entered.TrySetResult(); return release.Task.WaitAsync(cancellation); }; - var pending = rig.Invoke(channel: "voice"); + var voice = new { beforePasswordText = " Your code is ", password = "001234", language = "en" }; + var pending = rig.Invoke(channel: "voice", deliveryOverrides: JsonSerializer.SerializeToElement(new { textToVoice = voice })); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -43,15 +44,43 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() } AssertAccepted(await pending); using var body = JsonDocument.Parse(rig.Http.Body!); - Assert.Equal(Message, body.RootElement.GetProperty("text").GetString()); + Assert.False(body.RootElement.TryGetProperty("text", out _)); + Assert.Equal(JsonSerializer.Serialize(voice), body.RootElement.GetProperty("voice").GetProperty("text2voice").GetRawText()); + Assert.Equal("voice", body.RootElement.GetProperty("messageTypes")[0].GetString()); + Assert.Equal("private-api-id", rig.Http.Headers["X-MEMS-API-ID"]); + Assert.Equal("private-api-key", rig.Http.Headers["X-MEMS-API-Key"]); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); Assert.Equal(1, rig.Http.Calls); var log = Assert.Single(rig.Log.Messages); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Correlation)))[..16].ToLowerInvariant(); Assert.Contains("CorrelationId=" + hash, log); - foreach (var value in new[] { Phone, "918273", Nonce, Correlation, "private-api-key", "private-api-id" }) + foreach (var value in new[] { Phone, "918273", "001234", Nonce, Correlation, "private-api-key", "private-api-id" }) 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(); + 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() + { + var voice = new TextToVoice("", "001234", "en"); + Assert.True(voice.IsComplete); + Assert.Equal("TextToVoice", voice.ToString()); + } + [Fact] public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() { @@ -293,6 +322,7 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory { public int Calls { get; private set; } public string? Body { get; private set; } + public Dictionary Headers { get; private set; } = new(StringComparer.OrdinalIgnoreCase); public Func> Respond { get; set; } = _ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\"}")); public HttpClient CreateClient(string name) => new(this, disposeHandler: false); @@ -300,6 +330,7 @@ protected override async Task SendAsync(HttpRequestMessage { Calls++; Body = await request.Content!.ReadAsStringAsync(cancellationToken); + Headers = request.Headers.ToDictionary(header => header.Key, header => string.Join(",", header.Value), StringComparer.OrdinalIgnoreCase); return await Respond(cancellationToken); } } diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index e33383e..0f96293 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -9,7 +9,7 @@ const { compactDecrypt } = require('jose'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); -const { DeliveryContext } = require('./models'); +const { DeliveryContext, TextToVoice } = require('./models'); const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); @@ -131,6 +131,7 @@ function contextToDispatch(context, envelope, messageId) { messageId, correlationId: envelope.correlationId, locale: context.locale || undefined, + textToVoice: context.textToVoice, }; } @@ -306,6 +307,11 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; } + if (channel === 'voice' && manifest.requiresTextToVoice + && (!(dispatch.textToVoice instanceof TextToVoice) || !dispatch.textToVoice.isComplete)) { + return { httpStatus: 400, body: failBody(providerId, channel, 'incomplete voice context', dispatch, requestId) }; + } + const endpointBaseUrl = config.providerEndpoint; if (!isValidProviderUrl(endpointBaseUrl)) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint missing or invalid', dispatch, requestId) }; diff --git a/javascript/src/functions/models.js b/javascript/src/functions/models.js index ce863a5..aedc920 100644 --- a/javascript/src/functions/models.js +++ b/javascript/src/functions/models.js @@ -25,14 +25,35 @@ const { inspect } = require('node:util'); * @property {*} locale */ +class TextToVoice { + constructor({ beforePasswordText, password, language }) { + this.beforePasswordText = beforePasswordText; + this.password = password; + this.language = language; + } + + static fromPayload(payload) { + return payload && typeof payload === 'object' && !Array.isArray(payload) + ? new TextToVoice(payload) : null; + } + + get isComplete() { + return typeof this.beforePasswordText === 'string' + && [this.password, this.language].every(value => typeof value === 'string' && value.trim().length > 0); + } + + [inspect.custom]() { return '[TextToVoice]'; } +} + class DeliveryContext { - constructor({ nonce, phoneNumber, message, extension, locale, riskContext }) { + constructor({ nonce, phoneNumber, message, extension, locale, riskContext, textToVoice = null }) { this.nonce = nonce; this.phoneNumber = phoneNumber; this.message = message; this.extension = extension; this.locale = locale; this.riskContext = riskContext; + this.textToVoice = TextToVoice.fromPayload(textToVoice); } static fromPayload(payload) { @@ -72,4 +93,4 @@ class ParsedResponse { [inspect.custom]() { return '[ParsedResponse]'; } } -module.exports = { DeliveryContext, ParsedResponse }; +module.exports = { DeliveryContext, TextToVoice, ParsedResponse }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index de86805..8696c50 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,10 +4,11 @@ 'use strict'; -const { ParsedResponse } = require('../models'); +const { ParsedResponse, TextToVoice } = require('../models'); const manifest = { id: 'soprano', + requiresTextToVoice: true, auth: { mode: 'apiKey', keyVaultSecretName: 'soprano-api-key', @@ -40,12 +41,18 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { let destination = String(dispatch.destination || ''); while (destination.startsWith('+')) destination = destination.slice(1); const body = { - text: dispatch.message, destination, messageTypes: [channel === 'voice' ? 'voice' : 'sms'], correlationId: dispatch.correlationId || dispatch.messageId, 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 }; + } else { + body.text = dispatch.message; + } return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; } diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 28d7f51..34a8d77 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -4,12 +4,12 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { SecretClient } = require('@azure/keyvault-secrets'); const { AppConfig, readConfig } = require('../src/functions/config'); -const { DeliveryContext, ParsedResponse } = require('../src/functions/models'); +const { DeliveryContext, TextToVoice, ParsedResponse } = require('../src/functions/models'); const fixtures = require('../../tests/fixtures/contract.json'); const { inspect } = require('node:util'); const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, - parseEnvelope, parseProviderTimeout, isValidProviderUrl, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, } = require('../src/functions/dispatch'); const dispatch = { destination: '+15551234567', message: ' Your code is 918273.\n', channel: 'sms', messageId: 'message-id', correlationId: 'correlation-id' }; @@ -95,6 +95,40 @@ test('omnimsg preserves its API-key request and normalizes acceptance', () => { assert.equal(inspect(response), '[ParsedResponse]'); }); +test('Soprano Voice sends structured speech with API-key headers only', () => { + const textToVoice = TextToVoice.fromPayload({ beforePasswordText: ' Your code is ', password: '001234', + language: 'en', unexpected: 'must-not-be-forwarded' }); + const request = getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice', + dispatch: { ...dispatch, textToVoice }, credential: { ...input.credential, token: 'ignored-token' } }); + assert.equal(request.url, `${input.endpoint}/messages/omnimsg`); + assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', + 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); + 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' } } }); + assert.equal(inspect(textToVoice), '[TextToVoice]'); + assert.throws(() => getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice' }), + /incomplete voice context/); +}); + +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' } }); + assert.ok(contextToDispatch(context, { channel: 2 }, 'message-id').textToVoice.isComplete); + assert.equal(getSecret.mock.callCount(), 0); + assert.equal(fetchMock.mock.callCount(), 0); +}); + test('App-auth SMS preserves its request and normalizes acceptance', () => { const request = getProvider('infobip').adapter.buildRequest(input); assert.equal(request.url, 'https://provider.example/sms/3/messages'); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 0d79552..3867769 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -155,23 +155,34 @@ test('evaluation decrypts without provider config or I/O and checks the advisory 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' }; const forgedHeaders = { authorization: 'Bearer FORGED-BEARER', 'x-ms-client-principal': Buffer.from(JSON.stringify({ claims: [{ typ: 'appid', val: 'FORGED-CALLER' }], })).toString('base64') }; for (const [channel, name] of [[1, 'sms'], [2, 'voice']]) { const headers = channel === 1 ? {} : forgedHeaders; - const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }), headers); + const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }, + { ...delivery, textToVoice }), 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.text, sent.messageTypes, sent.correlationId], [delivery.message, [name], correlationId]); + assert.deepEqual([sent.messageTypes, sent.correlationId], [[name], correlationId]); + if (channel === 2) { + assert.deepEqual(sent.voice, { text2voice: textToVoice }); + assert.equal(sent.text, undefined); + } else { + assert.equal(sent.text, delivery.message); + assert.equal(sent.voice, undefined); + } + assert.deepEqual(init.headers, { 'Content-Type': 'application/json', Accept: 'application/json', + 'X-MEMS-API-ID': 'PRIVATE-API-KEY', 'X-MEMS-API-Key': 'PRIVATE-API-KEY' }); assert.equal(init.redirect, 'manual'); assert.equal(logs.length, 1); assert.deepEqual(Object.keys(logs[0]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); assert.equal(logs[0].correlationId, crypto.createHash('sha256').update(correlationId).digest('hex').slice(0, 16)); - assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|15551234567/); + assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|001234|15551234567/); const output = JSON.stringify([result.jsonBody, logs, warnings]); assert.doesNotMatch(output, /FORGED/); for (const value of Object.values(forgedHeaders)) assert.equal(output.includes(value), false); diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 47c7ccd..e08913c 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -9,7 +9,7 @@ from urllib3.exceptions import ReadTimeoutError from .config import read_config -from .models import DeliveryContext, DispatchRequest, Envelope, ParsedResponse +from .models import DeliveryContext, DispatchRequest, Envelope, ParsedResponse, TextToVoice DEFAULT_TIMEOUT_MS = 1500 DEFAULT_CHANNELS = ["sms", "voice"] @@ -179,6 +179,7 @@ def context_to_dispatch(context, envelope, message_id): message_id=message_id, correlation_id=envelope.correlation_id, locale=context.locale, + text_to_voice=context.text_to_voice, ) @@ -257,6 +258,11 @@ def dispatch(self, dispatch, request_id): if channel not in DEFAULT_CHANNELS: return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} + if channel == "voice" and manifest.get("requires_text_to_voice") and ( + not isinstance(dispatch.text_to_voice, TextToVoice) or not dispatch.text_to_voice.is_complete + ): + return 400, self._fail_body(provider_id, channel, "incomplete voice context", dispatch, request_id) + auth = manifest["auth"] if auth.get("mode") != "apiKey": return 502, self._fail_body(provider_id, channel, "unsupported provider auth mode", dispatch, request_id) diff --git a/python/src/models.py b/python/src/models.py index 8564af4..d1381d4 100644 --- a/python/src/models.py +++ b/python/src/models.py @@ -12,6 +12,25 @@ class Envelope: encrypted_delivery_context: str +@dataclass(repr=False) +class TextToVoice: + before_password_text: object + password: object + language: object + + @classmethod + def from_payload(cls, payload: object) -> "TextToVoice | None": + if not isinstance(payload, dict): + return None + return cls(payload.get("beforePasswordText"), payload.get("password"), payload.get("language")) + + @property + def is_complete(self) -> bool: + return isinstance(self.before_password_text, str) and all( + isinstance(value, str) and value.strip() for value in (self.password, self.language) + ) + + @dataclass(repr=False) class DeliveryContext: # Keep raw JSON values until is_complete validates the required strings. @@ -21,6 +40,7 @@ class DeliveryContext: locale: object = None extension: object = None risk_context: object = None + text_to_voice: TextToVoice | None = None @classmethod def from_payload(cls, payload: object) -> "DeliveryContext | None": @@ -33,6 +53,7 @@ def from_payload(cls, payload: object) -> "DeliveryContext | None": locale=payload.get("locale"), extension=payload.get("extension"), risk_context=payload.get("riskContext"), + text_to_voice=TextToVoice.from_payload(payload.get("textToVoice")), ) @property @@ -51,6 +72,7 @@ class DispatchRequest: message_id: str correlation_id: str | None locale: str | None + text_to_voice: TextToVoice | None = None @dataclass(repr=False) diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 93e088b..4a651bc 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,11 +1,12 @@ import json -from ..models import ParsedResponse +from ..models import ParsedResponse, TextToVoice class SopranoProvider: manifest = { "id": "soprano", + "requires_text_to_voice": True, "auth": { "mode": "apiKey", "key_vault_secret_name": "soprano-api-key", @@ -27,12 +28,22 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "Accept": "application/json", } body = { - "text": dispatch.message, "destination": str(dispatch.destination).lstrip("+"), "messageTypes": [message_type], "correlationId": dispatch.correlation_id or dispatch.message_id, "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, + }} + else: + body["text"] = dispatch.message return {"url": f"{endpoint.rstrip('/')}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 9bf87f7..74cca90 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -6,7 +6,7 @@ import pytest from src.dispatch import DispatchRequest, ProviderRegistry, context_to_dispatch, parse_envelope -from src.models import DeliveryContext, Envelope, ParsedResponse +from src.models import DeliveryContext, Envelope, ParsedResponse, TextToVoice from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider @@ -16,7 +16,8 @@ def _dispatch(channel="sms"): - return DispatchRequest("+15551234567", MESSAGE, channel, "message-id", "correlation-id", "en-US") + return DispatchRequest("+15551234567", MESSAGE, channel, "message-id", "correlation-id", "en-US", + TextToVoice("Your code is", "001234", "en") if channel == "voice" else None) @pytest.mark.parametrize("channel", ["sms", "voice"]) @@ -31,10 +32,15 @@ def test_soprano_exact_sms_and_voice_contract(channel): "X-MEMS-API-ID": "test-id", "X-MEMS-API-Key": "test-key", "Content-Type": "application/json", "Accept": "application/json", } - assert json.loads(request["body"]) == { - "text": MESSAGE, "destination": "15551234567", "messageTypes": [channel], + expected = { + "destination": "15551234567", "messageTypes": [channel], "correlationId": "correlation-id", "shutterMode": False, } + if channel == "voice": + expected["voice"] = {"text2voice": {"beforePasswordText": "Your code is", "password": "001234", "language": "en"}} + else: + expected["text"] = MESSAGE + assert json.loads(request["body"]) == expected response = SopranoProvider().parse_response(201, True, {"id": 123, "status": "ENROUTE"}) assert response == ParsedResponse(True, 201, provider_message_id="123", provider_status_name="ENROUTE") assert "ENROUTE" not in repr(response) diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 6d352f8..9f72022 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,3 +1,4 @@ +import json from unittest.mock import Mock import pytest @@ -6,6 +7,7 @@ import src.dispatch as dispatch_module from src.config import AppConfig, read_config from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry +from src.models import DeliveryContext, Envelope, TextToVoice from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider @@ -30,6 +32,39 @@ def test_missing_key_or_identity_never_sends(engine): dispatch_module.requests.request.assert_not_called() +def test_soprano_voice_payload_uses_api_key_only(engine): + speech = {"beforePasswordText": "Your code is", "password": "001234", "language": "en"} + context = DeliveryContext.from_payload({"nonce": "n", "phoneNumber": "+15551234567", + "message": "Your code is 001234", "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"})) + status, body = engine.dispatch(request, "r") + 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["messageTypes"] == ["voice"] and payload["destination"] == "15551234567" + assert "text" not in payload + assert sent["headers"]["X-MEMS-API-Key"] == "test-key" + assert sent["headers"]["X-MEMS-API-ID"] == "test-key" + assert "Authorization" not in sent["headers"] + 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): + request = _request("voice") + request.text_to_voice = TextToVoice.from_payload(speech) + status, body = engine.dispatch(request, "r") + assert status == 400 and body["reason"] == "incomplete voice context" + engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + def test_base_and_sinch_voice_final_url_guards(engine): for url in ("http://api.example", "https://api.example:0"): engine.env["EPP_PROVIDER_ENDPOINT"] = url diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 07a088d..f8f1ad9 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -160,8 +160,10 @@ def wait_for_acceptance(*args, **kwargs): send = Mock(side_effect=wait_for_acceptance) monkeypatch.setattr(dispatch_module.requests, "request", send) + speech = {"beforePasswordText": "Your code is", "password": "001234", "language": "en"} with ThreadPoolExecutor(max_workers=1) as executor: - request = _request(_envelope(channel=2), {"x-ms-client-request-id": "wire-message"}) + request = _request(_envelope(channel=2, encryptedDeliveryContext=_encrypt( + context={**_CONTEXT, "textToVoice": speech})), {"x-ms-client-request-id": "wire-message"}) pending = executor.submit(_HANDLER, request) try: assert entered.wait(5), "handler did not reach provider" @@ -176,12 +178,13 @@ 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["text"] == _MESSAGE and wire["messageTypes"] == ["voice"] and wire["correlationId"] == _CORRELATION + assert wire["voice"] == {"text2voice": speech} + 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 assert set(summary) == {"requestId", "correlationId", "httpStatus", "elapsedMs", "evaluation"} assert summary["correlationId"] == hashlib.sha256(_CORRELATION.encode()).hexdigest()[:16] - for private in (_NONCE, _PHONE, _MESSAGE, "123456", _CORRELATION, "wire-message", "test-key"): + for private in (_NONCE, _PHONE, _MESSAGE, "123456", "001234", _CORRELATION, "wire-message", "test-key"): assert private not in caplog.text