1use crate::cdp::{self, Cdp};
2use crate::net::{base64_decode, base64_encode};
3use crate::protected::Protected;
4use serde_json::{json, Value};
5use std::ops::{Deref, DerefMut};
6use std::time::Duration;
7
8pub const SIGNED_IN_HTML: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>Signed in</title></head><body style=\"font-family:system-ui,sans-serif;margin:4rem auto;max-width:28rem;text-align:center\"><h1>Signed in</h1><p>You can close this tab.</p></body></html>";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Stage {
12 Request,
13 Response,
14}
15
16impl Stage {
17 fn as_str(self) -> &'static str {
18 match self {
19 Stage::Request => "Request",
20 Stage::Response => "Response",
21 }
22 }
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Pattern {
27 pub url: String,
28 pub stage: Stage,
29 pub resource_type: Option<String>,
30}
31
32impl Pattern {
33 pub fn request(url: impl Into<String>) -> Self {
34 Self {
35 url: url.into(),
36 stage: Stage::Request,
37 resource_type: None,
38 }
39 }
40
41 pub fn response(url: impl Into<String>) -> Self {
42 Self {
43 url: url.into(),
44 stage: Stage::Response,
45 resource_type: None,
46 }
47 }
48
49 pub fn resource_type(mut self, kind: impl Into<String>) -> Self {
50 self.resource_type = Some(kind.into());
51 self
52 }
53
54 fn to_json(&self) -> Value {
55 let mut value = json!({ "urlPattern": self.url, "requestStage": self.stage.as_str() });
56 if let Some(kind) = &self.resource_type {
57 value["resourceType"] = json!(kind);
58 }
59 value
60 }
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct Paused {
65 pub session_id: String,
66 pub request_id: String,
67 pub frame_id: String,
68 pub resource_type: String,
69 pub network_id: Option<String>,
70 pub method: String,
71 pub url: String,
72 pub url_fragment: Option<String>,
73 pub headers: Vec<(String, String)>,
74 pub post_data: Option<Vec<u8>>,
75 pub status: Option<u16>,
76 pub status_text: Option<String>,
77 pub response_headers: Option<Vec<(String, String)>>,
78 pub error_reason: Option<String>,
79}
80
81impl Paused {
82 pub fn from_event(event: &Value) -> Option<Self> {
83 if event.get("method").and_then(|v| v.as_str()) != Some("Fetch.requestPaused") {
84 return None;
85 }
86 let params = event.get("params")?;
87 let request = params.get("request")?;
88 let text =
89 |value: &Value, key: &str| value.get(key).and_then(|v| v.as_str()).map(str::to_string);
90 let post_data = match request.get("postData").and_then(|v| v.as_str()) {
91 Some(data) => Some(data.as_bytes().to_vec()),
92 None => request
93 .get("postDataEntries")
94 .and_then(|v| v.as_array())
95 .map(|entries| {
96 let mut bytes = Vec::new();
97 for entry in entries {
98 if let Some(chunk) = entry.get("bytes").and_then(|v| v.as_str()) {
99 bytes.extend(base64_decode(chunk).unwrap_or_default());
100 }
101 }
102 bytes
103 }),
104 };
105 let status = params
106 .get("responseStatusCode")
107 .and_then(|v| v.as_u64())
108 .and_then(|code| u16::try_from(code).ok());
109 let response_headers = params
110 .get("responseHeaders")
111 .and_then(|v| v.as_array())
112 .map(|items| {
113 items
114 .iter()
115 .filter_map(|item| Some((text(item, "name")?, text(item, "value")?)))
116 .collect()
117 });
118 Some(Self {
119 session_id: text(event, "sessionId").unwrap_or_default(),
120 request_id: text(params, "requestId")?,
121 frame_id: text(params, "frameId").unwrap_or_default(),
122 resource_type: text(params, "resourceType").unwrap_or_default(),
123 network_id: text(params, "networkId"),
124 method: text(request, "method").unwrap_or_else(|| "GET".to_string()),
125 url: text(request, "url")?,
126 url_fragment: text(request, "urlFragment").filter(|fragment| !fragment.is_empty()),
127 headers: request
128 .get("headers")
129 .and_then(|v| v.as_object())
130 .map(|object| {
131 object
132 .iter()
133 .map(|(name, value)| {
134 (name.clone(), value.as_str().unwrap_or("").to_string())
135 })
136 .collect()
137 })
138 .unwrap_or_default(),
139 post_data,
140 status,
141 status_text: text(params, "responseStatusText"),
142 response_headers,
143 error_reason: text(params, "responseErrorReason"),
144 })
145 }
146
147 pub fn is_response(&self) -> bool {
148 self.status.is_some() || self.error_reason.is_some()
149 }
150
151 pub fn full_url(&self) -> String {
152 match &self.url_fragment {
153 Some(fragment) => format!("{}{fragment}", self.url),
154 None => self.url.clone(),
155 }
156 }
157
158 pub fn header(&self, name: &str) -> Option<&str> {
159 find(&self.headers, name)
160 }
161
162 pub fn response_header(&self, name: &str) -> Option<&str> {
163 find(self.response_headers.as_deref()?, name)
164 }
165
166 pub fn location(&self) -> Option<String> {
167 let raw = self.response_header("location")?;
168 let base = url::Url::parse(&self.url).ok()?;
169 Some(
170 base.join(raw)
171 .map(|url| url.to_string())
172 .unwrap_or_else(|_| raw.to_string()),
173 )
174 }
175}
176
177fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
178 headers
179 .iter()
180 .find(|(have, _)| have.eq_ignore_ascii_case(name))
181 .map(|(_, value)| value.as_str())
182}
183
184#[derive(Clone, Debug, Default, PartialEq, Eq)]
185pub struct Overrides {
186 pub url: Option<String>,
187 pub method: Option<String>,
188 pub post_data: Option<Vec<u8>>,
189 pub headers: Option<Vec<(String, String)>>,
190}
191
192#[derive(Clone, Debug, Default, PartialEq, Eq)]
193pub struct ResponseOverrides {
194 pub status: Option<u16>,
195 pub status_text: Option<String>,
196 pub headers: Option<Vec<(String, String)>>,
197}
198
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Fulfill {
201 pub status: u16,
202 pub status_text: Option<String>,
203 pub headers: Vec<(String, String)>,
204 pub body: Vec<u8>,
205}
206
207impl Fulfill {
208 pub fn new(status: u16) -> Self {
209 Self {
210 status,
211 status_text: None,
212 headers: Vec::new(),
213 body: Vec::new(),
214 }
215 }
216
217 pub fn html(status: u16, body: impl Into<Vec<u8>>) -> Self {
218 Self::new(status)
219 .header("Content-Type", "text/html; charset=utf-8")
220 .body(body)
221 }
222
223 pub fn signed_in() -> Self {
224 Self::html(200, SIGNED_IN_HTML).header("Cache-Control", "no-store")
225 }
226
227 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
228 self.headers.push((name.into(), value.into()));
229 self
230 }
231
232 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
233 self.body = body.into();
234 self
235 }
236}
237
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum ErrorReason {
240 Failed,
241 Aborted,
242 TimedOut,
243 AccessDenied,
244 ConnectionClosed,
245 ConnectionReset,
246 ConnectionRefused,
247 ConnectionAborted,
248 ConnectionFailed,
249 NameNotResolved,
250 InternetDisconnected,
251 AddressUnreachable,
252 BlockedByClient,
253 BlockedByResponse,
254}
255
256impl ErrorReason {
257 pub fn as_str(self) -> &'static str {
258 match self {
259 ErrorReason::Failed => "Failed",
260 ErrorReason::Aborted => "Aborted",
261 ErrorReason::TimedOut => "TimedOut",
262 ErrorReason::AccessDenied => "AccessDenied",
263 ErrorReason::ConnectionClosed => "ConnectionClosed",
264 ErrorReason::ConnectionReset => "ConnectionReset",
265 ErrorReason::ConnectionRefused => "ConnectionRefused",
266 ErrorReason::ConnectionAborted => "ConnectionAborted",
267 ErrorReason::ConnectionFailed => "ConnectionFailed",
268 ErrorReason::NameNotResolved => "NameNotResolved",
269 ErrorReason::InternetDisconnected => "InternetDisconnected",
270 ErrorReason::AddressUnreachable => "AddressUnreachable",
271 ErrorReason::BlockedByClient => "BlockedByClient",
272 ErrorReason::BlockedByResponse => "BlockedByResponse",
273 }
274 }
275
276 pub fn parse(text: &str) -> Option<Self> {
277 let all = [
278 ErrorReason::Failed,
279 ErrorReason::Aborted,
280 ErrorReason::TimedOut,
281 ErrorReason::AccessDenied,
282 ErrorReason::ConnectionClosed,
283 ErrorReason::ConnectionReset,
284 ErrorReason::ConnectionRefused,
285 ErrorReason::ConnectionAborted,
286 ErrorReason::ConnectionFailed,
287 ErrorReason::NameNotResolved,
288 ErrorReason::InternetDisconnected,
289 ErrorReason::AddressUnreachable,
290 ErrorReason::BlockedByClient,
291 ErrorReason::BlockedByResponse,
292 ];
293 all.into_iter()
294 .find(|reason| reason.as_str().eq_ignore_ascii_case(text))
295 }
296}
297
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub enum Decision {
300 Continue(Overrides),
301 ContinueResponse(ResponseOverrides),
302 Fulfill(Fulfill),
303 Fail(ErrorReason),
304}
305
306impl Default for Decision {
307 fn default() -> Self {
308 Decision::Continue(Overrides::default())
309 }
310}
311
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct Navigation {
314 pub session_id: String,
315 pub frame_id: String,
316 pub main_frame: bool,
317 pub url: String,
318}
319
320pub fn reaches_protected(guard: &Protected, paused: &Paused) -> bool {
321 guard.url(&paused.full_url()) || paused.location().is_some_and(|url| guard.url(&url))
322}
323
324pub fn navigation(guard: &Protected, event: &Value) -> Option<Navigation> {
325 if event.get("method").and_then(|v| v.as_str()) != Some("Page.frameNavigated") {
326 return None;
327 }
328 let frame = event.pointer("/params/frame")?;
329 let base = frame.get("url").and_then(|v| v.as_str())?;
330 let fragment = frame
331 .get("urlFragment")
332 .and_then(|v| v.as_str())
333 .unwrap_or("");
334 let url = format!("{base}{fragment}");
335 if guard.url(&url) || guard.url(base) {
336 return None;
337 }
338 Some(Navigation {
339 session_id: event
340 .get("sessionId")
341 .and_then(|v| v.as_str())
342 .unwrap_or("")
343 .to_string(),
344 frame_id: frame
345 .get("id")
346 .and_then(|v| v.as_str())
347 .unwrap_or("")
348 .to_string(),
349 main_frame: frame.get("parentId").is_none(),
350 url,
351 })
352}
353
354fn header_list(headers: &[(String, String)]) -> Value {
355 Value::Array(
356 headers
357 .iter()
358 .map(|(name, value)| json!({ "name": name, "value": value }))
359 .collect(),
360 )
361}
362
363fn refresh_target(value: &str) -> &str {
364 let rest = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
365 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_digit() || ch == '.');
366 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
367 let rest = rest
368 .strip_prefix(';')
369 .or_else(|| rest.strip_prefix(','))
370 .unwrap_or(rest);
371 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
372 let rest = match rest.get(..3) {
373 Some(head) if head.eq_ignore_ascii_case("url") => {
374 let after = rest[3..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
375 match after.strip_prefix('=') {
376 Some(value) => value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()),
377 None => rest,
378 }
379 }
380 _ => rest,
381 };
382 let rest = rest.trim_end_matches(|ch: char| ch.is_ascii_whitespace());
383 match rest.chars().next() {
384 Some(quote @ ('\'' | '"')) => {
385 let inner = &rest[1..];
386 inner.split(quote).next().unwrap_or(inner)
387 }
388 _ => rest,
389 }
390}
391
392fn redirect_refusal<'a>(guard: &'a Protected, base: &str, headers: &[(String, String)]) -> Option<&'a str> {
393 let base = url::Url::parse(base).ok();
394 let reaches = |target: &str| {
395 let resolved = base
396 .as_ref()
397 .and_then(|base| base.join(target).ok())
398 .map(|url| url.to_string())
399 .unwrap_or_else(|| target.to_string());
400 if guard.url(&resolved) {
401 Some(guard.refusal_for_url(&resolved))
402 } else if guard.url_read_only(target) || guard.pattern(target) {
403 Some(guard.refusal())
404 } else {
405 None
406 }
407 };
408 headers.iter().find_map(|(name, value)| {
409 if name.eq_ignore_ascii_case("location") {
410 reaches(value)
411 } else if name.eq_ignore_ascii_case("refresh") {
412 reaches(refresh_target(value))
413 } else {
414 None
415 }
416 })
417}
418
419fn location_refusal(guard: &Protected, base: &str, status: Option<u16>, headers: &[(String, String)]) -> Result<(), String> {
420 if !status.is_some_and(|status| (300..400).contains(&status)) {
421 return Ok(());
422 }
423 for (_, value) in headers.iter().filter(|(name, _)| name.trim().eq_ignore_ascii_case("location")) {
424 cdp::target_refusal(guard, base, value)?;
425 }
426 Ok(())
427}
428
429pub fn command(
430 guard: &Protected,
431 paused: &Paused,
432 decision: &Decision,
433) -> Result<(&'static str, Value), String> {
434 let id = paused.request_id.as_str();
435 match decision {
436 Decision::Continue(overrides) => {
437 let mut params = json!({ "requestId": id });
438 if let Some(url) = &overrides.url {
439 if guard.url(url) {
440 return Err(format!("{}\n", guard.refusal_for_url(url)));
441 }
442 cdp::target_refusal(guard, url, url)?;
443 params["url"] = json!(url);
444 }
445 if let Some(method) = &overrides.method {
446 params["method"] = json!(method);
447 }
448 if let Some(data) = &overrides.post_data {
449 params["postData"] = json!(base64_encode(data));
450 }
451 if let Some(headers) = &overrides.headers {
452 params["headers"] = header_list(headers);
453 }
454 Ok(("Fetch.continueRequest", params))
455 }
456 Decision::ContinueResponse(overrides) => {
457 if !paused.is_response() {
458 return Err(
459 "Fetch.continueResponse: the request is not at the response stage\n".into(),
460 );
461 }
462 let mut params = json!({ "requestId": id });
463 if let Some(status) = overrides.status {
464 params["responseCode"] = json!(status);
465 }
466 if let Some(text) = &overrides.status_text {
467 params["responsePhrase"] = json!(text);
468 }
469 if let Some(headers) = &overrides.headers {
470 if let Some(reason) = redirect_refusal(guard, &paused.url, headers) {
471 return Err(format!("{reason}\n"));
472 }
473 location_refusal(guard, &paused.url, overrides.status.or(paused.status), headers)?;
474 params["responseHeaders"] = header_list(headers);
475 }
476 Ok(("Fetch.continueResponse", params))
477 }
478 Decision::Fulfill(fulfill) => {
479 if let Some(reason) = redirect_refusal(guard, &paused.url, &fulfill.headers) {
480 return Err(format!("{reason}\n"));
481 }
482 location_refusal(guard, &paused.url, Some(fulfill.status), &fulfill.headers)?;
483 let mut params = json!({
484 "requestId": id,
485 "responseCode": fulfill.status,
486 "responseHeaders": header_list(&fulfill.headers),
487 "body": base64_encode(&fulfill.body),
488 });
489 if let Some(text) = &fulfill.status_text {
490 params["responsePhrase"] = json!(text);
491 }
492 Ok(("Fetch.fulfillRequest", params))
493 }
494 Decision::Fail(reason) => Ok((
495 "Fetch.failRequest",
496 json!({ "requestId": id, "errorReason": reason.as_str() }),
497 )),
498 }
499}
500
501fn need_session(session_id: &str) -> Result<(), String> {
502 if session_id.is_empty() {
503 return Err("Fetch needs a target session: browser-wide interception is refused\n".into());
504 }
505 Ok(())
506}
507
508pub fn enable(cdp: &mut Cdp, session_id: &str, patterns: &[Pattern]) -> Result<(), String> {
509 need_session(session_id)?;
510 if patterns.is_empty() {
511 return Err("Fetch.enable needs at least one pattern\n".into());
512 }
513 if let Some(pattern) = patterns
514 .iter()
515 .find(|pattern| cdp.guard().pattern(&pattern.url))
516 {
517 return Err(format!("{} ({})\n", cdp.guard().refusal(), pattern.url));
518 }
519 let list: Vec<Value> = patterns.iter().map(Pattern::to_json).collect();
520 cdp.call(
521 "Fetch.enable",
522 json!({ "patterns": list, "handleAuthRequests": false }),
523 Some(session_id),
524 )?;
525 Ok(())
526}
527
528pub fn disable(cdp: &mut Cdp, session_id: &str) {
529 cdp.forget_paused(session_id);
530 if session_id.is_empty() {
531 return;
532 }
533 let _ = cdp.call_for(
534 "Fetch.disable",
535 json!({}),
536 Some(session_id),
537 Duration::from_secs(5),
538 );
539}
540
541pub fn decide(cdp: &mut Cdp, paused: &Paused, decision: &Decision) -> Result<(), String> {
542 let (method, params) = command(cdp.guard(), paused, decision)?;
543 cdp.call(method, params, Some(&paused.session_id))?;
544 Ok(())
545}
546
547pub fn continue_request(
548 cdp: &mut Cdp,
549 paused: &Paused,
550 overrides: Overrides,
551) -> Result<(), String> {
552 decide(cdp, paused, &Decision::Continue(overrides))
553}
554
555pub fn continue_response(
556 cdp: &mut Cdp,
557 paused: &Paused,
558 overrides: ResponseOverrides,
559) -> Result<(), String> {
560 decide(cdp, paused, &Decision::ContinueResponse(overrides))
561}
562
563pub fn fulfill(cdp: &mut Cdp, paused: &Paused, response: Fulfill) -> Result<(), String> {
564 decide(cdp, paused, &Decision::Fulfill(response))
565}
566
567pub fn fail(cdp: &mut Cdp, paused: &Paused, reason: ErrorReason) -> Result<(), String> {
568 decide(cdp, paused, &Decision::Fail(reason))
569}
570
571pub fn response_body(cdp: &mut Cdp, paused: &Paused) -> Result<Vec<u8>, String> {
572 if !paused.is_response() {
573 return Err("Fetch.getResponseBody: the request is not at the response stage\n".into());
574 }
575 let result = cdp.call(
576 "Fetch.getResponseBody",
577 json!({ "requestId": paused.request_id }),
578 Some(&paused.session_id),
579 )?;
580 let body = result.get("body").and_then(|v| v.as_str()).unwrap_or("");
581 if result.get("base64Encoded").and_then(|v| v.as_bool()) == Some(true) {
582 base64_decode(body)
583 } else {
584 Ok(body.as_bytes().to_vec())
585 }
586}
587
588pub fn target_protected(cdp: &mut Cdp, target_id: &str) -> Result<bool, String> {
589 cdp.target_protected(target_id, false)
590}
591
592pub fn refuse_target(cdp: &mut Cdp, target_id: &str) -> Result<(), String> {
593 if target_protected(cdp, target_id)? {
594 return Err(format!("{}\n", cdp.guard().refusal()));
595 }
596 Ok(())
597}
598
599pub struct Interception<'a> {
600 cdp: &'a mut Cdp,
601 session_id: String,
602 owned: bool,
603 done: bool,
604}
605
606impl<'a> Interception<'a> {
607 pub fn attach(cdp: &'a mut Cdp, target_id: &str, patterns: &[Pattern]) -> Result<Self, String> {
608 refuse_target(cdp, target_id)?;
609 let session_id = cdp::attach(cdp, target_id)?;
610 if let Err(message) = enable(cdp, &session_id, patterns) {
611 cdp::detach(cdp, &session_id);
612 return Err(message);
613 }
614 Ok(Self {
615 cdp,
616 session_id,
617 owned: true,
618 done: false,
619 })
620 }
621
622 pub fn on_session(
623 cdp: &'a mut Cdp,
624 target_id: &str,
625 session_id: &str,
626 patterns: &[Pattern],
627 ) -> Result<Self, String> {
628 refuse_target(cdp, target_id)?;
629 enable(cdp, session_id, patterns)?;
630 Ok(Self {
631 cdp,
632 session_id: session_id.to_string(),
633 owned: false,
634 done: false,
635 })
636 }
637
638 pub fn session_id(&self) -> &str {
639 &self.session_id
640 }
641
642 pub fn next(&mut self, timeout: Duration) -> Result<Option<Paused>, String> {
643 let session = self.session_id.clone();
644 self.cdp.next_paused(Some(&session), timeout)
645 }
646
647 pub fn decide(&mut self, paused: &Paused, decision: &Decision) -> Result<(), String> {
648 decide(self.cdp, paused, decision)
649 }
650
651 pub fn response_body(&mut self, paused: &Paused) -> Result<Vec<u8>, String> {
652 response_body(self.cdp, paused)
653 }
654
655 pub fn finish(mut self) {
656 self.close();
657 }
658
659 fn close(&mut self) {
660 if self.done {
661 return;
662 }
663 self.done = true;
664 disable(self.cdp, &self.session_id);
665 if self.owned {
666 cdp::detach(self.cdp, &self.session_id);
667 }
668 }
669}
670
671impl Deref for Interception<'_> {
672 type Target = Cdp;
673
674 fn deref(&self) -> &Cdp {
675 self.cdp
676 }
677}
678
679impl DerefMut for Interception<'_> {
680 fn deref_mut(&mut self) -> &mut Cdp {
681 self.cdp
682 }
683}
684
685impl Drop for Interception<'_> {
686 fn drop(&mut self) {
687 self.close();
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::cdp::Transport;
695 use std::cell::RefCell;
696 use std::collections::VecDeque;
697 use std::rc::Rc;
698 use std::time::Instant;
699
700 type Reply = Box<dyn FnMut(&Value) -> Vec<Value>>;
701
702 struct Fake {
703 log: Rc<RefCell<Vec<Value>>>,
704 inbox: VecDeque<String>,
705 reply: Reply,
706 closed: Option<String>,
707 }
708
709 impl Transport for Fake {
710 fn send_text(&mut self, text: &str) -> Result<(), String> {
711 if let Some(closed) = &self.closed {
712 return Err(closed.clone());
713 }
714 let value: Value = serde_json::from_str(text).unwrap();
715 self.log.borrow_mut().push(value.clone());
716 for out in (self.reply)(&value) {
717 self.inbox.push_back(out.to_string());
718 }
719 Ok(())
720 }
721
722 fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
723 if let Some(closed) = &self.closed {
724 return Err(closed.clone());
725 }
726 let next = self.inbox.pop_front();
727 if let Some(text) = &next {
728 if let Some(reason) = serde_json::from_str::<Value>(text)
729 .ok()
730 .and_then(|value| value.get("close").and_then(|v| v.as_str()).map(str::to_string))
731 {
732 self.closed = Some(reason.clone());
733 return Err(reason);
734 }
735 }
736 Ok(next)
737 }
738 }
739
740 fn fake(inbox: Vec<Value>, reply: Reply) -> (Cdp, Rc<RefCell<Vec<Value>>>) {
741 let log = Rc::new(RefCell::new(Vec::new()));
742 let transport = Fake {
743 log: log.clone(),
744 inbox: inbox.into_iter().map(|value| value.to_string()).collect(),
745 reply,
746 closed: None,
747 };
748 let mut cdp = Cdp::from_transport(Box::new(transport));
749 cdp.fixture_session("S");
750 cdp.fixture_session("OTHER");
751 (cdp, log)
752 }
753
754 fn ok(message: &Value) -> Value {
755 json!({ "id": message["id"], "result": {} })
756 }
757
758 fn methods(log: &Rc<RefCell<Vec<Value>>>) -> Vec<String> {
759 log.borrow()
760 .iter()
761 .map(|item| item["method"].as_str().unwrap_or("").to_string())
762 .collect()
763 }
764
765 fn paused_event(session: &str, id: &str, url: &str) -> Value {
766 json!({
767 "method": "Fetch.requestPaused",
768 "sessionId": session,
769 "params": {
770 "requestId": id,
771 "frameId": "F1",
772 "resourceType": "Document",
773 "networkId": format!("N{id}"),
774 "request": {
775 "url": url,
776 "method": "GET",
777 "headers": { "Accept": "text/html", "Cookie": "a=b" }
778 }
779 }
780 })
781 }
782
783 #[test]
784 fn unparseable_overrides_and_redirects_use_the_catalog_refusal() {
785 let guard = Protected::default();
786 let url = "http://exa%20mple.sliccy.ai/";
787 let error = format!("{}\n", slicc_browsers::catalog().protected_origins.unparseable_refusal);
788 let mut paused = Paused::from_event(&paused_event("S", "R", "https://example.test/base")).unwrap();
789 let decision = Decision::Continue(Overrides { url: Some(url.into()), ..Overrides::default() });
790 assert_eq!(command(&guard, &paused, &decision).unwrap_err(), error);
791 paused.status = Some(200);
792 for (name, value) in [("Location", url.to_string()), ("Refresh", format!("0; url={url}"))] {
793 let headers = vec![(name.into(), value)];
794 let decision = Decision::ContinueResponse(ResponseOverrides { headers: Some(headers.clone()), ..ResponseOverrides::default() });
795 assert_eq!(command(&guard, &paused, &decision).unwrap_err(), error);
796 let decision = Decision::Fulfill(Fulfill { status: 302, status_text: None, headers, body: Vec::new() });
797 assert_eq!(command(&guard, &paused, &decision).unwrap_err(), error);
798 }
799 for location in ["../ordinary", "/ordinary", "https://example.test/ordinary"] {
800 let decision = Decision::Fulfill(Fulfill::new(302).header("Location", location));
801 assert!(command(&guard, &paused, &decision).is_ok(), "{location}");
802 }
803 }
804
805 #[test]
806 fn a_paused_request_and_response_are_read() {
807 let mut event = paused_event("S", "R1", "https://idp.example/cb?code=abc");
808 event["params"]["request"]["urlFragment"] = json!("#access_token=xyz");
809 event["params"]["request"]["method"] = json!("POST");
810 event["params"]["request"]["postDataEntries"] =
811 json!([{ "bytes": base64_encode(b"a=1&") }, { "bytes": base64_encode(b"b=2") }]);
812 let paused = Paused::from_event(&event).unwrap();
813 assert_eq!(paused.session_id, "S");
814 assert_eq!(paused.request_id, "R1");
815 assert_eq!(paused.method, "POST");
816 assert_eq!(
817 paused.full_url(),
818 "https://idp.example/cb?code=abc#access_token=xyz"
819 );
820 assert_eq!(paused.header("cookie"), Some("a=b"));
821 assert_eq!(paused.post_data.as_deref(), Some(&b"a=1&b=2"[..]));
822 assert!(!paused.is_response());
823 assert_eq!(paused.location(), None);
824 let mut response = paused_event("S", "R2", "https://idp.example/authorize");
825 response["params"]["responseStatusCode"] = json!(302);
826 response["params"]["responseStatusText"] = json!("Found");
827 response["params"]["responseHeaders"] =
828 json!([{ "name": "Location", "value": "/done#id_token=t&state=s" }]);
829 let paused = Paused::from_event(&response).unwrap();
830 assert!(paused.is_response());
831 assert_eq!(paused.status, Some(302));
832 assert_eq!(
833 paused.response_header("location"),
834 Some("/done#id_token=t&state=s")
835 );
836 assert_eq!(
837 paused.location().as_deref(),
838 Some("https://idp.example/done#id_token=t&state=s")
839 );
840 assert!(Paused::from_event(&json!({ "method": "Page.loadEventFired" })).is_none());
841 }
842
843 #[test]
844 fn a_decider_answers_a_pause_that_arrives_inside_a_call() {
845 let navigate = Rc::new(RefCell::new(None));
846 let held = navigate.clone();
847 let (mut cdp, log) = fake(
848 Vec::new(),
849 Box::new(move |message| match message["method"].as_str().unwrap() {
850 "Page.navigate" => {
851 *held.borrow_mut() = Some(message["id"].clone());
852 vec![paused_event("S", "R1", "https://app.example/api/users")]
853 }
854 "Fetch.fulfillRequest" => vec![
855 ok(message),
856 json!({ "id": held.borrow().clone().unwrap(), "result": { "frameId": "F1", "loaderId": "L" } }),
857 ],
858 _ => vec![ok(message)],
859 }),
860 );
861 cdp.set_decider(
862 "S",
863 Some(Box::new(|paused: &Paused| {
864 assert_eq!(paused.url, "https://app.example/api/users");
865 Decision::Fulfill(
866 Fulfill::new(201)
867 .header("Content-Type", "application/json")
868 .body("[]"),
869 )
870 })),
871 );
872 let result = cdp
873 .call(
874 "Page.navigate",
875 json!({ "url": "https://app.example/" }),
876 Some("S"),
877 )
878 .unwrap();
879 assert_eq!(result["loaderId"], "L");
880 let sent = log.borrow();
881 let fulfil = sent
882 .iter()
883 .find(|item| item["method"] == "Fetch.fulfillRequest")
884 .unwrap();
885 assert_eq!(fulfil["sessionId"], "S");
886 assert_eq!(fulfil["params"]["requestId"], "R1");
887 assert_eq!(fulfil["params"]["responseCode"], 201);
888 assert_eq!(fulfil["params"]["body"], base64_encode(b"[]"));
889 assert_eq!(
890 fulfil["params"]["responseHeaders"],
891 json!([{ "name": "Content-Type", "value": "application/json" }])
892 );
893 assert!(cdp.events().is_empty());
894 assert!(cdp.take_fetch_errors().is_empty());
895 }
896
897 #[test]
898 fn a_failed_decision_falls_back_to_continue() {
899 let (mut cdp, log) = fake(
900 vec![paused_event("S", "R1", "https://app.example/x")],
901 Box::new(|message| match message["method"].as_str().unwrap() {
902 "Fetch.fulfillRequest" => {
903 vec![json!({ "id": message["id"], "error": { "message": "Invalid header" } })]
904 }
905 _ => vec![ok(message)],
906 }),
907 );
908 cdp.set_decider(
909 "S",
910 Some(Box::new(|_: &Paused| {
911 Decision::Fulfill(Fulfill::new(200).header("Bad\n", "x"))
912 })),
913 );
914 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
915 assert!(cdp
916 .pump(Instant::now() + Duration::from_millis(10))
917 .unwrap());
918 assert_eq!(
919 methods(&log),
920 [
921 "Runtime.evaluate",
922 "Fetch.fulfillRequest",
923 "Fetch.continueRequest"
924 ]
925 );
926 assert_eq!(cdp.take_fetch_errors(), ["Fetch: Invalid header\n"]);
927 }
928
929 #[test]
930 fn seven_and_vault_requests_are_never_handed_out() {
931 let mut location = paused_event("S", "R3", "https://idp.example/authorize");
932 location["params"]["responseStatusCode"] = json!(302);
933 location["params"]["responseHeaders"] =
934 json!([{ "name": "location", "value": "https://vault.sliccy.ai/cb#token=t" }]);
935 let mut xhr = paused_event("S", "R2", "blob:https://vault.sliccy.ai/0b9e");
936 xhr["params"]["resourceType"] = json!("XHR");
937 let (mut cdp, log) = fake(
938 vec![
939 paused_event("S", "R1", "https://seven.sliccy.ai/api"),
940 xhr,
941 location,
942 paused_event("S", "R4", "https://app.example/ok"),
943 ],
944 Box::new(|message| vec![ok(message)]),
945 );
946 let got = cdp
947 .next_paused(Some("S"), Duration::from_millis(50))
948 .unwrap()
949 .unwrap();
950 assert_eq!(got.request_id, "R4");
951 assert!(cdp
952 .next_paused(Some("S"), Duration::from_millis(10))
953 .unwrap()
954 .is_none());
955 let sent = log.borrow();
956 let continued: Vec<&str> = sent
957 .iter()
958 .filter(|item| item["method"] == "Fetch.continueRequest")
959 .map(|item| item["params"]["requestId"].as_str().unwrap())
960 .collect();
961 assert_eq!(continued, ["R2"]);
962 let failed: Vec<(&str, &str)> = sent
963 .iter()
964 .filter(|item| item["method"] == "Fetch.failRequest")
965 .map(|item| {
966 (
967 item["params"]["requestId"].as_str().unwrap(),
968 item["params"]["errorReason"].as_str().unwrap(),
969 )
970 })
971 .collect();
972 assert_eq!(failed, [("R1", "BlockedByClient"), ("R3", "BlockedByClient")]);
973 assert!(sent.iter().all(|item| item["params"].get("url").is_none()));
974 assert_eq!(cdp.events().len(), 1);
975 assert_eq!(cdp.events()[0]["params"]["responseHeaders"][0]["value"], "https://vault.sliccy.ai");
976 }
977
978 #[test]
979 fn a_decider_never_sees_a_protected_request() {
980 let (mut cdp, log) = fake(
981 vec![paused_event(
982 "S",
983 "R1",
984 "https://www.sliccy.ai/auth/callback?code=c",
985 )],
986 Box::new(|message| vec![ok(message)]),
987 );
988 cdp.set_decider(
989 "S",
990 Some(Box::new(|_: &Paused| {
991 panic!("a protected request reached the decider")
992 })),
993 );
994 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
995 assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.failRequest"]);
996 }
997
998 fn auth_event(session: &str, id: &str, url: &str) -> Value {
999 json!({
1000 "method": "Fetch.authRequired",
1001 "sessionId": session,
1002 "params": {
1003 "requestId": id,
1004 "frameId": "F1",
1005 "resourceType": "Fetch",
1006 "request": { "url": url, "method": "GET", "headers": {} },
1007 "authChallenge": { "source": "Server", "origin": url, "scheme": "basic", "realm": "r" }
1008 }
1009 })
1010 }
1011
1012 fn refused(result: Result<Value, String>) -> bool {
1013 result.is_err_and(|message| message == crate::cdp::UNSEEN)
1014 }
1015
1016 #[test]
1017 fn an_issued_id_is_good_on_its_session_until_it_is_answered() {
1018 let mut xhr = paused_event("S", "R1", "https://app.example/api");
1019 xhr["params"]["resourceType"] = json!("XHR");
1020 let mut response = xhr.clone();
1021 response["params"]["responseStatusCode"] = json!(200);
1022 let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
1023 let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
1024 assert_eq!(got.request_id, "R1");
1025 assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R2", "responseCode": 200 }), Some("S"))));
1026 assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("T"))));
1027 assert!(refused(cdp.call("Fetch.continueRequest", json!({}), Some("S"))));
1028 cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).unwrap();
1029 cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).unwrap();
1030 for (method, params) in [
1031 ("Fetch.failRequest", json!({ "requestId": "R1", "errorReason": "Failed" })),
1032 ("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 })),
1033 ("Fetch.continueResponse", json!({ "requestId": "R1" })),
1034 ("Fetch.continueWithAuth", json!({ "requestId": "R1", "authChallengeResponse": { "response": "Default" } })),
1035 ] {
1036 assert!(refused(cdp.call(method, params, Some("S"))), "{method}");
1037 }
1038 assert!(refused(cdp.notify("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).map(|_| Value::Null)));
1039 assert_eq!(methods(&log), ["Fetch.getResponseBody", "Fetch.continueRequest"]);
1040 let (mut cdp, log) = fake(vec![response], Box::new(|message| vec![ok(message)]));
1041 let again = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
1042 assert!(again.is_response());
1043 cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S")).unwrap();
1044 assert!(refused(cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S"))));
1045 assert_eq!(methods(&log), ["Fetch.continueResponse"]);
1046 }
1047
1048 #[test]
1049 fn an_answer_chrome_rejects_leaves_the_id_issued() {
1050 let reject = |message: &Value| {
1051 if message["params"]["responseCode"] == 0 {
1052 vec![json!({ "id": message["id"], "error": { "message": "Invalid http status code" } })]
1053 } else {
1054 vec![ok(message)]
1055 }
1056 };
1057 let (mut cdp, log) = fake(
1058 vec![
1059 paused_event("S", "R1", "https://app.example/a"),
1060 paused_event("S", "R2", "https://app.example/b"),
1061 ],
1062 Box::new(reject),
1063 );
1064 while cdp.next_paused(Some("S"), Duration::from_millis(10)).unwrap().is_some() {}
1065 let bad = json!({ "requestId": "R1", "responseCode": 0 });
1066 let good = json!({ "requestId": "R1", "responseCode": 200 });
1067 assert!(cdp.call("Fetch.fulfillRequest", bad.clone(), Some("S")).unwrap_err().contains("Invalid http status"));
1068 cdp.call("Fetch.fulfillRequest", good.clone(), Some("S")).unwrap();
1069 assert!(refused(cdp.call("Fetch.fulfillRequest", good, Some("S"))));
1070 let bad = json!({ "requestId": "R2", "responseCode": 0 });
1071 cdp.notify("Fetch.fulfillRequest", bad, Some("S")).unwrap();
1072 cdp.call("Runtime.evaluate", json!({"expression":"0","returnByValue":true}), Some("S")).unwrap();
1073 cdp.notify("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S")).unwrap();
1074 cdp.call("Runtime.evaluate", json!({"expression":"0","returnByValue":true}), Some("S")).unwrap();
1075 assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S"))));
1076 assert_eq!(
1077 methods(&log),
1078 [
1079 "Fetch.fulfillRequest",
1080 "Fetch.fulfillRequest",
1081 "Fetch.fulfillRequest",
1082 "Runtime.evaluate",
1083 "Fetch.continueRequest",
1084 "Runtime.evaluate"
1085 ]
1086 );
1087 }
1088
1089 #[test]
1090 fn an_id_a_decider_answered_is_not_issued_again() {
1091 let (mut cdp, log) = fake(
1092 vec![paused_event("S", "R1", "https://app.example/x")],
1093 Box::new(|message| vec![ok(message)]),
1094 );
1095 cdp.set_decider("S", Some(Box::new(|_: &Paused| Decision::default())));
1096 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1097 assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 }), Some("S"))));
1098 assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.continueRequest"]);
1099 }
1100
1101 #[test]
1102 fn a_protected_id_is_never_issued_even_when_guessed() {
1103 let mut xhr = paused_event("S", "R1", "https://seven.sliccy.ai/data");
1104 xhr["params"]["resourceType"] = json!("XHR");
1105 let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
1106 assert!(cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().is_none());
1107 for method in [
1108 "Fetch.fulfillRequest",
1109 "Fetch.failRequest",
1110 "Fetch.continueRequest",
1111 "Fetch.continueWithAuth",
1112 "Fetch.continueResponse",
1113 "Fetch.getResponseBody",
1114 "Fetch.takeResponseBodyAsStream",
1115 "Network.continueInterceptedRequest",
1116 "Network.getResponseBodyForInterception",
1117 ] {
1118 assert!(refused(cdp.call(method, json!({ "requestId": "R1", "interceptionId": "R1" }), Some("S"))), "{method}");
1119 }
1120 assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1121 let old = cdp
1122 .call("Network.setRequestInterception", json!({ "patterns": [{ "urlPattern": "*" }] }), Some("S"))
1123 .unwrap_err();
1124 assert!(old.starts_with("slicc: refused"), "{old}");
1125 assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1126 }
1127
1128 #[test]
1129 fn a_protected_auth_challenge_gets_the_default_and_its_id_is_refused() {
1130 let (mut cdp, log) = fake(
1131 vec![
1132 auth_event("S", "A1", "https://vault.sliccy.ai/auth"),
1133 auth_event("S", "A2", "https://app.example/auth"),
1134 ],
1135 Box::new(|message| vec![ok(message)]),
1136 );
1137 while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1138 let sent = log.borrow().clone();
1139 assert_eq!(sent.len(), 1);
1140 assert_eq!(sent[0]["method"], "Fetch.continueWithAuth");
1141 assert_eq!(sent[0]["params"]["requestId"], "A1");
1142 assert_eq!(sent[0]["params"]["authChallengeResponse"], json!({ "response": "Default" }));
1143 let delivered: Vec<&Value> = cdp.events().iter().collect();
1144 assert_eq!(delivered.len(), 1);
1145 assert_eq!(delivered[0]["params"]["requestId"], "A2");
1146 let answer = json!({ "response": "ProvideCredentials", "username": "u", "password": "p" });
1147 assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A1", "authChallengeResponse": answer }), Some("S"))));
1148 cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S")).unwrap();
1149 assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S"))));
1150 assert_eq!(methods(&log), ["Fetch.continueWithAuth", "Fetch.enable", "Fetch.continueWithAuth"]);
1151 }
1152
1153 fn panic_decider() -> Option<crate::cdp::Decider> {
1154 Some(Box::new(|paused: &Paused| {
1155 panic!("the agent saw {} paused", paused.url)
1156 }))
1157 }
1158
1159 #[test]
1160 fn a_tab_that_commits_seven_stops_intercepting_and_hands_out_nothing() {
1161 let mut leak = paused_event("S", "R1", "https://api.model.example/v1/chat");
1162 leak["params"]["resourceType"] = json!("Fetch");
1163 let (mut cdp, log) = fake(
1164 vec![
1165 json!({
1166 "method": "Page.frameNavigated",
1167 "sessionId": "S",
1168 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
1169 }),
1170 leak,
1171 json!({
1172 "method": "Network.requestWillBeSent",
1173 "sessionId": "S",
1174 "params": { "requestId": "NR1", "documentURL": "https://seven.sliccy.ai/os/", "request": { "url": "https://api.model.example/v1/chat", "headers": { "Authorization": "Bearer KEY" } } }
1175 }),
1176 ],
1177 Box::new(|message| vec![ok(message)]),
1178 );
1179 cdp.set_decider("S", panic_decider());
1180 while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1181 assert!(cdp.session_blocked("S"));
1182 assert!(!cdp.has_decider("S"));
1183 assert_eq!(
1184 methods(&log),
1185 ["Fetch.disable", "Target.detachFromTarget", "Fetch.continueRequest"]
1186 );
1187 assert!(log.borrow()[2]["params"].get("headers").is_none());
1188 assert!(cdp.events().is_empty(), "{:?}", cdp.events());
1189 assert!(cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap_err().starts_with("slicc: refused"));
1190 }
1191
1192 #[test]
1193 fn a_worker_attaches_without_a_page_guard_and_a_page_without_page_domains_still_does() {
1194 let (mut cdp, log) = fake(
1195 Vec::new(),
1196 Box::new(|message| match message["method"].as_str().unwrap() {
1197 "Target.getTargetInfo" => {
1198 let kind = if message["params"]["targetId"] == "W" { "service_worker" } else { "page" };
1199 vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": message["params"]["targetId"], "type": kind, "url": "https://app.example/sw.js" } } })]
1200 }
1201 "Target.attachToTarget" => {
1202 vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })]
1203 }
1204 "Page.enable" | "Runtime.enable" => vec![json!({ "id": message["id"], "error": { "message": "'Page.enable' wasn't found" } })],
1205 _ => vec![ok(message)],
1206 }),
1207 );
1208 crate::cdp::attach(&mut cdp, "W").unwrap();
1209 assert_eq!(methods(&log), ["Target.getTargetInfo", "Target.attachToTarget"]);
1210 crate::cdp::attach(&mut cdp, "P").unwrap();
1211 assert!(methods(&log).contains(&"Network.setBypassServiceWorker".to_string()));
1212 }
1213
1214 #[test]
1215 fn a_takeover_close_frame_wins_over_the_guard_refusal() {
1216 let close = "websocket closed 1011 another page took over slicc-node's /cdp (superseded-by-new-cdp-client)\n";
1217 let (mut cdp, _log) = fake(
1218 Vec::new(),
1219 Box::new(move |message| match message["method"].as_str().unwrap() {
1220 "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1221 "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": "S" } })],
1222 "Target.setDiscoverTargets" => vec![json!({ "close": close })],
1223 _ => vec![ok(message)],
1224 }),
1225 );
1226 assert_eq!(crate::cdp::attach(&mut cdp, "T").unwrap_err(), close);
1227 assert!(cdp.call("Runtime.evaluate", json!({ "expression": "1" }), Some("S")).unwrap_err().starts_with("slicc: refused:"));
1228 let (mut live, _log) = fake(
1229 Vec::new(),
1230 Box::new(|message| match message["method"].as_str().unwrap() {
1231 "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1232 "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })],
1233 "Fetch.enable" => vec![json!({ "id": message["id"], "error": { "message": "Fetch is not available" } })],
1234 _ => vec![ok(message)],
1235 }),
1236 );
1237 assert_eq!(crate::cdp::attach(&mut live, "T").unwrap_err(), crate::cdp::UNGUARDED);
1238 }
1239
1240 #[test]
1241 fn a_target_that_landed_on_the_vault_is_reported_protected() {
1242 let (mut cdp, _log) = fake(
1243 Vec::new(),
1244 Box::new(|message| vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://vault.sliccy.ai/landing" } } })]),
1245 );
1246 assert!(!cdp.is_protected_target("T"));
1247 assert!(cdp.target_protected("T", false).unwrap());
1248 assert!(cdp.is_protected_target("T"));
1249 assert!(cdp.call("Target.attachToTarget", json!({ "targetId": "T", "flatten": true }), None).is_err());
1250 }
1251
1252 #[test]
1253 fn requests_from_a_protected_frame_or_referrer_are_continued_unseen() {
1254 let mut framed = paused_event("S", "R1", "https://api.example/a");
1255 framed["params"]["frameId"] = json!("CHILD");
1256 framed["params"]["resourceType"] = json!("XHR");
1257 let mut referred = paused_event("S", "R2", "https://api.example/b");
1258 referred["params"]["frameId"] = json!("OTHER");
1259 referred["params"]["resourceType"] = json!("XHR");
1260 referred["params"]["request"]["headers"] = json!({ "Referer": "https://vault.sliccy.ai/unlock" });
1261 let mut plain = paused_event("S", "R3", "https://api.example/c");
1262 plain["params"]["frameId"] = json!("OTHER");
1263 plain["params"]["resourceType"] = json!("XHR");
1264 let (mut cdp, log) = fake(
1265 vec![
1266 json!({
1267 "method": "Page.frameNavigated",
1268 "sessionId": "S",
1269 "params": { "frame": { "id": "CHILD", "parentId": "MAIN", "url": "https://vault.sliccy.ai/frame" } }
1270 }),
1271 framed,
1272 referred,
1273 plain,
1274 ],
1275 Box::new(|message| vec![ok(message)]),
1276 );
1277 let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
1278 assert_eq!(got.request_id, "R3");
1279 let continued: Vec<String> = log
1280 .borrow()
1281 .iter()
1282 .filter(|item| item["method"] == "Fetch.continueRequest")
1283 .map(|item| item["params"]["requestId"].as_str().unwrap().to_string())
1284 .collect();
1285 assert_eq!(continued, ["R1", "R2"]);
1286 assert!(cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).is_err());
1287 }
1288
1289 #[test]
1290 fn enable_refuses_protected_patterns_and_browser_wide_interception() {
1291 let (mut cdp, log) = fake(Vec::new(), Box::new(|message| vec![ok(message)]));
1292 let refused = enable(
1293 &mut cdp,
1294 "S",
1295 &[Pattern::request("https://vault.sliccy.ai/*")],
1296 )
1297 .unwrap_err();
1298 assert!(
1299 refused.starts_with("slicc: refused: target is on seven's origin"),
1300 "{refused}"
1301 );
1302 let refused = enable(&mut cdp, "S", &[Pattern::response("*://*.sliccy.ai/*")]).unwrap_err();
1303 assert!(refused.starts_with("slicc: refused"), "{refused}");
1304 assert!(enable(&mut cdp, "", &[Pattern::request("*")])
1305 .unwrap_err()
1306 .contains("browser-wide"));
1307 assert!(enable(&mut cdp, "S", &[]).is_err());
1308 assert!(log.borrow().is_empty());
1309 enable(
1310 &mut cdp,
1311 "S",
1312 &[
1313 Pattern::request("*"),
1314 Pattern::response("https://idp.example/*").resource_type("Document"),
1315 ],
1316 )
1317 .unwrap();
1318 let sent = log.borrow();
1319 assert_eq!(sent[0]["method"], "Fetch.enable");
1320 assert_eq!(sent[0]["sessionId"], "S");
1321 assert_eq!(
1322 sent[0]["params"]["patterns"],
1323 json!([
1324 { "urlPattern": "*", "requestStage": "Request" },
1325 { "urlPattern": "https://idp.example/*", "requestStage": "Response", "resourceType": "Document" }
1326 ])
1327 );
1328 }
1329
1330 #[test]
1331 fn decisions_cannot_send_a_tab_to_seven() {
1332 let guard = Protected::default();
1333 let paused = Paused::from_event(&paused_event("S", "R1", "https://app.example/")).unwrap();
1334 let fulfil =
1335 Decision::Fulfill(Fulfill::new(302).header("Location", "https://seven.sliccy.ai/"));
1336 assert!(command(&guard, &paused, &fulfil)
1337 .unwrap_err()
1338 .starts_with("slicc: refused"));
1339 let refresh = Decision::Fulfill(
1340 Fulfill::html(200, "x").header("Refresh", "0; url=https://vault.sliccy.ai/"),
1341 );
1342 assert!(command(&guard, &paused, &refresh).is_err());
1343 let rewrite = Decision::Continue(Overrides {
1344 url: Some("https://vault.sliccy.ai/".into()),
1345 ..Overrides::default()
1346 });
1347 assert!(command(&guard, &paused, &rewrite).is_err());
1348 let fine = Decision::Fulfill(Fulfill::new(302).header("Location", "/next"));
1349 assert_eq!(
1350 command(&guard, &paused, &fine).unwrap().0,
1351 "Fetch.fulfillRequest"
1352 );
1353 let (method, params) = command(
1354 &guard,
1355 &paused,
1356 &Decision::Fail(ErrorReason::BlockedByClient),
1357 )
1358 .unwrap();
1359 assert_eq!(method, "Fetch.failRequest");
1360 assert_eq!(params["errorReason"], "BlockedByClient");
1361 let (method, params) = command(
1362 &guard,
1363 &paused,
1364 &Decision::Continue(Overrides {
1365 method: Some("POST".into()),
1366 post_data: Some(b"x=1".to_vec()),
1367 headers: Some(vec![("X-A".into(), "1".into())]),
1368 ..Overrides::default()
1369 }),
1370 )
1371 .unwrap();
1372 assert_eq!(method, "Fetch.continueRequest");
1373 assert_eq!(params["postData"], base64_encode(b"x=1"));
1374 assert_eq!(params["headers"], json!([{ "name": "X-A", "value": "1" }]));
1375 assert!(command(
1376 &guard,
1377 &paused,
1378 &Decision::ContinueResponse(ResponseOverrides::default())
1379 )
1380 .is_err());
1381 assert_eq!(ErrorReason::parse("timedout"), Some(ErrorReason::TimedOut));
1382 assert_eq!(ErrorReason::parse("nope"), None);
1383 }
1384
1385 #[test]
1386 fn signed_in_page_is_small_html() {
1387 let page = Fulfill::signed_in();
1388 assert_eq!(page.status, 200);
1389 assert!(page
1390 .headers
1391 .iter()
1392 .any(|(name, value)| name == "Content-Type" && value.starts_with("text/html")));
1393 assert!(String::from_utf8(page.body)
1394 .unwrap()
1395 .contains("You can close this tab"));
1396 }
1397
1398 #[test]
1399 fn an_interception_reads_a_response_and_cleans_up_on_drop() {
1400 let mut response = paused_event("S1", "R1", "https://idp.example/authorize");
1401 response["params"]["responseStatusCode"] = json!(200);
1402 let queued = Rc::new(RefCell::new(Some(response)));
1403 let attaches = Rc::new(RefCell::new(0));
1404 let (mut cdp, log) = fake(
1405 Vec::new(),
1406 Box::new(move |message| match message["method"].as_str().unwrap() {
1407 "Target.getTargetInfo" => vec![
1408 json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T1", "type":"page", "url": "about:blank" } } }),
1409 ],
1410 "Target.attachToTarget" => {
1411 *attaches.borrow_mut() += 1;
1412 let session = if *attaches.borrow() == 1 { "S1" } else { "G1" };
1413 vec![json!({ "id": message["id"], "result": { "sessionId": session } })]
1414 }
1415 "Fetch.enable" if message["sessionId"] == "S1" => {
1416 vec![ok(message), queued.borrow_mut().take().unwrap()]
1417 }
1418 "Page.getFrameTree" => vec![json!({"id":message["id"],"result":{"frameTree":{"frame":{"id":"F1","loaderId":"L1","url":"about:blank","securityOrigin":"https://idp.example"}}}})],
1419 "Fetch.getResponseBody" => vec![
1420 json!({ "id": message["id"], "result": { "body": base64_encode(b"<p>hi</p>"), "base64Encoded": true } }),
1421 ],
1422 _ => vec![ok(message)],
1423 }),
1424 );
1425 {
1426 let mut interception = Interception::attach(
1427 &mut cdp,
1428 "T1",
1429 &[Pattern::response("https://idp.example/*")],
1430 )
1431 .unwrap();
1432 assert_eq!(interception.session_id(), "S1");
1433 let paused = interception
1434 .next(Duration::from_millis(50))
1435 .unwrap()
1436 .unwrap();
1437 assert_eq!(interception.response_body(&paused).unwrap(), b"<p>hi</p>");
1438 interception
1439 .decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1440 .unwrap();
1441 }
1442 assert_eq!(
1443 methods(&log),
1444 [
1445 "Target.getTargetInfo",
1446 "Target.getTargetInfo",
1447 "Target.attachToTarget",
1448 "Target.setDiscoverTargets",
1449 "Target.attachToTarget",
1450 "Inspector.enable",
1451 "Fetch.enable",
1452 "Network.enable",
1453 "Network.setBypassServiceWorker",
1454 "Page.enable",
1455 "Page.getFrameTree",
1456 "Page.getFrameTree",
1457 "Runtime.enable",
1458 "Fetch.enable",
1459 "Fetch.getResponseBody",
1460 "Fetch.fulfillRequest",
1461 "Fetch.disable",
1462 "Target.detachFromTarget"
1463 ]
1464 );
1465 }
1466
1467 #[test]
1468 fn an_interception_refuses_a_seven_tab_or_its_blank_popup() {
1469 let (mut cdp, log) = fake(
1470 Vec::new(),
1471 Box::new(|message| {
1472 let url = match message["params"]["targetId"].as_str() {
1473 Some("SEVEN") => "https://lars.sliccy.ai/",
1474 Some("POPUP") => "about:blank",
1475 _ => "",
1476 };
1477 let opener = if message["params"]["targetId"] == "POPUP" {
1478 json!("SEVEN")
1479 } else {
1480 Value::Null
1481 };
1482 vec![
1483 json!({ "id": message["id"], "result": { "targetInfo": { "url": url, "openerId": opener } } }),
1484 ]
1485 }),
1486 );
1487 for target in ["SEVEN", "POPUP"] {
1488 let refused = Interception::attach(&mut cdp, target, &[Pattern::request("*")])
1489 .err()
1490 .unwrap();
1491 assert!(refused.starts_with("slicc: refused"), "{target}: {refused}");
1492 }
1493 assert!(methods(&log)
1494 .iter()
1495 .all(|method| method == "Target.getTargetInfo"));
1496 }
1497
1498 #[test]
1499 fn navigation_keeps_the_fragment_and_hides_seven() {
1500 let guard = Protected::default();
1501 let event = json!({
1502 "method": "Page.frameNavigated",
1503 "sessionId": "S",
1504 "params": { "frame": { "id": "F", "url": "https://app.example/cb", "urlFragment": "#access_token=t" } }
1505 });
1506 let found = navigation(&guard, &event).unwrap();
1507 assert_eq!(found.url, "https://app.example/cb#access_token=t");
1508 assert!(found.main_frame);
1509 let seven = json!({
1510 "method": "Page.frameNavigated",
1511 "sessionId": "S",
1512 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/auth/callback", "urlFragment": "#t" } }
1513 });
1514 assert!(navigation(&guard, &seven).is_none());
1515 }
1516
1517 fn live(name: &str) -> String {
1518 std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"))
1519 }
1520
1521 fn encoded(text: &str) -> String {
1522 url::form_urlencoded::byte_serialize(text.as_bytes()).collect()
1523 }
1524
1525 fn evaluate(cdp: &mut Cdp, session: &str, expression: &str) -> Value {
1526 cdp.call(
1527 "Runtime.evaluate",
1528 json!({ "expression": expression, "returnByValue": true }),
1529 Some(session),
1530 )
1531 .unwrap_or_else(|error| {
1532 let admission = cdp.runtime_diagnostics().to_vec();
1533 let tree = cdp.call("Page.getFrameTree", json!({}), Some(session));
1534 let history = cdp.call("Page.getNavigationHistory", json!({}), Some(session));
1535 let contexts: Vec<Value> = cdp.events().iter().filter(|event| event["method"] == "Runtime.executionContextCreated" && event["sessionId"] == session).map(|event| event["params"]["context"].clone()).collect();
1536 panic!("Runtime.evaluate {expression} failed: {error}; admission={admission:?}; renderer={tree:?}; history={history:?}; contexts={contexts:?}")
1537 })
1538 .pointer("/result/value")
1539 .cloned()
1540 .unwrap_or(Value::Null)
1541 }
1542
1543 fn poll(cdp: &mut Cdp, session: &str, expression: &str, want: &str) {
1544 let deadline = Instant::now() + Duration::from_secs(10);
1545 let mut last = Value::Null;
1546 while Instant::now() < deadline {
1547 last = evaluate(cdp, session, expression);
1548 if last == json!(want) {
1549 return;
1550 }
1551 let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1552 }
1553 panic!("{expression} stayed {last}, wanted {want}");
1554 }
1555
1556 fn committed(cdp: &mut Cdp, session: &str, url: &str) {
1557 let deadline = Instant::now() + Duration::from_secs(10);
1558 let mut last = String::new();
1559 while Instant::now() < deadline {
1560 last = match cdp.call("Page.getNavigationHistory", json!({}), Some(session)) {
1561 Ok(history) => {
1562 let index = history["currentIndex"].as_u64().unwrap_or(0) as usize;
1563 let current = history["entries"][index]["url"].as_str().unwrap_or("");
1564 if current == url {
1565 return;
1566 }
1567 current.to_string()
1568 }
1569 Err(error) => error.trim().to_string(),
1570 };
1571 let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1572 }
1573 panic!("the browser never committed {url}: {last}");
1574 }
1575
1576 fn new_tab(cdp: &mut Cdp, url: &str) -> String {
1577 cdp.call(
1578 "Target.createTarget",
1579 json!({ "url": url, "background": true }),
1580 None,
1581 )
1582 .unwrap()["targetId"]
1583 .as_str()
1584 .unwrap()
1585 .to_string()
1586 }
1587
1588 fn unpaused(cdp: &mut Cdp, session: &str, app: &str, api: &str) -> String {
1589 let page = cdp
1590 .call_for(
1591 "Runtime.evaluate",
1592 json!({ "expression": "JSON.stringify({ href: location.href, readyState: document.readyState, got: window.__got ?? null })", "returnByValue": true }),
1593 Some(session),
1594 Duration::from_secs(5),
1595 )
1596 .map(|value| value.pointer("/result/value").cloned().unwrap_or(Value::Null).to_string())
1597 .unwrap_or_else(|error| format!("evaluate failed: {}", error.trim()));
1598 let path = url::form_urlencoded::byte_serialize(b"/api/headers").collect::<String>();
1599 let hits = crate::net::http_probe(&format!("{app}/hits?path={path}"), "text/plain")
1600 .map(|response| String::from_utf8_lossy(&response.body).into_owned())
1601 .unwrap_or_else(|error| format!("unknown ({})", error.trim()));
1602 while cdp
1603 .pump(std::time::Instant::now() + Duration::from_millis(200))
1604 .unwrap_or(false)
1605 {}
1606 let trail: Vec<String> = cdp
1607 .paused_trail
1608 .iter()
1609 .filter(|(_, url, _)| url.starts_with(api))
1610 .map(|(id, url, reason)| {
1611 let whose = if id == session { "this session" } else { "another session" };
1612 format!("{url} on {whose}: {reason}")
1613 })
1614 .collect();
1615 format!(
1616 "page {page}; fixture /api/headers hits {hits}; Fetch.requestPaused for it: {}",
1617 if trail.is_empty() { "none".to_string() } else { trail.join(", ") }
1618 )
1619 }
1620
1621 fn intercepting(seen: &mut Interception<'_>, session: &str, api: &str, app: &str) {
1622 let mut active = false;
1623 for probe in 1..=10 {
1624 let url = format!("{api}?probe={probe}");
1625 start_script(seen, session, &format!("fetch({}).catch(() => null)", json!(url)));
1626 let until = Instant::now() + Duration::from_secs(2);
1627 while let Some(paused) = seen
1628 .next(until.saturating_duration_since(Instant::now()))
1629 .unwrap()
1630 {
1631 assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1632 if paused.is_response() {
1633 seen.decide(&paused, &Decision::ContinueResponse(ResponseOverrides::default()))
1634 .unwrap();
1635 if active {
1636 break;
1637 }
1638 } else {
1639 active = true;
1640 seen.decide(&paused, &Decision::Continue(Overrides::default()))
1641 .unwrap();
1642 }
1643 }
1644 if active {
1645 while let Some(paused) = seen.next(Duration::from_millis(300)).unwrap() {
1646 assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1647 let decision = if paused.is_response() {
1648 Decision::ContinueResponse(ResponseOverrides::default())
1649 } else {
1650 Decision::Continue(Overrides::default())
1651 };
1652 seen.decide(&paused, &decision).unwrap();
1653 }
1654 return;
1655 }
1656 }
1657 panic!("interception never became active: {}", unpaused(seen, session, app, api));
1658 }
1659
1660 fn start_script(cdp: &mut Cdp, session: &str, expression: &str) {
1661 let _ = cdp.notify(
1662 "Runtime.evaluate",
1663 json!({ "expression": expression, "returnByValue": true }),
1664 Some(session),
1665 );
1666 }
1667
1668 #[test]
1669 #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
1670 fn live_chromium_interception() {
1671 let ws = live("SLICC_FETCH_LIVE_WS");
1672 let app = live("SLICC_FETCH_LIVE_APP");
1673 let idp = live("SLICC_FETCH_LIVE_IDP");
1674 let seven = live("SLICC_FETCH_LIVE_SEVEN");
1675 let mut cdp = Cdp::connect(&ws).unwrap();
1676 let tab = new_tab(&mut cdp, "about:blank");
1677
1678 let implicit = format!("{app}/cb#access_token=T&state=S");
1679 {
1680 let mut seen = Interception::attach(
1681 &mut cdp,
1682 &tab,
1683 &[Pattern::response(format!("{idp}/authorize*"))],
1684 )
1685 .unwrap();
1686 let session = seen.session_id().to_string();
1687 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1688 let start = format!("{idp}/authorize?to={}", encoded(&implicit));
1689 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1690 let paused = seen
1691 .next(Duration::from_secs(10))
1692 .unwrap()
1693 .expect("the redirect paused");
1694 assert!(paused.is_response());
1695 assert_eq!(paused.status, Some(302));
1696 assert_eq!(paused.location().as_deref(), Some(implicit.as_str()));
1697 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1698 .unwrap();
1699 poll(&mut seen, &session, "document.title", "Signed in");
1700 poll(
1701 &mut seen,
1702 &session,
1703 "location.href",
1704 &format!("{idp}/authorize?to={}", encoded(&implicit)),
1705 );
1706 }
1707
1708 let code = format!("{app}/cb?code=C&state=S");
1709 {
1710 let mut seen =
1711 Interception::attach(&mut cdp, &tab, &[Pattern::request(format!("{app}/cb*"))])
1712 .unwrap();
1713 let session = seen.session_id().to_string();
1714 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1715 let start = format!("{idp}/authorize?to={}", encoded(&code));
1716 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1717 let paused = seen
1718 .next(Duration::from_secs(10))
1719 .unwrap()
1720 .expect("the callback paused");
1721 assert!(!paused.is_response());
1722 assert_eq!(paused.url, code);
1723 assert_eq!(paused.method, "GET");
1724 assert_eq!(paused.resource_type, "Document");
1725 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1726 .unwrap();
1727 poll(&mut seen, &session, "document.title", "Signed in");
1728 }
1729
1730 {
1731 let session = cdp::attach(&mut cdp, &tab).unwrap();
1732 cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
1733 let mark = cdp.events().len();
1734 let landing = format!("{app}/landing#id_token=J");
1735 let start = format!("{idp}/js-redirect?to={}", encoded(&landing));
1736 let _ = cdp.notify("Page.navigate", json!({ "url": start }), Some(&session));
1737 let guard = cdp.guard().clone();
1738 let event = cdp
1739 .wait_event(mark, Duration::from_secs(10), |event| {
1740 navigation(&guard, event).is_some_and(|found| found.url.contains("/landing"))
1741 })
1742 .unwrap()
1743 .expect("the script redirect navigated");
1744 let found = navigation(&guard, &event).unwrap();
1745 assert_eq!(found.url, landing);
1746 assert!(found.main_frame);
1747 assert_eq!(found.session_id, session);
1748 committed(&mut cdp, &session, &landing);
1749 cdp::detach(&mut cdp, &session);
1750 }
1751
1752 {
1753 let api = format!("{app}/api/headers");
1754 let mut seen = Interception::attach(
1755 &mut cdp,
1756 &tab,
1757 &[
1758 Pattern::request(format!("{api}*")),
1759 Pattern::response(format!("{api}*")),
1760 ],
1761 )
1762 .unwrap();
1763 let session = seen.session_id().to_string();
1764 intercepting(&mut seen, &session, &api, &app);
1765 start_script(
1766 &mut seen,
1767 &session,
1768 &format!(
1769 "fetch({}).then((r) => r.text()).then((t) => {{ window.__got = t; }})",
1770 json!(api)
1771 ),
1772 );
1773 let request = match seen.next(Duration::from_secs(10)).unwrap() {
1774 Some(request) => request,
1775 None => panic!("the request paused: {}", unpaused(&mut seen, &session, &app, &api)),
1776 };
1777 assert!(!request.is_response());
1778 let mut headers = request.headers.clone();
1779 headers.push(("X-Added".into(), "1".into()));
1780 seen.decide(
1781 &request,
1782 &Decision::Continue(Overrides {
1783 headers: Some(headers),
1784 ..Overrides::default()
1785 }),
1786 )
1787 .unwrap();
1788 let response = seen
1789 .next(Duration::from_secs(10))
1790 .unwrap()
1791 .expect("the response paused");
1792 assert_eq!(response.status, Some(200));
1793 let body = String::from_utf8(seen.response_body(&response).unwrap()).unwrap();
1794 assert_eq!(body, "x-added=1 x-drop=none");
1795 seen.decide(
1796 &response,
1797 &Decision::ContinueResponse(ResponseOverrides::default()),
1798 )
1799 .unwrap();
1800 poll(&mut seen, &session, "window.__got || ''", &body);
1801 }
1802
1803 {
1804 let mut seen =
1805 Interception::attach(&mut cdp, &tab, &[Pattern::request("*/blocked*")]).unwrap();
1806 let session = seen.session_id().to_string();
1807 start_script(&mut seen, &session, "fetch('/blocked').then(() => 'ok', () => 'failed').then((t) => { window.__blocked = t; })");
1808 let paused = seen
1809 .next(Duration::from_secs(10))
1810 .unwrap()
1811 .expect("the blocked request paused");
1812 seen.decide(&paused, &Decision::Fail(ErrorReason::BlockedByClient))
1813 .unwrap();
1814 poll(&mut seen, &session, "window.__blocked || ''", "failed");
1815 }
1816
1817 {
1818 let mut seen = Interception::attach(&mut cdp, &tab, &[Pattern::request("*")]).unwrap();
1819 let session = seen.session_id().to_string();
1820 start_script(
1821 &mut seen,
1822 &session,
1823 &format!("fetch({}).then((r) => r.text(), (e) => 'error:' + e).then((t) => {{ window.__seven = t; }})", json!(format!("{seven}/ping"))),
1824 );
1825 assert!(seen.next(Duration::from_secs(2)).unwrap().is_none());
1826 poll(&mut seen, &session, "window.__seven || ''", "pong");
1827 let refused = enable(
1828 &mut seen,
1829 &session,
1830 &[Pattern::request(format!("{seven}/*"))],
1831 )
1832 .unwrap_err();
1833 assert!(refused.starts_with("slicc: refused"), "{refused}");
1834 }
1835
1836 let refused = cdp
1837 .call("Target.createTarget", json!({ "url": format!("{seven}/landing") }), None)
1838 .unwrap_err();
1839 assert!(refused.starts_with("slicc: refused"), "{refused}");
1840 let seven_tab = crate::raw::Raw::open(&ws).create(&format!("{seven}/landing"));
1841 let deadline = Instant::now() + Duration::from_secs(10);
1842 loop {
1843 match Interception::attach(&mut cdp, &seven_tab, &[Pattern::request("*")]) {
1844 Err(refused) => {
1845 assert!(refused.starts_with("slicc: refused"), "{refused}");
1846 break;
1847 }
1848 Ok(seen) => {
1849 seen.finish();
1850 assert!(Instant::now() < deadline, "a seven tab was intercepted");
1851 std::thread::sleep(Duration::from_millis(200));
1852 }
1853 }
1854 }
1855 for target in [tab, seven_tab] {
1856 let _ = cdp.call("Target.closeTarget", json!({ "targetId": target }), None);
1857 }
1858 }
1859}