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