Skip to content

Commit ba489b7

Browse files
committed
test: verify profile cookies
1 parent b534694 commit ba489b7

File tree

1 file changed

+221
-11
lines changed

1 file changed

+221
-11
lines changed

src/browser/profile_porter.rs

Lines changed: 221 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,9 +1292,9 @@ mod tests {
12921292
None
12931293
}
12941294

1295-
/// Extract the packaged zip and launch Chromium headless to verify the
1296-
/// profile loads without crashing.
1297-
fn verify_chromium_loads_profile(zip_buffer: &[u8]) {
1295+
/// Extract the packaged zip, launch Chromium with the profile, and verify
1296+
/// cookies are accessible via CDP (`Storage.getCookies`).
1297+
fn verify_chromium_loads_profile(zip_buffer: &[u8], expected_cookies: &[(&str, &str)]) {
12981298
let Some(chromium) = find_chromium_binary() else {
12991299
eprintln!("Skipping Chromium load test — no binary found");
13001300
return;
@@ -1305,25 +1305,228 @@ mod tests {
13051305
let mut archive = zip::ZipArchive::new(cursor).unwrap();
13061306
archive.extract(tmp.path()).unwrap();
13071307

1308-
let output = std::process::Command::new(&chromium)
1308+
// Find a free port for CDP
1309+
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1310+
let port = listener.local_addr().unwrap().port();
1311+
drop(listener);
1312+
1313+
let mut child = std::process::Command::new(&chromium)
13091314
.args([
13101315
"--headless",
13111316
"--disable-gpu",
13121317
"--no-sandbox",
13131318
"--no-first-run",
13141319
"--disable-sync",
13151320
&format!("--user-data-dir={}", tmp.path().display()),
1316-
"--dump-dom",
1321+
&format!("--remote-debugging-port={port}"),
13171322
"about:blank",
13181323
])
1319-
.output()
1324+
.stdout(std::process::Stdio::null())
1325+
.stderr(std::process::Stdio::null())
1326+
.spawn()
13201327
.expect("failed to launch Chromium");
13211328

1329+
// Wait for CDP to become available
1330+
let cdp_base = format!("http://127.0.0.1:{port}");
1331+
let mut cdp_ready = false;
1332+
for _ in 0..50 {
1333+
std::thread::sleep(std::time::Duration::from_millis(200));
1334+
if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() {
1335+
cdp_ready = true;
1336+
break;
1337+
}
1338+
}
1339+
if !cdp_ready {
1340+
child.kill().ok();
1341+
child.wait().ok();
1342+
panic!("Chromium CDP did not become available within 10s");
1343+
}
1344+
1345+
// Get the first page's WebSocket debugger URL
1346+
let version_body = http_get(&format!("{cdp_base}/json/version"));
1347+
let version: serde_json::Value = serde_json::from_str(&version_body)
1348+
.unwrap_or_else(|e| panic!("bad /json/version response: {e}\n{version_body}"));
1349+
let ws_url = version["webSocketDebuggerUrl"]
1350+
.as_str()
1351+
.expect("missing webSocketDebuggerUrl");
1352+
1353+
// Connect to WebSocket and call Storage.getCookies
1354+
let cookies_json = cdp_get_all_cookies(ws_url);
1355+
let cookies: Vec<serde_json::Value> = cookies_json
1356+
.as_array()
1357+
.expect("cookies should be an array")
1358+
.to_vec();
1359+
1360+
// Verify expected cookies are present
1361+
for (host, name) in expected_cookies {
1362+
let found = cookies.iter().any(|c| {
1363+
c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name)
1364+
});
1365+
assert!(found, "Cookie {name} on {host} not found via CDP. Got: {cookies:?}");
1366+
}
1367+
1368+
child.kill().ok();
1369+
child.wait().ok();
1370+
}
1371+
1372+
/// Minimal sync HTTP GET using std::net only (no reqwest dependency in tests).
1373+
fn http_get(url: &str) -> String {
1374+
let url = url.strip_prefix("http://").unwrap_or(url);
1375+
let (host_port, path) = url.split_once('/').unwrap_or((url, ""));
1376+
let path = format!("/{path}");
1377+
1378+
let mut stream = std::net::TcpStream::connect(host_port).unwrap();
1379+
stream
1380+
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
1381+
.ok();
1382+
let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
1383+
std::io::Write::write_all(&mut stream, request.as_bytes()).unwrap();
1384+
1385+
let mut response = Vec::new();
1386+
std::io::Read::read_to_end(&mut stream, &mut response).ok();
1387+
let response = String::from_utf8_lossy(&response);
1388+
1389+
// Split headers from body
1390+
response
1391+
.split_once("\r\n\r\n")
1392+
.map(|(_, body)| body.to_string())
1393+
.unwrap_or_default()
1394+
}
1395+
1396+
/// Connect to CDP WebSocket and call Storage.getCookies.
1397+
/// Uses a raw TCP + minimal WebSocket frame implementation.
1398+
fn cdp_get_all_cookies(ws_url: &str) -> serde_json::Value {
1399+
use sha1::{Digest, Sha1};
1400+
1401+
// Parse ws://host:port/path
1402+
let url = ws_url.strip_prefix("ws://").expect("expected ws:// URL");
1403+
let (host_port, path) = url.split_once('/').unwrap_or((url, ""));
1404+
let path = format!("/{path}");
1405+
1406+
let mut stream = std::net::TcpStream::connect(host_port).unwrap();
1407+
stream
1408+
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
1409+
.ok();
1410+
1411+
// WebSocket handshake
1412+
let ws_key = "dGhlIHNhbXBsZSBub25jZQ=="; // static key, fine for tests
1413+
let handshake = format!(
1414+
"GET {path} HTTP/1.1\r\n\
1415+
Host: {host_port}\r\n\
1416+
Upgrade: websocket\r\n\
1417+
Connection: Upgrade\r\n\
1418+
Sec-WebSocket-Key: {ws_key}\r\n\
1419+
Sec-WebSocket-Version: 13\r\n\r\n"
1420+
);
1421+
std::io::Write::write_all(&mut stream, handshake.as_bytes()).unwrap();
1422+
1423+
// Read until we get the end of HTTP headers
1424+
let mut header_buf = Vec::new();
1425+
loop {
1426+
let mut byte = [0u8; 1];
1427+
std::io::Read::read_exact(&mut stream, &mut byte).unwrap();
1428+
header_buf.push(byte[0]);
1429+
if header_buf.ends_with(b"\r\n\r\n") {
1430+
break;
1431+
}
1432+
}
1433+
1434+
let header_str = String::from_utf8_lossy(&header_buf);
13221435
assert!(
1323-
output.status.success(),
1324-
"Chromium failed to load packaged profile:\n{}",
1325-
String::from_utf8_lossy(&output.stderr)
1436+
header_str.contains("101"),
1437+
"WebSocket upgrade failed: {header_str}"
13261438
);
1439+
1440+
// Verify Sec-WebSocket-Accept
1441+
let expected_accept = {
1442+
let mut hasher = Sha1::new();
1443+
hasher.update(ws_key.as_bytes());
1444+
hasher.update(b"258EAFA5-E914-47DA-95CA-5AB5DC11650B");
1445+
use base64::Engine;
1446+
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
1447+
};
1448+
assert!(
1449+
header_str.contains(&expected_accept),
1450+
"bad Sec-WebSocket-Accept"
1451+
);
1452+
1453+
// Send CDP command: Storage.getCookies
1454+
let cmd = serde_json::json!({"id": 1, "method": "Storage.getCookies"});
1455+
let payload = serde_json::to_vec(&cmd).unwrap();
1456+
ws_send_frame(&mut stream, &payload);
1457+
1458+
// Read response frames until we get our result (id: 1)
1459+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1460+
loop {
1461+
if std::time::Instant::now() > deadline {
1462+
panic!("Timed out waiting for CDP response");
1463+
}
1464+
let frame = ws_read_frame(&mut stream);
1465+
if let Ok(msg) = serde_json::from_slice::<serde_json::Value>(&frame)
1466+
&& msg.get("id") == Some(&serde_json::json!(1))
1467+
{
1468+
return msg["result"]["cookies"].clone();
1469+
}
1470+
}
1471+
}
1472+
1473+
fn ws_send_frame(stream: &mut std::net::TcpStream, payload: &[u8]) {
1474+
let mut frame = Vec::new();
1475+
// FIN + text opcode
1476+
frame.push(0x81);
1477+
// Masked + length
1478+
let len = payload.len();
1479+
if len < 126 {
1480+
frame.push(0x80 | len as u8);
1481+
} else if len <= 65535 {
1482+
frame.push(0x80 | 126);
1483+
frame.extend_from_slice(&(len as u16).to_be_bytes());
1484+
} else {
1485+
frame.push(0x80 | 127);
1486+
frame.extend_from_slice(&(len as u64).to_be_bytes());
1487+
}
1488+
// Masking key (all zeros — simple, fine for tests)
1489+
let mask = [0u8; 4];
1490+
frame.extend_from_slice(&mask);
1491+
// Payload (mask XOR with zero = identity)
1492+
frame.extend_from_slice(payload);
1493+
std::io::Write::write_all(stream, &frame).unwrap();
1494+
}
1495+
1496+
fn ws_read_frame(stream: &mut std::net::TcpStream) -> Vec<u8> {
1497+
let mut header = [0u8; 2];
1498+
std::io::Read::read_exact(stream, &mut header).unwrap();
1499+
1500+
let masked = header[1] & 0x80 != 0;
1501+
let mut len = (header[1] & 0x7F) as u64;
1502+
1503+
if len == 126 {
1504+
let mut buf = [0u8; 2];
1505+
std::io::Read::read_exact(stream, &mut buf).unwrap();
1506+
len = u16::from_be_bytes(buf) as u64;
1507+
} else if len == 127 {
1508+
let mut buf = [0u8; 8];
1509+
std::io::Read::read_exact(stream, &mut buf).unwrap();
1510+
len = u64::from_be_bytes(buf);
1511+
}
1512+
1513+
let mask_key = if masked {
1514+
let mut buf = [0u8; 4];
1515+
std::io::Read::read_exact(stream, &mut buf).unwrap();
1516+
buf
1517+
} else {
1518+
[0u8; 4]
1519+
};
1520+
1521+
let mut payload = vec![0u8; len as usize];
1522+
std::io::Read::read_exact(stream, &mut payload).unwrap();
1523+
1524+
if masked {
1525+
for (i, byte) in payload.iter_mut().enumerate() {
1526+
*byte ^= mask_key[i % 4];
1527+
}
1528+
}
1529+
payload
13271530
}
13281531

13291532
// ─── Unit tests: reencrypt pipeline ──────────────────────────────────────
@@ -1618,8 +1821,15 @@ mod tests {
16181821
assert!(!name.ends_with(".pma"), "zip contains .pma: {name}");
16191822
}
16201823

1621-
// 7. Verify Chromium can load the packaged profile
1622-
verify_chromium_loads_profile(&pkg.zip_buffer);
1824+
// 7. Verify Chromium can load the packaged profile and read cookies via CDP
1825+
verify_chromium_loads_profile(
1826+
&pkg.zip_buffer,
1827+
&[
1828+
(".example.com", "session"),
1829+
(".test.org", "auth"),
1830+
("localhost", "dev"),
1831+
],
1832+
);
16231833

16241834
// 8. Cleanup
16251835
let _ = std::fs::remove_dir_all(&test_profile_dir);

0 commit comments

Comments
 (0)