1use 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");
12const READ_JSON: &str = r#"(value => JSON.stringify(value, (_key, item) =>
15 typeof item === 'string' ? item.toWellFormed() : item))"#;
16
17pub 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 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 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(¶ms, *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 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(¶ms, 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 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 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 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
188fn 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
208fn 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
248fn 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#[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 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 (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 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 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 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}