use jmap_base_client::auth::NoneAuth;
use jmap_base_client::client::JmapClient;
use jmap_base_client::error::ClientError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn session_fixture() -> serde_json::Value {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/jmap/session.json"),
)
.expect("cannot read session.json fixture");
serde_json::from_str(&text).expect("session.json must be valid JSON")
}
fn call_response_fixture() -> serde_json::Value {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/jmap/call_response.json"),
)
.expect("cannot read call_response.json fixture");
serde_json::from_str(&text).expect("call_response.json must be valid JSON")
}
fn minimal_request() -> jmap_types::JmapRequest {
jmap_types::JmapRequest::new(
vec!["urn:ietf:params:jmap:core".to_owned()],
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({"accountId": "A13824", "ids": null}),
"r1".to_owned(),
)],
None,
)
}
#[test]
fn test_new_rejects_empty_url() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"empty base_url must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_ftp_scheme() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"ftp://example.com",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"ftp scheme must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_url_with_path() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com/jmap",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"base_url with path must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_url_with_userinfo_password() {
let canary_password = "redaction-canary-pw-PSt8SiPS";
let url = format!("https://alice:{canary_password}@example.com");
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&url,
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
let err = result.expect_err("user-info in base_url must be rejected");
let rendered = format!("{err}");
assert!(
matches!(err, ClientError::InvalidArgument(_)),
"expected InvalidArgument, got {err:?}"
);
assert!(
!rendered.contains(canary_password),
"rejection error message must not echo the password back into the \
error chain; rendered: {rendered}"
);
assert!(
!rendered.contains("alice"),
"rejection error message must not echo the username back either; \
rendered: {rendered}"
);
assert!(
rendered.contains("user-info"),
"rejection error message must surface the actual reason for diagnostics; \
rendered: {rendered}"
);
}
#[test]
fn test_new_rejects_url_with_userinfo_username_only() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://alice@example.com",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"username-only user-info must be rejected, got {result:?}"
);
}
#[test]
fn test_new_accepts_https_origin() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(result.is_ok(), "valid https origin must be accepted");
}
#[test]
fn test_new_with_shared_auth_shares_the_arc() {
use std::sync::Arc;
let auth: Arc<dyn jmap_base_client::auth::AuthProvider> = Arc::new(NoneAuth);
assert_eq!(Arc::strong_count(&auth), 1, "fresh Arc starts at 1");
let client_a = JmapClient::new_with_shared_auth(
jmap_base_client::auth::DefaultTransport,
Arc::clone(&auth),
"https://a.example.com",
jmap_base_client::client::ClientConfig::default(),
)
.expect("valid https origin must be accepted");
assert_eq!(
Arc::strong_count(&auth),
2,
"client A must hold the second strong reference"
);
let client_b = JmapClient::new_with_shared_auth(
jmap_base_client::auth::DefaultTransport,
Arc::clone(&auth),
"https://b.example.com",
jmap_base_client::client::ClientConfig::default(),
)
.expect("valid https origin must be accepted");
assert_eq!(
Arc::strong_count(&auth),
3,
"client B must hold the third strong reference"
);
drop(auth);
drop(client_a);
drop(client_b);
}
#[test]
fn test_new_rejects_zero_request_timeout() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.request_timeout = std::time::Duration::ZERO;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"request_timeout == Duration::ZERO must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_zero_max_call_body() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.max_call_body = 0;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"max_call_body == 0 must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_zero_max_ws_message() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.max_ws_message = 0;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"max_ws_message == 0 must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_default_max_ws_message_is_1mib() {
let cfg = jmap_base_client::client::ClientConfig::default();
assert_eq!(cfg.max_ws_message, 1024 * 1024);
assert_eq!(cfg.max_sse_frame, 1024 * 1024);
}
#[tokio::test]
async fn test_connect_ws_with_limit_rejects_zero_max_message() {
let result = jmap_base_client::ws::connect_ws_with_limit("ws://localhost/", None, 0).await;
match result {
Err(jmap_base_client::ClientError::InvalidArgument(msg)) => {
assert!(
msg.contains("max_message_bytes"),
"error message must mention 'max_message_bytes': {msg}"
);
}
other => panic!("expected InvalidArgument(\"...max_message_bytes...\"), got {other:?}"),
}
}
#[tokio::test]
async fn test_fetch_session_returns_session() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(session_fixture()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let session = client
.fetch_session()
.await
.expect("fetch_session must succeed");
assert_eq!(session.username, "john@example.com");
assert_eq!(session.api_url, "https://jmap.example.com/api/");
assert_eq!(
session.upload_url,
"https://jmap.example.com/upload/{accountId}/"
);
assert_eq!(
session.download_url,
"https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
);
assert_eq!(
session.event_source_url,
"https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
);
assert_eq!(session.state, "75128aab4b1b");
assert!(
session.accounts.contains_key("A13824"),
"accounts must contain A13824"
);
}
#[tokio::test]
async fn test_fetch_session_size_cap() {
let oversized_body = "x".repeat(1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err("oversized response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_401_returns_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client.fetch_session().await.expect_err("401 must fail");
assert!(
matches!(err, ClientError::AuthFailed(401)),
"expected AuthFailed(401), got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_rejects_non_http_api_url() {
let server = MockServer::start().await;
let mut body = session_fixture();
body["apiUrl"] = serde_json::Value::String("ftp://example.com/api/".to_owned());
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err("ftp apiUrl must fail");
assert!(
matches!(err, ClientError::InvalidSession(_)),
"expected InvalidSession for ftp apiUrl, got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_rejects_non_http_other_urls() {
for field in &["uploadUrl", "downloadUrl", "eventSourceUrl"] {
let server = MockServer::start().await;
let mut body = session_fixture();
body[*field] = serde_json::Value::String("ftp://example.com/bad".to_owned());
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err(&format!("ftp {field} must fail"));
assert!(
matches!(err, ClientError::InvalidSession(_)),
"expected InvalidSession for ftp {field}, got {err:?}"
);
}
}
#[tokio::test]
async fn test_call_round_trip() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(200).set_body_json(call_response_fixture()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let resp = client
.call(&api_url, &minimal_request())
.await
.expect("call must succeed");
assert_eq!(resp.session_state, "sess1");
assert_eq!(resp.method_responses.len(), 1);
assert_eq!(resp.method_responses[0].0, "Mailbox/get");
assert_eq!(resp.method_responses[0].2, "r1");
let args = resp.method_responses[0]
.1
.as_object()
.expect("method-response args must be a JSON object");
assert_eq!(
args.get("accountId").and_then(|v| v.as_str()),
Some("A13824"),
"args.accountId must match call_response.json fixture"
);
assert_eq!(
args.get("state").and_then(|v| v.as_str()),
Some("m-state-1"),
"args.state must match call_response.json fixture"
);
assert!(
args.get("list")
.and_then(|v| v.as_array())
.is_some_and(Vec::is_empty),
"args.list must be an empty array"
);
assert!(
args.get("notFound")
.and_then(|v| v.as_array())
.is_some_and(Vec::is_empty),
"args.notFound must be an empty array"
);
}
#[tokio::test]
async fn test_call_session_routes_to_session_api_url() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(200).set_body_json(call_response_fixture()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let unmocked_url = format!("{}/UNMOCKED-must-not-be-hit", server.uri());
let session: jmap_base_client::request::Session = serde_json::from_value(serde_json::json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "",
"apiUrl": api_url,
"downloadUrl": unmocked_url,
"uploadUrl": unmocked_url,
"eventSourceUrl": unmocked_url,
"state": "",
}))
.expect("hand-rolled Session JSON must deserialize");
let resp = client
.call_session(&session, &minimal_request())
.await
.expect("call_session must succeed against session.api_url");
assert_eq!(resp.session_state, "sess1");
assert_eq!(resp.method_responses.len(), 1);
assert_eq!(resp.method_responses[0].0, "Mailbox/get");
}
#[tokio::test]
async fn test_upload_blob_session_routes_to_session_upload_url() {
use wiremock::matchers::header;
let payload: &[u8] = b"upload-session-bytes";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.and(header("Content-Type", "application/octet-stream"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"accountId":"account1","blobId":"B-session-1","type":"application/octet-stream","size":20}"#,
))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let upload_url = format!("{}/upload/{{accountId}}/", server.uri());
let unmocked = format!("{}/UNMOCKED-must-not-be-hit", server.uri());
let session: jmap_base_client::request::Session = serde_json::from_value(serde_json::json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "",
"apiUrl": unmocked,
"downloadUrl": unmocked,
"uploadUrl": upload_url,
"eventSourceUrl": unmocked,
"state": "",
}))
.expect("hand-rolled Session JSON must deserialize");
let resp = client
.upload_blob_session(
&session,
jmap_base_client::UploadBlobSessionParams {
account_id: "account1",
content_type: "application/octet-stream",
data: bytes::Bytes::copy_from_slice(payload),
},
)
.await
.expect("upload_blob_session must route via session.upload_url");
assert_eq!(resp.account_id, "account1");
assert_eq!(resp.blob_id, "B-session-1");
assert_eq!(resp.size, payload.len() as u64);
}
#[tokio::test]
async fn test_download_blob_session_routes_to_session_download_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download/account1/blob-abc/file.bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"download-session-bytes".to_vec()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let download_url = format!(
"{}/download/{{accountId}}/{{blobId}}/{{name}}",
server.uri()
);
let unmocked = format!("{}/UNMOCKED-must-not-be-hit", server.uri());
let session: jmap_base_client::request::Session = serde_json::from_value(serde_json::json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "",
"apiUrl": unmocked,
"downloadUrl": download_url,
"uploadUrl": unmocked,
"eventSourceUrl": unmocked,
"state": "",
}))
.expect("hand-rolled Session JSON must deserialize");
let bytes = client
.download_blob_session(
&session,
jmap_base_client::DownloadBlobSessionParams {
account_id: "account1",
blob_id: "blob-abc",
name: "file.bin",
accept_type: None,
expected_sha256: None,
},
)
.await
.expect("download_blob_session must route via session.download_url");
assert_eq!(bytes.as_ref(), b"download-session-bytes");
}
#[tokio::test]
async fn test_subscribe_events_session_routes_to_session_event_source_url() {
use futures::StreamExt as _;
let sse_body = "event: state\ndata: {\"changed\":{\"acc1\":{\"Email\":\"s1\"}}}\n\n";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events/"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(sse_body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_source_url = format!(
"{}/events/?types={{types}}&closeafter={{closeafter}}&ping={{ping}}",
server.uri()
);
let unmocked = format!("{}/UNMOCKED-must-not-be-hit", server.uri());
let session: jmap_base_client::request::Session = serde_json::from_value(serde_json::json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "",
"apiUrl": unmocked,
"downloadUrl": unmocked,
"uploadUrl": unmocked,
"eventSourceUrl": event_source_url,
"state": "",
}))
.expect("hand-rolled Session JSON must deserialize");
let mut stream = client
.subscribe_events_session(
&session,
jmap_base_client::SubscribeEventsSessionParams::default(),
)
.await
.expect("subscribe_events_session must route via session.event_source_url");
let frame = stream
.next()
.await
.expect("must receive at least one frame")
.expect("frame must parse");
match frame.event {
jmap_base_client::sse::SseEvent::StateChange(sc) => {
let acct = sc
.changed
.get("acc1")
.expect("server StateChange must carry acc1");
assert_eq!(acct.get("Email").map(|s| s.as_ref()), Some("s1"));
}
other => panic!("expected StateChange, got {other:?}"),
}
}
#[tokio::test]
async fn test_call_size_cap() {
let oversized_body = "x".repeat(8 * 1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let err = client
.call(&api_url, &minimal_request())
.await
.expect_err("oversized response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_call_401_returns_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let err = client
.call(&api_url, &minimal_request())
.await
.expect_err("401 must fail");
assert!(
matches!(err, ClientError::AuthFailed(401)),
"expected AuthFailed(401), got {err:?}"
);
}
#[tokio::test]
async fn test_upload_blob_typed_params_round_trip() {
use wiremock::matchers::{body_bytes, header};
let payload: &[u8] = b"hello world";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.and(header("Content-Type", "application/octet-stream"))
.and(body_bytes(payload.to_vec()))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"accountId":"account1","blobId":"B-typed-1","type":"application/octet-stream","size":11}"#,
))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template =
jmap_base_client::JmapUrlTemplate::new(format!("{}/upload/{{accountId}}/", server.uri()));
let resp = client
.upload_blob(jmap_base_client::UploadBlobParams {
upload_url_template: &template,
account_id: "account1",
content_type: "application/octet-stream",
data: bytes::Bytes::copy_from_slice(payload),
})
.await
.expect("typed upload must succeed");
assert_eq!(resp.account_id, "account1");
assert_eq!(resp.blob_id, "B-typed-1");
assert_eq!(resp.content_type, "application/octet-stream");
assert_eq!(resp.size, payload.len() as u64);
}
#[tokio::test]
async fn test_upload_blob_response_size_cap() {
let oversized_body = "x".repeat(1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template =
jmap_base_client::JmapUrlTemplate::new(format!("{}/upload/{{accountId}}/", server.uri()));
let err = client
.upload_blob(jmap_base_client::UploadBlobParams {
upload_url_template: &template,
account_id: "account1",
content_type: "application/octet-stream",
data: bytes::Bytes::from(b"hello".to_vec()),
})
.await
.expect_err("oversized upload response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_upload_blob_rejects_size_mismatch() {
let server = MockServer::start().await;
let buggy_resp =
r#"{"accountId":"account1","blobId":"B1","type":"application/octet-stream","size":0}"#;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.respond_with(ResponseTemplate::new(200).set_body_string(buggy_resp))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template =
jmap_base_client::JmapUrlTemplate::new(format!("{}/upload/{{accountId}}/", server.uri()));
let err = client
.upload_blob(jmap_base_client::UploadBlobParams {
upload_url_template: &template,
account_id: "account1",
content_type: "application/octet-stream",
data: bytes::Bytes::from(b"hello".to_vec()), })
.await
.expect_err("size mismatch must surface as an error");
match err {
ClientError::UnexpectedResponse(msg) => {
assert!(
msg.contains("size mismatch"),
"error must mention 'size mismatch': {msg}"
);
assert!(msg.contains("5"), "error must mention client size 5: {msg}");
assert!(msg.contains("0"), "error must mention server size 0: {msg}");
}
other => panic!("expected UnexpectedResponse, got {other:?}"),
}
}
#[tokio::test]
async fn test_download_blob_size_cap() {
let oversized_body = vec![b'x'; 64 * 1024 * 1024 + 1];
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download/account1/blob-abc/file.bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template = jmap_base_client::JmapUrlTemplate::new(format!(
"{}/download/{{accountId}}/{{blobId}}/{{name}}",
server.uri()
));
let err = client
.download_blob(jmap_base_client::DownloadBlobParams {
download_url_template: &template,
account_id: "account1",
blob_id: "blob-abc",
name: "file.bin",
accept_type: None,
expected_sha256: None,
})
.await
.expect_err("oversized download must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_subscribe_events_crlf_line_endings() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let crlf_body = "event: state\r\ndata: {\"changed\":{}}\r\n\r\n";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(crlf_body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"CRLF-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_lf_crlf_frame_delimiter() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let body = "event: state\ndata: {\"changed\":{}}\n\r\n";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"LF+CRLF-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_cr_line_endings() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let cr_body = "event: state\rdata: {\"changed\":{}}\r\r";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(cr_body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"CR-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_rejects_wrong_content_type() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/json")
.set_body_bytes(b"{}".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let result = client.subscribe_events(&event_url, None).await;
match result {
Ok(_) => panic!("wrong Content-Type must fail before streaming starts"),
Err(ref e) => assert!(
matches!(e, ClientError::UnexpectedResponse(_)),
"expected UnexpectedResponse for wrong Content-Type, got {e:?}"
),
}
}
#[tokio::test]
async fn test_subscribe_events_rejects_event_stream_suffix() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-streamish")
.set_body_bytes(b"data: hi\n\n".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let result = client.subscribe_events(&event_url, None).await;
match result {
Ok(_) => panic!("text/event-streamish must be rejected, not silently accepted"),
Err(ref e) => assert!(
matches!(e, ClientError::UnexpectedResponse(_)),
"expected UnexpectedResponse for streamish suffix, got {e:?}"
),
}
}
#[tokio::test]
async fn test_subscribe_events_accepts_charset_parameter() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream; charset=utf-8")
.set_body_bytes(b"data: hi\n\n".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let _stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must accept text/event-stream; charset=utf-8");
}
#[test]
fn test_extract_response_success() {
let resp = jmap_types::JmapResponse::new(
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({"accountId": "A13824", "state": "s1", "list": [], "notFound": []}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let val: serde_json::Value = jmap_base_client::client::extract_response(&resp, "r1")
.expect("extract_response must succeed");
let args = val
.as_object()
.expect("extract_response must return the args object verbatim");
assert_eq!(
args.get("accountId").and_then(|v| v.as_str()),
Some("A13824"),
"extract_response must preserve args.accountId from the invocation"
);
assert_eq!(
args.get("state").and_then(|v| v.as_str()),
Some("s1"),
"extract_response must preserve args.state from the invocation"
);
assert!(
args.contains_key("list"),
"extract_response must preserve args.list (even when empty)"
);
assert!(
args.contains_key("notFound"),
"extract_response must preserve args.notFound (even when empty)"
);
}
#[test]
fn test_extract_response_not_found() {
let resp = jmap_types::JmapResponse::new(
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r99")
.expect_err("wrong call_id must fail");
assert!(
matches!(err, ClientError::MethodNotFound(_)),
"expected MethodNotFound, got {err:?}"
);
}
#[test]
fn test_extract_response_method_error() {
let resp = jmap_types::JmapResponse::new(
vec![(
"error".to_owned(),
serde_json::json!({"type": "serverFail", "description": "oops"}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("error invocation must fail");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, description }
if error_type == "serverFail" && description.as_deref() == Some("oops")
),
"expected MethodError{{serverFail, Some(\"oops\")}}, got {err:?}"
);
}
#[test]
fn test_extract_response_error_after_success_takes_precedence() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Mailbox/get".to_owned(),
serde_json::json!({
"accountId": "A1",
"state": "s1",
"list": [],
"notFound": []
}),
"r1".to_owned(),
),
(
"error".to_owned(),
serde_json::json!({"type": "serverFail", "description": "implicit op failed"}),
"r1".to_owned(),
),
],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("error sibling must surface even with a success present");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, .. } if error_type == "serverFail"
),
"expected MethodError{{serverFail}}, got {err:?}"
);
}
#[test]
fn test_extract_response_first_success_when_no_error() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Todo/copy".to_owned(),
serde_json::json!({
"fromAccountId": "x",
"accountId": "y",
"created": {"k5122": {"id": "DAf97"}},
"oldState": "c1d64ecb038c",
"newState": "33844835152b"
}),
"0".to_owned(),
),
(
"Todo/set".to_owned(),
serde_json::json!({
"accountId": "x",
"oldState": "871903",
"newState": "871909",
"destroyed": ["a"]
}),
"0".to_owned(),
),
],
"sess1".into(),
None,
);
let v = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "0")
.expect("must succeed when all matches are successes");
assert_eq!(
v["fromAccountId"], "x",
"primary (first) response must be the Todo/copy result, got {v}"
);
assert!(
v.get("destroyed").is_none(),
"must NOT be the Todo/set result (which has 'destroyed' but no 'fromAccountId')"
);
}
#[test]
fn test_extract_response_error_after_multiple_successes() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Todo/copy".to_owned(),
serde_json::json!({"fromAccountId": "x"}),
"r1".to_owned(),
),
(
"Todo/set".to_owned(),
serde_json::json!({"accountId": "x"}),
"r1".to_owned(),
),
(
"error".to_owned(),
serde_json::json!({"type": "rateLimit"}),
"r1".to_owned(),
),
],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("trailing error must take precedence over earlier successes");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, .. } if error_type == "rateLimit"
),
"expected MethodError{{rateLimit}}, got {err:?}"
);
}
#[tokio::test]
async fn download_blob_with_typed_sha256_matches_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download/account1/blob-abc/file.bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"abc".to_vec()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let expected = jmap_cid_types::Sha256::from_hex(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
)
.expect("NIST oracle digest must parse as canonical Sha256");
let template = jmap_base_client::JmapUrlTemplate::new(format!(
"{}/download/{{accountId}}/{{blobId}}/{{name}}",
server.uri()
));
let bytes = client
.download_blob(jmap_base_client::DownloadBlobParams {
download_url_template: &template,
account_id: "account1",
blob_id: "blob-abc",
name: "file.bin",
accept_type: None,
expected_sha256: Some(&expected),
})
.await
.expect("integrity-matched download must succeed");
assert_eq!(bytes.as_ref(), b"abc");
}
#[tokio::test]
async fn download_blob_with_typed_sha256_mismatch_returns_integrity_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download/account1/blob-abc/file.bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"abc".to_vec()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let empty_sha256_hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let expected = jmap_cid_types::Sha256::from_hex(empty_sha256_hex)
.expect("RFC 6234 oracle digest must parse as canonical Sha256");
let template = jmap_base_client::JmapUrlTemplate::new(format!(
"{}/download/{{accountId}}/{{blobId}}/{{name}}",
server.uri()
));
let err = client
.download_blob(jmap_base_client::DownloadBlobParams {
download_url_template: &template,
account_id: "account1",
blob_id: "blob-abc",
name: "file.bin",
accept_type: None,
expected_sha256: Some(&expected),
})
.await
.expect_err("mismatched integrity check must fail");
match err {
ClientError::BlobIntegrityMismatch { expected, actual } => {
assert_eq!(
expected, empty_sha256_hex,
"expected must carry the typed caller-supplied digest verbatim"
);
assert_eq!(
actual, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"actual must be SHA-256(\"abc\") per NIST FIPS 180-4 Appendix A"
);
}
other => panic!("expected BlobIntegrityMismatch, got {other:?}"),
}
}