From 1da60326398a11dcc26a54fad75e6e374f70ccdc Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Mon, 25 May 2026 17:58:48 +0200 Subject: [PATCH] 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) * 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). --- crates/obscura-cdp/src/cookie_params.rs | 162 +++++++++++++++++ crates/obscura-cdp/src/domains/network.rs | 209 ++++++++++++++++++---- crates/obscura-cdp/src/domains/storage.rs | 101 ++--------- crates/obscura-cdp/src/lib.rs | 1 + crates/obscura-net/src/cookies.rs | 146 ++++++++++++++- 5 files changed, 492 insertions(+), 127 deletions(-) create mode 100644 crates/obscura-cdp/src/cookie_params.rs diff --git a/crates/obscura-cdp/src/cookie_params.rs b/crates/obscura-cdp/src/cookie_params.rs new file mode 100644 index 0000000..43209b5 --- /dev/null +++ b/crates/obscura-cdp/src/cookie_params.rs @@ -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 { + 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, +} + +pub fn parse_delete_cookies_params(params: &Value) -> Option { + 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")); + } +} diff --git a/crates/obscura-cdp/src/domains/network.rs b/crates/obscura-cdp/src/domains/network.rs index e3b6cd0..b9172d3 100644 --- a/crates/obscura-cdp/src/domains/network.rs +++ b/crates/obscura-cdp/src/domains/network.rs @@ -1,7 +1,29 @@ +use std::sync::Arc; + use serde_json::{json, Value}; +use obscura_net::CookieJar; + +use crate::cookie_params::{parse_cdp_cookie, parse_delete_cookies_params}; use crate::dispatch::CdpContext; +const SESSION_COOKIE_EXPIRES: i64 = -1; +const DEFAULT_SECURE_PORT: u16 = 443; +const DEFAULT_INSECURE_PORT: u16 = 80; +const SOURCE_SCHEME_SECURE: &str = "Secure"; +const SOURCE_SCHEME_NONSECURE: &str = "NonSecure"; +const DEFAULT_SAME_SITE: &str = "Lax"; + +// Resolve the cookie jar for a Network request: prefer the session's page jar, +// fall back to the default browser context. Puppeteer and Playwright both call +// Network.setCookie/getCookies/deleteCookies BEFORE attaching to a target — +// requiring a session would break those flows (Storage.* already mirrors this). +fn cookie_jar_for<'a>(ctx: &'a CdpContext, session_id: &Option) -> &'a Arc { + ctx.get_session_page(session_id) + .map(|p| &p.context.cookie_jar) + .unwrap_or(&ctx.default_context.cookie_jar) +} + pub async fn handle( method: &str, params: &Value, @@ -30,48 +52,36 @@ pub async fn handle( } Ok(json!({})) } - "getCookies" => { - let page = ctx.get_session_page(session_id).ok_or("No page")?; - let cookies = page.context.cookie_jar.get_all_cookies(); - let cdp_cookies: Vec = cookies.iter().map(|c| { - json!({ - "name": c.name, - "value": c.value, - "domain": c.domain, - "path": c.path, - "expires": -1, - "size": c.name.len() + c.value.len(), - "httpOnly": c.http_only, - "secure": c.secure, - "session": true, - "sameParty": false, - "sourceScheme": "Secure", - "sourcePort": 443, - }) - }).collect(); + "getCookies" | "getAllCookies" => { + let cookies = cookie_jar_for(ctx, session_id).get_all_cookies(); + let cdp_cookies: Vec = cookies.iter().map(cookie_info_to_cdp_json).collect(); Ok(json!({ "cookies": cdp_cookies })) } + "setCookie" => { + let cookie = parse_cdp_cookie(params) + .ok_or("setCookie: missing required name/domain (or url)")?; + cookie_jar_for(ctx, session_id).set_cookies_from_cdp(vec![cookie]); + Ok(json!({ "success": true })) + } "setCookies" => { - let page = ctx.get_session_page(session_id).ok_or("No page")?; if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) { - let cookie_infos: Vec = cookies.iter().filter_map(|c| { - Some(obscura_net::CookieInfo { - name: c.get("name")?.as_str()?.to_string(), - value: c.get("value")?.as_str()?.to_string(), - domain: c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string(), - path: c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string(), - secure: c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false), - http_only: c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false), - }) - }).collect(); - page.context.cookie_jar.set_cookies_from_cdp(cookie_infos); + let parsed: Vec<_> = cookies.iter().filter_map(parse_cdp_cookie).collect(); + cookie_jar_for(ctx, session_id).set_cookies_from_cdp(parsed); + } + Ok(json!({})) + } + "deleteCookies" => { + if let Some(filter) = parse_delete_cookies_params(params) { + cookie_jar_for(ctx, session_id).delete_cookies_filtered( + &filter.name, + &filter.domain, + filter.path.as_deref(), + ); } Ok(json!({})) } "clearBrowserCookies" => { - if let Some(page) = ctx.get_session_page(session_id) { - page.context.cookie_jar.clear(); - } + cookie_jar_for(ctx, session_id).clear(); Ok(json!({})) } "setCacheDisabled" => Ok(json!({})), @@ -79,3 +89,134 @@ pub async fn handle( _ => Err(format!("Unknown Network method: {}", method)), } } + +#[cfg(test)] +mod tests { + use super::*; + use obscura_net::CookieInfo; + + fn sample_cookie(name: &str) -> CookieInfo { + CookieInfo { + name: name.to_string(), + value: "v".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: false, + http_only: false, + same_site: String::new(), + expires: None, + } + } + + #[tokio::test] + async fn set_cookie_without_session_targets_default_context() { + let mut ctx = CdpContext::new(); + let params = json!({ + "name": "sid", + "value": "abc", + "domain": "example.com", + "path": "/" + }); + let resp = handle("setCookie", ¶ms, &mut ctx, &None) + .await + .expect("setCookie must succeed without a session"); + assert_eq!(resp["success"], json!(true)); + let cookies = ctx.default_context.cookie_jar.get_all_cookies(); + assert_eq!(cookies.len(), 1, "default cookie jar must receive the cookie"); + assert_eq!(cookies[0].name, "sid"); + } + + #[tokio::test] + async fn set_cookies_without_session_targets_default_context() { + let mut ctx = CdpContext::new(); + let params = json!({ + "cookies": [ + { "name": "a", "value": "1", "domain": "example.com", "path": "/" }, + { "name": "b", "value": "2", "domain": "example.com", "path": "/" } + ] + }); + handle("setCookies", ¶ms, &mut ctx, &None) + .await + .expect("setCookies must succeed without a session"); + assert_eq!(ctx.default_context.cookie_jar.get_all_cookies().len(), 2); + } + + #[tokio::test] + async fn delete_cookies_without_session_targets_default_context() { + let mut ctx = CdpContext::new(); + ctx.default_context + .cookie_jar + .set_cookies_from_cdp(vec![sample_cookie("sid")]); + let params = json!({ "name": "sid", "domain": "example.com" }); + handle("deleteCookies", ¶ms, &mut ctx, &None) + .await + .expect("deleteCookies must succeed without a session"); + assert!(ctx.default_context.cookie_jar.get_all_cookies().is_empty()); + } + + #[tokio::test] + async fn get_all_cookies_returns_every_cookie_in_jar() { + let mut ctx = CdpContext::new(); + ctx.default_context.cookie_jar.set_cookies_from_cdp(vec![ + sample_cookie("a"), + sample_cookie("b"), + ]); + let resp = handle("getAllCookies", &json!({}), &mut ctx, &None) + .await + .expect("getAllCookies must succeed"); + let arr = resp["cookies"].as_array().expect("cookies array"); + assert_eq!(arr.len(), 2); + } + + #[tokio::test] + async fn get_cookies_falls_back_to_default_context_when_no_session() { + let mut ctx = CdpContext::new(); + ctx.default_context + .cookie_jar + .set_cookies_from_cdp(vec![sample_cookie("sid")]); + let resp = handle("getCookies", &json!({}), &mut ctx, &None) + .await + .expect("getCookies must succeed without a session"); + let arr = resp["cookies"].as_array().expect("cookies array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "sid"); + } + + #[tokio::test] + async fn clear_browser_cookies_without_session_clears_default_context() { + let mut ctx = CdpContext::new(); + ctx.default_context + .cookie_jar + .set_cookies_from_cdp(vec![sample_cookie("sid")]); + handle("clearBrowserCookies", &json!({}), &mut ctx, &None) + .await + .expect("clearBrowserCookies must succeed"); + assert!(ctx.default_context.cookie_jar.get_all_cookies().is_empty()); + } +} + +pub(crate) fn cookie_info_to_cdp_json(c: &obscura_net::CookieInfo) -> Value { + let expires = c.expires.unwrap_or(SESSION_COOKIE_EXPIRES); + let session = c.expires.is_none(); + let same_site = if c.same_site.is_empty() { + DEFAULT_SAME_SITE + } else { + c.same_site.as_str() + }; + json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": expires, + "size": c.name.len() + c.value.len(), + "httpOnly": c.http_only, + "secure": c.secure, + "session": session, + "sameSite": same_site, + "sameParty": false, + "sourceScheme": if c.secure { SOURCE_SCHEME_SECURE } else { SOURCE_SCHEME_NONSECURE }, + "sourcePort": if c.secure { DEFAULT_SECURE_PORT } else { DEFAULT_INSECURE_PORT }, + "priority": "Medium", + }) +} diff --git a/crates/obscura-cdp/src/domains/storage.rs b/crates/obscura-cdp/src/domains/storage.rs index 01e1ed9..7f32d2e 100644 --- a/crates/obscura-cdp/src/domains/storage.rs +++ b/crates/obscura-cdp/src/domains/storage.rs @@ -1,6 +1,8 @@ use serde_json::{json, Value}; +use crate::cookie_params::{parse_cdp_cookie, parse_delete_cookies_params}; use crate::dispatch::CdpContext; +use crate::domains::network::cookie_info_to_cdp_json; pub async fn handle( method: &str, @@ -11,104 +13,23 @@ pub async fn handle( match method { "getCookies" => { let cookies = ctx.default_context.cookie_jar.get_all_cookies(); - let cdp_cookies: Vec = cookies - .iter() - .map(|c| { - json!({ - "name": c.name, - "value": c.value, - "domain": c.domain, - "path": c.path, - "expires": -1, - "size": c.name.len() + c.value.len(), - "httpOnly": c.http_only, - "secure": c.secure, - "session": true, - "priority": "Medium", - "sameParty": false, - "sourceScheme": if c.secure { "Secure" } else { "NonSecure" }, - "sourcePort": 80, - }) - }) - .collect(); + let cdp_cookies: Vec = cookies.iter().map(cookie_info_to_cdp_json).collect(); Ok(json!({ "cookies": cdp_cookies })) } "setCookies" => { if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs_f64(); - - for c in cookies { - let name = match c.get("name").and_then(|v| v.as_str()) { - Some(n) => n.to_string(), - None => continue, - }; - let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(); - - let domain = c - .get("domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| { - c.get("url") - .and_then(|v| v.as_str()) - .and_then(|u| url::Url::parse(u).ok()) - .and_then(|u| u.host_str().map(|h| h.to_string())) - }) - .unwrap_or_default(); - - let expires = c.get("expires").and_then(|v| v.as_f64()); - if let Some(exp) = expires { - if exp > 0.0 && exp < now { - ctx.default_context.cookie_jar.delete_cookie(&name, &domain); - continue; - } - } - - let path = c - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("/") - .to_string(); - let secure = - c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false); - let http_only = - c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false); - - ctx.default_context.cookie_jar.set_cookies_from_cdp(vec![ - obscura_net::CookieInfo { - name, - value, - domain, - path, - secure, - http_only, - }, - ]); - } + let parsed: Vec<_> = cookies.iter().filter_map(parse_cdp_cookie).collect(); + ctx.default_context.cookie_jar.set_cookies_from_cdp(parsed); } Ok(json!({})) } "deleteCookies" => { - let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let domain_from_url = params - .get("url") - .and_then(|v| v.as_str()) - .and_then(|u| url::Url::parse(u).ok()) - .and_then(|u| u.host_str().map(|h| h.to_string())); - let domain = params - .get("domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or(domain_from_url) - .unwrap_or_default(); - - if !name.is_empty() { - ctx.default_context - .cookie_jar - .delete_cookie(name, &domain); + if let Some(filter) = parse_delete_cookies_params(params) { + ctx.default_context.cookie_jar.delete_cookies_filtered( + &filter.name, + &filter.domain, + filter.path.as_deref(), + ); } Ok(json!({})) } diff --git a/crates/obscura-cdp/src/lib.rs b/crates/obscura-cdp/src/lib.rs index 8a308cb..e037ba5 100644 --- a/crates/obscura-cdp/src/lib.rs +++ b/crates/obscura-cdp/src/lib.rs @@ -2,6 +2,7 @@ pub mod server; pub mod dispatch; pub mod types; pub mod domains; +pub mod cookie_params; pub(crate) mod util; pub use server::{ diff --git a/crates/obscura-net/src/cookies.rs b/crates/obscura-net/src/cookies.rs index 9530ebc..3fcae0c 100644 --- a/crates/obscura-net/src/cookies.rs +++ b/crates/obscura-net/src/cookies.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::RwLock; use url::Url; +const DEFAULT_SAME_SITE: &str = "Lax"; + pub struct CookieJar { cookies: RwLock>>, } @@ -15,7 +17,6 @@ struct CookieEntry { secure: bool, http_only: bool, expires: Option, - #[allow(dead_code)] same_site: String, } @@ -165,6 +166,8 @@ impl CookieJar { path: entry.path.clone(), secure: entry.secure, http_only: entry.http_only, + same_site: entry.same_site.clone(), + expires: entry.expires.map(|e| e as i64), }); } } @@ -174,6 +177,12 @@ impl CookieJar { pub fn set_cookies_from_cdp(&self, cookies: Vec) { let mut jar = self.cookies.write().unwrap(); for cookie in cookies { + let same_site = if cookie.same_site.is_empty() { + DEFAULT_SAME_SITE.to_string() + } else { + cookie.same_site + }; + let expires = cookie.expires.and_then(|e| if e > 0 { Some(e as u64) } else { None }); let entry = CookieEntry { name: cookie.name.clone(), value: cookie.value, @@ -181,8 +190,8 @@ impl CookieJar { domain: cookie.domain.clone(), secure: cookie.secure, http_only: cookie.http_only, - expires: None, - same_site: "Lax".to_string(), + expires, + same_site, }; jar.entry(cookie.domain).or_default().insert(cookie.name, entry); } @@ -336,6 +345,30 @@ impl CookieJar { } } + pub fn delete_cookies_filtered(&self, name: &str, domain: &str, path: Option<&str>) { + let mut cookies = self.cookies.write().unwrap(); + let matches_path = |entry_path: &str| match path { + Some(p) => entry_path == p, + None => true, + }; + if domain.is_empty() { + for domain_cookies in cookies.values_mut() { + domain_cookies.retain(|n, e| !(n == name && matches_path(&e.path))); + } + } else { + let domains_to_try = [ + domain.to_string(), + format!(".{}", domain.trim_start_matches('.')), + domain.trim_start_matches('.').to_string(), + ]; + for d in &domains_to_try { + if let Some(domain_cookies) = cookies.get_mut(d.as_str()) { + domain_cookies.retain(|n, e| !(n == name && matches_path(&e.path))); + } + } + } + } + pub fn clear(&self) { self.cookies.write().unwrap().clear(); } @@ -356,6 +389,10 @@ pub struct CookieInfo { pub secure: bool, #[serde(rename = "httpOnly")] pub http_only: bool, + #[serde(default, rename = "sameSite")] + pub same_site: String, + #[serde(default)] + pub expires: Option, } fn parse_http_date(s: &str) -> Result { @@ -438,6 +475,8 @@ mod tests { path: "/".to_string(), secure: false, http_only: false, + same_site: String::new(), + expires: None, }]); let apex_url = Url::parse("https://example.com/").unwrap(); @@ -509,4 +548,105 @@ mod tests { jar.clear(); assert!(jar.get_cookie_header(&url).is_empty()); } + + #[test] + fn test_set_cookies_from_cdp_preserves_same_site_and_expires() { + let jar = CookieJar::new(); + let future_expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 3600; + jar.set_cookies_from_cdp(vec![CookieInfo { + name: "sid".to_string(), + value: "abc".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: true, + http_only: true, + same_site: "Strict".to_string(), + expires: Some(future_expiry), + }]); + + let cookies = jar.get_all_cookies(); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].same_site, "Strict"); + assert_eq!(cookies[0].expires, Some(future_expiry)); + } + + #[test] + fn test_set_cookies_from_cdp_session_when_expires_none() { + let jar = CookieJar::new(); + jar.set_cookies_from_cdp(vec![CookieInfo { + name: "n".to_string(), + value: "v".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: false, + http_only: false, + same_site: String::new(), + expires: None, + }]); + let cookies = jar.get_all_cookies(); + assert_eq!(cookies[0].expires, None); + assert_eq!(cookies[0].same_site, DEFAULT_SAME_SITE); + } + + #[test] + fn test_delete_cookies_filtered_path_mismatch_preserves_cookie() { + let jar = CookieJar::new(); + jar.set_cookies_from_cdp(vec![CookieInfo { + name: "sid".to_string(), + value: "v".to_string(), + domain: "example.com".to_string(), + path: "/admin".to_string(), + secure: false, + http_only: false, + same_site: String::new(), + expires: None, + }]); + jar.delete_cookies_filtered("sid", "example.com", Some("/other")); + assert_eq!(jar.get_all_cookies().len(), 1); + + jar.delete_cookies_filtered("sid", "example.com", Some("/admin")); + assert!(jar.get_all_cookies().is_empty()); + } + + #[test] + fn test_delete_cookies_filtered_no_path_deletes_regardless() { + let jar = CookieJar::new(); + jar.set_cookies_from_cdp(vec![CookieInfo { + name: "sid".to_string(), + value: "v".to_string(), + domain: "example.com".to_string(), + path: "/admin".to_string(), + secure: false, + http_only: false, + same_site: String::new(), + expires: None, + }]); + jar.delete_cookies_filtered("sid", "example.com", None); + assert!(jar.get_all_cookies().is_empty()); + } + + #[test] + fn test_set_cookies_from_cdp_expired_does_not_persist() { + let jar = CookieJar::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + jar.set_cookies_from_cdp(vec![CookieInfo { + name: "old".to_string(), + value: "v".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: false, + http_only: false, + same_site: String::new(), + expires: Some(now - 1), + }]); + let url = Url::parse("https://example.com/").unwrap(); + assert!(jar.get_cookie_header(&url).is_empty()); + } }