feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity (#170)

* feat(cdp): implement Network.setCookie + deleteCookies, full field fidelity

Closes #168.

Adds the missing Network.setCookie (singular) and Network.deleteCookies
methods and upgrades the existing Network.setCookies/getCookies handlers
so sameSite, expires, httpOnly, secure, and the url-fallback parameter
all round-trip correctly.

Network and Storage cookie handlers share a single parse_cdp_cookie /
parse_delete_cookies_params helper to avoid drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(cdp): fall back to default cookie jar, add Network.getAllCookies

Addresses @SGavrl review on PR #170:

- Network.{set,get,delete}Cookie(s)/clearBrowserCookies now resolve the
  cookie jar via a shared helper that prefers the session page's jar and
  falls back to ctx.default_context.cookie_jar when no target has been
  attached. Mirrors how Storage.setCookies already routes when there is
  no session, so Puppeteer/Playwright flows that issue cookie methods
  before Target.attachToTarget no longer hit "No page".
- Add Network.getAllCookies (separate CDP method per spec); aliases the
  existing getCookies handler so both arms return the full jar.

Tests: 6 new #[tokio::test] cases in domains::network::tests cover the
no-session fallback for set/get/setMany/delete/clear and the new
getAllCookies arm. RED on prod-reverted file (6/6 fail with "No page" /
"Unknown Network method: getAllCookies"), GREEN on patched
(22 -> 30 obscura-cdp lib tests, obscura-net 22/22 baseline preserved).
This commit is contained in:
Ben Younes authored and GitHub committed 2026-05-25 17:58:48 +02:00
1 parent 52fcf2a35a
commit 1da6032639
5 files changed
+492 -127

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
use obscura_net::CookieInfo;
use serde_json::Value;
const DEFAULT_COOKIE_PATH: &str = "/";
pub fn parse_cdp_cookie(value: &Value) -> Option<CookieInfo> {
let name = value.get("name").and_then(|v| v.as_str())?.to_string();
let cookie_value = value
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let url_parsed = value
.get("url")
.and_then(|v| v.as_str())
.and_then(|u| url::Url::parse(u).ok());
let domain = value
.get("domain")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| url_parsed.as_ref().and_then(|u| u.host_str().map(|h| h.to_string())))
.unwrap_or_default();
if domain.is_empty() {
return None;
}
let path = value
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| url_parsed.as_ref().map(|u| u.path().to_string()))
.unwrap_or_else(|| DEFAULT_COOKIE_PATH.to_string());
let secure = value.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
let http_only = value.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false);
let same_site = value
.get("sameSite")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let expires = value.get("expires").and_then(|v| v.as_f64()).map(|f| f as i64);
Some(CookieInfo {
name,
value: cookie_value,
domain,
path,
secure,
http_only,
same_site,
expires,
})
}
pub struct DeleteCookiesFilter {
pub name: String,
pub domain: String,
pub path: Option<String>,
}
pub fn parse_delete_cookies_params(params: &Value) -> Option<DeleteCookiesFilter> {
let name = params.get("name").and_then(|v| v.as_str())?.to_string();
if name.is_empty() {
return None;
}
let url_parsed = params
.get("url")
.and_then(|v| v.as_str())
.and_then(|u| url::Url::parse(u).ok());
let domain = params
.get("domain")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| url_parsed.as_ref().and_then(|u| u.host_str().map(|h| h.to_string())))
.unwrap_or_default();
let path = params
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| url_parsed.as_ref().map(|u| u.path().to_string()));
Some(DeleteCookiesFilter { name, domain, path })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_with_explicit_domain_path() {
let v = json!({
"name": "session",
"value": "abc",
"domain": ".example.com",
"path": "/app",
"secure": true,
"httpOnly": true,
"sameSite": "Strict",
"expires": 1_900_000_000.0,
});
let c = parse_cdp_cookie(&v).unwrap();
assert_eq!(c.name, "session");
assert_eq!(c.value, "abc");
assert_eq!(c.domain, ".example.com");
assert_eq!(c.path, "/app");
assert!(c.secure);
assert!(c.http_only);
assert_eq!(c.same_site, "Strict");
assert_eq!(c.expires, Some(1_900_000_000));
}
#[test]
fn parse_with_url_fallback_for_domain_and_path() {
let v = json!({
"name": "tok",
"value": "xyz",
"url": "https://api.example.com/v1/things",
});
let c = parse_cdp_cookie(&v).unwrap();
assert_eq!(c.domain, "api.example.com");
assert_eq!(c.path, "/v1/things");
}
#[test]
fn parse_rejects_when_no_domain_or_url() {
let v = json!({ "name": "x", "value": "y" });
assert!(parse_cdp_cookie(&v).is_none());
}
#[test]
fn parse_default_path_when_url_has_no_path() {
let v = json!({
"name": "tok",
"value": "v",
"domain": "example.com",
});
let c = parse_cdp_cookie(&v).unwrap();
assert_eq!(c.path, "/");
}
#[test]
fn delete_filter_requires_name() {
let v = json!({ "domain": "example.com" });
assert!(parse_delete_cookies_params(&v).is_none());
}
#[test]
fn delete_filter_uses_url_for_domain_and_path() {
let v = json!({ "name": "session", "url": "https://example.com/admin" });
let f = parse_delete_cookies_params(&v).unwrap();
assert_eq!(f.name, "session");
assert_eq!(f.domain, "example.com");
assert_eq!(f.path.as_deref(), Some("/admin"));
}
}