Skip to main content

jmap_base_client/ws/
mod.rs

1//! WebSocket transport for JMAP (RFC 8887).
2//!
3//! Provides [`connect_ws`] which establishes a WebSocket connection and
4//! returns a [`WsSession`] for sending and receiving frames.
5//!
6//! URL source: `Session::capabilities["urn:ietf:params:jmap:websocket"].url`
7//! (the session document advertises the WebSocket endpoint).
8
9use std::str::FromStr as _;
10
11use futures::SinkExt as _;
12use futures::StreamExt as _;
13use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
14use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
15use tokio_tungstenite::tungstenite::Message;
16
17use crate::push::StateChange;
18
19/// Wire frame sent from the client to the server over WebSocket (RFC 8887 §4.3.2).
20///
21/// Wraps a [`jmap_types::JmapRequest`] and injects the mandatory `@type: "Request"`
22/// field (and optional `id`) in a single `serde_json::to_string` pass, avoiding
23/// the `to_value` + mutation + `to_string` double-serialization that the naive
24/// approach requires.
25#[derive(serde::Serialize)]
26struct WsRequestFrame<'a> {
27    /// RFC 8887 §4.3.2 — every JMAP request frame MUST carry "@type": "Request".
28    #[serde(rename = "@type")]
29    ws_type: &'static str,
30    /// Optional correlation ID echoed back in the server's Response frame.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    id: Option<&'a str>,
33    /// The JMAP request payload; flattened into the enclosing JSON object.
34    #[serde(flatten)]
35    inner: &'a jmap_types::JmapRequest,
36}
37
38/// Maximum WebSocket message size (1 MiB), consistent with the SSE frame limit.
39/// Prevents a misbehaving or hostile server from forcing the client to buffer
40/// large messages over the event connection.
41/// Default per-message / per-frame byte cap for WebSocket connections opened
42/// via [`connect_ws`] (which does not take a limit parameter). Callers that
43/// need a different cap should use [`connect_ws_with_limit`] or the
44/// [`crate::JmapClient::connect_ws_session`] convenience method which
45/// reads the `max_ws_message` field from `ClientConfig`. Default: 1 MiB.
46pub const DEFAULT_WS_MAX_MESSAGE_BYTES: usize = 1 << 20;
47
48/// A parsed frame received from the JMAP WebSocket.
49///
50/// Marked `#[non_exhaustive]` because the spec may define additional
51/// `@type` values in future revisions.
52#[non_exhaustive]
53#[derive(Debug, Clone, PartialEq)]
54pub enum WsFrame {
55    /// RFC 8620 §7.1 StateChange — one or more object types have changed
56    /// state; client must re-fetch the affected data types.
57    StateChange(StateChange),
58    /// RFC 8887 Response — reply to a JMAP request sent on this connection.
59    Response(jmap_types::JmapResponse),
60    /// Unrecognized `@type` — silently ignored per forward-compatibility rules
61    /// (RFC 8887 §4.3.1: clients SHOULD ignore unknown message types).
62    ///
63    /// Also produced when a known type (`"Response"` or `"StateChange"`) fails
64    /// to deserialize — `type_name` will be `"Response"` or `"StateChange"` in
65    /// that case, which can signal server misbehavior or a schema version
66    /// mismatch. Callers that log unknown frames should check for these names.
67    Unknown {
68        /// Value of the `@type` field. Either an unrecognized message type
69        /// per RFC 8887 §4.3.1, or `"Response"` / `"StateChange"` when a known
70        /// type failed to deserialize into its typed variant.
71        type_name: String,
72        /// Raw JSON object as received from the server, preserved for
73        /// forward-compatibility diagnostics.
74        ///
75        /// **DO NOT log this field verbatim.** Future or extension JMAP
76        /// WebSocket message types may carry credential-grade material —
77        /// push verification codes (RFC 8887 §7.2), federation handshake
78        /// tokens, session-rotation challenges, etc. — and a malformed
79        /// `Response` to a method like `PushSubscription/get` can echo a
80        /// `verificationCode` back into this field. The enum derives
81        /// `Debug`, so a `{:?}`-format of any `WsFrame::Unknown` writes
82        /// this Value to the output stream.
83        ///
84        /// For operator logs, prefer logging `type_name` only, or apply a
85        /// project-specific redaction filter before passing `raw` to a
86        /// logging sink. See bd:JMAP-sc1b.98.
87        raw: serde_json::Value,
88    },
89}
90
91type Inner =
92    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
93
94/// An established JMAP WebSocket session (RFC 8887).
95///
96/// Call [`next_frame`](WsSession::next_frame) in a loop to receive events.
97/// Use [`send_request`](WsSession::send_request) to transmit JMAP requests.
98///
99/// The caller is responsible for reconnecting after the stream ends or returns
100/// a transport error. Use exponential backoff.
101pub struct WsSession {
102    sink: futures::stream::SplitSink<Inner, Message>,
103    stream: futures::stream::SplitStream<Inner>,
104}
105
106/// Maximum number of consecutive non-Text non-Close non-Binary frames
107/// (Ping, Pong, Frame, etc.) `next_frame` will silently skip in a single call.
108///
109/// Tungstenite handles ping/pong at the protocol layer, so seeing them at the
110/// `Message` layer is unusual but legal — we skip them. A misbehaving or
111/// hostile server that floods the stream with no-op frames could otherwise
112/// starve a caller of `next_frame` indefinitely; this cap surfaces an
113/// `UnexpectedResponse` error before that can happen. 64 is high enough that
114/// a normal connection never trips it (typical SSE/WS streams interleave at
115/// most a handful of pings between data frames) and low enough that the
116/// caller doesn't wait long if a bad server is talking nonsense.
117///
118/// `Binary` frames are NOT counted here — they violate RFC 8887 §4.1 and
119/// surface as `UnexpectedResponse` immediately on the first occurrence.
120const MAX_CONSECUTIVE_NON_TEXT_FRAMES: usize = 64;
121
122/// Classify a single tungstenite [`Message`] into a [`MessageDisposition`]
123/// that tells the [`WsSession::next_frame`] loop what to do with it.
124///
125/// Extracted as a free function so the policy is unit-testable without a
126/// real WebSocket: see the inline test module. Pure function over the
127/// message variant.
128fn classify_message(msg: &Message) -> MessageDisposition {
129    match msg {
130        Message::Text(_) => MessageDisposition::Text,
131        Message::Close(_) => MessageDisposition::Close,
132        Message::Binary(_) => MessageDisposition::Binary,
133        // Ping, Pong, Frame, and any future variants: skip, but count.
134        _ => MessageDisposition::Skip,
135    }
136}
137
138/// Decision a `next_frame` loop iteration takes after looking at one
139/// [`Message`]. See [`classify_message`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141enum MessageDisposition {
142    /// Text frame: hand to `parse_ws_frame` and return its result.
143    Text,
144    /// Close frame: end the stream by returning `None`.
145    Close,
146    /// Binary frame: violates RFC 8887 §4.1; surface as
147    /// `UnexpectedResponse` immediately on the first occurrence.
148    Binary,
149    /// Ping / Pong / Frame / future variants: silently skip and continue
150    /// the loop, subject to [`MAX_CONSECUTIVE_NON_TEXT_FRAMES`].
151    Skip,
152}
153
154impl WsSession {
155    /// Receive the next parsed frame from the server.
156    ///
157    /// Returns `None` when the server has cleanly closed the connection.
158    /// Returns `Some(Err(...))` on parse failure, transport error, RFC 8887
159    /// §4.1 violation (Binary frame), or starvation cap (more than 64
160    /// consecutive Ping/Pong/Frame messages — see the private
161    /// `MAX_CONSECUTIVE_NON_TEXT_FRAMES` constant for the exact value).
162    /// After a transport error the connection is broken and `next_frame`
163    /// must not be called again. After an `UnexpectedResponse` error the
164    /// underlying stream is still healthy — the caller may choose to
165    /// ignore it and retry, or to disconnect.
166    pub async fn next_frame(&mut self) -> Option<Result<WsFrame, crate::error::ClientError>> {
167        let mut consecutive_skips = 0usize;
168        loop {
169            let msg = match self.stream.next().await? {
170                Ok(m) => m,
171                Err(e) => return Some(Err(crate::error::ClientError::from_ws(e))),
172            };
173            match classify_message(&msg) {
174                MessageDisposition::Text => {
175                    let Message::Text(text) = msg else {
176                        // Unreachable: classify_message returned Text only for
177                        // Message::Text. Defensive in case the variant grows.
178                        return Some(Err(crate::error::ClientError::UnexpectedResponse(
179                            "WebSocket: classify_message returned Text for non-Text variant".into(),
180                        )));
181                    };
182                    return Some(parse_ws_frame(&text));
183                }
184                MessageDisposition::Close => return None,
185                MessageDisposition::Binary => {
186                    // RFC 8887 §4.1: JMAP only uses text frames. Surface the
187                    // violation; underlying stream is still healthy so the
188                    // caller can choose to retry next_frame if it wants.
189                    return Some(Err(crate::error::ClientError::UnexpectedResponse(
190                        "WebSocket: server sent Binary frame; RFC 8887 §4.1 mandates text frames"
191                            .into(),
192                    )));
193                }
194                MessageDisposition::Skip => {
195                    consecutive_skips = consecutive_skips.saturating_add(1);
196                    if consecutive_skips > MAX_CONSECUTIVE_NON_TEXT_FRAMES {
197                        return Some(Err(crate::error::ClientError::UnexpectedResponse(
198                            format!(
199                                "WebSocket: exceeded {MAX_CONSECUTIVE_NON_TEXT_FRAMES} consecutive non-text frames; possible server misbehaviour"
200                            ),
201                        )));
202                    }
203                }
204            }
205        }
206    }
207
208    /// Send a raw text frame over the WebSocket connection.
209    ///
210    /// Used by extension crates to send non-JMAP frames (e.g., JMAP Chat
211    /// ephemeral stream control messages).
212    pub async fn send_text(&mut self, text: String) -> Result<(), crate::error::ClientError> {
213        self.sink
214            .send(Message::Text(text.into()))
215            .await
216            .map_err(crate::error::ClientError::from_ws)
217    }
218
219    /// Send a JMAP request over the WebSocket connection.
220    ///
221    /// Serializes `req` and injects `"@type": "Request"` into the outgoing
222    /// JSON object as required by RFC 8887 §4.3.2.  The optional `id` is
223    /// echoed back in the corresponding `Response` frame, enabling out-of-order
224    /// correlation.
225    ///
226    /// # Errors
227    ///
228    /// Returns `ClientError::Serialize` if `req` cannot be serialized, or
229    /// `ClientError::WebSocket` on a transport failure.
230    pub async fn send_request(
231        &mut self,
232        req: &jmap_types::JmapRequest,
233        id: Option<&str>,
234    ) -> Result<(), crate::error::ClientError> {
235        // Wrap req in WsRequestFrame to inject @type and optional id in one
236        // serialization pass (no intermediate serde_json::Value allocation).
237        let frame = WsRequestFrame {
238            ws_type: "Request",
239            id,
240            inner: req,
241        };
242        let text = serde_json::to_string(&frame).map_err(crate::error::ClientError::Serialize)?;
243        self.sink
244            .send(Message::Text(text.into()))
245            .await
246            .map_err(crate::error::ClientError::from_ws)
247    }
248}
249
250/// Parse a raw WebSocket text frame into a `WsFrame`.
251///
252/// Two passes over `text`:
253///
254/// 1. Parse to [`serde_json::Value`] to extract `@type` (and to keep a
255///    structured fallback alive for the Unknown branch).
256/// 2. For the typed branches (`StateChange`, `Response`), call
257///    [`serde_json::from_str`] directly against the original `text`.
258///
259/// The previous shape `let raw = val.clone(); from_value::<T>(val)` paid a
260/// deep Value clone on every successful frame even though `raw` was thrown
261/// away. For 1-MiB-cap WS messages on a hot push path, the clone allocates
262/// a HashMap per `Value::Object` and a `String` per `Value::String` and
263/// dropped them moments later. Two text parses are cheaper for typical
264/// payload shapes than one parse + one deep Value clone, and the borrow
265/// checker no longer needs ownership tricks (bd:JMAP-6lsm.11).
266fn parse_ws_frame(text: &str) -> Result<WsFrame, crate::error::ClientError> {
267    let val: serde_json::Value =
268        serde_json::from_str(text).map_err(crate::error::ClientError::Parse)?;
269
270    let type_name = val
271        .get("@type")
272        .and_then(|v| v.as_str())
273        .unwrap_or("<no @type>")
274        .to_owned();
275
276    match type_name.as_str() {
277        // A malformed StateChange is degraded to Unknown rather than a
278        // transport error. A single bad server frame must not kill the
279        // entire WebSocket connection; only tungstenite transport errors
280        // warrant a reconnect. The `val` we already parsed is the Unknown
281        // payload — no clone needed.
282        "StateChange" => match serde_json::from_str::<StateChange>(text) {
283            Ok(sc) => Ok(WsFrame::StateChange(sc)),
284            Err(_) => Ok(WsFrame::Unknown {
285                type_name,
286                raw: val,
287            }),
288        },
289        // Same degradation policy for malformed Response frames.
290        "Response" => match serde_json::from_str::<jmap_types::JmapResponse>(text) {
291            Ok(r) => Ok(WsFrame::Response(r)),
292            Err(_) => Ok(WsFrame::Unknown {
293                type_name,
294                raw: val,
295            }),
296        },
297        _ => Ok(WsFrame::Unknown {
298            type_name,
299            raw: val,
300        }),
301    }
302}
303
304/// Open a JMAP WebSocket connection (RFC 8887).
305///
306/// `ws_url` must come from the session document's WebSocket capability URL
307/// (a `wss://` endpoint in production; `ws://` is accepted in tests).
308///
309/// `auth_header` is an optional `(header-name, header-value)` pair injected
310/// into the WebSocket upgrade request. Pass `None` when the server does not
311/// require authentication headers on the WebSocket handshake.
312///
313/// Returns `ClientError::InvalidArgument` if the URL scheme is not
314/// `ws://` or `wss://`, preventing accidental use with untrusted URLs.
315///
316/// The returned [`WsSession`] provides [`WsSession::next_frame`] for receiving
317/// events. The caller is responsible for reconnecting after disconnect with
318/// exponential backoff.
319///
320/// Uses [`DEFAULT_WS_MAX_MESSAGE_BYTES`] as the per-message / per-frame cap.
321/// Callers that need a different cap should use [`connect_ws_with_limit`] or
322/// [`crate::JmapClient::connect_ws_session`] (which reads `ClientConfig::max_ws_message`).
323///
324/// # Security
325///
326/// The `auth_header` value is a credential and must not be logged or
327/// echoed back to other systems. Treat it with the same care as a
328/// [`crate::auth::BearerAuth`] token.
329pub async fn connect_ws(
330    ws_url: &str,
331    auth_header: Option<(&str, &str)>,
332) -> Result<WsSession, crate::error::ClientError> {
333    connect_ws_with_limit(ws_url, auth_header, DEFAULT_WS_MAX_MESSAGE_BYTES).await
334}
335
336/// Establish a WebSocket connection with an explicit per-message / per-frame
337/// byte cap.
338///
339/// Same contract as [`connect_ws`] but lets the caller pin the
340/// `max_message_size` / `max_frame_size` config passed to tungstenite.
341/// Useful when the JMAP server is known to send larger pushes than the
342/// 1 MiB default (e.g. some Mailbox/changes push payloads on accounts with
343/// many mailboxes can exceed 1 MiB).
344///
345/// `max_message_bytes` MUST be > 0; tungstenite treats `Some(0)` as
346/// "no message of any size is acceptable" which is a misconfiguration trap.
347/// We surface `ClientError::InvalidArgument` instead.
348///
349/// # Security
350///
351/// The `auth_header` value is a credential and must not be logged or
352/// echoed back to other systems. Treat it with the same care as a
353/// [`crate::auth::BearerAuth`] token. The `ClientError::InvalidArgument`
354/// values produced for malformed auth header names or values are
355/// constructed without the original bytes, but callers should still
356/// avoid printing or storing the `auth_header` they passed in.
357pub async fn connect_ws_with_limit(
358    ws_url: &str,
359    auth_header: Option<(&str, &str)>,
360    max_message_bytes: usize,
361) -> Result<WsSession, crate::error::ClientError> {
362    if max_message_bytes == 0 {
363        return Err(crate::error::ClientError::InvalidArgument(
364            "connect_ws_with_limit: max_message_bytes must be > 0".to_owned(),
365        ));
366    }
367    // Validate scheme to prevent SSRF via a compromised or MITM'd session.
368    // Case-insensitive check per RFC 3986 §3.1: only the SCHEME component is
369    // case-insensitive, not the path/query — so split off the scheme and
370    // compare with eq_ignore_ascii_case rather than lowercasing the whole
371    // URL. Lowercasing the whole URL allocated a fresh String the size of
372    // the URL on every connect (bd:JMAP-6lsm.9). The original (unmodified)
373    // URL is passed to tungstenite and kept in error messages for diagnostics.
374    let scheme_ok = ws_url
375        .split_once("://")
376        .is_some_and(|(s, _)| s.eq_ignore_ascii_case("ws") || s.eq_ignore_ascii_case("wss"));
377    if !scheme_ok {
378        return Err(crate::error::ClientError::InvalidArgument(format!(
379            "WebSocket URL must start with ws:// or wss://, got: {ws_url:?}"
380        )));
381    }
382
383    let mut request = ws_url
384        .into_client_request()
385        .map_err(crate::error::ClientError::from_ws)?;
386
387    if let Some((name, value)) = auth_header {
388        // Both arms construct ClientError::InvalidArgument with a fixed
389        // string and deliberately discard the http-crate's Display output
390        // for the inner error. The original `name` / `value` bytes are
391        // credential-adjacent (the name component is less sensitive than
392        // the value, but a future http-crate version could begin echoing
393        // bytes in its Display impl). Defense-in-depth: keep neither in
394        // the error chain.
395        let hdr_name = http::HeaderName::from_str(name).map_err(|_| {
396            crate::error::ClientError::InvalidArgument("invalid auth header name".to_owned())
397        })?;
398        let hdr_value = http::HeaderValue::from_str(value).map_err(|_| {
399            crate::error::ClientError::InvalidArgument("invalid auth header value".to_owned())
400        })?;
401        request.headers_mut().insert(hdr_name, hdr_value);
402    }
403
404    // WebSocketConfig is #[non_exhaustive] in tungstenite; use Default + field assignment.
405    let mut config = WebSocketConfig::default();
406    config.max_message_size = Some(max_message_bytes);
407    config.max_frame_size = Some(max_message_bytes);
408
409    // Apply a 10-second connect timeout, consistent with the HTTP transport's
410    // connect_timeout in DefaultTransport/CustomCaTransport.  tungstenite does
411    // not expose a connect timeout parameter, so we wrap at the Future level.
412    // A stalled TCP or TLS handshake would otherwise block indefinitely.
413    let connect_result = tokio::time::timeout(
414        std::time::Duration::from_secs(10),
415        tokio_tungstenite::connect_async_with_config(request, Some(config), false),
416    )
417    .await
418    .map_err(|_elapsed| {
419        // Synthesize an Io-kind transport error to surface the timeout
420        // through the public WebSocketError accessors (is_io() will be
421        // true). The third-party error type is constructed locally and
422        // immediately wrapped, so it does not leak to callers.
423        crate::error::ClientError::from_ws(tokio_tungstenite::tungstenite::Error::Io(
424            std::io::Error::new(
425                std::io::ErrorKind::TimedOut,
426                "WebSocket connect timed out after 10 seconds",
427            ),
428        ))
429    })?;
430    let (ws_stream, _response) = connect_result.map_err(crate::error::ClientError::from_ws)?;
431
432    let (sink, stream) = ws_stream.split();
433    Ok(WsSession { sink, stream })
434}
435
436impl std::fmt::Debug for WsSession {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        f.debug_struct("WsSession").finish_non_exhaustive()
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    /// Verify WsFrame does not contain ChatTyping or ChatPresence variants.
447    /// This exhaustive match will fail to compile if either variant is reintroduced.
448    #[test]
449    fn ws_frame_has_no_chat_variants() {
450        let frame = WsFrame::Unknown {
451            type_name: "test".to_owned(),
452            raw: serde_json::Value::Null,
453        };
454        match frame {
455            WsFrame::StateChange(_) => {}
456            WsFrame::Response(_) => {}
457            WsFrame::Unknown { .. } => {}
458        }
459    }
460
461    /// Oracle: parse_ws_frame dispatches on @type field and produces a typed StateChange.
462    /// Wire format from RFC 8620 §7.1.1 example.
463    #[test]
464    fn parse_state_change() {
465        let json = r#"{"@type":"StateChange","changed":{"account1":{"Mail":"s2"}}}"#;
466        let frame = parse_ws_frame(json).expect("must parse");
467        match frame {
468            WsFrame::StateChange(sc) => {
469                let account = sc
470                    .changed
471                    .get("account1")
472                    .expect("account1 must be present");
473                assert_eq!(account.get("Mail").map(|s| s.as_ref()), Some("s2"));
474            }
475            other => panic!("expected StateChange, got {other:?}"),
476        }
477    }
478
479    /// Oracle: a StateChange with missing `changed` field degrades to Unknown.
480    #[test]
481    fn parse_malformed_state_change_degrades_to_unknown() {
482        let json = r#"{"@type":"StateChange","unexpected_field":42}"#;
483        let frame = parse_ws_frame(json).expect("must not error");
484        match frame {
485            WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "StateChange"),
486            other => panic!("expected Unknown, got {other:?}"),
487        }
488    }
489
490    /// Oracle: parse_ws_frame returns Unknown for unrecognized @type.
491    /// Derived from parse_unknown_type test in source ws/mod.rs.
492    #[test]
493    fn parse_unknown_type() {
494        let json = r#"{"@type":"FutureEvent","foo":"bar"}"#;
495        let frame = parse_ws_frame(json).expect("must parse");
496        match frame {
497            WsFrame::Unknown { type_name, .. } => assert_eq!(type_name, "FutureEvent"),
498            other => panic!("expected Unknown, got {other:?}"),
499        }
500    }
501
502    /// Oracle: parse_ws_frame returns Unknown for missing @type.
503    /// Derived from parse_missing_type_field test in source ws/mod.rs.
504    #[test]
505    fn parse_missing_type_field() {
506        let json = r#"{"foo":"bar"}"#;
507        let frame = parse_ws_frame(json).expect("must parse");
508        assert!(matches!(frame, WsFrame::Unknown { .. }));
509    }
510
511    /// Oracle: parse_ws_frame returns Err(Parse) for invalid JSON.
512    /// Derived from parse_invalid_json_returns_parse_error test in source ws/mod.rs.
513    #[test]
514    fn parse_invalid_json_returns_parse_error() {
515        let err = parse_ws_frame("not json").expect_err("must fail");
516        assert!(matches!(err, crate::error::ClientError::Parse(_)));
517    }
518
519    /// Oracle: RFC 8887 §4.3.2 — every JMAP request sent over WebSocket MUST
520    /// include "@type": "Request".  Tests WsRequestFrame serde directly to
521    /// verify the #[serde(rename = "@type")] attribute and flatten are correct.
522    #[test]
523    fn send_request_includes_at_type_request() {
524        let req = jmap_types::JmapRequest::new(
525            vec!["urn:ietf:params:jmap:core".to_owned()],
526            vec![],
527            None,
528        );
529        let frame = WsRequestFrame {
530            ws_type: "Request",
531            id: None,
532            inner: &req,
533        };
534        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
535        assert!(
536            serialized.contains("\"@type\":\"Request\""),
537            "RFC 8887 §4.3.2 requires @type:Request in outgoing WS frames; got: {serialized}"
538        );
539    }
540
541    /// Oracle: RFC 8887 §4.3.2 — optional `id` field is echoed in the response.
542    /// When an id is supplied, WsRequestFrame must include it in the serialized frame.
543    #[test]
544    fn send_request_includes_id_when_provided() {
545        let req = jmap_types::JmapRequest::new(
546            vec!["urn:ietf:params:jmap:core".to_owned()],
547            vec![],
548            None,
549        );
550        let frame = WsRequestFrame {
551            ws_type: "Request",
552            id: Some("req-42"),
553            inner: &req,
554        };
555        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
556        assert!(
557            serialized.contains("\"id\":\"req-42\""),
558            "RFC 8887 §4.3.2 optional id must be present when provided; got: {serialized}"
559        );
560    }
561
562    /// Oracle: RFC 8887 §4.3.2 — when id is None, no `id` field appears in the frame.
563    /// WsRequestFrame uses skip_serializing_if to omit the field entirely.
564    #[test]
565    fn send_request_omits_id_when_none() {
566        let req = jmap_types::JmapRequest::new(
567            vec!["urn:ietf:params:jmap:core".to_owned()],
568            vec![],
569            None,
570        );
571        let frame = WsRequestFrame {
572            ws_type: "Request",
573            id: None,
574            inner: &req,
575        };
576        let serialized = serde_json::to_string(&frame).expect("WsRequestFrame must serialize");
577        assert!(
578            !serialized.contains("\"id\":"),
579            "RFC 8887 §4.3.2: no id field must appear when id is None; got: {serialized}"
580        );
581    }
582
583    /// Oracle: connect_ws must reject http:// and https:// URLs with InvalidArgument.
584    ///
585    /// This is the documented SSRF prevention guard: a compromised or MITM'd session
586    /// could send an http:// URL; we must not follow it as a WebSocket URL.
587    /// The scheme check runs before any network I/O.
588    /// Derived from connect_ws_rejects_non_ws_schemes test in source ws/mod.rs.
589    #[tokio::test]
590    async fn connect_ws_rejects_non_ws_schemes() {
591        for bad_url in &["http://host/", "https://host/", "ftp://host/"] {
592            let result = connect_ws(bad_url, None).await.map(|_| ());
593            match result {
594                Err(crate::error::ClientError::InvalidArgument(_)) => {}
595                other => panic!("expected InvalidArgument for {bad_url:?}, got {other:?}"),
596            }
597        }
598    }
599
600    // -----------------------------------------------------------------------
601    // classify_message — bd:JMAP-6lsm.6
602    // -----------------------------------------------------------------------
603
604    /// Oracle: Text frames classify as Text. The independent oracle is
605    /// the next_frame contract in the docstring above.
606    #[test]
607    fn classify_text_message() {
608        let m = Message::Text("hi".into());
609        assert_eq!(classify_message(&m), MessageDisposition::Text);
610    }
611
612    /// Oracle: Close frames classify as Close, ending the stream.
613    #[test]
614    fn classify_close_message() {
615        let m = Message::Close(None);
616        assert_eq!(classify_message(&m), MessageDisposition::Close);
617    }
618
619    /// Oracle: Binary frames violate RFC 8887 §4.1 and must classify as
620    /// Binary so the next_frame loop surfaces UnexpectedResponse rather
621    /// than silently skipping (the bug JMAP-6lsm.6 fixes). The independent
622    /// oracle is RFC 8887 §4.1.
623    #[test]
624    fn classify_binary_message_is_not_skipped() {
625        let m = Message::Binary(vec![1, 2, 3].into());
626        assert_eq!(classify_message(&m), MessageDisposition::Binary);
627        assert_ne!(
628            classify_message(&m),
629            MessageDisposition::Skip,
630            "Binary must NOT be silently skipped (RFC 8887 §4.1)"
631        );
632    }
633
634    /// Oracle: Ping/Pong frames classify as Skip. Tungstenite handles
635    /// them at the protocol layer, so seeing them at the Message layer
636    /// is unusual but legal — skip and continue.
637    #[test]
638    fn classify_ping_pong_messages_are_skipped() {
639        let ping = Message::Ping(vec![].into());
640        let pong = Message::Pong(vec![].into());
641        assert_eq!(classify_message(&ping), MessageDisposition::Skip);
642        assert_eq!(classify_message(&pong), MessageDisposition::Skip);
643    }
644
645    /// Tripwire: the consecutive-skip cap is the documented value.
646    /// A future retune will fail this test loudly so the change is
647    /// visible in CI. Documented value is 64 (see the const docstring).
648    #[test]
649    fn consecutive_skip_cap_matches_documented_value() {
650        assert_eq!(MAX_CONSECUTIVE_NON_TEXT_FRAMES, 64);
651    }
652}