diff --git a/TECHNICAL.md b/TECHNICAL.md index 239c94b..ab639f5 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -252,7 +252,7 @@ For example, `EPP_PROVIDER_NAME` becomes `config.providerName` in JavaScript, - **Provider:** `EPP_PROVIDER_NAME`, `EPP_PROVIDER_AUTH_MODE`, and `EPP_PROVIDER_ENDPOINT_REGION`. - **Endpoints:** `EPP_PROVIDER_SMS_ENDPOINT` and `EPP_PROVIDER_VOICE_ENDPOINT`. -- **Timeout and account:** `EPP_PROVIDER_TIMEOUT_MS` and `EPP_PROVIDER_ACCOUNT_NAME`. +- **Timeout:** `EPP_PROVIDER_TIMEOUT_MS`. - **Key Vault:** `KEY_VAULT_URL` and optional `AZURE_CLIENT_ID`. - **OAuth:** `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, and `EPP_OUTBOUND_MI_CLIENT_ID`. @@ -344,8 +344,11 @@ Both channel settings are **complete HTTPS URLs**, used unchanged by the adapter | Global | `https://api.infobip.com/sms/3/messages` | `https://api.infobip.com/tts/3/advanced` | | EU | `https://api-eu.infobip.com/sms/3/messages` | `https://api-eu.infobip.com/tts/3/advanced` | -Set `EPP_PROVIDER_ACCOUNT_NAME` to an Infobip-approved sender/caller ID and confirm it works for -both intended channels. The adapter's existing `Verify` fallback is not proof of sender approval. +Infobip manages sender selection for SMS and caller ID selection for voice. +The SMS `sender` and voice `from` fields are optional in the published +[SMS schema](https://www.infobip.com/docs/api/channels/sms/outbound-sms/send-message/send-sms-messages) +and [voice schema](https://www.infobip.com/docs/api/channels/voice/voice-message/voice-message-sending-operations/send-advanced-voice-tts). +Confirm provider-side sender/caller configuration and destination coverage for both channels. ### Soprano diff --git a/docs/MULTI-PROVIDER-IMPLEMENTATION.md b/docs/MULTI-PROVIDER-IMPLEMENTATION.md index fca9a89..183d845 100644 --- a/docs/MULTI-PROVIDER-IMPLEMENTATION.md +++ b/docs/MULTI-PROVIDER-IMPLEMENTATION.md @@ -76,7 +76,6 @@ The following customer-owned format is not loaded by the sample. Replace each "EPP_PROVIDER_NAME": "", "EPP_PROVIDER_AUTH_MODE": "apiKey", "EPP_PROVIDER_SMS_ENDPOINT": "", - "EPP_PROVIDER_ACCOUNT_NAME": "", "KEY_VAULT_URL": "https://.vault.azure.net", "AZURE_CLIENT_ID": "" } diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index ef78b27..aedd1c7 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -109,11 +109,11 @@ Guided setup supports **Infobip, Soprano, and Telesign**. | Provider | Authentication | Customer action | | --- | --- | --- | -| Infobip | OAuth client assertion through managed identity federation | Complete provider-side authorization and configure an approved sender/caller ID in `EPP_PROVIDER_ACCOUNT_NAME`; no Infobip API-key secret | +| Infobip | OAuth client assertion through managed identity federation | Complete provider-side authorization and confirm provider-managed sender/caller selection for both channels; no Infobip API-key secret | | Soprano | OAuth client assertion through managed identity federation | Complete provider-side authorization and confirm tenant, app, scope, account, and channels | | Telesign | OAuth client assertion through managed identity federation | Complete [OAuth configuration and handoff](#telesign-oauth-configuration-and-handoff) | -Sinch is bundled but does not have a guided setup profile. Twilio requires the +Twilio requires the [Add Twilio](BRING-YOUR-OWN-PROVIDER.md) development process. ### Telesign: OAuth configuration and handoff @@ -128,7 +128,7 @@ Use a Function package supporting Telesign OAuth and complete the ### Add API-key provider secrets -This applies to API-key adapters such as Sinch, not the guided OAuth providers. Use the exact +This applies to custom API-key adapters, not the bundled OAuth providers. Use the exact secret names required by the selected adapter. 1. In the deployment summary, find `resources.keyVault`. @@ -137,7 +137,7 @@ secret names required by the selected adapter. ![CYOT-CONSTO Key Vault Objects menu with the consto vault name, Secrets selected, and Generate/Import visible](images/onboarding/key-vault-secrets.png) -4. Create the required secret with the raw credential, such as `sinch-api-token` for Sinch. +4. Create each required secret with the raw credential and the exact name specified by your adapter. 5. Verify its name, enabled state, dates, and version without viewing its value. 6. Confirm the Function's system-assigned identity has **Key Vault Secrets User**. @@ -182,9 +182,10 @@ access. Then inspect **App registrations > Certificates & secrets > Federated cr The identity client ID is not the federation subject. Customer-side configuration alone does not prove provider-side authorization. Verify the integration with an approved controlled live test. -For Infobip, set the Function App's `EPP_PROVIDER_ACCOUNT_NAME` to the approved sender/caller ID -and confirm account access to SMS, voice, and the selected regional host. Do not assume the existing -`Verify` fallback is an approved voice caller ID. No API key or client secret is required. +For Infobip, confirm account access to SMS, voice, and the selected regional host, plus +provider-managed sender/caller selection for the intended destinations. +Authentication uses managed-identity OAuth client assertion. +See the [API schema references](../TECHNICAL.md#infobip). diff --git a/dotnet/Program.cs b/dotnet/Program.cs index f5adccd..59c98b7 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -20,7 +20,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index ffcdb17..a7d7f56 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -26,16 +26,15 @@ public override Task SendOtpAsync( string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => SendJsonAsync( - () => CreateRequest(channel, endpoint, delivery, credentials, env), + () => CreateRequest(channel, endpoint, delivery, credentials), MapResponse, client, timeoutMs, logger); private static HttpRequestMessage CreateRequest( - string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential) { - var senderId = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify"; var messageId = delivery.CorrelationId ?? delivery.MessageId; object body; @@ -44,7 +43,6 @@ private static HttpRequestMessage CreateRequest( body = new VoiceRequest( [ new VoiceMessage( - senderId, [new Destination(delivery.PhoneNumber, messageId)], delivery.Message, delivery.Locale ?? "en", @@ -56,7 +54,6 @@ [new Destination(delivery.PhoneNumber, messageId)], body = new SmsRequest( [ new SmsMessage( - senderId, [new Destination(delivery.PhoneNumber, messageId)], new SmsContent(delivery.Message)) ]); @@ -105,7 +102,6 @@ private sealed record SmsRequest( [property: JsonPropertyName("messages")] IReadOnlyList Messages); private sealed record SmsMessage( - [property: JsonPropertyName("sender")] string Sender, [property: JsonPropertyName("destinations")] IReadOnlyList Destinations, [property: JsonPropertyName("content")] SmsContent Content); @@ -116,7 +112,6 @@ private sealed record VoiceRequest( [property: JsonPropertyName("messages")] IReadOnlyList Messages); private sealed record VoiceMessage( - [property: JsonPropertyName("from")] string From, [property: JsonPropertyName("destinations")] IReadOnlyList Destinations, [property: JsonPropertyName("text")] string? Text, [property: JsonPropertyName("language")] string Language, diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs deleted file mode 100644 index 25e29a0..0000000 --- a/dotnet/Src/Providers/SinchProvider.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; - -namespace Epp.Otp.Providers; - -public sealed class SinchProvider : PhoneProviderBase -{ - private readonly ISecretResolver? _secrets; - - public SinchProvider(ISecretResolver? secrets = null) => _secrets = secrets; - - public override string Name => "sinch"; - public override string AuthenticationMode => "apiKey"; - - public override Task SendOtpAsync( - string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, - IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => - SendJsonAsync( - () => CreateRequest(channel, endpoint, delivery, credentials, env), - MapResponse, - client, - timeoutMs, - logger); - - private static HttpRequestMessage CreateRequest( - string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) - { - var reference = delivery.CorrelationId ?? delivery.MessageId; - object body; - string url; - - if (channel == "voice") - { - body = new VoiceRequest( - "ttsCallout", - new TtsCallout( - new Destination("number", delivery.PhoneNumber), - delivery.Message, - delivery.Locale ?? "en-US", - reference)); - url = $"{endpoint}/calling/v1/callouts"; - } - else - { - var servicePlanId = env.Get("SINCH_SERVICE_PLAN_ID") ?? string.Empty; - body = new SmsRequest( - env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify", - [delivery.PhoneNumber], - delivery.Message, - reference); - url = $"{endpoint}/xms/v1/{servicePlanId}/batches"; - } - - var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = JsonContent.Create(body), - }; - request.Headers.TryAddWithoutValidation("Authorization", "******"); - request.Headers.Accept.ParseAdd("application/json"); - return request; - } - - private static ProviderResult MapResponse(Response? payload, HttpStatusCode httpStatus) - { - var id = payload?.Id ?? payload?.CallId; - var status = payload?.Status; - var successful = (int)httpStatus is >= 200 and < 300; - if (payload?.Status is null && successful && !string.IsNullOrWhiteSpace(id)) - status = "Dispatched"; - var (outcome, recognized) = MapStatus(status); - var hasMessageId = !string.IsNullOrWhiteSpace(id); - var finalOutcome = successful && hasMessageId ? outcome : Outcome.Fail; - var failureReason = successful && !hasMessageId - ? "missing_provider_message_id" - : ClassifyFailure(httpStatus, finalOutcome, recognized); - return new ProviderResult( - finalOutcome, - recognized, - (int)httpStatus, - id, - status, - ProviderStatusDescription: payload?.Text) - { - FailureReason = failureReason, - }; - } - - public override async Task FetchCredentialsAsync( - AppConfig config, CancellationToken cancellationToken = default) - { - if (_secrets is null) throw CredentialTokenService.Unavailable(); - var secret = await _secrets.ResolveAsync( - "sinch-api-token", cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(secret)) throw CredentialTokenService.Unavailable(); - return new ProviderCredentials( - AuthenticationMode, - Secret: secret); - } - - private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch - { - "Dispatched" or "Delivered" or "Queued" => (Outcome.Continue, true), - "Failed" or "Rejected" => (Outcome.Fail, true), - _ => (Outcome.Fail, false), - }; - - private sealed record VoiceRequest( - [property: JsonPropertyName("method")] string Method, - [property: JsonPropertyName("ttsCallout")] TtsCallout Callout); - - private sealed record TtsCallout( - [property: JsonPropertyName("destination")] Destination Destination, - [property: JsonPropertyName("text")] string? Text, - [property: JsonPropertyName("locale")] string Locale, - [property: JsonPropertyName("custom")] string Custom); - - private sealed record Destination( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("endpoint")] string Endpoint); - - private sealed record SmsRequest( - [property: JsonPropertyName("from")] string From, - [property: JsonPropertyName("to")] IReadOnlyList To, - [property: JsonPropertyName("body")] string? Body, - [property: JsonPropertyName("client_reference")] string ClientReference); - - private sealed record Response( - [property: JsonPropertyName("id")] string? Id, - [property: JsonPropertyName("callId")] string? CallId, - [property: JsonPropertyName("text")] string? Text, - [property: JsonPropertyName("status")] string? Status); -} diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 9ecdb87..9aee51b 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -165,7 +165,7 @@ public void ProviderResultKeepsExistingConstructorAndDeconstructionShape() [InlineData("https://eu.provider.example/another/path")] public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string endpoint) { - var env = new TestEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify" }; + var env = new TestEnv(); var credential = new ProviderCredentials("oauth", AccessToken: "test-token"); Assert.Equal("oauth", new InfobipProvider().AuthenticationMode); @@ -178,7 +178,6 @@ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string e { new { - sender = "Verify", destinations = new[] { new { to = Delivery().PhoneNumber, messageId = "correlation-id" } }, content = new { text = Delivery().Message }, }, @@ -195,7 +194,6 @@ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string e { new { - from = "Verify", destinations = new[] { new { to = Delivery().PhoneNumber, messageId = "correlation-id" } }, text = Delivery().Message, language = "en-US", @@ -206,50 +204,6 @@ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string e Assert.Equal(JsonSerializer.Serialize(expectedVoice), voice.Body); } - [Fact] - public async Task SinchRequestsKeepExactSmsAndVoiceWireContracts() - { - var env = new TestEnv - { - ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify", - ["SINCH_SERVICE_PLAN_ID"] = "service-plan", - }; - var credential = new ProviderCredentials("apiKey", "test-key", "test-id"); - - var sms = await new SinchProvider().CaptureRequestAsync( - "sms", "https://provider.example", Delivery(), credential, env); - AssertJsonRequest( - sms, - "https://provider.example/xms/v1/service-plan/batches", - "******"); - Assert.Equal( - JsonSerializer.Serialize(new - { - from = "Verify", - to = new[] { Delivery().PhoneNumber }, - body = Delivery().Message, - client_reference = "correlation-id", - }), - sms.Body); - - var voice = await new SinchProvider().CaptureRequestAsync( - "voice", "https://calling.example", Delivery("voice"), credential, env); - AssertJsonRequest(voice, "https://calling.example/calling/v1/callouts", "******"); - Assert.Equal( - JsonSerializer.Serialize(new - { - method = "ttsCallout", - ttsCallout = new - { - destination = new { type = "number", endpoint = Delivery().PhoneNumber }, - text = Delivery().Message, - locale = "en-US", - custom = "correlation-id", - }, - }), - voice.Body); - } - [Theory] [InlineData("sms", "en")] [InlineData("voice", "en")] @@ -444,20 +398,6 @@ public async Task InfobipOwnsEveryRecognizedStatusMapping(string status, Outcome Assert.True(result.StatusRecognized); } - [Theory] - [InlineData("Dispatched", Outcome.Continue)] - [InlineData("Delivered", Outcome.Continue)] - [InlineData("Queued", Outcome.Continue)] - [InlineData("Failed", Outcome.Fail)] - [InlineData("Rejected", Outcome.Fail)] - public async Task SinchOwnsEveryRecognizedStatusMapping(string status, Outcome expected) - { - using var response = JsonResponse(200, JsonSerializer.Serialize(new { id = "provider-id", status })); - var result = await new SinchProvider().SendResponseAsync(response, default); - Assert.Equal(expected, result.Outcome); - Assert.True(result.StatusRecognized); - } - [Fact] public async Task TypedProviderResponsesNormalizeIdsStatusesAndDescriptions() { @@ -469,12 +409,6 @@ public async Task TypedProviderResponsesNormalizeIdsStatusesAndDescriptions() "infobip-id", "PENDING", "queued"), - ( - new SinchProvider(), - "{\"callId\":\"sinch-id\",\"text\":\"queued\"}", - "sinch-id", - "Dispatched", - "queued"), ( new TelesignProvider(), "{\"reference_id\":\"telesign-id\",\"status\":{\"code\":3001,\"description\":\"queued\"}}", @@ -503,7 +437,6 @@ public async Task MissingProviderResponseDataFailsClosed(string payload) PhoneProviderBase[] providers = [ new InfobipProvider(), - new SinchProvider(), new TelesignProvider(), new SopranoProvider(), ]; @@ -536,12 +469,9 @@ public async Task MalformedAndValidButUnbindableProviderJsonReturnProviderFailur [Theory] [InlineData("{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":123}}]}")] [InlineData("{\"messages\":[{\"messageId\":\"id\",\"status\":[]}]}")] - [InlineData("{\"id\":\"id\",\"status\":123}")] public async Task WronglyTypedProviderStatusesAreParseFailures(string payload) { - PhoneProviderBase provider = payload.Contains("\"messages\"", StringComparison.Ordinal) - ? new InfobipProvider() - : new SinchProvider(); + var provider = new InfobipProvider(); using var response = JsonResponse(200, payload); var error = await Assert.ThrowsAsync( async () => await provider.SendResponseAsync(response, default)); @@ -556,7 +486,6 @@ public async Task HttpFailureCannotBecomeSuccessFromProviderBody() (new InfobipProvider(), "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}"), (new SopranoProvider(), "{\"messageId\":\"id\",\"status\":\"ACCEPTED\"}"), (new TelesignProvider(), "{\"reference_id\":\"id\",\"status\":{\"code\":290}}"), - (new SinchProvider(), "{\"id\":\"id\",\"status\":\"Delivered\"}"), }; foreach (var item in cases) @@ -631,15 +560,10 @@ public static async ValueTask SendResponseAsync( { Content = new StringContent(body, Encoding.UTF8, "application/json"), })); - var env = new TestEnv - { - ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify", - ["SINCH_SERVICE_PLAN_ID"] = "service-plan", - }; + var env = new TestEnv(); var endpoint = provider.Name switch { "infobip" => "https://provider.example", - "sinch" => "https://provider.example", "telesign" => "https://verify.telesign.com/epp/sms", _ => "https://provider.example/oauth/messages", }; diff --git a/dotnet/tests/SendOtpTests.cs b/dotnet/tests/SendOtpTests.cs index 80696c2..ab5847e 100644 --- a/dotnet/tests/SendOtpTests.cs +++ b/dotnet/tests/SendOtpTests.cs @@ -180,7 +180,6 @@ public async Task RequestSelectsEitherChannelRegardlessOfLegacySetting(string pr [InlineData("telesign", "", "")] [InlineData("soprano", "", "")] [InlineData("infobip", "", "")] - [InlineData("sinch", "/xms/v1/plan/batches", "/calling/v1/callouts")] public async Task RequestChannelSelectsDistinctConfiguredEndpoints(string provider, string smsPath, string voicePath) { using var rig = new HandlerRig( @@ -188,18 +187,15 @@ public async Task RequestChannelSelectsDistinctConfiguredEndpoints(string provid ValueTask.FromResult(new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))), (_, _, _) => new TestTokenCredential((_, _) => ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1))))); - if (provider != "sinch") ConfigureOAuth(rig, provider); + ConfigureOAuth(rig, provider); rig.Env["EPP_PROVIDER_NAME"] = provider; - rig.Env["EPP_PROVIDER_AUTH_MODE"] = provider == "sinch" ? "apiKey" : "oauth"; + rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth"; rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "https://sms.provider.example/send"; rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://voice.provider.example/send"; - rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan"; - rig.Env["SINCH_VOICE_ENDPOINT"] = "https://legacy.provider.example"; var response = provider switch { "telesign" => "{\"status\":{\"code\":3001}}", "soprano" => "{\"status\":\"ENROUTE\"}", - "sinch" => "{\"id\":\"provider-id\"}", _ => "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}", }; rig.Http.Respond = _ => Task.FromResult(Json(200, response)); @@ -257,8 +253,7 @@ public async Task ChannelRequiresItsEndpointWithoutLegacyOrCrossChannelFallback( public async Task OnlyLiveRequestsFetchSecretsAndObsoleteCacheSettingsAreIgnored() { using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "sinch"; - rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan"; + rig.Env["EPP_PROVIDER_NAME"] = "custom"; rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"id\":\"provider-id\"}")); rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = "PRIVATE-OBSOLETE"; rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = "PRIVATE-OBSOLETE"; @@ -579,8 +574,7 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() public async Task MissingApiKeyFailsClosedBeforeHttp() { using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "sinch"; - rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan"; + rig.Env["EPP_PROVIDER_NAME"] = "custom"; rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey"; rig.Secrets.Secret = ""; AssertFailure(rig, await rig.Invoke(), 502); @@ -591,7 +585,6 @@ public async Task MissingApiKeyFailsClosedBeforeHttp() public async Task ConfiguredVoiceUrlMustBeHttpsBeforeHttp() { using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "sinch"; rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "http://provider.example"; AssertFailure(rig, await rig.Invoke(channel: "voice"), 502); rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://provider.example:0"; @@ -950,8 +943,7 @@ public async Task SuccessfulLifecycleLogsAllowedBodyMetadataRawOAuthIdsAndEndpoi public async Task ApiKeyLifecycleIdentifiesKeyVaultWithoutLoggingCredentials() { using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "sinch"; - rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan"; + rig.Env["EPP_PROVIDER_NAME"] = "custom"; rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"id\":\"provider-id\"}")); AssertAccepted(await rig.Invoke()); var summary = Summary(rig); @@ -969,12 +961,12 @@ public async Task ApiKeyLifecycleIdentifiesKeyVaultWithoutLoggingCredentials() public async Task RequestPreparationLogsTheAdapterFinalUrlNotTheConfiguredBase() { using var rig = new HandlerRig(); - rig.Env["EPP_PROVIDER_NAME"] = "sinch"; + rig.Env["EPP_PROVIDER_NAME"] = "custom"; rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://different-provider.example/api/final"; - rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"callId\":\"sinch-call-id\"}")); + rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"id\":\"provider-id\"}")); AssertAccepted(await rig.Invoke(channel: "voice")); var summary = Summary(rig); - var expected = rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] + "/calling/v1/callouts"; + var expected = rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] + "/messages"; Assert.Equal(expected, summary.GetProperty("providerEndpoint").GetString()); Assert.NotEqual(rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"]!, summary.GetProperty("providerEndpoint").GetString()); Assert.Equal("POST", summary.GetProperty("providerHttpMethod").GetString()); @@ -1216,7 +1208,7 @@ public HandlerRig(Func? createIdentity = null, Infobip, new TelesignProvider(createIdentity, createOAuth), new SopranoProvider(createIdentity, createOAuth), - new SinchProvider(Secrets), + new ApiKeyProvider(Secrets), }; Credentials = new CredentialTokenService(); _function = new SendOtp(providers, Credentials, Http, new JweDecryptor(Env), Env, Log); @@ -1262,6 +1254,40 @@ public async Task InvokePayload( public void Dispose() { Keys.Dispose(); Http.Dispose(); } } + private sealed class ApiKeyProvider(ISecretResolver secrets) : PhoneProviderBase + { + public override string Name => "custom"; + public override string AuthenticationMode => "apiKey"; + + public override async Task FetchCredentialsAsync( + AppConfig config, CancellationToken cancellationToken = default) + { + var secret = await secrets.ResolveAsync("custom-api-key", cancellationToken); + if (string.IsNullOrWhiteSpace(secret)) throw CredentialTokenService.Unavailable(); + return new ProviderCredentials(AuthenticationMode, Secret: secret); + } + + public override Task SendOtpAsync( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, + IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => + SendJsonAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Post, endpoint + "/messages") + { + Content = new StringContent( + JsonSerializer.Serialize(new { text = delivery.Message }), + Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new("Bearer", credentials.Secret); + return request; + }, + (_, status) => new ProviderResult( + (int)status is >= 200 and < 300 ? Outcome.Continue : Outcome.Fail, + true, (int)status), + client, timeoutMs, logger); + } + private sealed class CapturingProvider(PhoneProviderBase inner) : PhoneProviderBase { public OtpDelivery? Delivery { get; private set; } diff --git a/javascript/src/functions/providers/index.js b/javascript/src/functions/providers/index.js index 298fa2f..a78a0d3 100644 --- a/javascript/src/functions/providers/index.js +++ b/javascript/src/functions/providers/index.js @@ -1,18 +1,16 @@ 'use strict'; const infobip = require('./infobip'); -const sinch = require('./sinch'); const soprano = require('./soprano'); const telesign = require('./telesign'); function selectProvider(name) { switch (String(name || '').trim().toLowerCase()) { case 'infobip': return infobip; - case 'sinch': return sinch; case 'soprano': return soprano; case 'telesign': return telesign; default: return null; } } -module.exports = { selectProvider, infobip, sinch, soprano, telesign }; +module.exports = { selectProvider, infobip, soprano, telesign }; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index a6f497a..1eb5cf4 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -10,8 +10,7 @@ const provider = Object.freeze({ authenticationMode: 'oauth', credentialSpec: Object.freeze({ mode: 'oauth' }), - createRequest({ channel, endpoint, delivery, credential, env }) { - const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify'; + createRequest({ channel, endpoint, delivery, credential }) { const messageId = delivery.correlationId || delivery.messageId; const headers = { Authorization: ['Bearer', credential.accessToken].join(' '), @@ -25,7 +24,6 @@ const provider = Object.freeze({ headers, body: JSON.stringify({ messages: [{ - from: senderId, destinations: [{ to: delivery.phoneNumber, messageId }], text: delivery.message, language: delivery.locale || 'en', @@ -40,7 +38,6 @@ const provider = Object.freeze({ headers, body: JSON.stringify({ messages: [{ - sender: senderId, destinations: [{ to: delivery.phoneNumber, messageId }], content: { text: delivery.message }, }], diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js deleted file mode 100644 index 221ecda..0000000 --- a/javascript/src/functions/providers/sinch.js +++ /dev/null @@ -1,82 +0,0 @@ -'use strict'; - -const { OUTCOME, ProviderResult, classifyFailure } = require('../providerResult'); - -const SUCCESS = new Set(['Dispatched', 'Delivered', 'Queued']); -const REJECTED = new Set(['Failed', 'Rejected']); - -function nonblankString(value) { - return typeof value === 'string' && value.trim() ? value : null; -} - -const provider = Object.freeze({ - name: 'sinch', - authenticationMode: 'apiKey', - credentialSpec: Object.freeze({ mode: 'apiKey', keyVaultSecretName: 'sinch-api-token' }), - - createRequest({ channel, endpoint, delivery, credential, env }) { - const headers = { - Authorization: ['Bearer', credential.secret].join(' '), - 'Content-Type': 'application/json', - Accept: 'application/json', - }; - if (channel === 'voice') { - return { - url: `${endpoint}/calling/v1/callouts`, - method: 'POST', - headers, - body: JSON.stringify({ - method: 'ttsCallout', - ttsCallout: { - destination: { type: 'number', endpoint: delivery.phoneNumber }, - text: delivery.message, - locale: delivery.locale || 'en-US', - custom: delivery.correlationId || delivery.messageId, - }, - }), - }; - } - const servicePlanId = env.SINCH_SERVICE_PLAN_ID || ''; - return { - url: `${endpoint}/xms/v1/${servicePlanId}/batches`, - method: 'POST', - headers, - body: JSON.stringify({ - from: env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify', - to: [delivery.phoneNumber], - body: delivery.message, - client_reference: delivery.correlationId || delivery.messageId, - }), - }; - }, - - interpretResponse(response) { - const payload = response.json && typeof response.json === 'object' && !Array.isArray(response.json) - ? response.json : {}; - const messageId = nonblankString(payload.id) || nonblankString(payload.callId); - let status = nonblankString(payload.status); - if (payload.status == null && response.ok && messageId) status = 'Dispatched'; - const recognized = SUCCESS.has(status) || REJECTED.has(status); - const mappedOutcome = SUCCESS.has(status) ? OUTCOME.CONTINUE : OUTCOME.FAIL; - const outcome = response.ok && messageId ? mappedOutcome : OUTCOME.FAIL; - const failureReason = response.validJson && response.ok && !messageId - ? 'missing_provider_message_id' - : classifyFailure({ - providerHttpStatus: response.providerHttpStatus, - outcome, - statusRecognized: recognized, - validJson: response.validJson, - }); - return new ProviderResult({ - outcome, - statusRecognized: recognized, - providerHttpStatus: response.providerHttpStatus, - providerMessageId: messageId, - providerStatusName: status, - providerStatusDescription: typeof payload.text === 'string' ? payload.text : null, - failureReason, - }); - }, -}); - -module.exports = provider; diff --git a/javascript/test/provider-flow.test.js b/javascript/test/provider-flow.test.js index dca6145..8d5b3b9 100644 --- a/javascript/test/provider-flow.test.js +++ b/javascript/test/provider-flow.test.js @@ -30,8 +30,8 @@ const input = { channel: 'sms', endpoint: 'https://provider.example', delivery, - credential: { mode: 'apiKey', identity: 'id', secret: 'key' }, - env: { SINCH_SERVICE_PLAN_ID: 'plan' }, + credential: { mode: 'oauth', accessToken: 'token' }, + env: {}, }; const response = (json, providerHttpStatus = 200, validJson = true) => ({ json, @@ -49,11 +49,11 @@ const envelope = (overrides = {}) => ({ }); test('config and fixed provider lookup have no default or registry', () => { - const config = readConfig({ EPP_PROVIDER_NAME: ' SiNcH ', EPP_PROVIDER_TIMEOUT_MS: ' 0012 ' }); + const config = readConfig({ EPP_PROVIDER_NAME: ' SoPrAnO ', EPP_PROVIDER_TIMEOUT_MS: ' 0012 ' }); assert.ok(config instanceof AppConfig); assert.equal(inspect(config), '[AppConfig]'); - assert.deepEqual([config.providerName, config.providerTimeoutMs], ['sinch', ' 0012 ']); - assert.equal(selectProvider(' SiNcH ').name, 'sinch'); + assert.deepEqual([config.providerName, config.providerTimeoutMs], ['soprano', ' 0012 ']); + assert.equal(selectProvider(' SoPrAnO ').name, 'soprano'); assert.equal(selectProvider('unknown'), null); assert.equal(selectProvider(''), null); }); @@ -243,7 +243,6 @@ test('Infobip uses configured URLs unchanged with OAuth on both setup routes', ( channel, endpoint, credential: { mode: 'oauth', accessToken: 'provider-token' }, - env: { EPP_PROVIDER_ACCOUNT_NAME: 'EPP' }, }); assert.equal(request.url, endpoint); assert.equal(request.method, 'POST'); @@ -255,13 +254,11 @@ test('Infobip uses configured URLs unchanged with OAuth on both setup routes', ( const destinations = [{ to: delivery.phoneNumber, messageId: 'correlation-id' }]; assert.deepEqual(JSON.parse(request.body), { messages: [channel === 'voice' ? { - from: 'EPP', destinations, text: delivery.message, language: 'fr-FR', voice: { name: 'Joanna', gender: 'female' }, } : { - sender: 'EPP', destinations, content: { text: delivery.message }, }], @@ -291,24 +288,6 @@ test('Infobip accepts only strict recognized status strings', () => { } }); -test('Sinch HTTP success requires a nonblank string ID and may infer Dispatched', () => { - const sinch = selectProvider('sinch'); - const request = sinch.createRequest(input); - assert.equal(request.headers.Authorization, 'Bearer key'); - assert.equal(JSON.parse(request.body).body, delivery.message); - for (const [json, outcome, reason, status] of [ - [{ id: 'batch-id' }, OUTCOME.CONTINUE, null, 'Dispatched'], - [{ callId: 'call-id', status: 'Delivered' }, OUTCOME.CONTINUE, null, 'Delivered'], - [{ id: ' ', status: 'Delivered' }, OUTCOME.FAIL, 'missing_provider_message_id', 'Delivered'], - [{ id: 123, status: 'Delivered' }, OUTCOME.FAIL, 'missing_provider_message_id', 'Delivered'], - [{ id: 'batch-id', status: true }, OUTCOME.FAIL, 'unrecognized_provider_status', null], - ]) { - const result = sinch.interpretResponse(response(json)); - assert.deepEqual([result.outcome, result.failureReason, result.providerStatusName], - [outcome, reason, status]); - } -}); - test('Telesign request validates E.164 and voice pacing remains unchanged', () => { const telesign = selectProvider('telesign'); const credential = { mode: 'oauth', accessToken: 'provider-token' }; @@ -404,7 +383,7 @@ test('providers fail closed with fixed safe classifications', () => { [selectProvider('telesign'), response({ status: { code: 290 } }, 429), 'provider_http_error', 429], [selectProvider('telesign'), response({ status: { code: 3400 } }, 401), 'provider_http_error', 400], [selectProvider('telesign'), response({ status: { code: 3400 } }, 403), 'provider_http_error', 400], - [selectProvider('sinch'), response({ id: 'id' }, 500), 'provider_http_error', 502], + [selectProvider('soprano'), response({ status: 'ACCEPTED' }, 500), 'provider_http_error', 502], ]; for (const [provider, providerResponse, reason, httpStatus] of cases) { const result = provider.interpretResponse(providerResponse); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index cf8424d..281f28b 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -53,7 +53,7 @@ const envKeys = [ 'EPP_PROVIDER_SMS_ENDPOINT', 'EPP_PROVIDER_VOICE_ENDPOINT', 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE', 'EPP_OUTBOUND_CLIENT_ID', 'EPP_OUTBOUND_MI_CLIENT_ID', 'KEY_VAULT_URL', - 'EPP_DECRYPTION_KEY_PEM', 'SINCH_SERVICE_PLAN_ID', 'SINCH_VOICE_ENDPOINT', + 'EPP_DECRYPTION_KEY_PEM', 'EPP_KEY_VAULT_CACHE_ENABLED', 'EPP_ACCESS_TOKEN_CACHE_ENABLED', ]; const baseDelivery = { @@ -290,17 +290,23 @@ for (const mode of [1, 2]) { }); } -for (const [provider, mode] of [['sinch', 'apiKey'], ['telesign', 'oauth'], ['soprano', 'oauth'], ['infobip', 'oauth']]) { +for (const [provider, mode] of [['custom', 'apiKey'], ['telesign', 'oauth'], ['soprano', 'oauth'], ['infobip', 'oauth']]) { test(`${provider} ${mode}: only live requests acquire credentials and obsolete cache settings are ignored`, async () => { const apiKey = mode === 'apiKey'; process.env.EPP_PROVIDER_NAME = provider; process.env.EPP_PROVIDER_AUTH_MODE = apiKey ? 'apiKey' : 'oauth'; process.env.EPP_KEY_VAULT_CACHE_ENABLED = 'PRIVATE-OBSOLETE'; process.env.EPP_ACCESS_TOKEN_CACHE_ENABLED = 'PRIVATE-OBSOLETE'; - if (provider === 'sinch') { - process.env.SINCH_SERVICE_PLAN_ID = 'plan'; - fetchMock.mock.mockImplementation(async () => ({ - ok: true, status: 200, text: async () => JSON.stringify({ id: 'provider-id' }), + if (apiKey) { + const adapter = providers.selectProvider('soprano'); + mock.method(providers, 'selectProvider', () => ({ + ...adapter, + name: 'custom', + authenticationMode: 'apiKey', + credentialSpec: { mode: 'apiKey', keyVaultSecretName: 'custom-api-key' }, + createRequest: (input) => adapter.createRequest({ + ...input, credential: { accessToken: input.credential.secret }, + }), })); } if (provider === 'telesign') fetchMock.mock.mockImplementation(async () => ({ @@ -628,16 +634,13 @@ for (const [provider, response, smsPath, voicePath] of [ ['telesign', { status: { code: 3001 } }, '', ''], ['soprano', { status: 'ENROUTE' }, '', ''], ['infobip', { messages: [{ status: { groupName: 'PENDING' } }] }, '', ''], - ['sinch', { id: 'provider-id' }, '/xms/v1/plan/batches', '/calling/v1/callouts'], ]) { test(`${provider}: request channel selects distinct configured endpoints`, async () => { Object.assign(process.env, { EPP_PROVIDER_NAME: provider, - EPP_PROVIDER_AUTH_MODE: provider === 'sinch' ? 'apiKey' : 'oauth', + EPP_PROVIDER_AUTH_MODE: 'oauth', EPP_PROVIDER_SMS_ENDPOINT: 'https://sms.provider.example/send', EPP_PROVIDER_VOICE_ENDPOINT: 'https://voice.provider.example/send', - SINCH_SERVICE_PLAN_ID: 'plan', - SINCH_VOICE_ENDPOINT: 'https://legacy.provider.example', }); fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, text: async () => JSON.stringify(response), @@ -847,19 +850,6 @@ for (const status of [401, 403]) { }); } -test('Sinch successful HTTP without a nonblank message ID fails closed', async () => { - process.env.EPP_PROVIDER_NAME = 'sinch'; - process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; - process.env.SINCH_SERVICE_PLAN_ID = 'plan'; - fetchMock.mock.mockImplementation(async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify({ id: ' ', status: 'Delivered' }), - })); - failure(await invoke(await envelope()), 502); - assert.equal(event('request_failed').failureReason, 'missing_provider_message_id'); -}); - test('Telesign strict integer status and voice pacing keep endpoint outcomes stable', async () => { process.env.EPP_PROVIDER_NAME = 'telesign'; process.env.EPP_PROVIDER_AUTH_MODE = 'oauth'; diff --git a/python/function_app.py b/python/function_app.py index 023d30f..99905f6 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -19,7 +19,6 @@ to_endpoint_http_status, ) from src.providers.infobip import InfobipProvider -from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider from src.secrets import SecretResolver @@ -28,7 +27,7 @@ _providers = { provider.name: provider - for provider in (InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()) + for provider in (InfobipProvider(), TelesignProvider(), SopranoProvider()) } _credentials = CredentialTokenService(SecretResolver()) _decryptor = JweDecryptor(os.environ) diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py index 79becb2..353bf8f 100644 --- a/python/src/providers/infobip.py +++ b/python/src/providers/infobip.py @@ -13,14 +13,12 @@ def credential_spec(self): return {"mode": self.authentication_mode} def build_request(self, channel, endpoint, delivery, credential, env): - sender_id = env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify" authorization = " ".join(("Bearer", credential["access_token"])) headers = {"Authorization": authorization, "Content-Type": "application/json", "Accept": "application/json"} message_id = delivery.correlation_id or delivery.message_id if channel == "voice": body = {"messages": [{ - "from": sender_id, "destinations": [{"to": delivery.phone_number, "messageId": message_id}], "text": delivery.message, "language": delivery.locale or "en", @@ -29,7 +27,6 @@ def build_request(self, channel, endpoint, delivery, credential, env): return OutboundRequest("POST", endpoint, headers, json.dumps(body)) body = {"messages": [{ - "sender": sender_id, "destinations": [{"to": delivery.phone_number, "messageId": message_id}], "content": {"text": delivery.message}, }]} diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py deleted file mode 100644 index f4458ce..0000000 --- a/python/src/providers/sinch.py +++ /dev/null @@ -1,65 +0,0 @@ -import json - -from ..models import Outcome, ProviderResult -from ..provider import OutboundRequest, PhoneProviderBase, classify_failure - - -class SinchProvider(PhoneProviderBase): - name = "sinch" - authentication_mode = "apiKey" - - @property - def credential_spec(self): - return {"mode": self.authentication_mode, "key_vault_secret_name": "sinch-api-token"} - - def build_request(self, channel, endpoint, delivery, credential, env): - headers = { - "Authorization": f"Bearer {credential['secret']}", - "Content-Type": "application/json", - "Accept": "application/json", - } - reference = delivery.correlation_id or delivery.message_id - - if channel == "voice": - body = {"method": "ttsCallout", "ttsCallout": { - "destination": {"type": "number", "endpoint": delivery.phone_number}, - "text": delivery.message, - "locale": delivery.locale or "en-US", - "custom": reference, - }} - return OutboundRequest("POST", f"{endpoint}/calling/v1/callouts", headers, json.dumps(body)) - - service_plan_id = env.get("SINCH_SERVICE_PLAN_ID") or "" - body = { - "from": env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify", - "to": [delivery.phone_number], - "body": delivery.message, - "client_reference": reference, - } - return OutboundRequest("POST", f"{endpoint}/xms/v1/{service_plan_id}/batches", headers, json.dumps(body)) - - def map_response(self, payload, http_status): - identifier = payload.get("id") or payload.get("callId") if isinstance(payload, dict) else None - status = payload.get("status") if isinstance(payload, dict) else None - description = payload.get("text") if isinstance(payload, dict) else None - successful = 200 <= http_status < 300 - if status is None and successful and isinstance(identifier, str) and identifier.strip(): - status = "Dispatched" - recognized = status in {"Dispatched", "Delivered", "Queued", "Failed", "Rejected"} - outcome = Outcome.CONTINUE if status in {"Dispatched", "Delivered", "Queued"} else Outcome.FAIL - has_message_id = isinstance(identifier, str) and bool(identifier.strip()) - final_outcome = outcome if successful and has_message_id else Outcome.FAIL - failure_reason = ( - "missing_provider_message_id" - if successful and not has_message_id - else classify_failure(http_status, final_outcome, recognized) - ) - return ProviderResult( - final_outcome, - recognized, - http_status, - identifier if isinstance(identifier, str) else None, - status if isinstance(status, str) else None, - provider_status_description=description if isinstance(description, str) else None, - failure_reason=failure_reason, - ) diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index cb2577d..0ae6fee 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -7,7 +7,6 @@ from src.models import DeliveryContext, EntraSendOtpPayload, Outcome, OtpDelivery, ProviderResult, RiskContext from src.provider import to_endpoint_http_status from src.providers.infobip import InfobipProvider -from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider @@ -98,7 +97,7 @@ def test_infobip_uses_configured_urls_unchanged_with_oauth_on_both_setup_routes( endpoint = profile["deployment"]["routes"][region][f"{channel}Endpoint"] request = provider.build_request( channel, endpoint, _delivery(channel), - {"mode": "oauth", "access_token": "provider-token"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"}) + {"mode": "oauth", "access_token": "provider-token"}, {}) assert request.url == endpoint assert request.method == "POST" assert request.headers == { @@ -108,13 +107,11 @@ def test_infobip_uses_configured_urls_unchanged_with_oauth_on_both_setup_routes( } destinations = [{"to": "+15551234567", "messageId": "correlation-id"}] expected = { - "from": "EPP", "destinations": destinations, "text": MESSAGE, "language": "en-US", "voice": {"name": "Joanna", "gender": "female"}, } if channel == "voice" else { - "sender": "EPP", "destinations": destinations, "content": {"text": MESSAGE}, } @@ -129,16 +126,6 @@ def test_infobip_contract_is_strict_and_provider_owned(): Outcome.CONTINUE, True, 200, "message-id", "PENDING") -def test_sinch_requires_a_provider_message_id_for_success(): - provider = SinchProvider() - accepted = provider.map_response({"id": "message-id"}, 200) - assert accepted.outcome == Outcome.CONTINUE - assert accepted.provider_status_name == "Dispatched" - missing = provider.map_response({}, 200) - assert missing.outcome == Outcome.FAIL - assert missing.failure_reason == "missing_provider_message_id" - - @pytest.mark.parametrize("channel", ["sms", "voice"]) def test_telesign_request_contract(channel): provider = TelesignProvider() diff --git a/python/tests/test_credentials.py b/python/tests/test_credentials.py index 7797c35..0eaa6fe 100644 --- a/python/tests/test_credentials.py +++ b/python/tests/test_credentials.py @@ -14,7 +14,6 @@ from src.config import read_config from src.credentials import CredentialTokenService from src.providers.infobip import InfobipProvider -from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider @@ -234,7 +233,7 @@ def test_invalid_configuration_does_not_poison_later_requests(): with pytest.raises(ValueError, match="unavailable"): manager.resolve(spec, CONFIG) secrets.resolve.assert_not_called() - provider = SinchProvider() + provider = SimpleNamespace(credential_spec={"mode": "apiKey", "key_vault_secret_name": "key"}) manager.get_credentials(provider, CONFIG) manager.get_credentials(provider, CONFIG) assert secrets.resolve.call_count == 2 diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index f6171df..6557d74 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -14,6 +14,7 @@ from src.jwe import JweDecryptor from src.credentials import CredentialTokenService from src.models import RiskContext +from src.providers.soprano import SopranoProvider _KEY = jwk.JWK.generate(kty="RSA", size=2048) _PRIVATE_PEM = _KEY.export_to_pem(True, None).decode() @@ -198,7 +199,6 @@ def test_non_object_risk_context_fails_before_provider_work(caplog, mode, risk_c @pytest.mark.parametrize(("provider", "mode"), [ - ("sinch", "apiKey"), ("telesign", "oauth"), ("soprano", "oauth"), ("infobip", "oauth"), @@ -206,7 +206,6 @@ def test_non_object_risk_context_fails_before_provider_work(caplog, mode, risk_c def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode): monkeypatch.setenv("EPP_PROVIDER_NAME", provider) monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode) - monkeypatch.setenv("SINCH_SERVICE_PLAN_ID", "plan") for name in ("EPP_KEY_VAULT_CACHE_ENABLED", "EPP_ACCESS_TOKEN_CACHE_ENABLED"): monkeypatch.setenv(name, "PRIVATE-OBSOLETE") function_app._credentials.get_credentials.return_value = { @@ -215,8 +214,6 @@ def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode): payload = {"messages": [{"status": {"groupName": "PENDING"}}]} if provider == "infobip" else { "status": {"code": 3001} if provider == "telesign" else "ENROUTE", } - if provider == "sinch": - payload = {"id": "provider-id"} provider_module.requests.request.return_value = Mock(status_code=200, json=Mock(return_value=payload)) response = _HANDLER(_request(_envelope(mode=2))) assert response.status_code == 200 and json.loads(response.get_body())["nonce"] == _NONCE @@ -228,9 +225,18 @@ def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode): def test_credential_failure_is_request_correlated_and_next_request_recovers(monkeypatch, caplog): + class ApiKeyProvider(SopranoProvider): + name = "custom" + authentication_mode = "apiKey" + credential_spec = {"mode": "apiKey", "key_vault_secret_name": "custom-api-key"} + + def build_request(self, channel, endpoint, delivery, credential, env): + return super().build_request( + channel, endpoint, delivery, {"access_token": credential["secret"]}, env) + caplog.set_level(logging.INFO) - monkeypatch.setenv("EPP_PROVIDER_NAME", "sinch") - monkeypatch.setenv("SINCH_SERVICE_PLAN_ID", "plan") + monkeypatch.setitem(function_app._providers, "custom", ApiKeyProvider()) + monkeypatch.setenv("EPP_PROVIDER_NAME", "custom") monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", "apiKey") monkeypatch.setenv("EPP_KEY_VAULT_CACHE_ENABLED", "PRIVATE-OBSOLETE") monkeypatch.setenv("EPP_ACCESS_TOKEN_CACHE_ENABLED", "PRIVATE-OBSOLETE") @@ -249,7 +255,7 @@ def test_credential_failure_is_request_correlated_and_next_request_recovers(monk secrets.resolve.side_effect = None secrets.resolve.return_value = "key" provider_module.requests.request.return_value = Mock( - status_code=200, json=Mock(return_value={"id": "provider-id"})) + status_code=200, json=Mock(return_value={"status": "ENROUTE"})) assert _HANDLER(_request(_envelope())).status_code == 200 assert secrets.resolve.call_count == 2 provider_module.requests.request.assert_called_once() @@ -414,16 +420,13 @@ def test_telesign_oauth_live_send_never_forwards_inbound_credentials(monkeypatch ("telesign", {"status": {"code": 3001}}, "", ""), ("soprano", {"status": "ENROUTE"}, "", ""), ("infobip", {"messages": [{"status": {"groupName": "PENDING"}}]}, "", ""), - ("sinch", {"id": "provider-id"}, "/xms/v1/plan/batches", "/calling/v1/callouts"), ]) def test_request_channel_selects_distinct_configured_endpoints(monkeypatch, provider, payload, sms_path, voice_path): - mode = "apiKey" if provider == "sinch" else "oauth" + mode = "oauth" monkeypatch.setenv("EPP_PROVIDER_NAME", provider) monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode) monkeypatch.setenv("EPP_PROVIDER_SMS_ENDPOINT", "https://sms.provider.example/send") monkeypatch.setenv("EPP_PROVIDER_VOICE_ENDPOINT", "https://voice.provider.example/send") - monkeypatch.setenv("SINCH_SERVICE_PLAN_ID", "plan") - monkeypatch.setenv("SINCH_VOICE_ENDPOINT", "https://legacy.provider.example") function_app._credentials.get_credentials.return_value = { "mode": mode, "secret": "key", "identity": "customer", "access_token": "token", } diff --git a/setup/docs/README.md b/setup/docs/README.md index 0403292..259668a 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -44,7 +44,6 @@ Soprano and Telesign currently use identical Global/EU URLs. Infobip uses the `a host for Global and `api-eu.infobip.com` for EU. Each OAuth provider uses the same API app ID/scope across its two routes, except that Telesign's OAuth values are pending provider confirmation. The route label is not a residency or Azure-region selection. -Sinch remains a bundled adapter without a guided profile. ### Provider tenant configuration diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index 8071276..436e08c 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -32,7 +32,7 @@ phone numbers, OTPs, private keys, encrypted bodies, and raw responses. Keep bod | Endpoint returns 401/403 with no invocation | Azure operator checks Easy Auth, issuer/audience/caller restrictions, network access, and available platform/auth logs. An ordinary customer CLI token is not the allowlisted Microsoft caller. | Do not disable Easy Auth, add a test caller, or interpret rejection as readiness. | | App failure after invocation | Inspect correlated `request_failed` and provider-response events. Provider 401/403 maps to handler 400; an explicit provider block maps to 422 with `provider_blocked`. | Do not change inbound trust to fix outbound provider authorization. Existing SAS may still use permitted native fallback after a provider block. | | `decryption_failed` or unresolved key reference | Compare summary certificate/thumbprint/key ID and versioned reference; verify Function identity vault access and reference resolution, then use an authorized evaluation. | Do not export the private key, remove certificates outside approved cleanup, or assume new vault versions automatically update Entra. | -| Provider credentials unavailable | For guided providers, check customer federation and provider authorization in `AccessTokenScopeTenant`, plus the API scope. API-key adapters such as Sinch require their configured vault secrets. | Evaluation does not validate outbound credentials. | +| Provider credentials unavailable | For bundled providers, check customer federation and provider authorization in `AccessTokenScopeTenant`, plus the API scope. Custom API-key adapters require their configured vault secrets. | Evaluation does not validate outbound credentials. | | Telesign profile is not deployment-ready | Have the provider owner complete `AccessTokenScopeTenant` and both routes' `appId`/`scope` placeholders. | Do not invent values, substitute the Security Store or customer tenant, or bypass validation. | | Timeout, 502/504, or provider acceptance without handset receipt | Separate credential, outbound HTTP, host, and caller timings; ask the provider for supported delivery evidence. Confirm locale/digit clarity for voice. | Do not blindly resend: the first request may already have been accepted. | | No requests/traces in Application Insights | Check the exact component/workspace, UTC window, actual authorized traffic, host/worker startup, ingestion identity/network, filters/sampling/caps, and effective exporter. Use [collection guidance](../../docs/APPLICATION-INSIGHTS.md). | Do not treat an empty table/Failures view as healthy or search every runtime for a JSON request summary. |