Skip to main content

teams_cli/
graph.rs

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    /// Set when items come from a local cache rather than the live service.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub source: Option<&'static str>,
19    /// The signed-in user's id when the backend knows it, so text output can say "You".
20    #[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            // A write must never be replayed by transport policy or a redirect.
41            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    /// Search directory-backed identities, never turn an unverified email into a recipient.
60    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    /// Graph reuses the existing one-on-one chat for this pair of users.
96    /// This creates no message, and a possibly accepted request is never retried.
97    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        // Filtering on userType is supported by /users. Request only the basic ID,
160        // rather than a broader profile scope or inferring roles from a UPN.
161        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        // Once the request has left, a server fault or an unreadable answer can hide a
281        // delivered message; never let a caller treat that as safe to resend.
282        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(&current) {
350                return Err(AppError::Api(
351                    "Graph returned an invalid continuation URL".into(),
352                ));
353            }
354            let response = self
355                .http
356                .get(&current)
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        // A converted guest can keep an #EXT# UPN while being a Member.
638        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}