jmap_base_client/request.rs
1//! Base JMAP request and session types: [`JmapRequestBuilder`], [`Session`],
2//! [`AccountInfo`], [`WebSocketCapability`].
3//!
4//! Types that belong to the base JMAP client layer (RFC 8620 §2, §3.3, RFC 8887).
5//! Chat-specific and Mail-specific types live in their own crates.
6//!
7//! Types already in `jmap-types` and NOT redefined here:
8//! `Id`, `UTCDate`, `State`, `Date`, `JmapRequest`, `JmapResponse`, `Invocation`,
9//! `ResultReference`.
10
11use std::collections::HashMap;
12use std::collections::HashSet;
13
14use serde::Deserialize;
15
16use jmap_types::{Invocation, JmapRequest, State};
17
18use crate::error::ClientError;
19
20// ---------------------------------------------------------------------------
21// JmapRequestBuilder (RFC 8620 §3.3)
22// ---------------------------------------------------------------------------
23
24/// Fluent builder for multi-method [`JmapRequest`] objects.
25///
26/// Collects method calls and produces a [`JmapRequest`] ready for dispatch.
27///
28/// The `using` capability URIs passed to `new` apply to the whole request;
29/// callers must include every capability required by the methods they add.
30///
31/// Spec: RFC 8620 §3.3
32#[derive(Debug)]
33pub struct JmapRequestBuilder {
34 using: Vec<String>,
35 method_calls: Vec<Invocation>,
36 call_ids: HashSet<String>,
37}
38
39impl JmapRequestBuilder {
40 /// Create a new builder with the given capability URIs.
41 ///
42 /// The `using` list MUST include `"urn:ietf:params:jmap:core"` (always
43 /// required by RFC 8620 §3.3) plus every capability URI needed by the
44 /// methods added via [`add_call`](JmapRequestBuilder::add_call). An
45 /// incorrect or empty `using` list will cause the server to return an
46 /// `"unknownCapability"` error — the builder does not validate it.
47 pub fn new(using: &[&str]) -> Self {
48 Self {
49 using: using.iter().map(|&s| s.to_owned()).collect(),
50 method_calls: Vec::new(),
51 call_ids: HashSet::new(),
52 }
53 }
54
55 /// Add one method call to the request.
56 ///
57 /// `call_id` must be unique within this request; callers use it to match
58 /// responses back to the originating call.
59 ///
60 /// Returns `Err(ClientError::InvalidArgument)` if `call_id` has already
61 /// been used in this builder. Duplicate call IDs violate RFC 8620 §3.5.
62 pub fn add_call(
63 &mut self,
64 method: impl Into<String>,
65 args: serde_json::Value,
66 call_id: impl Into<String>,
67 ) -> Result<&mut Self, ClientError> {
68 let call_id = call_id.into();
69 if !self.call_ids.insert(call_id.clone()) {
70 return Err(ClientError::InvalidArgument(format!(
71 "JmapRequestBuilder: duplicate call_id {call_id:?}"
72 )));
73 }
74 self.method_calls.push((method.into(), args, call_id));
75 Ok(self)
76 }
77
78 /// Consume the builder and produce the [`JmapRequest`].
79 ///
80 /// Returns `Err(ClientError::InvalidArgument)` if no method calls have
81 /// been added. An empty `methodCalls` array is invalid per RFC 8620 §3.3.
82 pub fn build(self) -> Result<JmapRequest, ClientError> {
83 if self.method_calls.is_empty() {
84 return Err(ClientError::InvalidArgument("no method calls added".into()));
85 }
86 Ok(JmapRequest::new(self.using, self.method_calls, None))
87 }
88}
89
90// ---------------------------------------------------------------------------
91// Session (RFC 8620 §2)
92// ---------------------------------------------------------------------------
93
94/// JMAP Session object returned by `GET /.well-known/jmap` (RFC 8620 §2).
95///
96/// Contains only the base RFC 8620 fields. Extension-specific fields
97/// (e.g. JMAP Chat `ownerUserId`) are surfaced by extension crates that
98/// parse the `capabilities` and `accounts` maps.
99#[non_exhaustive]
100#[derive(Clone, PartialEq, Eq, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Session {
103 /// Map of capability URI → capability object (RFC 8620 §2).
104 ///
105 /// Values are kept as raw JSON so callers can extract extension-specific
106 /// capability objects without this crate knowing their schema.
107 pub capabilities: HashMap<String, serde_json::Value>,
108
109 /// Map of account ID → [`AccountInfo`] (RFC 8620 §2).
110 pub accounts: HashMap<String, AccountInfo>,
111
112 /// Map of capability URI → primary account ID (RFC 8620 §2).
113 pub primary_accounts: HashMap<String, String>,
114
115 /// Username associated with the current credentials (RFC 8620 §2).
116 pub username: String,
117
118 /// URL for JMAP API POST requests (RFC 8620 §2).
119 pub api_url: String,
120
121 /// URL template for blob downloads (RFC 8620 §2).
122 ///
123 /// URI Template (level 1) containing variables `accountId`, `blobId`,
124 /// `type`, and `name`.
125 pub download_url: String,
126
127 /// URL template for blob uploads (RFC 8620 §2).
128 ///
129 /// URI Template (level 1) containing variable `accountId`.
130 pub upload_url: String,
131
132 /// URL template for SSE push event stream (RFC 8620 §2, §7.3).
133 ///
134 /// URI Template (level 1) containing variables `types`, `closeafter`,
135 /// and `ping`.
136 pub event_source_url: String,
137
138 /// Opaque session state token (RFC 8620 §2).
139 ///
140 /// Changes whenever any session property changes. Returned in every API
141 /// response as `sessionState`; clients compare to detect staleness.
142 pub state: State,
143
144 /// Catch-all for vendor / site / private extension fields not covered
145 /// by the typed fields above. Preserves unknown fields across
146 /// deserialize/serialize round-trip per workspace extras-preservation
147 /// policy (see workspace AGENTS.md).
148 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
149 pub extra: serde_json::Map<String, serde_json::Value>,
150}
151
152impl Session {
153 /// Returns the primary account ID for the given capability URI, if set.
154 ///
155 /// Example: `session.primary_account_id("urn:ietf:params:jmap:mail")`
156 pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
157 self.primary_accounts.get(capability).map(String::as_str)
158 }
159
160 /// Returns the parsed [`WebSocketCapability`] for the JMAP WebSocket
161 /// transport, if advertised (RFC 8887).
162 ///
163 /// - `Ok(None)` — server does not advertise JMAP WebSocket support.
164 /// - `Ok(Some(...))` — WebSocket is supported; use `result.url` to connect.
165 /// - `Err` — capability key is present but the value is malformed.
166 pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
167 let Some(raw) = self.capabilities.get("urn:ietf:params:jmap:websocket") else {
168 return Ok(None);
169 };
170 WebSocketCapability::deserialize(raw)
171 .map(Some)
172 .map_err(ClientError::Parse)
173 }
174
175 /// Returns `true` if the server advertises the JMAP Blob Content
176 /// Identifiers extension (draft-atwood-jmap-cid-00).
177 ///
178 /// Checks for presence of `capabilities["urn:ietf:params:jmap:cid"]`.
179 /// The capability value object is empty per the draft (§2: "no
180 /// capability fields defined at this time"), so the presence of the
181 /// key is sufficient — no value-shape check is required.
182 ///
183 /// When `true`, the server commits to including a `sha256` field
184 /// (the 64-character lowercase-hex SHA-256 digest of the uploaded
185 /// content) on Blob upload responses, and on FileNode objects when
186 /// the JMAP FileNode extension is also supported. See
187 /// [`jmap_cid_types::Sha256`] for the typed wire shape.
188 ///
189 /// Mirrors the `supports_*` capability-probe pattern established by
190 /// `ChatSessionExt::supports_quotas` and
191 /// `ChatSessionExt::supports_refplus` in `jmap-chat-client`.
192 ///
193 /// [`jmap_cid_types::Sha256`]: https://docs.rs/jmap-cid-types
194 pub fn supports_cid(&self) -> bool {
195 self.capabilities.contains_key("urn:ietf:params:jmap:cid")
196 }
197}
198
199/// Manual `Debug` impl that redacts privacy-sensitive fields (bd:JMAP-sc1b.99).
200///
201/// `Session.username` is the authenticated user's identifier — typically a
202/// full email address, which is PII under GDPR/CCPA. `Session.state` is the
203/// opaque RFC 8620 §2 session-state token; it is not an auth credential, but
204/// it uniquely identifies the client's session and is the same shape of leak
205/// as logging a session cookie. Both are replaced with `"[REDACTED]"` /
206/// `"[opaque]"` in the Debug output.
207///
208/// All other URL/map fields are surfaced — they are deployment metadata and
209/// not credential-grade. `AccountInfo.name` is redacted by `AccountInfo`'s
210/// own manual `Debug` impl, so the `accounts` map below does not leak
211/// owner emails transitively (bd:JMAP-sc1b.104).
212impl std::fmt::Debug for Session {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 f.debug_struct("Session")
215 .field("capabilities", &self.capabilities)
216 .field("accounts", &self.accounts)
217 .field("primary_accounts", &self.primary_accounts)
218 .field("username", &"[REDACTED]")
219 .field("api_url", &self.api_url)
220 .field("download_url", &self.download_url)
221 .field("upload_url", &self.upload_url)
222 .field("event_source_url", &self.event_source_url)
223 .field("state", &"[opaque]")
224 .field("extra", &self.extra)
225 .finish()
226 }
227}
228
229// ---------------------------------------------------------------------------
230// AccountInfo (RFC 8620 §2 Account object)
231// ---------------------------------------------------------------------------
232
233/// Per-account metadata in a JMAP Session (RFC 8620 §2).
234///
235/// `Debug` is hand-written to redact `name` because the field's own
236/// definition identifies it as "typically the owner's email address"
237/// (PII under GDPR/CCPA). The other fields are non-credential metadata
238/// and are surfaced directly. See bd:JMAP-sc1b.104.
239#[non_exhaustive]
240#[derive(Clone, PartialEq, Eq, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct AccountInfo {
243 /// Human-readable account name (e.g. the owner's email address).
244 pub name: String,
245
246 /// `true` if this is the authenticated user's own personal account.
247 pub is_personal: bool,
248
249 /// `true` if the entire account is read-only for the current user.
250 pub is_read_only: bool,
251
252 /// Map of capability URI → capability object for this account.
253 ///
254 /// Values are kept as raw JSON so extension crates can extract
255 /// their own capability objects.
256 pub account_capabilities: HashMap<String, serde_json::Value>,
257
258 /// Catch-all for vendor / site / private extension fields not covered
259 /// by the typed fields above. Preserves unknown fields across
260 /// deserialize/serialize round-trip per workspace extras-preservation
261 /// policy (see workspace AGENTS.md).
262 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
263 pub extra: serde_json::Map<String, serde_json::Value>,
264}
265
266/// Manual `Debug` impl that redacts `name` (bd:JMAP-sc1b.104).
267///
268/// `AccountInfo.name` is typically the owner's email address, which is
269/// PII under GDPR/CCPA. The other fields (`is_personal`, `is_read_only`,
270/// `account_capabilities`) are non-credential metadata and are surfaced
271/// directly so `{:?}` output remains useful for debugging.
272///
273/// This redaction closes the transitive leak through `Session.accounts`
274/// — `Session`'s own Debug impl (bd:JMAP-sc1b.99) only redacted
275/// `username` and `state` directly and was silent about the accounts
276/// map. With `AccountInfo` redacting itself, any `{:?}` of a `Session`
277/// is now safe with respect to the canonical email-shaped PII.
278impl std::fmt::Debug for AccountInfo {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.debug_struct("AccountInfo")
281 .field("name", &"[REDACTED]")
282 .field("is_personal", &self.is_personal)
283 .field("is_read_only", &self.is_read_only)
284 .field("account_capabilities", &self.account_capabilities)
285 .field("extra", &self.extra)
286 .finish()
287 }
288}
289
290// ---------------------------------------------------------------------------
291// WebSocketCapability (RFC 8887)
292// ---------------------------------------------------------------------------
293
294/// Capability object for `"urn:ietf:params:jmap:websocket"` (RFC 8887).
295///
296/// Advertised in `Session.capabilities` when the server supports JMAP over
297/// WebSocket. The `url` field is the `wss://` endpoint to connect to.
298#[non_exhaustive]
299#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct WebSocketCapability {
302 /// The WebSocket endpoint URL (`wss://`).
303 pub url: String,
304
305 /// Whether the server supports push notifications over this WebSocket.
306 #[serde(default)]
307 pub supports_push: bool,
308
309 /// Catch-all for vendor / site / private extension fields not covered
310 /// by the typed fields above. Preserves unknown fields across
311 /// deserialize/serialize round-trip per workspace extras-preservation
312 /// policy (see workspace AGENTS.md).
313 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
314 pub extra: serde_json::Map<String, serde_json::Value>,
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use serde_json::json;
325
326 // -----------------------------------------------------------------------
327 // JmapRequestBuilder
328 // -----------------------------------------------------------------------
329
330 /// Oracle: RFC 8620 §3.3 — a request with two method calls serializes to
331 /// a JSON object with a "methodCalls" array containing two 3-element arrays.
332 /// The expected JSON shape is derived directly from the RFC §3.3 example.
333 #[test]
334 fn builder_two_calls_serializes_correctly() {
335 let mut builder =
336 JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
337 builder
338 .add_call(
339 "Mailbox/get",
340 json!({"accountId": "A13824", "ids": null}),
341 "r1",
342 )
343 .expect("add_call r1 must succeed");
344 builder
345 .add_call(
346 "Email/get",
347 json!({"accountId": "A13824", "ids": ["e001"]}),
348 "r2",
349 )
350 .expect("add_call r2 must succeed");
351 let req = builder.build().expect("build must succeed with two calls");
352
353 let v = serde_json::to_value(&req).expect("serialize JmapRequest");
354
355 // Oracle: RFC 8620 §3.3 — "using" must be present
356 assert!(v.get("using").is_some(), "must have 'using' field");
357 let using = v["using"].as_array().expect("using must be array");
358 assert_eq!(using.len(), 2);
359 assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
360 assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
361
362 // Oracle: RFC 8620 §3.3 — "methodCalls" must be present
363 let calls = v["methodCalls"]
364 .as_array()
365 .expect("methodCalls must be array");
366 assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
367
368 // Oracle: RFC 8620 §3.2 — each invocation is [methodName, args, callId]
369 assert_eq!(calls[0][0], json!("Mailbox/get"));
370 assert_eq!(calls[0][2], json!("r1"));
371 assert_eq!(calls[1][0], json!("Email/get"));
372 assert_eq!(calls[1][2], json!("r2"));
373 }
374
375 /// Oracle: RFC 8620 §3.3 — build() with no method calls is invalid;
376 /// must return Err(InvalidArgument) rather than produce an empty batch.
377 #[test]
378 fn builder_returns_err_on_empty_build() {
379 let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
380 assert!(
381 matches!(result, Err(ClientError::InvalidArgument(_))),
382 "empty build must return Err(InvalidArgument), got {result:?}"
383 );
384 }
385
386 /// Oracle: RFC 8620 §3.5 — call IDs must be unique within a request.
387 /// Duplicate call ID returns Err(ClientError::InvalidArgument).
388 #[test]
389 fn builder_returns_err_on_duplicate_call_id() {
390 let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
391 builder
392 .add_call("Foo/get", json!({}), "r1")
393 .expect("first add_call must succeed");
394 let result = builder.add_call("Bar/get", json!({}), "r1"); // duplicate
395 assert!(
396 matches!(result, Err(ClientError::InvalidArgument(_))),
397 "duplicate call_id must return Err(InvalidArgument), got {result:?}"
398 );
399 }
400
401 // -----------------------------------------------------------------------
402 // Session
403 // -----------------------------------------------------------------------
404
405 /// Oracle: RFC 8620 §2.1 example Session JSON, transcribed from the RFC text.
406 /// All field names and values come from the RFC, not from the code under test.
407 #[test]
408 fn session_deserializes_rfc8620_example() {
409 // RFC 8620 §2.1 example — hand-transcribed from spec text.
410 let raw = r#"{
411 "capabilities": {
412 "urn:ietf:params:jmap:core": {
413 "maxSizeUpload": 50000000,
414 "maxConcurrentUpload": 8,
415 "maxSizeRequest": 10000000,
416 "maxConcurrentRequest": 8,
417 "maxCallsInRequest": 32,
418 "maxObjectsInGet": 256,
419 "maxObjectsInSet": 128,
420 "collationAlgorithms": [
421 "i;ascii-numeric",
422 "i;ascii-casemap",
423 "i;unicode-casemap"
424 ]
425 },
426 "urn:ietf:params:jmap:mail": {},
427 "urn:ietf:params:jmap:contacts": {},
428 "https://example.com/apis/foobar": {
429 "maxFoosFinangled": 42
430 }
431 },
432 "accounts": {
433 "A13824": {
434 "name": "john@example.com",
435 "isPersonal": true,
436 "isReadOnly": false,
437 "accountCapabilities": {
438 "urn:ietf:params:jmap:mail": {
439 "maxMailboxesPerEmail": null,
440 "maxMailboxDepth": 10
441 },
442 "urn:ietf:params:jmap:contacts": {}
443 }
444 },
445 "A97813": {
446 "name": "jane@example.com",
447 "isPersonal": false,
448 "isReadOnly": true,
449 "accountCapabilities": {
450 "urn:ietf:params:jmap:mail": {
451 "maxMailboxesPerEmail": 1,
452 "maxMailboxDepth": 10
453 }
454 }
455 }
456 },
457 "primaryAccounts": {
458 "urn:ietf:params:jmap:mail": "A13824",
459 "urn:ietf:params:jmap:contacts": "A13824"
460 },
461 "username": "john@example.com",
462 "apiUrl": "https://jmap.example.com/api/",
463 "downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
464 "uploadUrl": "https://jmap.example.com/upload/{accountId}/",
465 "eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
466 "state": "75128aab4b1b"
467 }"#;
468
469 let session: Session =
470 serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
471
472 // Oracle: RFC 8620 §2.1
473 assert_eq!(session.username, "john@example.com");
474 assert_eq!(session.api_url, "https://jmap.example.com/api/");
475 assert_eq!(
476 session.upload_url,
477 "https://jmap.example.com/upload/{accountId}/"
478 );
479 assert_eq!(
480 session.download_url,
481 "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
482 );
483 assert_eq!(
484 session.event_source_url,
485 "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
486 );
487 assert_eq!(session.state, "75128aab4b1b");
488
489 // Oracle: RFC 8620 §2.1 — capabilities map
490 assert!(
491 session
492 .capabilities
493 .contains_key("urn:ietf:params:jmap:core"),
494 "must have core capability"
495 );
496 assert!(
497 session
498 .capabilities
499 .contains_key("urn:ietf:params:jmap:mail"),
500 "must have mail capability"
501 );
502 assert!(
503 session
504 .capabilities
505 .contains_key("https://example.com/apis/foobar"),
506 "must have vendor capability"
507 );
508
509 // Oracle: RFC 8620 §2.1 — accounts map
510 assert!(
511 session.accounts.contains_key("A13824"),
512 "must have account A13824"
513 );
514 assert!(
515 session.accounts.contains_key("A97813"),
516 "must have account A97813"
517 );
518
519 // Oracle: RFC 8620 §2.1 — primaryAccounts
520 assert_eq!(
521 session.primary_account_id("urn:ietf:params:jmap:mail"),
522 Some("A13824")
523 );
524 assert_eq!(
525 session.primary_account_id("urn:ietf:params:jmap:contacts"),
526 Some("A13824")
527 );
528 assert_eq!(
529 session.primary_account_id("urn:ietf:params:jmap:core"),
530 None
531 );
532 }
533
534 // -----------------------------------------------------------------------
535 // AccountInfo
536 // -----------------------------------------------------------------------
537
538 /// Oracle: RFC 8620 §2.1 example — account A13824 (john@example.com).
539 /// Field names and values transcribed directly from the RFC.
540 #[test]
541 fn account_info_deserializes_rfc8620_example() {
542 // RFC 8620 §2.1 example account entry
543 let raw = r#"{
544 "name": "john@example.com",
545 "isPersonal": true,
546 "isReadOnly": false,
547 "accountCapabilities": {
548 "urn:ietf:params:jmap:mail": {
549 "maxMailboxesPerEmail": null,
550 "maxMailboxDepth": 10
551 },
552 "urn:ietf:params:jmap:contacts": {}
553 }
554 }"#;
555
556 let account: AccountInfo =
557 serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
558
559 // Oracle: RFC 8620 §2 Account object fields
560 assert_eq!(account.name, "john@example.com");
561 assert!(account.is_personal, "isPersonal must be true");
562 assert!(!account.is_read_only, "isReadOnly must be false");
563 assert!(
564 account
565 .account_capabilities
566 .contains_key("urn:ietf:params:jmap:mail"),
567 "must have mail capability"
568 );
569 assert!(
570 account
571 .account_capabilities
572 .contains_key("urn:ietf:params:jmap:contacts"),
573 "must have contacts capability"
574 );
575
576 // Oracle: RFC 8620 §2.1 — read-only account (A97813 / jane@example.com)
577 let raw2 = r#"{
578 "name": "jane@example.com",
579 "isPersonal": false,
580 "isReadOnly": true,
581 "accountCapabilities": {
582 "urn:ietf:params:jmap:mail": {
583 "maxMailboxesPerEmail": 1,
584 "maxMailboxDepth": 10
585 }
586 }
587 }"#;
588 let account2: AccountInfo = serde_json::from_str(raw2)
589 .expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
590
591 assert_eq!(account2.name, "jane@example.com");
592 assert!(!account2.is_personal, "isPersonal must be false");
593 assert!(account2.is_read_only, "isReadOnly must be true");
594 }
595
596 // -----------------------------------------------------------------------
597 // WebSocketCapability
598 // -----------------------------------------------------------------------
599
600 /// Oracle: RFC 8887 §3 — WebSocketCapability has url and supportsPush fields.
601 /// Transcribed from the RFC 8887 capability object definition.
602 #[test]
603 fn websocket_capability_deserializes() {
604 let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
605 let cap: WebSocketCapability =
606 serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
607 assert_eq!(cap.url, "wss://jmap.example.com/ws");
608 assert!(cap.supports_push);
609 }
610
611 /// Oracle: RFC 8887 §3 — supportsPush defaults to false when absent.
612 #[test]
613 fn websocket_capability_supports_push_defaults_false() {
614 let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
615 let cap: WebSocketCapability =
616 serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
617 assert_eq!(cap.url, "wss://jmap.example.com/ws");
618 assert!(!cap.supports_push, "supportsPush must default to false");
619 }
620
621 /// Oracle: Session.websocket_capability() returns Ok(None) when key absent.
622 #[test]
623 fn session_websocket_capability_absent_returns_ok_none() {
624 let raw = r#"{
625 "capabilities": {},
626 "accounts": {},
627 "primaryAccounts": {},
628 "username": "u@example.com",
629 "apiUrl": "https://jmap.example.com/api/",
630 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
631 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
632 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
633 "state": "s1"
634 }"#;
635 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
636 let result = session.websocket_capability();
637 assert!(
638 matches!(result, Ok(None)),
639 "expected Ok(None), got {result:?}"
640 );
641 }
642
643 /// Oracle: Session.websocket_capability() returns Ok(Some) when key present and valid.
644 #[test]
645 fn session_websocket_capability_present_and_valid() {
646 let raw = r#"{
647 "capabilities": {
648 "urn:ietf:params:jmap:websocket": {
649 "url": "wss://jmap.example.com/ws",
650 "supportsPush": true
651 }
652 },
653 "accounts": {},
654 "primaryAccounts": {},
655 "username": "u@example.com",
656 "apiUrl": "https://jmap.example.com/api/",
657 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
658 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
659 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
660 "state": "s1"
661 }"#;
662 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
663 let ws = session
664 .websocket_capability()
665 .expect("must not error")
666 .expect("websocket capability must be present");
667 assert_eq!(ws.url, "wss://jmap.example.com/ws");
668 assert!(ws.supports_push);
669 }
670
671 /// Oracle: `Session::supports_cid()` returns `false` when the JMAP
672 /// CID capability URI is not present in the capabilities map
673 /// (bd:JMAP-v9py.14).
674 ///
675 /// Mirrors the absent-key precedent of
676 /// `session_websocket_capability_absent_returns_ok_none`. The test
677 /// fixture has an empty capabilities map; the negative answer must
678 /// be `false`, not `Err` or panic.
679 #[test]
680 fn supports_cid_returns_false_when_capability_absent() {
681 let raw = r#"{
682 "capabilities": {},
683 "accounts": {},
684 "primaryAccounts": {},
685 "username": "u@example.com",
686 "apiUrl": "https://jmap.example.com/api/",
687 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
688 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
689 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
690 "state": "s1"
691 }"#;
692 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
693 assert!(!session.supports_cid());
694 }
695
696 /// Oracle: `Session::supports_cid()` returns `true` when the JMAP
697 /// CID capability URI is present in the capabilities map, even
698 /// though the value object is empty per draft-atwood-jmap-cid-00
699 /// §2 ("no capability fields defined at this time")
700 /// (bd:JMAP-v9py.14).
701 #[test]
702 fn supports_cid_returns_true_when_capability_present_empty_value() {
703 let raw = r#"{
704 "capabilities": {
705 "urn:ietf:params:jmap:cid": {}
706 },
707 "accounts": {},
708 "primaryAccounts": {},
709 "username": "u@example.com",
710 "apiUrl": "https://jmap.example.com/api/",
711 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
712 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
713 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
714 "state": "s1"
715 }"#;
716 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
717 assert!(session.supports_cid());
718 }
719
720 /// Oracle: `Session::supports_cid()` checks only for the URI key —
721 /// presence with a non-empty value object (vendor extras inside the
722 /// CID capability) still returns `true`. The draft reserves the
723 /// shape of the capability value but does not currently define any
724 /// fields; a server that pre-populates vendor fields under the URI
725 /// must still be detected as supporting CID.
726 #[test]
727 fn supports_cid_returns_true_when_capability_present_with_extra_fields() {
728 let raw = r#"{
729 "capabilities": {
730 "urn:ietf:params:jmap:cid": {
731 "x-vendor-flag": "future-shape"
732 }
733 },
734 "accounts": {},
735 "primaryAccounts": {},
736 "username": "u@example.com",
737 "apiUrl": "https://jmap.example.com/api/",
738 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
739 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
740 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
741 "state": "s1"
742 }"#;
743 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
744 assert!(session.supports_cid());
745 }
746
747 /// Oracle: Session's manual Debug impl never reveals the authenticated
748 /// `username` or the opaque `state` token (bd:JMAP-sc1b.99), AND the
749 /// `accounts` map does not transitively leak `AccountInfo.name`
750 /// (bd:JMAP-sc1b.104). Mirrors the canary tripwire pattern used by
751 /// `bearer_auth_debug_does_not_leak_token` and
752 /// `basic_auth_debug_does_not_leak_credentials` in auth.rs.
753 ///
754 /// The canary literals are independent of the Session's internal state —
755 /// the test is the oracle, not the code under test. A regression that
756 /// re-derives `Debug` on `Session` or `AccountInfo`, or that prints the
757 /// username/state/name via a manual impl, would fail the assertion.
758 ///
759 /// We deliberately reuse `CANARY_USER` in two distinct locations
760 /// (`username` and `accounts["a1"].name`) so a single negative
761 /// `assert!(!dbg.contains(...))` catches a leak from either path —
762 /// the same kind of email-shaped PII surfacing through either field
763 /// is the failure we want to fail loudly.
764 #[test]
765 fn session_debug_does_not_leak_username_or_state() {
766 const CANARY_USER: &str = "CANARY-USERNAME-DO-NOT-LEAK@example.com";
767 const CANARY_STATE: &str = "CANARY-STATE-TOKEN-DO-NOT-LEAK";
768 let raw = format!(
769 r#"{{
770 "capabilities": {{}},
771 "accounts": {{
772 "a1": {{
773 "name": "{CANARY_USER}",
774 "isPersonal": true,
775 "isReadOnly": false,
776 "accountCapabilities": {{}}
777 }}
778 }},
779 "primaryAccounts": {{}},
780 "username": "{CANARY_USER}",
781 "apiUrl": "https://jmap.example.com/api/",
782 "downloadUrl": "https://jmap.example.com/dl/{{accountId}}/",
783 "uploadUrl": "https://jmap.example.com/ul/{{accountId}}/",
784 "eventSourceUrl": "https://jmap.example.com/sse/",
785 "state": "{CANARY_STATE}"
786 }}"#
787 );
788 let session: Session = serde_json::from_str(&raw).expect("Session must deserialize");
789
790 // Sanity-check: the canary really did land in the AccountInfo —
791 // otherwise an empty accounts map would silently make the
792 // transitive-leak assertion below tautologically pass.
793 let account = session
794 .accounts
795 .get("a1")
796 .expect("accounts['a1'] must deserialize");
797 assert_eq!(account.name, CANARY_USER);
798
799 let dbg = format!("{session:?}");
800 assert!(
801 !dbg.contains(CANARY_USER),
802 "Session Debug must not contain the raw username or AccountInfo.name; got: {dbg}"
803 );
804 assert!(
805 !dbg.contains(CANARY_STATE),
806 "Session Debug must not contain the raw state token; got: {dbg}"
807 );
808 }
809
810 /// Oracle: AccountInfo's manual Debug impl never reveals the raw
811 /// `name` field (bd:JMAP-sc1b.104). Independent of the Session-level
812 /// test above: a regression on AccountInfo alone (e.g. re-deriving
813 /// `#[derive(Debug)]`) would be caught here without needing the
814 /// Session wrapper.
815 #[test]
816 fn account_info_debug_does_not_leak_name() {
817 const CANARY_NAME: &str = "CANARY-ACCOUNT-NAME-DO-NOT-LEAK@example.com";
818 let raw = format!(
819 r#"{{
820 "name": "{CANARY_NAME}",
821 "isPersonal": true,
822 "isReadOnly": false,
823 "accountCapabilities": {{}}
824 }}"#
825 );
826 let account: AccountInfo =
827 serde_json::from_str(&raw).expect("AccountInfo must deserialize");
828 // Sanity-check that the canary really did populate `name`.
829 assert_eq!(account.name, CANARY_NAME);
830
831 let dbg = format!("{account:?}");
832 assert!(
833 !dbg.contains(CANARY_NAME),
834 "AccountInfo Debug must not contain the raw name; got: {dbg}"
835 );
836 }
837
838 // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
839 //
840 // Synthetic `acmeCorp*` vendor keys cannot collide with any RFC 8620 /
841 // RFC 8887 typed field, so the tests are independent of the code under
842 // test (workspace test-integrity rule).
843
844 /// `Session.extra` captures unknown fields on deserialize.
845 #[test]
846 fn session_preserves_vendor_extras() {
847 let raw = json!({
848 "capabilities": {},
849 "accounts": {},
850 "primaryAccounts": {},
851 "username": "u@example.com",
852 "apiUrl": "https://jmap.example.com/api/",
853 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
854 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
855 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
856 "state": "s1",
857 "acmeCorpDeployment": "prod-eu-west-1"
858 });
859 let obj: Session = serde_json::from_value(raw).expect("Session must deserialize");
860 assert_eq!(
861 obj.extra.get("acmeCorpDeployment").and_then(|v| v.as_str()),
862 Some("prod-eu-west-1")
863 );
864 }
865
866 /// `AccountInfo.extra` captures unknown fields on deserialize.
867 #[test]
868 fn account_info_preserves_vendor_extras() {
869 let raw = json!({
870 "name": "u@example.com",
871 "isPersonal": true,
872 "isReadOnly": false,
873 "accountCapabilities": {},
874 "acmeCorpQuotaTier": "gold"
875 });
876 let obj: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
877 assert_eq!(
878 obj.extra.get("acmeCorpQuotaTier").and_then(|v| v.as_str()),
879 Some("gold")
880 );
881 }
882
883 /// `WebSocketCapability.extra` captures unknown fields on deserialize.
884 #[test]
885 fn websocket_capability_preserves_vendor_extras() {
886 let raw = json!({
887 "url": "wss://jmap.example.com/ws",
888 "supportsPush": true,
889 "acmeCorpHeartbeatMs": 30000
890 });
891 let obj: WebSocketCapability =
892 serde_json::from_value(raw).expect("WebSocketCapability must deserialize");
893 assert_eq!(
894 obj.extra
895 .get("acmeCorpHeartbeatMs")
896 .and_then(|v| v.as_u64()),
897 Some(30000)
898 );
899 }
900}