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 PEOPLE_SCRIPT: &str = include_str!("../desktop_people.js");
12const CACHE_SCRIPT: &str = include_str!("../desktop_cache.js");
13const READ_JSON: &str = r#"(value => JSON.stringify(value, (_key, item) =>
16 typeof item === 'string' ? item.toWellFormed() : item))"#;
17
18pub 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 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 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(¶ms, *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 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(¶ms, 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 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 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 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 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
229fn 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
249fn 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
280fn 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
294fn 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#[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 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 (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 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 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 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}