1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::error::AppError;
5
6const GRAPH: &str = "https://graph.microsoft.com/v1.0";
7
8#[derive(Debug, Serialize)]
9pub struct Page {
10 pub items: Vec<Value>,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub next_cursor: Option<String>,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub next_offset: Option<usize>,
15 pub truncated: bool,
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub source: Option<&'static str>,
19 #[serde(skip)]
21 pub viewer: Option<String>,
22}
23
24#[derive(Debug, Deserialize)]
25struct GraphPage {
26 value: Vec<Value>,
27 #[serde(rename = "@odata.nextLink")]
28 next_link: Option<String>,
29}
30
31pub struct GraphClient {
32 http: reqwest::Client,
33 token: String,
34 base_url: String,
35}
36
37impl GraphClient {
38 pub fn new(token: String) -> Self {
39 Self {
40 http: reqwest::Client::builder()
42 .retry(reqwest::retry::never())
43 .redirect(reqwest::redirect::Policy::none())
44 .build()
45 .expect("Microsoft Graph HTTP client configuration is valid"),
46 token,
47 base_url: GRAPH.into(),
48 }
49 }
50
51 pub async fn me(&self) -> Result<Value, AppError> {
52 self.get_value(&format!(
53 "{}/me?$select=id,displayName,userPrincipalName,mail",
54 self.base_url
55 ))
56 .await
57 }
58
59 pub async fn search_people(&self, query: &str, limit: usize) -> Result<Page, AppError> {
61 validate_people_query(query, limit)?;
62 let mut url = url::Url::parse(&format!("{}/users", self.base_url))
63 .map_err(|e| AppError::Unexpected(e.to_string()))?;
64 let literal = query.trim().replace('\'', "''");
65 let filter = format!(
66 "startswith(displayName,'{literal}') or startswith(mail,'{literal}') or startswith(userPrincipalName,'{literal}')"
67 );
68 url.query_pairs_mut()
69 .append_pair("$select", "id,displayName,mail,userPrincipalName")
70 .append_pair("$filter", &filter)
71 .append_pair("$top", &limit.to_string());
72 let value = self.get_value(url.as_str()).await.map_err(|error| match error {
73 AppError::Permission(_) => error.with_hint("Recipient search needs User.ReadBasic.All. Sign in again with `teams auth login` and grant directory-profile access; your organization may restrict consent or guest directory access."),
74 _ => error,
75 })?;
76 let page: GraphPage = serde_json::from_value(value)
77 .map_err(|e| AppError::Api(format!("invalid Graph recipient response: {e}")))?;
78 let truncated = page.next_link.is_some() || page.value.len() > limit;
79 let items = page
80 .value
81 .iter()
82 .take(limit)
83 .map(normalize_person)
84 .collect::<Result<_, _>>()?;
85 Ok(Page {
86 items,
87 next_cursor: None,
88 next_offset: None,
89 truncated,
90 source: None,
91 viewer: None,
92 })
93 }
94
95 pub async fn create_chat(&self, recipient: &str) -> Result<Value, AppError> {
98 if !is_directory_id(recipient) {
99 return Err(AppError::InvalidInput(
100 "--recipient must be a directory user ID returned by `teams people search`".into(),
101 ));
102 }
103 let person = self
104 .get_value(&format!(
105 "{}/users/{recipient}?$select=id,displayName,mail,userPrincipalName",
106 self.base_url
107 ))
108 .await?;
109 if person["id"]
110 .as_str()
111 .is_none_or(|id| !id.eq_ignore_ascii_case(recipient))
112 {
113 return Err(AppError::Api(
114 "Graph did not confirm the selected recipient's identity".into(),
115 ));
116 }
117 let me = self.me().await?;
118 let viewer = me["id"]
119 .as_str()
120 .filter(|id| is_directory_id(id))
121 .ok_or_else(|| {
122 AppError::Api("Graph did not return the signed-in user's directory ID".into())
123 })?;
124 if viewer.eq_ignore_ascii_case(recipient) {
125 return Err(AppError::InvalidInput("Choose another person; Graph one-on-one chat creation does not support a chat with yourself".into()));
126 }
127 let role = self.recipient_role(recipient).await?;
128 let body = one_on_one_body(viewer, recipient, role);
129 let response = self
130 .http
131 .post(format!("{}/chats", self.base_url))
132 .bearer_auth(&self.token)
133 .json(&body)
134 .send()
135 .await
136 .map_err(|error| {
137 if error.is_connect() || error.is_builder() {
138 AppError::Unexpected(format!("Microsoft Graph is unreachable: {error}"))
139 } else {
140 unconfirmed_chat()
141 }
142 })?;
143 if response.status().is_server_error() || response.status().as_u16() == 408 {
144 return Err(unconfirmed_chat());
145 }
146 let value: Value = self
147 .checked(response)
148 .await?
149 .json()
150 .await
151 .map_err(|_| unconfirmed_chat())?;
152 if value["id"].as_str().is_none_or(str::is_empty) || value["chatType"] != "oneOnOne" {
153 return Err(unconfirmed_chat());
154 }
155 Ok(value)
156 }
157
158 async fn recipient_role(&self, recipient: &str) -> Result<&'static str, AppError> {
159 for (user_type, role) in [("Guest", "guest"), ("Member", "owner")] {
162 let mut url = url::Url::parse(&format!("{}/users", self.base_url))
163 .map_err(|error| AppError::Unexpected(error.to_string()))?;
164 url.query_pairs_mut()
165 .append_pair("$select", "id")
166 .append_pair(
167 "$filter",
168 &format!("id eq '{recipient}' and userType eq '{user_type}'"),
169 )
170 .append_pair("$top", "1");
171 let value = self.get_value(url.as_str()).await?;
172 let page: GraphPage = serde_json::from_value(value).map_err(|_| {
173 AppError::Api("Graph did not confirm the selected recipient's chat role".into())
174 })?;
175 if page.next_link.is_some() || page.value.len() > 1 {
176 return Err(AppError::Api(
177 "Graph returned an ambiguous recipient role".into(),
178 ));
179 }
180 if let Some(person) = page.value.first() {
181 if person["id"]
182 .as_str()
183 .is_some_and(|id| id.eq_ignore_ascii_case(recipient))
184 {
185 return Ok(role);
186 }
187 return Err(AppError::Api(
188 "Graph did not confirm the selected recipient's chat role".into(),
189 ));
190 }
191 }
192 Err(AppError::Unsupported("Graph could not confirm whether this directory recipient is a member or guest; open the conversation in Teams instead. No conversation was created and no message was sent".into()))
193 }
194
195 pub async fn teams(&self, limit: u16, offset: usize) -> Result<Page, AppError> {
196 self.get_offset_page(&format!("{GRAPH}/me/joinedTeams"), limit, offset)
197 .await
198 }
199
200 pub async fn teams_available(&self) -> Result<(), AppError> {
201 self.get_value(&format!("{GRAPH}/me/joinedTeams"))
202 .await
203 .map(|_| ())
204 }
205
206 pub async fn channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
207 self.get_offset_page(
208 &format!(
209 "{GRAPH}/teams/{team}/channels?$select=id,displayName,description,membershipType"
210 ),
211 limit,
212 offset,
213 )
214 .await
215 }
216
217 pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
218 self.get_page(
219 cursor,
220 &format!("{GRAPH}/me/chats?$expand=members&$top={limit}"),
221 limit,
222 )
223 .await
224 }
225
226 pub async fn messages(
227 &self,
228 chat: Option<&str>,
229 team: Option<&str>,
230 channel: Option<&str>,
231 limit: u16,
232 cursor: Option<&str>,
233 ) -> Result<Page, AppError> {
234 let base = match (chat, team, channel) {
235 (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages?$top={limit}"),
236 (None, Some(team), Some(channel)) => {
237 format!("{GRAPH}/teams/{team}/channels/{channel}/messages?$top={limit}")
238 }
239 _ => {
240 return Err(AppError::InvalidInput(
241 "choose either --chat, or both --team and --channel".into(),
242 ));
243 }
244 };
245 self.get_page(cursor, &base, limit).await
246 }
247
248 pub async fn send(
249 &self,
250 chat: Option<&str>,
251 team: Option<&str>,
252 channel: Option<&str>,
253 body: &str,
254 ) -> Result<Value, AppError> {
255 let url = match (chat, team, channel) {
256 (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages"),
257 (None, Some(team), Some(channel)) => {
258 format!("{GRAPH}/teams/{team}/channels/{channel}/messages")
259 }
260 _ => {
261 return Err(AppError::InvalidInput(
262 "choose either --chat, or both --team and --channel".into(),
263 ));
264 }
265 };
266 let response = self
267 .http
268 .post(url)
269 .bearer_auth(&self.token)
270 .json(&serde_json::json!({"body": {"contentType": "text", "content": body}}))
271 .send()
272 .await
273 .map_err(|e| {
274 if e.is_connect() || e.is_builder() {
275 AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
276 } else {
277 unconfirmed()
278 }
279 })?;
280 if matches!(response.status().as_u16(), 500 | 502 | 504) {
283 return Err(unconfirmed());
284 }
285 self.checked(response)
286 .await?
287 .json()
288 .await
289 .map_err(|_| unconfirmed())
290 }
291
292 async fn get_value(&self, url: &str) -> Result<Value, AppError> {
293 let response = self
294 .http
295 .get(url)
296 .bearer_auth(&self.token)
297 .send()
298 .await
299 .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
300 self.response_value(response).await
301 }
302
303 async fn get_page(
304 &self,
305 cursor: Option<&str>,
306 first: &str,
307 limit: u16,
308 ) -> Result<Page, AppError> {
309 let url = cursor.unwrap_or(first);
310 if !is_graph_url(url) {
311 return Err(AppError::InvalidInput(
312 "--cursor must be an opaque Microsoft Graph v1.0 continuation URL".into(),
313 ));
314 }
315 let response = self
316 .http
317 .get(url)
318 .bearer_auth(&self.token)
319 .send()
320 .await
321 .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
322 let response = self.checked(response).await?;
323 let mut page: GraphPage = response
324 .json()
325 .await
326 .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
327 let over_limit = page.value.len() > limit as usize;
328 page.value.truncate(limit as usize);
329 let truncated = over_limit || page.next_link.is_some();
330 Ok(Page {
331 items: page.value,
332 next_cursor: page.next_link,
333 next_offset: None,
334 truncated,
335 source: None,
336 viewer: None,
337 })
338 }
339
340 async fn get_offset_page(
341 &self,
342 first: &str,
343 limit: u16,
344 offset: usize,
345 ) -> Result<Page, AppError> {
346 let mut url = Some(first.to_string());
347 let mut all = Vec::new();
348 while let Some(current) = url {
349 if !is_graph_url(¤t) {
350 return Err(AppError::Api(
351 "Graph returned an invalid continuation URL".into(),
352 ));
353 }
354 let response = self
355 .http
356 .get(¤t)
357 .bearer_auth(&self.token)
358 .send()
359 .await
360 .map_err(|e| {
361 AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
362 })?;
363 let page: GraphPage = self
364 .checked(response)
365 .await?
366 .json()
367 .await
368 .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
369 all.extend(page.value);
370 url = page.next_link;
371 if all.len() > 10_000 {
372 return Err(AppError::Api(
373 "Graph returned more than 10,000 records while preparing the requested page"
374 .into(),
375 ));
376 }
377 }
378 let total = all.len();
379 let items = all.into_iter().skip(offset).take(limit as usize).collect();
380 let consumed = offset.saturating_add(limit as usize);
381 let truncated = consumed < total;
382 Ok(Page {
383 items,
384 next_cursor: None,
385 next_offset: truncated.then_some(consumed),
386 truncated,
387 source: None,
388 viewer: None,
389 })
390 }
391
392 async fn response_value(&self, response: reqwest::Response) -> Result<Value, AppError> {
393 self.checked(response)
394 .await?
395 .json()
396 .await
397 .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))
398 }
399
400 async fn checked(&self, response: reqwest::Response) -> Result<reqwest::Response, AppError> {
401 let status = response.status();
402 if status.is_success() {
403 return Ok(response);
404 }
405 let retry_after = response
406 .headers()
407 .get("retry-after")
408 .and_then(|v| v.to_str().ok())
409 .and_then(|v| v.parse().ok());
410 if status.as_u16() == 429 {
411 return Err(AppError::RateLimit(retry_after));
412 }
413 let body: Value = response.json().await.unwrap_or(Value::Null);
414 let message = body
415 .pointer("/error/message")
416 .and_then(Value::as_str)
417 .unwrap_or("Microsoft Graph rejected the request");
418 match status.as_u16() {
419 401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
420 403 => Err(permission_error(message)),
421 404 => Err(AppError::NotFound(message.into())),
422 _ => Err(AppError::Api(format!("{message} ({status})"))),
423 }
424 }
425}
426
427fn unconfirmed() -> AppError {
428 AppError::Unconfirmed(
429 "Microsoft Graph did not confirm the message; it may still be delivered. Check the conversation before sending again".into(),
430 )
431}
432
433fn unconfirmed_chat() -> AppError {
434 AppError::Unconfirmed("Microsoft Graph did not confirm the conversation; it may have been created. Check your chats before trying again. No message was sent".into())
435}
436
437pub fn validate_people_query(query: &str, limit: usize) -> Result<(), AppError> {
438 if !(2..=256).contains(&query.trim().chars().count()) || query.chars().any(char::is_control) {
439 return Err(AppError::InvalidInput(
440 "recipient search must contain 2–256 characters and no control characters".into(),
441 ));
442 }
443 if !(1..=50).contains(&limit) {
444 return Err(AppError::InvalidInput(
445 "recipient search limit must be between 1 and 50".into(),
446 ));
447 }
448 Ok(())
449}
450
451fn is_directory_id(id: &str) -> bool {
452 id.len() == 36
453 && id.bytes().enumerate().all(|(index, byte)| {
454 if [8, 13, 18, 23].contains(&index) {
455 byte == b'-'
456 } else {
457 byte.is_ascii_hexdigit()
458 }
459 })
460}
461
462fn normalize_person(person: &Value) -> Result<Value, AppError> {
463 let id = person["id"]
464 .as_str()
465 .filter(|id| is_directory_id(id))
466 .ok_or_else(|| {
467 AppError::Api("Graph returned a recipient without a valid directory ID".into())
468 })?;
469 let email = person["mail"]
470 .as_str()
471 .filter(|mail| !mail.trim().is_empty())
472 .or_else(|| {
473 person["userPrincipalName"]
474 .as_str()
475 .filter(|mail| !mail.trim().is_empty())
476 });
477 let name = person["displayName"]
478 .as_str()
479 .filter(|name| !name.trim().is_empty())
480 .or(email)
481 .unwrap_or(id);
482 let mut value = serde_json::json!({"id":id,"displayName":name,"source":"graph"});
483 if let Some(email) = email {
484 value["email"] = Value::String(email.into());
485 }
486 Ok(value)
487}
488
489fn one_on_one_body(viewer: &str, recipient: &str, recipient_role: &str) -> Value {
490 serde_json::json!({"chatType":"oneOnOne","members":[
491 {"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":["owner"],"user@odata.bind":format!("{GRAPH}/users('{viewer}')")},
492 {"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":[recipient_role],"user@odata.bind":format!("{GRAPH}/users('{recipient}')")}
493 ]})
494}
495
496fn permission_error(message: &str) -> AppError {
497 let message = message.trim();
498 let lower = message.to_ascii_lowercase();
499 let hint = if lower.contains("hasn't been provisioned")
500 || lower.contains("has not been provisioned")
501 || lower.contains("valid office365 subscription")
502 || lower.contains("valid microsoft 365 subscription")
503 {
504 "sign in with a work or school account that has Microsoft Teams enabled"
505 } else {
506 "check delegated scopes and tenant consent with `teams doctor`"
507 };
508 let separator = if matches!(message.chars().last(), Some('.' | '!' | '?')) {
509 " "
510 } else {
511 "; "
512 };
513 AppError::Permission(format!("{message}{separator}{hint}"))
514}
515
516fn is_graph_url(raw: &str) -> bool {
517 url::Url::parse(raw).is_ok_and(|url| {
518 url.scheme() == "https"
519 && url.host_str() == Some("graph.microsoft.com")
520 && url.path().starts_with("/v1.0/")
521 })
522}
523
524pub fn select_fields(page: &mut Page, fields: Option<&str>) -> Result<(), AppError> {
525 let Some(fields) = fields else {
526 return Ok(());
527 };
528 let names: Vec<&str> = fields
529 .split(',')
530 .map(str::trim)
531 .filter(|s| !s.is_empty())
532 .collect();
533 if names.is_empty() {
534 return Err(AppError::InvalidInput(
535 "--fields must name at least one field".into(),
536 ));
537 }
538 for item in &mut page.items {
539 let source = item
540 .as_object()
541 .ok_or_else(|| AppError::Api("Graph returned a non-object collection item".into()))?;
542 let filtered = names
543 .iter()
544 .filter_map(|name| {
545 source
546 .get(*name)
547 .cloned()
548 .map(|value| ((*name).to_string(), value))
549 })
550 .collect();
551 *item = Value::Object(filtered);
552 }
553 Ok(())
554}
555
556#[cfg(test)]
557mod tests {
558 use serde_json::json;
559 use wiremock::{
560 Mock, MockServer, ResponseTemplate,
561 matchers::{body_json, method, path, query_param},
562 };
563
564 const VIEWER: &str = "11111111-1111-4111-8111-111111111111";
565 const RECIPIENT: &str = "22222222-2222-4222-8222-222222222222";
566
567 fn client(server: &MockServer) -> super::GraphClient {
568 let mut client = super::GraphClient::new("test-token".into());
569 client.base_url = server.uri();
570 client
571 }
572
573 async fn directory(server: &MockServer, recipient_upn: &str, user_type: &str) {
574 Mock::given(method("GET")).and(path(format!("/users/{RECIPIENT}")))
575 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": RECIPIENT, "displayName":"Alex", "mail":"alex@example.test", "userPrincipalName":recipient_upn})))
576 .expect(1).mount(server).await;
577 Mock::given(method("GET"))
578 .and(path("/me"))
579 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id":VIEWER})))
580 .expect(1)
581 .mount(server)
582 .await;
583 for kind in ["Guest", "Member"] {
584 Mock::given(method("GET"))
585 .and(path("/users"))
586 .and(query_param("$select", "id"))
587 .and(query_param("$filter", format!("id eq '{RECIPIENT}' and userType eq '{kind}'")))
588 .and(query_param("$top", "1"))
589 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value":if user_type == kind { vec![json!({"id":RECIPIENT})] } else { vec![] }})))
590 .expect(if user_type == "Guest" && kind == "Member" { 0 } else { 1 })
591 .mount(server).await;
592 }
593 }
594
595 #[tokio::test]
596 async fn recipient_search_escapes_odata_literals_and_keeps_only_verified_identities() {
597 let server = MockServer::start().await;
598 Mock::given(method("GET")).and(path("/users"))
599 .and(query_param("$filter", "startswith(displayName,'O''Brien & Co') or startswith(mail,'O''Brien & Co') or startswith(userPrincipalName,'O''Brien & Co')"))
600 .and(query_param("$select", "id,displayName,mail,userPrincipalName"))
601 .and(query_param("$top", "1"))
602 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value":[{"id":RECIPIENT,"displayName":"O'Brien & Co","mail":"alex@example.test","ignored":"secret"}],"@odata.nextLink":"https://graph.microsoft.com/v1.0/users?$skiptoken=opaque"})))
603 .expect(1).mount(&server).await;
604 let page = client(&server)
605 .search_people(" O'Brien & Co ", 1)
606 .await
607 .unwrap();
608 assert_eq!(
609 page.items,
610 vec![
611 json!({"id":RECIPIENT,"displayName":"O'Brien & Co","email":"alex@example.test","source":"graph"})
612 ]
613 );
614 assert!(page.truncated);
615 assert!(page.next_cursor.is_none());
616 }
617
618 #[tokio::test]
619 async fn malformed_recipient_search_results_fail_closed() {
620 let server = MockServer::start().await;
621 Mock::given(method("GET"))
622 .and(path("/users"))
623 .respond_with(ResponseTemplate::new(200).set_body_json(
624 json!({"value":[{"displayName":"Alex","mail":"alex@example.test"}]}),
625 ))
626 .mount(&server)
627 .await;
628 assert!(matches!(
629 client(&server).search_people("Alex", 20).await,
630 Err(crate::error::AppError::Api(_))
631 ));
632 }
633
634 #[tokio::test]
635 async fn creating_a_chat_uses_confirmed_directory_ids_and_sends_no_message() {
636 let server = MockServer::start().await;
637 directory(&server, "alex_example.test#EXT#@tenant.example", "Member").await;
639 Mock::given(method("POST"))
640 .and(path("/chats"))
641 .and(body_json(super::one_on_one_body(
642 VIEWER, RECIPIENT, "owner",
643 )))
644 .respond_with(
645 ResponseTemplate::new(201)
646 .set_body_json(json!({"id":"existing-chat","chatType":"oneOnOne"})),
647 )
648 .expect(1)
649 .mount(&server)
650 .await;
651 let value = client(&server).create_chat(RECIPIENT).await.unwrap();
652 assert_eq!(value["id"], "existing-chat");
653 assert_eq!(server.received_requests().await.unwrap().len(), 5);
654 }
655
656 #[tokio::test]
657 async fn custom_directory_guests_receive_the_required_guest_role() {
658 let server = MockServer::start().await;
659 directory(&server, "custom-upn@tenant.example", "Guest").await;
660 Mock::given(method("POST"))
661 .and(path("/chats"))
662 .and(body_json(super::one_on_one_body(
663 VIEWER, RECIPIENT, "guest",
664 )))
665 .respond_with(
666 ResponseTemplate::new(201)
667 .set_body_json(json!({"id":"guest-chat","chatType":"oneOnOne"})),
668 )
669 .expect(1)
670 .mount(&server)
671 .await;
672 assert!(client(&server).create_chat(RECIPIENT).await.is_ok());
673 }
674
675 #[tokio::test]
676 async fn uncertain_chat_creation_is_not_retried() {
677 for response in [
678 ResponseTemplate::new(503),
679 ResponseTemplate::new(408),
680 ResponseTemplate::new(201).set_body_string("invalid json"),
681 ] {
682 let server = MockServer::start().await;
683 directory(&server, "alex@example.test", "Member").await;
684 Mock::given(method("POST"))
685 .and(path("/chats"))
686 .respond_with(response)
687 .expect(1)
688 .mount(&server)
689 .await;
690 let error = client(&server).create_chat(RECIPIENT).await.unwrap_err();
691 assert_eq!(error.contract().kind, "unconfirmed");
692 assert!(!error.contract().retryable);
693 assert!(error.to_string().contains("No message was sent"));
694 assert_eq!(server.received_requests().await.unwrap().len(), 5);
695 }
696 }
697
698 #[tokio::test]
699 async fn an_unconfirmed_directory_role_stops_before_creating_a_chat() {
700 let server = MockServer::start().await;
701 directory(&server, "alex@example.test", "Unknown").await;
702 let error = client(&server).create_chat(RECIPIENT).await.unwrap_err();
703 assert_eq!(error.contract().kind, "unsupported");
704 assert_eq!(server.received_requests().await.unwrap().len(), 4);
705 }
706
707 #[tokio::test]
708 async fn creation_redirects_cannot_replay_the_post() {
709 let server = MockServer::start().await;
710 directory(&server, "alex@example.test", "Member").await;
711 Mock::given(method("POST"))
712 .and(path("/chats"))
713 .respond_with(
714 ResponseTemplate::new(307)
715 .insert_header("location", format!("{}/replayed", server.uri())),
716 )
717 .expect(1)
718 .mount(&server)
719 .await;
720 assert!(client(&server).create_chat(RECIPIENT).await.is_err());
721 assert_eq!(server.received_requests().await.unwrap().len(), 5);
722 }
723
724 #[tokio::test]
725 async fn invalid_inputs_fail_before_any_request() {
726 let server = MockServer::start().await;
727 let client = client(&server);
728 for query in ["", "a", "Alex\nInjected", &"a".repeat(257)] {
729 assert_eq!(
730 client
731 .search_people(query, 20)
732 .await
733 .unwrap_err()
734 .contract()
735 .kind,
736 "invalid_input"
737 );
738 }
739 for id in [
740 "alex@example.test",
741 "../me",
742 "22222222-2222-4222-8222-22222222222z",
743 ] {
744 assert_eq!(
745 client.create_chat(id).await.unwrap_err().contract().kind,
746 "invalid_input"
747 );
748 }
749 assert!(server.received_requests().await.unwrap().is_empty());
750 }
751
752 #[test]
753 fn continuation_urls_cannot_exfiltrate_bearer_tokens() {
754 assert!(super::is_graph_url(
755 "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=abc"
756 ));
757 assert!(!super::is_graph_url(
758 "https://graph.microsoft.com.evil.example/v1.0/me/chats"
759 ));
760 assert!(!super::is_graph_url(
761 "http://graph.microsoft.com/v1.0/me/chats"
762 ));
763 }
764
765 #[test]
766 fn unprovisioned_tenants_get_a_teams_specific_hint() {
767 let error = super::permission_error(
768 "Microsoft Teams hasn't been provisioned on the tenant. Ensure the tenant has a valid Office365 subscription.",
769 );
770 assert!(
771 error
772 .to_string()
773 .contains("account that has Microsoft Teams enabled")
774 );
775 assert!(!error.to_string().contains(".;"));
776 }
777}