Skip to main content

cdp_client/
fetch.rs

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}