Skip to main content

teams_cli/desktop/
session.rs

1//! A verified page connection kept for the life of the process, so only the first read
2//! pays for verification and the socket handshake. Sends never reuse it: each one verifies
3//! afresh and evaluates exactly once.
4use super::{ConnectionMetadata, ProbeStatus, avatars, cdp::Cdp, chat_id, valid_body, valid_id};
5use crate::config::DesktopAccount;
6use crate::error::AppError;
7use serde_json::{Value, json};
8
9const READ_SCRIPT: &str = include_str!("../desktop.js");
10const SEND_SCRIPT: &str = include_str!("../desktop_send.js");
11const PEOPLE_SCRIPT: &str = include_str!("../desktop_people.js");
12const CACHE_SCRIPT: &str = include_str!("../desktop_cache.js");
13// Teams can cache lone UTF-16 surrogates. JSON.stringify escapes them, but Rust's
14// JSON strings require Unicode scalar values. Repair only the projected output.
15const READ_JSON: &str = r#"(value => JSON.stringify(value, (_key, item) =>
16    typeof item === 'string' ? item.toWellFormed() : item))"#;
17
18/// Platform-specific verification before any session access. Raw target URLs remain
19/// internal; only sanitized connection metadata can be returned to the caller.
20pub trait Verifier: Send + Sync {
21    fn core_only(&self, _port: u16) -> bool {
22        false
23    }
24    fn verify(
25        &self,
26        port: u16,
27    ) -> impl std::future::Future<Output = Result<(ConnectionMetadata, Vec<Value>), AppError>> + Send;
28}
29
30pub struct Session<S> {
31    system: S,
32    page: Option<(u16, Cdp, bool, DesktopAccount)>,
33}
34
35impl<S: Verifier> Session<S> {
36    pub fn new(system: S) -> Self {
37        Self { system, page: None }
38    }
39
40    /// Always verifies afresh; never touches the page.
41    pub async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
42        valid_port(port)?;
43        Ok(self.system.verify(port).await?.0)
44    }
45
46    /// Answers a read request with `{"error": code}` or `{"result": <evaluate result>}`.
47    pub async fn read(&mut self, request: &Value) -> Result<Value, AppError> {
48        let (port, params) = read_params(request)?;
49        let core = self.system.core_only(port);
50        let account = DesktopAccount::from_request(&request["account_type"])?;
51        if let Some((connected, page, personal, scope)) = &mut self.page
52            && *connected == port
53            && *scope == account
54            && let Ok(mut result) = page
55                .evaluate(&core_guard(&read_expression(&params, *personal), core))
56                .await
57            && !origin_changed(&result)
58        {
59            if params["query"] == "chats" {
60                avatars::enrich(page, &mut result, *personal).await;
61            }
62            return Ok(json!({"result": result}));
63        }
64        // No page yet, another port, or a failed socket: verify again before any read.
65        self.page = None;
66        let Ok((metadata, targets)) = self.system.verify(port).await else {
67            return Ok(refusal(ProbeStatus::MetadataProbeFailed));
68        };
69        if metadata.status != ProbeStatus::MetadataOk {
70            return Ok(refusal_for(&metadata));
71        }
72        let candidates = match selected_pages(&targets, account) {
73            Ok(pages) => pages,
74            Err(refused) => return Ok(refused),
75        };
76        let Some(target) = candidates.into_iter().next() else {
77            return Ok(json!({"error": "no_target"}));
78        };
79        let Ok(mut page) = Cdp::connect(port, target.id).await else {
80            return Ok(json!({"error": "read_failed"}));
81        };
82        match page
83            .evaluate(&core_guard(
84                &read_expression(&params, target.personal),
85                core,
86            ))
87            .await
88        {
89            Ok(mut result) => {
90                if origin_changed(&result) {
91                    return Ok(json!({"error": "no_target"}));
92                }
93                if params["query"] == "chats" {
94                    avatars::enrich(&mut page, &mut result, target.personal).await;
95                }
96                self.page = Some((port, page, target.personal, account));
97                Ok(json!({"result": result}))
98            }
99            Err(_) => Ok(json!({"error": "read_failed"})),
100        }
101    }
102
103    /// Whether a send could start: `{"ok": true}` or `{"error": code}`. Sends nothing.
104    pub async fn send_check(&self, port: u16, account: DesktopAccount) -> Result<Value, AppError> {
105        valid_port(port)?;
106        Ok(match self.main_page(port, account).await {
107            Ok(_) => json!({"ok": true}),
108            Err(refused) => refused,
109        })
110    }
111
112    /// Sends one message: verifies the listener, finds the Teams window and evaluates the
113    /// send script exactly once. A lost answer is `unconfirmed`, never retried.
114    pub async fn send(&self, request: &Value) -> Result<Value, AppError> {
115        let (port, params) = send_params(request)?;
116        let account = DesktopAccount::from_request(&request["account_type"])?;
117        let (mut page, personal) = match self.main_page(port, account).await {
118            Ok(page) => page,
119            Err(refused) => return Ok(refused),
120        };
121        Ok(
122            match page
123                .evaluate(&core_guard(
124                    &scoped_expression(
125                        &format!("({SEND_SCRIPT})({params},({CACHE_SCRIPT})).then(JSON.stringify)"),
126                        personal,
127                    ),
128                    self.system.core_only(port),
129                ))
130                .await
131            {
132                Ok(result) => json!({"result": result}),
133                Err(_) => json!({"error": "unconfirmed"}),
134            },
135        )
136    }
137
138    /// Search is read-only. Chat creation is evaluated once, never retried.
139    pub async fn people(&self, request: &Value) -> Result<Value, AppError> {
140        let port = request["port"]
141            .as_u64()
142            .and_then(|p| u16::try_from(p).ok())
143            .ok_or_else(|| AppError::InvalidInput("invalid desktop port".into()))?;
144        valid_port(port)?;
145        let account = DesktopAccount::from_request(&request["account_type"])?;
146        let create = request["op"] == "create";
147        if create {
148            super::check_recipient(request["recipient"].as_str().unwrap_or_default())?;
149        } else if request["op"] == "search" {
150            crate::graph::validate_people_query(
151                request["query"].as_str().unwrap_or_default(),
152                request["limit"].as_u64().unwrap_or_default() as usize,
153            )?;
154        } else {
155            return Err(AppError::InvalidInput("invalid recipient operation".into()));
156        }
157        let (mut page, personal) = match self.main_page(port, account).await {
158            Ok(page) => page,
159            Err(refused) => return Ok(refused),
160        };
161        let mut params = request.clone();
162        params["personal"] = json!(personal);
163        let expression = scoped_expression(
164            &format!("({PEOPLE_SCRIPT})({params},({CACHE_SCRIPT})).then({READ_JSON})"),
165            personal,
166        );
167        Ok(
168            match page
169                .evaluate(&core_guard(&expression, self.system.core_only(port)))
170                .await
171            {
172                Ok(result) => json!({"result":result}),
173                Err(_) => json!({"error":if create { "unconfirmed" } else { "search_failed" }}),
174            },
175        )
176    }
177
178    /// A fresh connection to the Teams window that hosts the send client, or a refusal.
179    async fn main_page(&self, port: u16, account: DesktopAccount) -> Result<(Cdp, bool), Value> {
180        let Ok((metadata, targets)) = self.system.verify(port).await else {
181            return Err(refusal(ProbeStatus::MetadataProbeFailed));
182        };
183        if metadata.status != ProbeStatus::MetadataOk {
184            return Err(refusal_for(&metadata));
185        }
186        let candidates = selected_pages(&targets, account)?;
187        if candidates.is_empty() {
188            return Err(json!({"error": "no_target"}));
189        }
190        let probe = format!("({SEND_SCRIPT})({{\"probe\":true}}).then(JSON.stringify)");
191        for target in candidates {
192            let Ok(mut page) = Cdp::connect(port, target.id).await else {
193                continue;
194            };
195            let main = page
196                .evaluate(&core_guard(
197                    &scoped_expression(&probe, target.personal),
198                    self.system.core_only(port),
199                ))
200                .await
201                .is_ok_and(|result| result["result"]["value"].as_str() == Some(r#"{"main":true}"#));
202            if main {
203                return Ok((page, target.personal));
204            }
205        }
206        Err(json!({"error": "layout"}))
207    }
208}
209
210fn core_guard(expression: &str, core: bool) -> String {
211    if core {
212        format!(
213            "(window.__teamsCliCore?.ready===true?({expression}):JSON.stringify({{error:'core_not_ready'}}))"
214        )
215    } else {
216        expression.to_owned()
217    }
218}
219
220fn read_expression(params: &Value, personal: bool) -> String {
221    let mut params = params.clone();
222    params["personal"] = json!(personal);
223    scoped_expression(
224        &format!("({READ_SCRIPT})({params},({CACHE_SCRIPT})).then({READ_JSON})"),
225        personal,
226    )
227}
228
229/// A page can navigate after discovery or between reused reads. Check its current
230/// origin in the same evaluation before touching cache data or the send client.
231fn scoped_expression(expression: &str, personal: bool) -> String {
232    let origins = if personal {
233        json!(["https://teams.live.com"])
234    } else {
235        json!([
236            "https://teams.microsoft.com",
237            "https://teams.cloud.microsoft"
238        ])
239    };
240    format!(
241        "({origins}.includes(globalThis.location?.origin)?({expression}):JSON.stringify({{error:'no_target'}}))"
242    )
243}
244
245fn origin_changed(result: &Value) -> bool {
246    result["result"]["value"].as_str() == Some(r#"{"error":"no_target"}"#)
247}
248
249/// Revalidates a send request and builds the page parameters; they reach the page only as
250/// a JSON literal.
251fn send_params(request: &Value) -> Result<(u16, Value), AppError> {
252    let invalid = || AppError::InvalidInput("invalid desktop send request".into());
253    let port = request["port"]
254        .as_u64()
255        .and_then(|p| u16::try_from(p).ok())
256        .ok_or_else(invalid)?;
257    valid_port(port)?;
258    let chat = request["chat_id"]
259        .as_str()
260        .filter(|chat| chat_id(chat))
261        .ok_or_else(invalid)?;
262    let content = request["body"]
263        .as_str()
264        .filter(|body| valid_body(body))
265        .ok_or_else(invalid)?;
266    let id = request["client_message_id"]
267        .as_str()
268        .filter(|id| id.len() == 18 && id.bytes().all(|b| b.is_ascii_digit()))
269        .ok_or_else(invalid)?;
270    Ok((
271        port,
272        json!({"chat": chat, "content": content, "clientMessageId": id}),
273    ))
274}
275
276fn refusal(status: ProbeStatus) -> Value {
277    json!({"error": status})
278}
279
280/// Carries the sanitized diagnosis so the caller can explain the refusal.
281fn refusal_for(metadata: &ConnectionMetadata) -> Value {
282    json!({"error": metadata.status, "connection": metadata})
283}
284
285fn valid_port(port: u16) -> Result<(), AppError> {
286    if port < 1024 {
287        return Err(AppError::InvalidInput(
288            "desktop port must be between 1024 and 65535".into(),
289        ));
290    }
291    Ok(())
292}
293
294/// Revalidates a read request and builds the page parameters; they reach the page
295/// only as a JSON literal.
296fn read_params(request: &Value) -> Result<(u16, Value), AppError> {
297    let invalid = || AppError::InvalidInput("invalid desktop read request".into());
298    let port = request["port"]
299        .as_u64()
300        .and_then(|p| u16::try_from(p).ok())
301        .ok_or_else(invalid)?;
302    valid_port(port)?;
303    let query = request["query"].as_str().ok_or_else(invalid)?;
304    let limit = request["limit"]
305        .as_u64()
306        .filter(|l| (1..=200).contains(l))
307        .ok_or_else(invalid)?;
308    let chat = match (query, request["chat_id"].as_str()) {
309        ("chats", None) => "",
310        ("messages", Some(chat)) if chat_id(chat) => chat,
311        _ => return Err(invalid()),
312    };
313    let after = match &request["after"] {
314        Value::Null => Value::Null,
315        after => {
316            let t = after["t"]
317                .as_i64()
318                .filter(|t| *t >= 0)
319                .ok_or_else(invalid)?;
320            let id = after["id"]
321                .as_str()
322                .filter(|id| valid_id(id))
323                .ok_or_else(invalid)?;
324            json!({"t": t, "id": id})
325        }
326    };
327    Ok((
328        port,
329        json!({"query": query, "chat": chat, "limit": limit, "after": after}),
330    ))
331}
332
333/// The Teams pages in endpoint order; their ids become part of the socket path, so they
334/// are validated.
335#[derive(Debug, PartialEq)]
336struct TeamsPage<'a> {
337    id: &'a str,
338    personal: bool,
339}
340
341fn teams_pages(targets: &[Value]) -> Vec<TeamsPage<'_>> {
342    targets
343        .iter()
344        .filter_map(|target| {
345            let url = url::Url::parse(target["url"].as_str()?).ok()?;
346            let id = target["id"].as_str()?;
347            let host = url.host_str()?;
348            (target["type"] == "page"
349                && url.scheme() == "https"
350                && url.port_or_known_default() == Some(443)
351                && url.username().is_empty()
352                && url.password().is_none()
353                && matches!(
354                    host,
355                    "teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
356                )
357                && (8..=64).contains(&id.len())
358                && id.bytes().all(|b| b.is_ascii_hexdigit()))
359            .then_some(TeamsPage {
360                id,
361                personal: host == "teams.live.com",
362            })
363        })
364        .collect()
365}
366
367fn selected_pages(targets: &[Value], account: DesktopAccount) -> Result<Vec<TeamsPage<'_>>, Value> {
368    let pages: Vec<_> = teams_pages(targets)
369        .into_iter()
370        .filter(|page| match account {
371            DesktopAccount::Auto => true,
372            DesktopAccount::Personal => page.personal,
373            DesktopAccount::WorkSchool => !page.personal,
374        })
375        .collect();
376    if pages.iter().any(|page| page.personal) && pages.iter().any(|page| !page.personal) {
377        return Err(json!({"error": "account_ambiguous"}));
378    }
379    Ok(pages)
380}
381
382#[cfg(test)]
383mod tests {
384    #[test]
385    fn core_reads_are_guarded_even_when_a_cached_page_navigates_to_full_ui() {
386        let expression = super::core_guard("cache_read()", true);
387        let output = std::process::Command::new("node")
388            .args(["-e", r#"
389                const assert=require('node:assert/strict'), vm=require('node:vm');
390                let reads=0;const window={},context={window,cache_read(){reads++;return 42}};
391                assert.equal(vm.runInNewContext(process.argv[1],context),JSON.stringify({error:'core_not_ready'}));
392                assert.equal(reads,0);
393                window.__teamsCliCore={ready:true};assert.equal(vm.runInNewContext(process.argv[1],context),42);
394                assert.equal(reads,1);
395                delete window.__teamsCliCore;vm.runInNewContext(process.argv[1],context);assert.equal(reads,1);
396            "#, &expression])
397            .output().expect("Node.js is required for desktop script checks");
398        assert!(
399            output.status.success(),
400            "{}",
401            String::from_utf8_lossy(&output.stderr)
402        );
403    }
404
405    use super::*;
406    use crate::desktop::{
407        MAX_BODY, cdp::tests::devtools_server, owner::tests::FakeSystem, read_request,
408    };
409    use std::sync::{
410        Arc,
411        atomic::{AtomicUsize, Ordering},
412    };
413
414    #[test]
415    fn read_json_repairs_unpaired_utf16_and_preserves_valid_text() {
416        let output = std::process::Command::new("node")
417            .args([
418                "-e",
419                &format!(
420                    r#"const value={{items:[{{topic:'a\ud800b\udc00c',body:'🙂 é 中文',literal:'\\ud800',nested:['\ud83d\ude42','\ud800\ud800\udc00']}}]}};
421                    console.log(JSON.stringify({{raw:JSON.stringify(value),clean:({READ_JSON})(value)}}));"#
422                ),
423            ])
424            .output()
425            .expect("Node.js 20 or newer is required for desktop script tests");
426        assert!(output.status.success());
427        let result: Value = serde_json::from_slice(&output.stdout).unwrap();
428        assert!(serde_json::from_str::<Value>(result["raw"].as_str().unwrap()).is_err());
429        let clean: Value = serde_json::from_str(result["clean"].as_str().unwrap()).unwrap();
430        assert_eq!(
431            clean,
432            json!({"items":[{"topic":"a�b�c","body":"🙂 é 中文","literal":"\\ud800","nested":["🙂","�𐀀"]}]})
433        );
434    }
435
436    #[test]
437    fn current_origin_guard_blocks_other_surfaces_and_lookalikes_before_evaluation() {
438        for (personal, origin, allowed) in [
439            (true, "https://teams.live.com", true),
440            (true, "https://teams.microsoft.com", false),
441            (true, "https://teams.live.com.evil.example", false),
442            (false, "https://teams.microsoft.com", true),
443            (false, "https://teams.cloud.microsoft", true),
444            (false, "https://teams.live.com", false),
445            (false, "http://teams.microsoft.com", false),
446        ] {
447            let expression =
448                scoped_expression("(++globalThis.calls,JSON.stringify({ran:true}))", personal);
449            let output = std::process::Command::new("node").args([
450                "-e",
451                "const vm=require('node:vm');const context={location:{origin:process.argv[1]},calls:0};const value=vm.runInNewContext(process.argv[2],context);console.log(JSON.stringify({value:JSON.parse(value),calls:context.calls}));",
452                origin, &expression,
453            ]).output().expect("Node.js is required for desktop script tests");
454            assert!(output.status.success());
455            let result: Value = serde_json::from_slice(&output.stdout).unwrap();
456            assert_eq!(result["calls"], if allowed { 1 } else { 0 });
457            assert_eq!(
458                result["value"],
459                if allowed {
460                    json!({"ran":true})
461                } else {
462                    json!({"error":"no_target"})
463                }
464            );
465        }
466    }
467
468    #[tokio::test]
469    async fn navigated_cached_pages_are_dropped_and_verified_again_before_reading() {
470        let evaluations = Arc::new(AtomicUsize::new(0));
471        let counter = evaluations.clone();
472        let port = devtools_server(
473            json!([{"type":"page","url":"https://teams.live.com/v2/","id":"ABCDEF12"}]),
474            move |request| {
475                let value = if counter.fetch_add(1, Ordering::SeqCst) == 1 {
476                    r#"{"error":"no_target"}"#
477                } else {
478                    "{}"
479                };
480                Some(
481                    json!({"id":request["id"],"result":{"result":{"type":"string","value":value}}})
482                        .to_string(),
483                )
484            },
485        )
486        .await;
487        let mut session = Session::new(FakeSystem::teams());
488        let request = read_request(port, None, 1, None).unwrap();
489        for _ in 0..2 {
490            assert_eq!(
491                session.read(&request).await.unwrap()["result"]["result"]["value"],
492                "{}"
493            );
494        }
495        assert_eq!(evaluations.load(Ordering::SeqCst), 3);
496    }
497
498    #[test]
499    fn read_params_are_revalidated() {
500        let (port, params) = read_params(&read_request(9222, None, 5, None).unwrap()).unwrap();
501        assert_eq!(port, 9222);
502        assert_eq!(
503            params,
504            json!({"query": "chats", "chat": "", "limit": 5, "after": null})
505        );
506        let base = read_request(9222, Some("19:a@thread.v2"), 5, None).unwrap();
507        assert_eq!(read_params(&base).unwrap().1["chat"], "19:a@thread.v2");
508        for (key, value) in [
509            ("port", json!(80)),
510            ("port", json!(70000)),
511            ("query", json!("teams")),
512            ("limit", json!(0)),
513            ("limit", json!(201)),
514            ("chat_id", json!("19:a'); x")),
515            ("chat_id", Value::Null),
516            ("after", json!({"t": -1, "id": "x"})),
517            ("after", json!({"t": 1, "id": "a b"})),
518        ] {
519            let mut request = base.clone();
520            request[key] = value;
521            assert!(read_params(&request).is_err(), "{key}");
522        }
523    }
524
525    #[test]
526    fn only_teams_pages_with_hex_ids_are_selected() {
527        let targets = [
528            json!({"type": "service_worker", "url": "https://teams.microsoft.com/", "id": "AAAAAAAA"}),
529            json!({"type": "page", "url": "https://teams.microsoft.com.evil.example/", "id": "BBBBBBBB"}),
530            json!({"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "../../x"}),
531            json!({"type": "page", "url": "https://teams.cloud.microsoft/v2/", "id": "CCCCCCCC"}),
532        ];
533        assert_eq!(
534            teams_pages(&targets),
535            [TeamsPage {
536                id: "CCCCCCCC",
537                personal: false
538            }]
539        );
540        assert!(teams_pages(&targets[..3]).is_empty());
541    }
542
543    #[test]
544    fn personal_pages_require_the_exact_secure_origin_and_safe_ids() {
545        let targets = [
546            json!({"type":"page","url":"https://teams.live.com/v2/","id":"AAAAAAAA"}),
547            json!({"type":"page","url":"https://teams.live.com:443","id":"BBBBBBBB"}),
548            json!({"type":"page","url":"https://teams.live.com.evil.example/v2/","id":"CCCCCCCC"}),
549            json!({"type":"page","url":"http://teams.live.com/v2/","id":"DDDDDDDD"}),
550            json!({"type":"page","url":"https://teams.live.com:444/v2/","id":"EEEEEEEE"}),
551            json!({"type":"page","url":"https://teams.live.com@evil.example/v2/","id":"FFFFFFFF"}),
552            json!({"type":"page","url":"https://evil.example@teams.live.com/v2/","id":"11111111"}),
553            json!({"type":"page","url":"https://teams.live.com/v2/","id":"../../x"}),
554            json!({"type":"service_worker","url":"https://teams.live.com/v2/","id":"22222222"}),
555        ];
556        assert_eq!(
557            teams_pages(&targets),
558            [
559                TeamsPage {
560                    id: "AAAAAAAA",
561                    personal: true
562                },
563                TeamsPage {
564                    id: "BBBBBBBB",
565                    personal: true
566                },
567            ]
568        );
569    }
570
571    #[tokio::test]
572    async fn personal_reads_keep_the_identity_context_when_reusing_the_page() {
573        let evaluations = Arc::new(AtomicUsize::new(0));
574        let counter = evaluations.clone();
575        let port = devtools_server(
576            json!([{"type":"page","url":"https://teams.live.com/v2/","id":"ABCDEF12"}]),
577            move |request| {
578                counter.fetch_add(1, Ordering::SeqCst);
579                assert!(
580                    request["params"]["expression"]
581                        .as_str()
582                        .unwrap()
583                        .contains(r#""personal":true"#)
584                );
585                Some(
586                    json!({"id":request["id"],"result":{"result":{"type":"string","value":"{}"}}})
587                        .to_string(),
588                )
589            },
590        )
591        .await;
592        let mut session = Session::new(FakeSystem::teams());
593        for _ in 0..2 {
594            assert_eq!(
595                session
596                    .read(&read_request(port, None, 5, None).unwrap())
597                    .await
598                    .unwrap()["result"]["result"]["value"],
599                "{}"
600            );
601        }
602        assert_eq!(evaluations.load(Ordering::SeqCst), 2);
603    }
604
605    #[tokio::test]
606    async fn mixed_personal_and_work_pages_are_refused_before_evaluation() {
607        let evaluations = Arc::new(AtomicUsize::new(0));
608        let counter = evaluations.clone();
609        let port = devtools_server(
610            json!([
611                {"type":"page","url":"https://teams.live.com/v2/","id":"AAAAAAAA"},
612                {"type":"page","url":"https://teams.microsoft.com/v2/","id":"BBBBBBBB"},
613            ]),
614            move |_| {
615                counter.fetch_add(1, Ordering::SeqCst);
616                None
617            },
618        )
619        .await;
620        let mut session = Session::new(FakeSystem::teams());
621        let refused = json!({"error":"account_ambiguous"});
622        assert_eq!(
623            session
624                .read(&read_request(port, None, 5, None).unwrap())
625                .await
626                .unwrap(),
627            refused
628        );
629        assert_eq!(
630            session
631                .send_check(port, DesktopAccount::Auto)
632                .await
633                .unwrap(),
634            refused
635        );
636        assert_eq!(session.send(&send_request(port)).await.unwrap(), refused);
637        assert_eq!(evaluations.load(Ordering::SeqCst), 0);
638    }
639
640    #[tokio::test]
641    async fn explicit_account_selection_filters_pages_and_revalidates_cached_read_scope() {
642        let targets = json!([
643            {"type":"page","url":"https://teams.microsoft.com/v2/","id":"AAAAAAAA"},
644            {"type":"page","url":"https://teams.live.com/v2/","id":"BBBBBBBB"},
645        ]);
646        let selected =
647            selected_pages(targets.as_array().unwrap(), DesktopAccount::Personal).unwrap();
648        assert_eq!(
649            selected,
650            [TeamsPage {
651                id: "BBBBBBBB",
652                personal: true
653            }]
654        );
655        assert_eq!(
656            selected_pages(targets.as_array().unwrap(), DesktopAccount::WorkSchool).unwrap(),
657            [TeamsPage {
658                id: "AAAAAAAA",
659                personal: false
660            }]
661        );
662        let evaluations = Arc::new(AtomicUsize::new(0));
663        let counter = evaluations.clone();
664        let port = devtools_server(targets, move |request| {
665            let expression = request["params"]["expression"].as_str().unwrap();
666            if expression.contains("\"query\":\"chats\"") {
667                let personal = counter.fetch_add(1, Ordering::SeqCst) == 0;
668                assert!(expression.contains(if personal {
669                    "\"personal\":true"
670                } else {
671                    "\"personal\":false"
672                }));
673            }
674            let value = if expression.contains("\"probe\":true") {
675                "{\"main\":true}"
676            } else {
677                "{}"
678            };
679            Some(
680                json!({"id":request["id"],"result":{"result":{"type":"string","value":value}}})
681                    .to_string(),
682            )
683        })
684        .await;
685        let mut session = Session::new(FakeSystem::teams());
686        for account in [DesktopAccount::Personal, DesktopAccount::WorkSchool] {
687            let mut request = read_request(port, None, 1, None).unwrap();
688            request["account_type"] = json!(account);
689            assert!(
690                session
691                    .read(&request)
692                    .await
693                    .unwrap()
694                    .get("result")
695                    .is_some()
696            );
697            assert_eq!(
698                session.send_check(port, account).await.unwrap(),
699                json!({"ok":true})
700            );
701            let mut send = send_request(port);
702            send["account_type"] = json!(account);
703            assert!(session.send(&send).await.unwrap().get("result").is_some());
704        }
705        assert_eq!(evaluations.load(Ordering::SeqCst), 2);
706        let mut invalid = read_request(port, None, 1, None).unwrap();
707        invalid["account_type"] = json!("all");
708        assert!(session.read(&invalid).await.is_err());
709        let mut invalid = send_request(port);
710        invalid["account_type"] = json!(true);
711        assert!(session.send(&invalid).await.is_err());
712        assert_eq!(evaluations.load(Ordering::SeqCst), 2);
713        // A work/school-only endpoint cannot fall back when personal was selected.
714        let work = [json!({"type":"page","url":"https://teams.microsoft.com/v2/","id":"AAAAAAAA"})];
715        assert!(
716            selected_pages(&work, DesktopAccount::Personal)
717                .unwrap()
718                .is_empty()
719        );
720    }
721
722    #[tokio::test]
723    async fn reads_reuse_the_page_and_reverify_after_a_failure() {
724        let evaluations = Arc::new(AtomicUsize::new(0));
725        let counter = evaluations.clone();
726        let port = devtools_server(
727            json!([{"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "ABCDEF12"}]),
728            move |request| {
729                let n = counter.fetch_add(1, Ordering::SeqCst);
730                let expression = request["params"]["expression"].as_str().unwrap();
731                assert!(expression.contains(r#""query":"chats""#));
732                // The second evaluation drops the socket, as when Teams reloads.
733                (n != 1).then(|| {
734                    json!({"id": request["id"], "result": {"result": {"type": "string", "value": "{}"}}})
735                        .to_string()
736                })
737            },
738        )
739        .await;
740        let mut session = Session::new(FakeSystem::teams());
741        let request = read_request(port, None, 5, None).unwrap();
742        for _ in 0..3 {
743            let reply = session.read(&request).await.unwrap();
744            assert_eq!(reply["result"]["result"]["value"], "{}");
745        }
746        assert_eq!(evaluations.load(Ordering::SeqCst), 4);
747        assert!(session.probe(port).await.unwrap().endpoint_available());
748    }
749
750    #[tokio::test]
751    async fn refusals_are_reported_as_codes() {
752        let port = devtools_server(json!([]), |_| None).await;
753        let request = read_request(port, None, 5, None).unwrap();
754        let mut session = Session::new(FakeSystem::teams());
755        assert_eq!(
756            session.read(&request).await.unwrap(),
757            json!({"error": "no_target"})
758        );
759        let mut session = Session::new(FakeSystem {
760            trusted: vec![],
761            ..FakeSystem::teams()
762        });
763        let reply = session.read(&request).await.unwrap();
764        assert_eq!(reply["error"], "non_teams_listener_refused");
765        assert_eq!(reply["connection"]["teams_owned"], false);
766    }
767
768    fn send_request(port: u16) -> Value {
769        crate::desktop::send_request(port, "19:a@thread.v2", "hi <b>\nthere").unwrap()
770    }
771
772    #[test]
773    fn send_params_are_revalidated() {
774        let base = send_request(9222);
775        let (port, params) = send_params(&base).unwrap();
776        assert_eq!(port, 9222);
777        assert_eq!(params["chat"], "19:a@thread.v2");
778        assert_eq!(params["content"], "hi <b>\nthere");
779        assert_eq!(params["clientMessageId"].as_str().unwrap().len(), 18);
780        for (key, value) in [
781            ("port", json!(80)),
782            ("chat_id", json!("19:a'); x")),
783            ("chat_id", json!("48:other")),
784            ("body", json!("  \n")),
785            ("body", json!("a\0b")),
786            ("body", json!("x".repeat(MAX_BODY + 1))),
787            ("body", Value::Null),
788            ("client_message_id", json!("12345")),
789            ("client_message_id", json!("12345678901234567x")),
790        ] {
791            let mut request = base.clone();
792            request[key] = value;
793            assert!(send_params(&request).is_err(), "{key}");
794        }
795    }
796
797    /// Three Teams pages; only the second hosts the send client. Counts send evaluations.
798    async fn teams_window(sends: Arc<AtomicUsize>, answer_send: bool, origin: &str) -> u16 {
799        devtools_server(
800            json!([
801                {"type": "page", "url": format!("{origin}/v2/"), "id": "AAAAAAAA"},
802                {"type": "page", "url": format!("{origin}/v2/"), "id": "BBBBBBBB"},
803                {"type": "page", "url": format!("{origin}/v2/"), "id": "CCCCCCCC"},
804            ]),
805            move |request| {
806                let expression = request["params"]["expression"].as_str().unwrap();
807                assert!(expression.contains("autoSendMessage"));
808                let value = if expression.contains(r#"({"probe":true})"#) {
809                    // The page id is not visible here; answer by probe order instead.
810                    if sends.fetch_add(1000, Ordering::SeqCst) / 1000 == 1 {
811                        r#"{"main":true}"#
812                    } else {
813                        r#"{"main":false}"#
814                    }
815                } else {
816                    assert!(expression.contains(r#""chat":"19:a@thread.v2""#));
817                    sends.fetch_add(1, Ordering::SeqCst);
818                    if !answer_send {
819                        return None;
820                    }
821                    r#"{"id":"1700000000000"}"#
822                };
823                Some(json!({"id": request["id"], "result": {"result": {"type": "string", "value": value}}}).to_string())
824            },
825        )
826        .await
827    }
828
829    #[tokio::test]
830    async fn sends_go_to_the_window_with_the_client_and_run_once() {
831        for origin in ["https://teams.microsoft.com", "https://teams.live.com"] {
832            let sends = Arc::new(AtomicUsize::new(0));
833            let port = teams_window(sends.clone(), true, origin).await;
834            let session = Session::new(FakeSystem::teams());
835            assert_eq!(
836                session
837                    .send_check(port, DesktopAccount::Auto)
838                    .await
839                    .unwrap(),
840                json!({"ok": true})
841            );
842            sends.store(0, Ordering::SeqCst);
843            let reply = session.send(&send_request(port)).await.unwrap();
844            assert_eq!(
845                reply["result"]["result"]["value"],
846                r#"{"id":"1700000000000"}"#
847            );
848            // Two probes (the first page is not the window), then one send.
849            assert_eq!(sends.load(Ordering::SeqCst), 2001);
850        }
851    }
852
853    #[tokio::test]
854    async fn a_lost_send_answer_is_unconfirmed_and_not_retried() {
855        let sends = Arc::new(AtomicUsize::new(0));
856        let port = teams_window(sends.clone(), false, "https://teams.microsoft.com").await;
857        let session = Session::new(FakeSystem::teams());
858        assert_eq!(
859            session.send(&send_request(port)).await.unwrap(),
860            json!({"error": "unconfirmed"})
861        );
862        assert_eq!(sends.load(Ordering::SeqCst) % 1000, 1);
863    }
864
865    #[tokio::test]
866    async fn sends_are_refused_without_a_verified_window() {
867        let untrusted = Session::new(FakeSystem {
868            trusted: vec![],
869            ..FakeSystem::teams()
870        });
871        let sends = Arc::new(AtomicUsize::new(0));
872        let port = teams_window(sends.clone(), true, "https://teams.microsoft.com").await;
873        let reply = untrusted.send(&send_request(port)).await.unwrap();
874        assert_eq!(reply["error"], "non_teams_listener_refused");
875        assert_eq!(reply["connection"]["teams_owned"], false);
876        assert_eq!(
877            reply["connection"]["foreign_listeners"],
878            json!(["ms-teams.exe"])
879        );
880        assert_eq!(sends.load(Ordering::SeqCst), 0);
881        let empty = devtools_server(json!([]), |_| None).await;
882        let session = Session::new(FakeSystem::teams());
883        assert_eq!(
884            session
885                .send_check(empty, DesktopAccount::Auto)
886                .await
887                .unwrap(),
888            json!({"error": "no_target"})
889        );
890        let none = devtools_server(
891            json!([{"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "AAAAAAAA"}]),
892            |request| {
893                Some(json!({"id": request["id"], "result": {"result": {"type": "string", "value": r#"{"main":false}"#}}}).to_string())
894            },
895        )
896        .await;
897        assert_eq!(
898            session.send(&send_request(none)).await.unwrap(),
899            json!({"error": "layout"})
900        );
901    }
902}