Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/sms.rs

32.1 KiB, 149 runs

created by r1870400018:21417, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! A bring-your-own-key client for the SMS gateways, in the half that is not a socket.
2//!
3//! # What it is for
4//!
5//! One message, one receipt. It exists because a text message is the only alert channel that
6//! reaches a person with no data connection: push needs the internet, mail needs the internet,
7//! and a host that has just gone dark is exactly when neither may be to hand. So this is the
8//! last leg of an alerting path rather than a messaging feature, and it is deliberately small.
9//!
10//! # Why it does not send anything
11//!
12//! [`Provider::request`] returns the *parts* of a call -- host, port, path, method, headers,
13//! body -- and stops. The caller dials. That is the same division [`crate::search`] draws and
14//! for the same reason: the caller resolves the host, refuses a private address and repeats the
15//! refusal on every redirect hop, and a module that opened its own socket would walk around all
16//! of it. There is deliberately no convenience here that sends.
17//!
18//! # Three vendors, one authentication scheme
19//!
20//! Every provider here reads its credential from an HTTP `Authorization: Basic` header. That is
21//! not a coincidence, it is the entry requirement: a vendor whose scheme puts the secret in the
22//! request *body* is excluded, because bodies are logged, echoed in error messages and captured
23//! by proxies in ways headers are not. Vonage is the notable absence on exactly that ground --
24//! it takes `api_key` and `api_secret` as body parameters. Adding it would mean the module could
25//! no longer promise what [`Provider::request`]'s tests assert, which is that **the secret
26//! appears in the headers and nowhere else**.
27//!
28//! The three differ in everything else: two take JSON and one takes a form body, two put the
29//! account identifier in the path and one does not, and each names its fields differently. So
30//! the enum carries real per-arm code, and [`Receipt`] is the narrow common shape they are
31//! flattened into.
32//!
33//! # A credential is a pair
34//!
35//! All three authenticate as a user and a secret, though each calls the pair something else: a
36//! username and an API key, an account identifier and an auth token. [`Credential`] carries the
37//! two without adopting any one vendor's names for them.
38//!
39//! # A receipt means the vendor took the message
40//!
41//! [`Provider::parse`] returns a [`Receipt`] only for a message the vendor accepted. Every
42//! refusal is an error that carries the vendor's own words, and a refusal can arrive three ways:
43//! an HTTP status that is not `2xx`, an error document, or a per-message status inside an
44//! otherwise successful reply. ClickSend uses the third for an unfunded account. It answers
45//! `200` and `response_code: SUCCESS` for the call, and marks the message itself
46//! `INSUFFICIENT_CREDIT`. Until 2026-09-23 that status was passed through as a receipt, and from
47//! 2026-08-31 every text in one estate was refused and logged as sent.
48//!
49//! # What is not here
50//!
51//! No delivery receipts, no inbound messages, no scheduling, no templates, no contact lists. An
52//! alert is sent and forgotten; whether it arrived is answered by the person's phone buzzing,
53//! and a delivery-receipt webhook is a second service to run on the host that may be the one in
54//! trouble.
55//!
56//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
57//! Anthropic Claude
58
59use oxedyne_fe2o3_core::prelude::*;
60use oxedyne_fe2o3_jdat::{
61 prelude::*,
62 string::dec::DecoderConfig,
63 usr::{
64 UsrKind,
65 UsrKindCode,
66 UsrKindId,
67 },
68};
69use oxedyne_fe2o3_text::base64;
70
71use std::collections::BTreeMap;
72
73use crate::http::{
74 header::HttpMethod,
75 pct,
76};
77
78
79// The longest body a single call will carry. Not a protocol limit -- a gateway will happily
80// accept more and bill it as many segments -- but an alert that runs past this has stopped
81// being an alert. Ten segments of plain GSM text is far more than a sentence naming a host and
82// a fault, and the refusal is louder than a silent truncation would be.
83pub const MAX_BODY_LEN: usize = 1530;
84
85/// The account and secret a gateway authenticates with.
86///
87/// Two fields, because all three vendors here want a pair and each calls it something
88/// different: a username and an API key, an account identifier and an auth token. Naming them
89/// after any one vendor would make the other two read as exceptions.
90///
91/// **Neither field is ever written to a log by this module**, and [`Provider::request`] puts
92/// both only in the `Authorization` header. See the module documentation for why that is a
93/// requirement rather than a habit.
94pub struct Credential<'a> {
95 pub user: &'a str, // a username, an account identifier, an authentication identifier
96 pub secret: &'a str, // an API key, an auth token
97}
98
99/// One message to one number.
100pub struct Message<'a> {
101 pub to: &'a str, // E.164, with the leading `+`
102 // As the vendor wants it: a number the account owns, or an alphanumeric identifier where
103 // the destination permits one. Empty asks the vendor for its default, which is what an
104 // account with a single number should do rather than repeat itself.
105 pub from: &'a str,
106 pub body: &'a str,
107}
108
109/// Everything needed to place one call, and nothing else.
110///
111/// The caller owns the socket. See the module documentation.
112pub struct SmsCall {
113 pub host: String, // dialled, and the name the certificate is validated against
114 pub port: u16, // always 443 for these vendors, carried rather than assumed
115 pub path: String, // with any account identifier already escaped into it
116 pub method: HttpMethod,
117 pub headers: Vec<(String, String)>, // including the credential under `Authorization`
118 pub body: Vec<u8>,
119}
120
121/// What a gateway said when it took the message.
122///
123/// Only an accepted message has one: a refusal is an error from [`Provider::parse`], never a
124/// receipt with a refusing status in it.
125///
126/// Every field is a string except the count, because the three vendors disagree about the type
127/// of every one of them -- a price arrives as a number from one and as a quoted decimal from
128/// another -- and a receipt that reformatted them would be asserting a precision none of them
129/// promises. The price in particular is passed through exactly as written, in whatever currency
130/// the account is billed in, and is never parsed into a figure this module would then be
131/// claiming to understand.
132#[derive(Clone, Debug, Default, Eq, PartialEq)]
133pub struct Receipt {
134 pub id: String, // the vendor's own, for a support ticket
135 // The vendor's word for an accepted message, not normalised: `SUCCESS`, `queued` and
136 // `message(s) queued` all mean accepted, and flattening them into one word would throw away
137 // the only text a person can quote back to the vendor.
138 pub status: String,
139 pub parts: u32, // segments billed, or zero where the vendor did not say
140 pub price: String, // verbatim and unparsed, or empty where the vendor did not say
141}
142
143/// An SMS gateway.
144///
145/// Three, all authenticating by `Authorization: Basic`. See the module documentation for why
146/// that is the entry requirement and which vendor it excludes.
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum Provider {
149 ClickSend, // Australian, billing in Australian dollars, JSON body, no account in the path
150 Twilio, // form-encoded body, account identifier in the path
151 Plivo, // JSON body, account identifier in the path
152}
153
154impl Provider {
155 // A list rather than a `match`, so a variant added and not listed here is unreachable
156 // through `Self::from_id` -- the safe direction for a set whose members each carry a
157 // credential.
158 pub const ALL: [Self; 3] = [Self::ClickSend, Self::Twilio, Self::Plivo];
159
160 /// The id, one spelling everywhere: this enum, a configuration value, a log line.
161 pub fn id(&self) -> &'static str {
162 match self {
163 Self::ClickSend => "clicksend",
164 Self::Twilio => "twilio",
165 Self::Plivo => "plivo",
166 }
167 }
168
169 pub fn from_id(s: &str) -> Option<Self> {
170 let s = s.trim().to_lowercase();
171 Self::ALL.into_iter().find(|p| p.id() == s)
172 }
173
174 /// Dialled, and the name the certificate is validated against.
175 pub fn host(&self) -> &'static str {
176 match self {
177 Self::ClickSend => "rest.clicksend.com",
178 Self::Twilio => "api.twilio.com",
179 Self::Plivo => "api.plivo.com",
180 }
181 }
182
183 /// The `Authorization` value for a credential.
184 ///
185 /// One place, because three vendors spelling out the same base64 would be three places for
186 /// it to be spelled wrong.
187 fn authorization(&self, cred: &Credential) -> String {
188 fmt!("Basic {}", base64::encode(fmt!("{}:{}", cred.user, cred.secret).as_bytes()))
189 }
190
191 /// Host, path, method, headers and body for one message. **Not a request that is sent**:
192 /// the caller owns the transport, the address check and the TLS.
193 pub fn request(&self, cred: &Credential, m: &Message) -> Outcome<SmsCall> {
194 // Checked here rather than left to the vendor, because a malformed number comes back as
195 // a 400 with a vendor-specific code at the far end of a socket, and this is an alerting
196 // path: the failure that matters is the one discovered while the operator is asleep.
197 if !is_e164(m.to) {
198 return Err(err!(
199 "An SMS recipient must be in E.164 with a leading '+', got {:?}.", m.to;
200 Invalid, Input));
201 }
202 if m.body.is_empty() {
203 return Err(err!("An SMS needs something to say."; Invalid, Input, Missing));
204 }
205 if m.body.len() > MAX_BODY_LEN {
206 return Err(err!(
207 "An SMS body of {} bytes is past the {} this will carry. An alert longer than \
208 that has stopped being an alert.", m.body.len(), MAX_BODY_LEN;
209 Invalid, Input, TooBig));
210 }
211
212 let headers = |ct: &str| vec![
213 (fmt!("Authorization"), self.authorization(cred)),
214 (fmt!("Content-Type"), fmt!("{}", ct)),
215 (fmt!("Accept"), fmt!("application/json")),
216 ];
217
218 match self {
219 Self::ClickSend => {
220 // The account is identified by the credential alone, so the path is fixed and
221 // carries nothing. One message per call: this is an alerter, not a campaign.
222 let mut one = DaticleMap::new();
223 one.insert(dat!("to"), dat!(m.to.to_string()));
224 one.insert(dat!("body"), dat!(m.body.to_string()));
225 if !m.from.is_empty() {
226 one.insert(dat!("from"), dat!(m.from.to_string()));
227 }
228 let mut b = DaticleMap::new();
229 b.insert(dat!("messages"), Dat::List(vec![Dat::Map(one)]));
230 Ok(SmsCall {
231 host: self.host().to_string(),
232 port: 443,
233 path: fmt!("/v3/sms/send"),
234 method: HttpMethod::POST,
235 headers: headers("application/json"),
236 body: res!(Dat::Map(b).json()).into_bytes(),
237 })
238 },
239 Self::Twilio => {
240 // The account identifier is in the path as well as in the credential. It is
241 // escaped even though it is an opaque identifier of known shape, because a path
242 // built by concatenation is a path that will one day be built from something
243 // else.
244 let path = fmt!("/2010-04-01/Accounts/{}/Messages.json",
245 pct::encode_component(cred.user));
246 let mut form = fmt!("To={}&Body={}",
247 pct::encode_component(m.to), pct::encode_component(m.body));
248 if !m.from.is_empty() {
249 form.push_str(&fmt!("&From={}", pct::encode_component(m.from)));
250 }
251 Ok(SmsCall {
252 host: self.host().to_string(),
253 port: 443,
254 path,
255 method: HttpMethod::POST,
256 headers: headers("application/x-www-form-urlencoded"),
257 body: form.into_bytes(),
258 })
259 },
260 Self::Plivo => {
261 let path = fmt!("/v1/Account/{}/Message/", pct::encode_component(cred.user));
262 let mut b = DaticleMap::new();
263 b.insert(dat!("dst"), dat!(m.to.to_string()));
264 b.insert(dat!("text"), dat!(m.body.to_string()));
265 if !m.from.is_empty() {
266 b.insert(dat!("src"), dat!(m.from.to_string()));
267 }
268 Ok(SmsCall {
269 host: self.host().to_string(),
270 port: 443,
271 path,
272 method: HttpMethod::POST,
273 headers: headers("application/json"),
274 body: res!(Dat::Map(b).json()).into_bytes(),
275 })
276 },
277 }
278 }
279
280 /// Read a gateway's answer to one call: a [`Receipt`] when the vendor took the message, and an
281 /// error in the vendor's own words when it did not.
282 ///
283 /// `status` is the HTTP status the answer came with. A refusal is surfaced with the provider's
284 /// own words rather than a summary, since that text is where a vendor explains a rejected
285 /// credential or an unfunded account.
286 pub fn parse(&self, status: u16, body: &[u8]) -> Outcome<Receipt> {
287 let refused = !(200..300).contains(&status);
288 let txt = match std::str::from_utf8(body) {
289 Ok(s) => s,
290 Err(e) => return Err(err!(e,
291 "{} answered HTTP {} with something that is not text.", self.id(), status;
292 Network, Data, Decode)),
293 };
294 let dat = match Dat::decode_string_with_config(txt.to_string(), &json_decoder()) {
295 Ok(d) => d,
296 // A refusal from a proxy in front of the vendor is often a page rather than JSON. It is
297 // still a refusal, and its text is still the best explanation to hand.
298 Err(e) => return Err(if refused {
299 err!(e, "{} refused the message with HTTP {}: {}", self.id(), status, clip(txt);
300 Network, Invalid)
301 } else {
302 err!(e, "{} answered with something that is not JSON: {}", self.id(), clip(txt);
303 Network, Data, Decode)
304 }),
305 };
306 let map = match &dat {
307 Dat::Map(m) => m,
308 other => return Err(err!(
309 "{} answered HTTP {} with a JSON {:?} rather than an object: {}",
310 self.id(), status, other.kind(), clip(txt); Network, Data, Mismatch)),
311 };
312
313 // The error document first, and before the happy path, because two of these vendors
314 // answer a rejected credential with HTTP 200 and an error object. A parser that read the
315 // success fields first would find them absent and report a shape problem, hiding the
316 // sentence that says the account is out of credit.
317 if let Some(msg) = error_text(map) {
318 return Err(err!("{} refused the message: {}", self.id(), msg; Network, Invalid));
319 }
320 // A status that is not a success is a refusal whatever the body holds. `message` is read
321 // here although `error_text` passes it over: on a refusal it is the explanation, while on
322 // one vendor's success it is the success text.
323 if refused {
324 let msg = text(map, "message");
325 return Err(err!("{} refused the message with HTTP {}: {}", self.id(), status,
326 if msg.is_empty() { clip(txt) } else { msg };
327 Network, Invalid));
328 }
329
330 match self {
331 Self::ClickSend => {
332 // data.messages[0]. The call can succeed while the message is refused: an unfunded
333 // account is `response_code: SUCCESS` for the call and `INSUFFICIENT_CREDIT` for
334 // the message. `SUCCESS` is the one status that means the message was taken.
335 let one = res!(first_message(map, "data", "messages").ok_or_else(|| err!(
336 "{} answered with no message record: {}", self.id(), clip(txt);
337 Network, Data, Missing)));
338 let st = text(&one, "status");
339 if !st.eq_ignore_ascii_case("SUCCESS") {
340 return Err(err!("{} refused the message: {}", self.id(),
341 if st.is_empty() { fmt!("no status given, {}", clip(txt)) } else { st };
342 Network, Invalid));
343 }
344 Ok(Receipt {
345 id: text(&one, "message_id"),
346 status: st,
347 parts: number(&one, "message_parts"),
348 price: text(&one, "message_price"),
349 })
350 },
351 Self::Twilio => {
352 // A created message carries its `sid`. One that failed at once says so in its
353 // `status`, with an `error_code` and the vendor's `error_message`.
354 let sid = text(map, "sid");
355 if sid.is_empty() {
356 return Err(err!(
357 "{} answered with no message record: {}", self.id(), clip(txt);
358 Network, Data, Missing));
359 }
360 let st = text(map, "status");
361 let code = text(map, "error_code");
362 let failed = ["failed", "undelivered", "canceled"].iter()
363 .any(|f| st.eq_ignore_ascii_case(f));
364 if failed || !code.is_empty() {
365 let words: Vec<String> = [
366 st.clone(),
367 if code.is_empty() { String::new() } else { fmt!("error {}", code) },
368 text(map, "error_message"),
369 ].into_iter().filter(|w| !w.is_empty()).collect();
370 return Err(err!("{} refused the message: {}", self.id(), words.join(", ");
371 Network, Invalid));
372 }
373 Ok(Receipt {
374 id: sid,
375 status: st,
376 parts: number(map, "num_segments"),
377 price: text(map, "price"),
378 })
379 },
380 Self::Plivo => {
381 // The identifier arrives as a list of one, since the endpoint can take several
382 // destinations. This module sends to one, and a message with no identifier is one
383 // the vendor did not queue.
384 let id = match map.get(&dat!("message_uuid")) {
385 Some(Dat::List(l)) => l.first().map(scalar_text).unwrap_or_default(),
386 Some(d) => scalar_text(d),
387 None => String::new(),
388 };
389 if id.is_empty() {
390 return Err(err!(
391 "{} answered with no message identifier: {}", self.id(), clip(txt);
392 Network, Data, Missing));
393 }
394 Ok(Receipt {
395 id,
396 status: text(map, "message"),
397 parts: 0,
398 price: String::new(),
399 })
400 },
401 }
402 }
403}
404
405/// Is this an E.164 number?
406///
407/// A leading `+`, then between eight and fifteen digits and nothing else. Deliberately strict:
408/// spaces, hyphens and brackets are how a human writes a number and every vendor here refuses
409/// them, so accepting them would only move the refusal to the far end of a socket.
410pub fn is_e164(s: &str) -> bool {
411 let mut it = s.chars();
412 if it.next() != Some('+') {
413 return false;
414 }
415 let digits = s.len() - 1;
416 digits >= 8 && digits <= 15 && it.all(|c| c.is_ascii_digit())
417}
418
419fn json_decoder() -> DecoderConfig<
420 BTreeMap<UsrKindCode, UsrKind>,
421 BTreeMap<String, UsrKindId>,
422>
423{
424 DecoderConfig::json(None)
425}
426
427/// As much of a reply as belongs in an error message.
428fn clip(s: &str) -> String {
429 let s = s.trim();
430 if s.len() <= 200 {
431 return s.to_string();
432 }
433 // Back to a character boundary: a byte slice through a multi-byte character panics, and
434 // this runs in the alerting path, on text a vendor wrote.
435 let mut end = 200;
436 while !s.is_char_boundary(end) {
437 end -= 1;
438 }
439 fmt!("{}...", &s[..end])
440}
441
442/// A scalar as text, whatever the vendor made it.
443///
444/// A price arrives quoted from one vendor and bare from another; a segment count arrives as a
445/// string from one and an integer from another. Reading either shape is not laxity, it is the
446/// only way one receipt can describe three vendors without lying about one of them.
447///
448/// The integer widths are spelled out rather than left to `Display`, because a daticle knows its
449/// own width and says so, and a receipt carrying `(u64|1)` where a person expected `1` is a
450/// receipt that has quietly leaked the serialisation format into a support ticket.
451fn scalar_text(d: &Dat) -> String {
452 match d {
453 Dat::Str(s) => s.clone(),
454 Dat::Empty => String::new(),
455 // JSON's `null` decodes as an absent option, and it means the vendor said nothing. Left
456 // to the fallback it read "(none)", so a Twilio receipt carried that as its price.
457 Dat::Opt(o) => match &**o {
458 Some(inner) => scalar_text(inner),
459 None => String::new(),
460 },
461 Dat::U8(n) => fmt!("{}", n),
462 Dat::U16(n) => fmt!("{}", n),
463 Dat::U32(n) => fmt!("{}", n),
464 Dat::U64(n) => fmt!("{}", n),
465 Dat::I32(n) => fmt!("{}", n),
466 Dat::I64(n) => fmt!("{}", n),
467 Dat::F32(n) => fmt!("{}", n),
468 Dat::F64(n) => fmt!("{}", n),
469 other => fmt!("{:?}", other),
470 }
471}
472
473/// Empty when absent.
474fn text(map: &DaticleMap, key: &str) -> String {
475 map.get(&dat!(key)).map(scalar_text).unwrap_or_default()
476}
477
478/// Zero when absent or unreadable. Both shapes are read, because one vendor quotes its segment
479/// count and another sends it as a number.
480///
481/// The widths are listed rather than parsed back out of a rendered daticle: a daticle prints its
482/// own width, so `parse::<u32>()` over `Display` silently returned zero for every integer reply.
483fn number(map: &DaticleMap, key: &str) -> u32 {
484 match map.get(&dat!(key)) {
485 Some(Dat::Str(s)) => s.trim().parse::<u32>().unwrap_or(0),
486 Some(Dat::U8(n)) => *n as u32,
487 Some(Dat::U16(n)) => *n as u32,
488 Some(Dat::U32(n)) => *n,
489 // Saturating rather than wrapping. A segment count cannot reach this, which is the
490 // point: if one ever does, the reply is not a segment count and a large number is a
491 // better clue than a small one produced by truncation.
492 Some(Dat::U64(n)) => (*n).min(u32::MAX as u64) as u32,
493 Some(Dat::I32(n)) => if *n > 0 { *n as u32 } else { 0 },
494 Some(Dat::I64(n)) => if *n > 0 { (*n as u64).min(u32::MAX as u64) as u32 } else { 0 },
495 _ => 0,
496 }
497}
498
499/// `outer.inner[0]` as a map, for a vendor that nests its receipt in a list.
500fn first_message(map: &DaticleMap, outer: &str, inner: &str) -> Option<DaticleMap> {
501 let d = match map.get(&dat!(outer)) {
502 Some(Dat::Map(m)) => m,
503 _ => return None,
504 };
505 match d.get(&dat!(inner)) {
506 Some(Dat::List(l)) => match l.first() {
507 Some(Dat::Map(m)) => Some(m.clone()),
508 _ => None,
509 },
510 _ => None,
511 }
512}
513
514/// The vendor's own words for a refusal, where the document is one.
515///
516/// Checked before the success fields: two of these vendors answer a rejected credential with
517/// HTTP 200 and an error object, so a parser that looked for the receipt first would report a
518/// missing field where the vendor had written a sentence explaining itself.
519fn error_text(map: &DaticleMap) -> Option<String> {
520 // A response code that is present and is not a success is the plainest signal.
521 if let Some(Dat::Str(code)) = map.get(&dat!("response_code")) {
522 if !code.eq_ignore_ascii_case("SUCCESS") {
523 let msg = text(map, "response_msg");
524 return Some(if msg.is_empty() { code.clone() } else { fmt!("{} ({})", msg, code) });
525 }
526 }
527 // Otherwise a message-shaped error field, under whichever name the vendor uses. `message`
528 // is not among them: one vendor uses it for the SUCCESS text. The vendor's error code goes
529 // with the words where the document carries one, since it is what a support ticket quotes.
530 for k in ["error", "error_message", "error-message", "detail"] {
531 let msg = match map.get(&dat!(k)) {
532 Some(Dat::Str(s)) if !s.is_empty() => s.clone(),
533 Some(Dat::Map(m)) => text(m, "message"),
534 _ => String::new(),
535 };
536 if !msg.is_empty() {
537 let code = match text(map, "error_code") {
538 c if !c.is_empty() => c,
539 _ => text(map, "code"),
540 };
541 return Some(if code.is_empty() { msg } else { fmt!("{} (error {})", msg, code) });
542 }
543 }
544 None
545}
546
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 /// A header by name, compared as HTTP compares them.
553 fn hdr<'a>(c: &'a SmsCall, name: &str) -> Option<&'a str> {
554 c.headers.iter()
555 .find(|(k, _)| k.eq_ignore_ascii_case(name))
556 .map(|(_, v)| v.as_str())
557 }
558
559 fn cred() -> Credential<'static> {
560 Credential { user: "acct-identifier", secret: "s3cr3t-token-value" }
561 }
562
563 fn msg() -> Message<'static> {
564 Message { to: "+61400000000", from: "", body: "jarrah gateway down" }
565 }
566
567 /// THE PROPERTY THIS MODULE PROMISES: the secret is in the headers and nowhere else.
568 ///
569 /// Asserted per provider rather than once over a list, so a fourth arm added without
570 /// thinking about it fails here rather than quietly widening the promise. See the module
571 /// documentation for why a body is a worse place for a secret than a header.
572 #[test]
573 fn secret_only_ever_in_the_authorization_header() {
574 let c = cred();
575 let m = msg();
576 for p in Provider::ALL {
577 let call = match p.request(&c, &m) {
578 Ok(call) => call,
579 Err(e) => panic!("{} would not build a request: {}", p.id(), e),
580 };
581 let body = String::from_utf8_lossy(&call.body).to_string();
582 assert!(!call.path.contains(c.secret),
583 "{} put the secret in the path: {}", p.id(), call.path);
584 assert!(!body.contains(c.secret),
585 "{} put the secret in the body: {}", p.id(), body);
586 // And it IS present, in the one place it belongs -- so this test cannot pass by
587 // the credential having been dropped altogether.
588 let auth = match hdr(&call, "authorization") {
589 Some(a) => a,
590 None => panic!("{} sent no Authorization header", p.id()),
591 };
592 let expect = base64::encode(fmt!("{}:{}", c.user, c.secret).as_bytes());
593 assert_eq!(auth, fmt!("Basic {}", expect),
594 "{} did not send the credential as Basic", p.id());
595 assert!(!call.host.is_empty(), "{} did not name its host", p.id());
596 assert_eq!(call.port, 443, "{} did not use TLS", p.id());
597 }
598 }
599
600 /// The recipient and the text survive into the request, whatever the vendor's field names.
601 #[test]
602 fn the_message_reaches_the_body() {
603 let c = cred();
604 let m = msg();
605 for p in Provider::ALL {
606 let call = res_unwrap(p.request(&c, &m), p);
607 let body = String::from_utf8_lossy(&call.body).to_string();
608 // The number is percent-escaped in a form body and plain in a JSON one, so the
609 // digits are what is looked for rather than the whole string.
610 assert!(body.contains("61400000000"),
611 "{} lost the recipient: {}", p.id(), body);
612 assert!(body.contains("gateway") || body.contains("gateway%20"),
613 "{} lost the text: {}", p.id(), body);
614 }
615 }
616
617 #[test]
618 fn a_number_that_is_not_e164_is_refused_before_a_socket_opens() {
619 let c = cred();
620 for bad in ["0400 000 000", "61400000000", "+61-400-000-000", "+123", ""] {
621 let m = Message { to: bad, from: "", body: "x" };
622 for p in Provider::ALL {
623 assert!(p.request(&c, &m).is_err(),
624 "{} accepted {:?} as a number", p.id(), bad);
625 }
626 }
627 assert!(is_e164("+61400000000"));
628 assert!(is_e164("+14155550123"));
629 }
630
631 #[test]
632 fn an_empty_or_enormous_body_is_refused() {
633 let c = cred();
634 let long = "x".repeat(MAX_BODY_LEN + 1);
635 for p in Provider::ALL {
636 assert!(p.request(&c, &Message { to: "+61400000000", from: "", body: "" }).is_err(),
637 "{} accepted an empty body", p.id());
638 assert!(p.request(&c, &Message { to: "+61400000000", from: "", body: &long }).is_err(),
639 "{} accepted a body past the cap", p.id());
640 }
641 }
642
643 /// An id round-trips, and an unknown one is refused rather than defaulted.
644 #[test]
645 fn ids_are_one_spelling() {
646 for p in Provider::ALL {
647 assert_eq!(Provider::from_id(p.id()), Some(p));
648 assert_eq!(Provider::from_id(&p.id().to_uppercase()), Some(p));
649 }
650 assert_eq!(Provider::from_id("vonage"), None);
651 assert_eq!(Provider::from_id(""), None);
652 }
653
654 #[test]
655 fn a_receipt_is_read_from_each_vendors_own_shape() {
656 let cs = br#"{"http_code":200,"response_code":"SUCCESS","data":{"messages":[
657 {"message_id":"ABC-123","status":"SUCCESS","message_parts":1,"message_price":"0.0790"}]}}"#;
658 let r = res_unwrap(Provider::ClickSend.parse(200, cs), Provider::ClickSend);
659 assert_eq!(r.id, "ABC-123");
660 assert_eq!(r.status, "SUCCESS");
661 assert_eq!(r.parts, 1);
662 assert_eq!(r.price, "0.0790", "the price is passed through verbatim");
663
664 let tw = br#"{"sid":"SM9","status":"queued","num_segments":"2","price":null,
665 "error_code":null,"error_message":null}"#;
666 let r = res_unwrap(Provider::Twilio.parse(201, tw), Provider::Twilio);
667 assert_eq!(r.id, "SM9");
668 assert_eq!(r.status, "queued");
669 assert_eq!(r.parts, 2, "a count quoted as a string is still a count");
670 assert_eq!(r.price, "", "a null price is no price, not the text of a null");
671
672 let pl = br#"{"message_uuid":["uu-1"],"message":"message(s) queued"}"#;
673 let r = res_unwrap(Provider::Plivo.parse(202, pl), Provider::Plivo);
674 assert_eq!(r.id, "uu-1", "the identifier is lifted out of its list of one");
675 }
676
677 /// A refusal that arrives with HTTP 200 is still a refusal, and it says why.
678 #[test]
679 fn an_error_document_is_an_error_and_repeats_the_vendors_words() {
680 let out_of_credit = br#"{"http_code":400,"response_code":"NO_CREDIT",
681 "response_msg":"Insufficient credit"}"#;
682 match Provider::ClickSend.parse(200, out_of_credit) {
683 Ok(r) => panic!("an unfunded account read as a receipt: {:?}", r),
684 Err(e) => {
685 let s = e.to_string();
686 assert!(s.contains("Insufficient credit"),
687 "the vendor's own sentence was thrown away: {}", s);
688 },
689 }
690 let bad_key = br#"{"status":401,"message":"Authenticate","error":"authentication failed"}"#;
691 assert!(Provider::Twilio.parse(200, bad_key).is_err(),
692 "a rejected credential read as a receipt");
693 }
694
695 /// THE FAULT OF 2026-08-31: the call succeeds and the message inside it is refused. This is
696 /// the shape ClickSend answers an unfunded account with, `SUCCESS` for the call and
697 /// `INSUFFICIENT_CREDIT` for the message, and every such text was logged as sent for three
698 /// weeks. It is a refusal, and it names the vendor's status.
699 #[test]
700 fn a_message_refused_inside_a_successful_call_is_a_refusal() {
701 let unfunded = br#"{"http_code":200,"response_code":"SUCCESS",
702 "response_msg":"Messages queued for delivery.","data":{"total_price":0,"total_count":1,
703 "queued_count":0,"messages":[{"direction":"out","date":1756600000,"to":"+61400000000",
704 "body":"birch copy is DOWN","from":"","schedule":0,
705 "message_id":"4C1F2D3E-5A6B-4C7D-8E9F-0A1B2C3D4E5F","message_parts":1,
706 "message_price":"0.0000","from_email":null,"list_id":null,"custom_string":"",
707 "contact_id":null,"user_id":1,"subaccount_id":1,"country":"AU","carrier":"Telstra",
708 "status":"INSUFFICIENT_CREDIT"}],"_currency":{"currency_name_short":"AUD",
709 "currency_prefix_d":"$","currency_prefix_c":"c",
710 "currency_name_long":"Australian Dollars"}}}"#;
711 match Provider::ClickSend.parse(200, unfunded) {
712 Ok(r) => panic!("a text refused for want of credit read as sent: {:?}", r),
713 Err(e) => {
714 let s = e.to_string();
715 assert!(s.contains("INSUFFICIENT_CREDIT") && s.contains("clicksend"),
716 "the refusal must name the vendor and its status: {}", s);
717 },
718 }
719 // Any status but SUCCESS is a refusal, and so is none at all.
720 let bad_number = br#"{"response_code":"SUCCESS","data":{"messages":[
721 {"message_id":"X","status":"INVALID_RECIPIENT"}]}}"#;
722 assert!(Provider::ClickSend.parse(200, bad_number).is_err());
723 let silent = br#"{"response_code":"SUCCESS","data":{"messages":[{"message_id":"X"}]}}"#;
724 assert!(Provider::ClickSend.parse(200, silent).is_err(),
725 "a message with no status is not a message the vendor took");
726 }
727
728 /// A status that is not a `2xx` is a refusal whatever the body holds, in the vendor's words
729 /// where it wrote any and in the page's own text where it did not.
730 #[test]
731 fn a_status_that_is_not_a_success_is_a_refusal_in_the_vendors_words() {
732 let bad_to = br#"{"code":21211,"message":"Invalid 'To' Phone Number: +6140000000",
733 "more_info":"https://www.twilio.com/docs/errors/21211","status":400}"#;
734 match Provider::Twilio.parse(400, bad_to) {
735 Ok(r) => panic!("a 400 read as a receipt: {:?}", r),
736 Err(e) => {
737 let s = e.to_string();
738 assert!(s.contains("Invalid 'To' Phone Number") && s.contains("400"),
739 "the vendor's explanation and the status were thrown away: {}", s);
740 },
741 }
742 let page = b"<html><body>502 Bad Gateway</body></html>";
743 match Provider::Plivo.parse(502, page) {
744 Ok(r) => panic!("a proxy's error page read as a receipt: {:?}", r),
745 Err(e) => assert!(e.to_string().contains("502 Bad Gateway"),
746 "the page's own text is the best explanation to hand: {}", e),
747 }
748 // A body that would be a receipt under a 2xx is still refused under a 5xx.
749 let looks_fine = br#"{"message_uuid":["uu-1"],"message":"message(s) queued"}"#;
750 assert!(Provider::Plivo.parse(503, looks_fine).is_err());
751 }
752
753 /// A message the vendor created and failed at once is a refusal, with its code and words;
754 /// an answer with no message in it at all is no receipt either.
755 #[test]
756 fn a_message_that_failed_at_once_or_was_never_made_is_a_refusal() {
757 let filtered = br#"{"sid":"SM10","status":"failed","error_code":30007,
758 "error_message":"Message filtered","num_segments":"1","price":null}"#;
759 match Provider::Twilio.parse(201, filtered) {
760 Ok(r) => panic!("a failed message read as a receipt: {:?}", r),
761 Err(e) => {
762 let s = e.to_string();
763 assert!(s.contains("30007") && s.contains("Message filtered"),
764 "the code and the words were thrown away: {}", s);
765 },
766 }
767 assert!(Provider::Twilio.parse(201, br#"{"status":"queued"}"#).is_err(),
768 "no sid means no message was made");
769 assert!(Provider::Plivo.parse(202, br#"{"message":"message(s) queued"}"#).is_err(),
770 "no identifier means nothing was queued");
771 assert!(Provider::Plivo.parse(202, br#"{"message_uuid":[]}"#).is_err());
772 }
773
774 /// An error message quotes at most 200 bytes of a reply, and never cuts a character in half:
775 /// a panic here would take the alerting task with it.
776 #[test]
777 fn a_long_reply_is_clipped_on_a_character_boundary() {
778 let long = "\u{00e9}".repeat(150); // 300 bytes, each character two
779 let c = clip(&long);
780 assert!(c.ends_with("..."));
781 assert!(c.len() <= 203);
782 let refusal = fmt!("<p>{}</p>", "\u{20ac}".repeat(100)); // three bytes a character
783 assert!(Provider::Twilio.parse(500, refusal.as_bytes()).is_err());
784 }
785
786 /// Unwrap in a test, naming which provider failed.
787 fn res_unwrap<T>(r: Outcome<T>, p: Provider) -> T {
788 match r {
789 Ok(v) => v,
790 Err(e) => panic!("{}: {}", p.id(), e),
791 }
792 }
793}