From b5346943c68a889ca305cd278d1e3a1da6293cb2 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 25 Mar 2026 23:08:02 +0900 Subject: [PATCH 1/6] test: add profiles e2e tests --- .github/workflows/browser-integration.yml | 142 ++++++ src/browser/profile_porter.rs | 583 +++++++++++++++++++++- 2 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/browser-integration.yml diff --git a/.github/workflows/browser-integration.yml b/.github/workflows/browser-integration.yml new file mode 100644 index 0000000..0f1744c --- /dev/null +++ b/.github/workflows/browser-integration.yml @@ -0,0 +1,142 @@ +name: Browser Integration Tests + +on: + pull_request: + paths: + - "src/browser/profile_porter.rs" + - "src/browser/profile_store.rs" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + linux: + name: ${{ matrix.browser }} (Linux) + runs-on: ubuntu-latest + env: + DISPLAY: ":99" + strategy: + fail-fast: false + matrix: + include: + - browser: chrome + test_filter: integration_chrome + binary: google-chrome-stable + setup: | + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update && sudo apt-get install -y google-chrome-stable + - browser: brave + test_filter: integration_brave + binary: brave-browser + setup: | + sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" | sudo tee /etc/apt/sources.list.d/brave-browser.list + sudo apt-get update && sudo apt-get install -y brave-browser + - browser: edge + test_filter: integration_edge + binary: microsoft-edge-stable + setup: | + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-edge.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-edge.gpg] https://packages.microsoft.com/repos/edge stable main" | sudo tee /etc/apt/sources.list.d/microsoft-edge.list + sudo apt-get update && sudo apt-get install -y microsoft-edge-stable + - browser: opera + test_filter: integration_opera + binary: opera + setup: | + curl -fsSL https://deb.opera.com/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/opera.gpg + echo "deb [signed-by=/usr/share/keyrings/opera.gpg] https://deb.opera.com/opera-stable/ stable non-free" | sudo tee /etc/apt/sources.list.d/opera.list + sudo apt-get update && sudo apt-get install -y opera-stable + - browser: vivaldi + test_filter: integration_vivaldi + binary: vivaldi-stable + setup: | + curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/vivaldi.gpg + echo "deb [signed-by=/usr/share/keyrings/vivaldi.gpg] https://repo.vivaldi.com/archive/deb/ stable main" | sudo tee /etc/apt/sources.list.d/vivaldi.list + sudo apt-get update && sudo apt-get install -y vivaldi-stable + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Install ${{ matrix.browser }} + run: ${{ matrix.setup }} + + - name: Ensure Chrome available (for Chromium load test) + run: | + command -v google-chrome-stable &>/dev/null || ( + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list && + sudo apt-get update && sudo apt-get install -y google-chrome-stable + ) + + - name: Start Xvfb + run: | + sudo apt-get install -y xvfb + Xvfb :99 -screen 0 1920x1080x24 & + sleep 2 + + - name: Initialize browser profile + run: | + timeout 15 ${{ matrix.binary }} --no-first-run --disable-sync --disable-gpu about:blank || true + pkill -f "${{ matrix.binary }}" 2>/dev/null || true + sleep 1 + find ~/.config -name "Cookies" -type f || true + + - name: Run integration tests + run: cargo nextest run --run-ignored only -E 'test(${{ matrix.test_filter }})' + + gnome-keyring: + name: GNOME Keyring (Linux) + runs-on: ubuntu-latest + env: + DISPLAY: ":99" + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Install Chrome + GNOME Keyring + run: | + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update && sudo apt-get install -y google-chrome-stable gnome-keyring libsecret-tools xvfb dbus-x11 + + - name: Setup, initialize, and test + run: | + Xvfb :99 -screen 0 1920x1080x24 & + sleep 2 + eval $(dbus-launch --sh-syntax) + echo "" | gnome-keyring-daemon --unlock --components=secrets + echo -n "my-custom-chrome-password" | secret-tool store --label="Chrome Safe Storage" application chrome + timeout 15 google-chrome-stable --no-first-run --disable-sync --disable-gpu about:blank || true + pkill -f "google-chrome" 2>/dev/null || true + sleep 1 + STEEL_TEST_GNOME_KEYRING=1 cargo nextest run --run-ignored only -E 'test(integration_gnome_keyring)' + + macos: + name: Chrome (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Initialize Chrome profile + run: | + timeout 15 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --no-first-run --disable-sync about:blank || true + pkill -f "Google Chrome" 2>/dev/null || true + sleep 1 + + - name: Run integration tests + run: cargo nextest run --run-ignored only -E 'test(integration_chrome)' diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 9876676..ca04cb2 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -673,9 +673,11 @@ fn reencrypt_cookies_db(original_path: &Path, provider: &KeyProvider) -> Result< let conn = rusqlite::Connection::open(&tmp_path)?; let meta_version: i64 = conn - .query_row("SELECT value FROM meta WHERE key='version'", [], |row| { - row.get(0) - }) + .query_row( + "SELECT CAST(value AS INTEGER) FROM meta WHERE key='version'", + [], + |row| row.get(0), + ) .unwrap_or(0); let mut stmt = conn.prepare( @@ -1128,4 +1130,579 @@ mod tests { assert_eq!(files.len(), 1); assert!(files.contains_key("good.txt")); } + + struct TestCookie { + host_key: &'static str, + name: &'static str, + value: &'static str, + } + + /// Create a minimal Chromium Cookies SQLite DB with the real schema. + fn create_test_cookies_db(path: &Path, meta_version: i64, cookies: &[(&str, &str, &[u8])]) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR); + CREATE TABLE cookies( + creation_utc INTEGER NOT NULL, + host_key TEXT NOT NULL DEFAULT '', + top_frame_site_key TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL DEFAULT X'', + path TEXT NOT NULL DEFAULT '/', + expires_utc INTEGER NOT NULL DEFAULT 0, + is_secure INTEGER NOT NULL DEFAULT 0, + is_httponly INTEGER NOT NULL DEFAULT 0, + last_access_utc INTEGER NOT NULL DEFAULT 0, + has_expires INTEGER NOT NULL DEFAULT 1, + is_persistent INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 1, + samesite INTEGER NOT NULL DEFAULT -1, + source_scheme INTEGER NOT NULL DEFAULT 0, + source_port INTEGER NOT NULL DEFAULT -1, + last_update_utc INTEGER NOT NULL DEFAULT 0, + source_type INTEGER NOT NULL DEFAULT 0, + has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO meta (key, value) VALUES ('version', ?1)", + rusqlite::params![meta_version], + ) + .unwrap(); + for (i, (host_key, name, encrypted_value)) in cookies.iter().enumerate() { + conn.execute( + "INSERT INTO cookies (creation_utc, host_key, name, encrypted_value, \ + last_access_utc, last_update_utc) VALUES (?1, ?2, ?3, ?4, ?1, ?1)", + rusqlite::params![i as i64 + 1, host_key, name, *encrypted_value], + ) + .unwrap(); + } + } + + /// Create a browser profile directory with encrypted test cookies. + fn scaffold_browser_profile( + base_dir: &Path, + profile_name: &str, + meta_version: i64, + test_cookies: &[TestCookie], + source_key: &[u8; 16], + ) -> PathBuf { + let profile_dir = base_dir.join(profile_name); + std::fs::create_dir_all(&profile_dir).unwrap(); + + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, source_key, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&profile_dir.join("Cookies"), meta_version, &cookie_refs); + + let ls_dir = profile_dir.join("Local Storage").join("leveldb"); + std::fs::create_dir_all(&ls_dir).unwrap(); + std::fs::write(ls_dir.join("CURRENT"), "MANIFEST-000001\n").unwrap(); + + profile_dir + } + + /// Verify that a re-encrypted DB can be decrypted with the "peanuts" key. + fn verify_reencrypted_db( + db_bytes: &[u8], + expected_cookies: &[(&str, &str, &str)], + meta_version: i64, + ) { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), db_bytes).unwrap(); + let conn = rusqlite::Connection::open(tmp.path()).unwrap(); + let peanuts_key = derive_target_key(); + + let mut stmt = conn + .prepare("SELECT host_key, name, encrypted_value FROM cookies ORDER BY creation_utc") + .unwrap(); + let rows: Vec<(String, String, Vec)> = stmt + .query_map([], |row| { + let ev: Vec = match row.get_ref(2)? { + rusqlite::types::ValueRef::Blob(b) => b.to_vec(), + rusqlite::types::ValueRef::Text(t) => t.to_vec(), + _ => Vec::new(), + }; + Ok((row.get(0)?, row.get(1)?, ev)) + }) + .unwrap() + .collect::>() + .unwrap(); + + assert_eq!(rows.len(), expected_cookies.len(), "cookie count mismatch"); + for ((host_key, name, encrypted_value), (exp_host, exp_name, exp_value)) in + rows.iter().zip(expected_cookies.iter()) + { + assert_eq!(host_key, exp_host); + assert_eq!(name, exp_name); + let decrypted = decrypt_cookie( + encrypted_value, + &peanuts_key, + host_key, + meta_version, + CryptoAlgorithm::Aes128Cbc, + ); + assert_eq!( + decrypted.as_deref(), + Some(*exp_value), + "Cookie {name} on {host_key} not decryptable with peanuts key" + ); + } + } + + /// Find a Chromium-based binary on the system for compatibility testing. + fn find_chromium_binary() -> Option { + let candidates: &[&str] = if cfg!(target_os = "macos") { + &["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + } else { + &[ + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + ] + }; + for &c in candidates { + let ok = if c.contains('/') { + Path::new(c).exists() + } else { + std::process::Command::new(c) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + if ok { + return Some(c.to_string()); + } + } + None + } + + /// Extract the packaged zip and launch Chromium headless to verify the + /// profile loads without crashing. + fn verify_chromium_loads_profile(zip_buffer: &[u8]) { + let Some(chromium) = find_chromium_binary() else { + eprintln!("Skipping Chromium load test — no binary found"); + return; + }; + + let tmp = tempfile::TempDir::new().unwrap(); + let cursor = std::io::Cursor::new(zip_buffer); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + archive.extract(tmp.path()).unwrap(); + + let output = std::process::Command::new(&chromium) + .args([ + "--headless", + "--disable-gpu", + "--no-sandbox", + "--no-first-run", + "--disable-sync", + &format!("--user-data-dir={}", tmp.path().display()), + "--dump-dom", + "about:blank", + ]) + .output() + .expect("failed to launch Chromium"); + + assert!( + output.status.success(), + "Chromium failed to load packaged profile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // ─── Unit tests: reencrypt pipeline ────────────────────────────────────── + + #[test] + fn reencrypt_cookies_db_synthetic() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let target_key = derive_target_key(); + assert_ne!(source_key, target_key, "source and target keys must differ"); + + let meta_version = 20; + let test_cookies = [ + TestCookie { + host_key: ".example.com", + name: "session", + value: "abc123", + }, + TestCookie { + host_key: ".google.com", + name: "NID", + value: "some_value", + }, + TestCookie { + host_key: "localhost", + name: "dev_token", + value: "dev_secret", + }, + ]; + + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 3); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[ + (".example.com", "session", "abc123"), + (".google.com", "NID", "some_value"), + ("localhost", "dev_token", "dev_secret"), + ], + meta_version, + ); + } + + #[test] + fn reencrypt_cookies_db_v24_domain_hash() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let meta_version = 24; + + let test_cookies = [ + TestCookie { + host_key: ".example.com", + name: "session", + value: "v24_value", + }, + TestCookie { + host_key: ".domain.org", + name: "pref", + value: "domain_pref", + }, + ]; + + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 2); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[ + (".example.com", "session", "v24_value"), + (".domain.org", "pref", "domain_pref"), + ], + meta_version, + ); + } + + #[test] + fn reencrypt_mixed_valid_and_corrupt() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let meta_version = 20; + + let valid_enc = encrypt_cookie("good_value", &source_key, ".example.com", meta_version); + let corrupt1 = b"v10garbage_not_valid_ciphertext".to_vec(); + let corrupt2 = b"v10\x00\x01\x02\x03".to_vec(); + + let cookies: Vec<(&str, &str, &[u8])> = vec![ + (".example.com", "good", valid_enc.as_slice()), + (".bad1.com", "corrupt1", corrupt1.as_slice()), + (".bad2.com", "corrupt2", corrupt2.as_slice()), + ]; + + create_test_cookies_db(&cookies_path, meta_version, &cookies); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 1); + assert_eq!(result.skipped, 2); + } + + // ─── Browser integration tests (#[ignore]) ────────────────────────────── + + fn run_browser_integration_test(browser: BrowserId) { + if cfg!(target_os = "windows") { + eprintln!("Skipping integration test on Windows"); + return; + } + + // 1. Browser installation check + let profiles = find_browser_profiles(browser); + if profiles.is_empty() { + eprintln!("Skipping {} — no profiles found", browser.display_name()); + return; + } + + // 2. Key extraction + let provider = get_key_provider(browser) + .unwrap_or_else(|e| panic!("Failed to get key for {}: {e}", browser.display_name())); + + // 3. Schema verification on existing profile + let desc = browser.descriptor(); + let base_dir = desc.profile_base_dir().unwrap(); + let first_cookies = base_dir.join(&profiles[0].dir_name).join("Cookies"); + let meta_version: i64 = if first_cookies.exists() { + let conn = rusqlite::Connection::open(&first_cookies).unwrap(); + let v: i64 = conn + .query_row( + "SELECT CAST(value AS INTEGER) FROM meta WHERE key='version'", + [], + |r| r.get(0), + ) + .expect("meta table should have version"); + let mut stmt = conn.prepare("PRAGMA table_info(cookies)").unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>() + .unwrap(); + assert!( + columns.contains(&"host_key".to_string()), + "missing host_key" + ); + assert!( + columns.contains(&"encrypted_value".to_string()), + "missing encrypted_value" + ); + v + } else { + 20 + }; + + // 4. Create test profile + let test_cookies = [ + TestCookie { + host_key: ".example.com", + name: "session", + value: "integration_session", + }, + TestCookie { + host_key: ".test.org", + name: "auth", + value: "integration_auth", + }, + TestCookie { + host_key: "localhost", + name: "dev", + value: "integration_dev", + }, + ]; + let source_key_16: [u8; 16] = provider.source_key[..16].try_into().unwrap(); + let test_profile_name = "SteelTest"; + let test_profile_dir = base_dir.join(test_profile_name); + + if test_profile_dir.exists() { + std::fs::remove_dir_all(&test_profile_dir).unwrap(); + } + scaffold_browser_profile( + &base_dir, + test_profile_name, + meta_version, + &test_cookies, + &source_key_16, + ); + + // 5. reencrypt_cookies_db + let reencrypt = reencrypt_cookies_db(&test_profile_dir.join("Cookies"), &provider).unwrap(); + assert!(reencrypt.converted > 0, "should convert cookies"); + assert_eq!(reencrypt.skipped, 0, "should not skip cookies"); + + // 6. package_profile + let pkg = package_profile(browser, test_profile_name, false, &|_| {}).unwrap(); + assert!(pkg.cookies_reencrypted > 0); + assert_eq!(pkg.cookies_skipped, 0); + + let cursor = std::io::Cursor::new(&pkg.zip_buffer); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + + // Default/Cookies — valid SQLite, decryptable with peanuts key + { + let mut entry = archive + .by_name("Default/Cookies") + .expect("zip missing Default/Cookies"); + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); + assert_eq!(&buf[..16], b"SQLite format 3\0", "not a valid SQLite file"); + verify_reencrypted_db( + &buf, + &[ + (".example.com", "session", "integration_session"), + (".test.org", "auth", "integration_auth"), + ("localhost", "dev", "integration_dev"), + ], + meta_version, + ); + } + + // Default/Preferences — exit_type: Normal + { + let mut entry = archive + .by_name("Default/Preferences") + .expect("zip missing Default/Preferences"); + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); + let prefs: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(prefs["profile"]["exit_type"], "Normal"); + } + + // No LOCK, .log, .pma files + for i in 0..archive.len() { + let name = archive.by_index(i).unwrap().name().to_string(); + assert!(!name.contains("LOCK"), "zip contains LOCK: {name}"); + assert!(!name.ends_with(".log"), "zip contains .log: {name}"); + assert!(!name.ends_with(".pma"), "zip contains .pma: {name}"); + } + + // 7. Verify Chromium can load the packaged profile + verify_chromium_loads_profile(&pkg.zip_buffer); + + // 8. Cleanup + let _ = std::fs::remove_dir_all(&test_profile_dir); + } + + #[test] + #[ignore] + fn integration_chrome_profile_import() { + run_browser_integration_test(BrowserId::Chrome); + } + + #[test] + #[ignore] + fn integration_brave_profile_import() { + run_browser_integration_test(BrowserId::Brave); + } + + #[test] + #[ignore] + fn integration_edge_profile_import() { + run_browser_integration_test(BrowserId::Edge); + } + + #[test] + #[ignore] + fn integration_opera_profile_import() { + run_browser_integration_test(BrowserId::Opera); + } + + #[test] + #[ignore] + fn integration_vivaldi_profile_import() { + run_browser_integration_test(BrowserId::Vivaldi); + } + + #[test] + #[ignore] + fn integration_gnome_keyring_key_differentiation() { + if std::env::var("STEEL_TEST_GNOME_KEYRING").is_err() { + eprintln!("Skipping — STEEL_TEST_GNOME_KEYRING not set"); + return; + } + + let provider = + get_key_provider(BrowserId::Chrome).expect("Chrome key provider with GNOME Keyring"); + let source_key_16: [u8; 16] = provider.source_key[..16].try_into().unwrap(); + let peanuts_key = derive_target_key(); + assert_ne!( + source_key_16, peanuts_key, + "GNOME Keyring source key should differ from peanuts" + ); + + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + let meta_version = 20; + + let test_cookies = [TestCookie { + host_key: ".gnome.test", + name: "keyring", + value: "secret_from_keyring", + }]; + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key_16, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 1); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[(".gnome.test", "keyring", "secret_from_keyring")], + meta_version, + ); + } } From ba489b7b0470a7b374ba889ab52eef397eefb236 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 26 Mar 2026 20:26:54 +0900 Subject: [PATCH 2/6] test: verify profile cookies --- src/browser/profile_porter.rs | 232 ++++++++++++++++++++++++++++++++-- 1 file changed, 221 insertions(+), 11 deletions(-) diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index ca04cb2..c0e1f79 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -1292,9 +1292,9 @@ mod tests { None } - /// Extract the packaged zip and launch Chromium headless to verify the - /// profile loads without crashing. - fn verify_chromium_loads_profile(zip_buffer: &[u8]) { + /// Extract the packaged zip, launch Chromium with the profile, and verify + /// cookies are accessible via CDP (`Storage.getCookies`). + fn verify_chromium_loads_profile(zip_buffer: &[u8], expected_cookies: &[(&str, &str)]) { let Some(chromium) = find_chromium_binary() else { eprintln!("Skipping Chromium load test — no binary found"); return; @@ -1305,7 +1305,12 @@ mod tests { let mut archive = zip::ZipArchive::new(cursor).unwrap(); archive.extract(tmp.path()).unwrap(); - let output = std::process::Command::new(&chromium) + // Find a free port for CDP + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let mut child = std::process::Command::new(&chromium) .args([ "--headless", "--disable-gpu", @@ -1313,17 +1318,215 @@ mod tests { "--no-first-run", "--disable-sync", &format!("--user-data-dir={}", tmp.path().display()), - "--dump-dom", + &format!("--remote-debugging-port={port}"), "about:blank", ]) - .output() + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() .expect("failed to launch Chromium"); + // Wait for CDP to become available + let cdp_base = format!("http://127.0.0.1:{port}"); + let mut cdp_ready = false; + for _ in 0..50 { + std::thread::sleep(std::time::Duration::from_millis(200)); + if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { + cdp_ready = true; + break; + } + } + if !cdp_ready { + child.kill().ok(); + child.wait().ok(); + panic!("Chromium CDP did not become available within 10s"); + } + + // Get the first page's WebSocket debugger URL + let version_body = http_get(&format!("{cdp_base}/json/version")); + let version: serde_json::Value = serde_json::from_str(&version_body) + .unwrap_or_else(|e| panic!("bad /json/version response: {e}\n{version_body}")); + let ws_url = version["webSocketDebuggerUrl"] + .as_str() + .expect("missing webSocketDebuggerUrl"); + + // Connect to WebSocket and call Storage.getCookies + let cookies_json = cdp_get_all_cookies(ws_url); + let cookies: Vec = cookies_json + .as_array() + .expect("cookies should be an array") + .to_vec(); + + // Verify expected cookies are present + for (host, name) in expected_cookies { + let found = cookies.iter().any(|c| { + c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name) + }); + assert!(found, "Cookie {name} on {host} not found via CDP. Got: {cookies:?}"); + } + + child.kill().ok(); + child.wait().ok(); + } + + /// Minimal sync HTTP GET using std::net only (no reqwest dependency in tests). + fn http_get(url: &str) -> String { + let url = url.strip_prefix("http://").unwrap_or(url); + let (host_port, path) = url.split_once('/').unwrap_or((url, "")); + let path = format!("/{path}"); + + let mut stream = std::net::TcpStream::connect(host_port).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .ok(); + let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut stream, request.as_bytes()).unwrap(); + + let mut response = Vec::new(); + std::io::Read::read_to_end(&mut stream, &mut response).ok(); + let response = String::from_utf8_lossy(&response); + + // Split headers from body + response + .split_once("\r\n\r\n") + .map(|(_, body)| body.to_string()) + .unwrap_or_default() + } + + /// Connect to CDP WebSocket and call Storage.getCookies. + /// Uses a raw TCP + minimal WebSocket frame implementation. + fn cdp_get_all_cookies(ws_url: &str) -> serde_json::Value { + use sha1::{Digest, Sha1}; + + // Parse ws://host:port/path + let url = ws_url.strip_prefix("ws://").expect("expected ws:// URL"); + let (host_port, path) = url.split_once('/').unwrap_or((url, "")); + let path = format!("/{path}"); + + let mut stream = std::net::TcpStream::connect(host_port).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .ok(); + + // WebSocket handshake + let ws_key = "dGhlIHNhbXBsZSBub25jZQ=="; // static key, fine for tests + let handshake = format!( + "GET {path} HTTP/1.1\r\n\ + Host: {host_port}\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Key: {ws_key}\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n" + ); + std::io::Write::write_all(&mut stream, handshake.as_bytes()).unwrap(); + + // Read until we get the end of HTTP headers + let mut header_buf = Vec::new(); + loop { + let mut byte = [0u8; 1]; + std::io::Read::read_exact(&mut stream, &mut byte).unwrap(); + header_buf.push(byte[0]); + if header_buf.ends_with(b"\r\n\r\n") { + break; + } + } + + let header_str = String::from_utf8_lossy(&header_buf); assert!( - output.status.success(), - "Chromium failed to load packaged profile:\n{}", - String::from_utf8_lossy(&output.stderr) + header_str.contains("101"), + "WebSocket upgrade failed: {header_str}" ); + + // Verify Sec-WebSocket-Accept + let expected_accept = { + let mut hasher = Sha1::new(); + hasher.update(ws_key.as_bytes()); + hasher.update(b"258EAFA5-E914-47DA-95CA-5AB5DC11650B"); + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) + }; + assert!( + header_str.contains(&expected_accept), + "bad Sec-WebSocket-Accept" + ); + + // Send CDP command: Storage.getCookies + let cmd = serde_json::json!({"id": 1, "method": "Storage.getCookies"}); + let payload = serde_json::to_vec(&cmd).unwrap(); + ws_send_frame(&mut stream, &payload); + + // Read response frames until we get our result (id: 1) + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if std::time::Instant::now() > deadline { + panic!("Timed out waiting for CDP response"); + } + let frame = ws_read_frame(&mut stream); + if let Ok(msg) = serde_json::from_slice::(&frame) + && msg.get("id") == Some(&serde_json::json!(1)) + { + return msg["result"]["cookies"].clone(); + } + } + } + + fn ws_send_frame(stream: &mut std::net::TcpStream, payload: &[u8]) { + let mut frame = Vec::new(); + // FIN + text opcode + frame.push(0x81); + // Masked + length + let len = payload.len(); + if len < 126 { + frame.push(0x80 | len as u8); + } else if len <= 65535 { + frame.push(0x80 | 126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + frame.push(0x80 | 127); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + // Masking key (all zeros — simple, fine for tests) + let mask = [0u8; 4]; + frame.extend_from_slice(&mask); + // Payload (mask XOR with zero = identity) + frame.extend_from_slice(payload); + std::io::Write::write_all(stream, &frame).unwrap(); + } + + fn ws_read_frame(stream: &mut std::net::TcpStream) -> Vec { + let mut header = [0u8; 2]; + std::io::Read::read_exact(stream, &mut header).unwrap(); + + let masked = header[1] & 0x80 != 0; + let mut len = (header[1] & 0x7F) as u64; + + if len == 126 { + let mut buf = [0u8; 2]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + len = u16::from_be_bytes(buf) as u64; + } else if len == 127 { + let mut buf = [0u8; 8]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + len = u64::from_be_bytes(buf); + } + + let mask_key = if masked { + let mut buf = [0u8; 4]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + buf + } else { + [0u8; 4] + }; + + let mut payload = vec![0u8; len as usize]; + std::io::Read::read_exact(stream, &mut payload).unwrap(); + + if masked { + for (i, byte) in payload.iter_mut().enumerate() { + *byte ^= mask_key[i % 4]; + } + } + payload } // ─── Unit tests: reencrypt pipeline ────────────────────────────────────── @@ -1618,8 +1821,15 @@ mod tests { assert!(!name.ends_with(".pma"), "zip contains .pma: {name}"); } - // 7. Verify Chromium can load the packaged profile - verify_chromium_loads_profile(&pkg.zip_buffer); + // 7. Verify Chromium can load the packaged profile and read cookies via CDP + verify_chromium_loads_profile( + &pkg.zip_buffer, + &[ + (".example.com", "session"), + (".test.org", "auth"), + ("localhost", "dev"), + ], + ); // 8. Cleanup let _ = std::fs::remove_dir_all(&test_profile_dir); From b499800a9434c34f43356c23dce77ed9610ca0a4 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 26 Mar 2026 20:36:22 +0900 Subject: [PATCH 3/6] test: use real profiles --- src/browser/profile_porter.rs | 168 ++++++++++------------------------ 1 file changed, 50 insertions(+), 118 deletions(-) diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index c0e1f79..38fabef 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -1181,38 +1181,6 @@ mod tests { } } - /// Create a browser profile directory with encrypted test cookies. - fn scaffold_browser_profile( - base_dir: &Path, - profile_name: &str, - meta_version: i64, - test_cookies: &[TestCookie], - source_key: &[u8; 16], - ) -> PathBuf { - let profile_dir = base_dir.join(profile_name); - std::fs::create_dir_all(&profile_dir).unwrap(); - - let encrypted: Vec<(&str, &str, Vec)> = test_cookies - .iter() - .map(|c| { - let enc = encrypt_cookie(c.value, source_key, c.host_key, meta_version); - (c.host_key, c.name, enc) - }) - .collect(); - let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted - .iter() - .map(|(h, n, e)| (*h, *n, e.as_slice())) - .collect(); - - create_test_cookies_db(&profile_dir.join("Cookies"), meta_version, &cookie_refs); - - let ls_dir = profile_dir.join("Local Storage").join("leveldb"); - std::fs::create_dir_all(&ls_dir).unwrap(); - std::fs::write(ls_dir.join("CURRENT"), "MANIFEST-000001\n").unwrap(); - - profile_dir - } - /// Verify that a re-encrypted DB can be decrypted with the "peanuts" key. fn verify_reencrypted_db( db_bytes: &[u8], @@ -1293,8 +1261,8 @@ mod tests { } /// Extract the packaged zip, launch Chromium with the profile, and verify - /// cookies are accessible via CDP (`Storage.getCookies`). - fn verify_chromium_loads_profile(zip_buffer: &[u8], expected_cookies: &[(&str, &str)]) { + /// all cookies from the DB are accessible via CDP (`Storage.getCookies`). + fn verify_chromium_loads_profile(zip_buffer: &[u8]) { let Some(chromium) = find_chromium_binary() else { eprintln!("Skipping Chromium load test — no binary found"); return; @@ -1305,6 +1273,23 @@ mod tests { let mut archive = zip::ZipArchive::new(cursor).unwrap(); archive.extract(tmp.path()).unwrap(); + // Extract expected cookies directly from the packaged DB + let expected_cookies: Vec<(String, String)> = { + let db_path = tmp.path().join("Default/Cookies"); + if !db_path.exists() { + Vec::new() + } else { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let mut stmt = conn + .prepare("SELECT host_key, name FROM cookies") + .unwrap(); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap() + } + }; + // Find a free port for CDP let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); @@ -1342,7 +1327,7 @@ mod tests { panic!("Chromium CDP did not become available within 10s"); } - // Get the first page's WebSocket debugger URL + // Get the browser's WebSocket debugger URL let version_body = http_get(&format!("{cdp_base}/json/version")); let version: serde_json::Value = serde_json::from_str(&version_body) .unwrap_or_else(|e| panic!("bad /json/version response: {e}\n{version_body}")); @@ -1350,19 +1335,22 @@ mod tests { .as_str() .expect("missing webSocketDebuggerUrl"); - // Connect to WebSocket and call Storage.getCookies + // Query cookies via CDP let cookies_json = cdp_get_all_cookies(ws_url); - let cookies: Vec = cookies_json + let cdp_cookies: Vec = cookies_json .as_array() .expect("cookies should be an array") .to_vec(); - // Verify expected cookies are present - for (host, name) in expected_cookies { - let found = cookies.iter().any(|c| { + // Every cookie from the packaged DB must be visible to Chromium + for (host, name) in &expected_cookies { + let found = cdp_cookies.iter().any(|c| { c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name) }); - assert!(found, "Cookie {name} on {host} not found via CDP. Got: {cookies:?}"); + assert!( + found, + "Cookie {name} on {host} not found via CDP. Got: {cdp_cookies:?}" + ); } child.kill().ok(); @@ -1705,13 +1693,15 @@ mod tests { let provider = get_key_provider(browser) .unwrap_or_else(|e| panic!("Failed to get key for {}: {e}", browser.display_name())); - // 3. Schema verification on existing profile + // 3. Schema verification on the real profile let desc = browser.descriptor(); let base_dir = desc.profile_base_dir().unwrap(); - let first_cookies = base_dir.join(&profiles[0].dir_name).join("Cookies"); - let meta_version: i64 = if first_cookies.exists() { - let conn = rusqlite::Connection::open(&first_cookies).unwrap(); - let v: i64 = conn + let profile = &profiles[0]; + let cookies_path = base_dir.join(&profile.dir_name).join("Cookies"); + + if cookies_path.exists() { + let conn = rusqlite::Connection::open(&cookies_path).unwrap(); + let _v: i64 = conn .query_row( "SELECT CAST(value AS INTEGER) FROM meta WHERE key='version'", [], @@ -1724,66 +1714,29 @@ mod tests { .unwrap() .collect::>() .unwrap(); - assert!( - columns.contains(&"host_key".to_string()), - "missing host_key" - ); + assert!(columns.contains(&"host_key".to_string()), "missing host_key"); assert!( columns.contains(&"encrypted_value".to_string()), "missing encrypted_value" ); - v - } else { - 20 - }; - - // 4. Create test profile - let test_cookies = [ - TestCookie { - host_key: ".example.com", - name: "session", - value: "integration_session", - }, - TestCookie { - host_key: ".test.org", - name: "auth", - value: "integration_auth", - }, - TestCookie { - host_key: "localhost", - name: "dev", - value: "integration_dev", - }, - ]; - let source_key_16: [u8; 16] = provider.source_key[..16].try_into().unwrap(); - let test_profile_name = "SteelTest"; - let test_profile_dir = base_dir.join(test_profile_name); - - if test_profile_dir.exists() { - std::fs::remove_dir_all(&test_profile_dir).unwrap(); } - scaffold_browser_profile( - &base_dir, - test_profile_name, - meta_version, - &test_cookies, - &source_key_16, - ); - // 5. reencrypt_cookies_db - let reencrypt = reencrypt_cookies_db(&test_profile_dir.join("Cookies"), &provider).unwrap(); - assert!(reencrypt.converted > 0, "should convert cookies"); - assert_eq!(reencrypt.skipped, 0, "should not skip cookies"); + // 4. Reencrypt the real cookies DB + let reencrypt = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + eprintln!( + "{}: {} converted, {} skipped", + browser.display_name(), + reencrypt.converted, + reencrypt.skipped + ); - // 6. package_profile - let pkg = package_profile(browser, test_profile_name, false, &|_| {}).unwrap(); - assert!(pkg.cookies_reencrypted > 0); - assert_eq!(pkg.cookies_skipped, 0); + // 5. Package the real profile + let pkg = package_profile(browser, &profile.dir_name, false, &|_| {}).unwrap(); + // 6. Verify zip structure let cursor = std::io::Cursor::new(&pkg.zip_buffer); let mut archive = zip::ZipArchive::new(cursor).unwrap(); - // Default/Cookies — valid SQLite, decryptable with peanuts key { let mut entry = archive .by_name("Default/Cookies") @@ -1791,18 +1744,8 @@ mod tests { let mut buf = Vec::new(); std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); assert_eq!(&buf[..16], b"SQLite format 3\0", "not a valid SQLite file"); - verify_reencrypted_db( - &buf, - &[ - (".example.com", "session", "integration_session"), - (".test.org", "auth", "integration_auth"), - ("localhost", "dev", "integration_dev"), - ], - meta_version, - ); } - // Default/Preferences — exit_type: Normal { let mut entry = archive .by_name("Default/Preferences") @@ -1813,7 +1756,6 @@ mod tests { assert_eq!(prefs["profile"]["exit_type"], "Normal"); } - // No LOCK, .log, .pma files for i in 0..archive.len() { let name = archive.by_index(i).unwrap().name().to_string(); assert!(!name.contains("LOCK"), "zip contains LOCK: {name}"); @@ -1821,18 +1763,8 @@ mod tests { assert!(!name.ends_with(".pma"), "zip contains .pma: {name}"); } - // 7. Verify Chromium can load the packaged profile and read cookies via CDP - verify_chromium_loads_profile( - &pkg.zip_buffer, - &[ - (".example.com", "session"), - (".test.org", "auth"), - ("localhost", "dev"), - ], - ); - - // 8. Cleanup - let _ = std::fs::remove_dir_all(&test_profile_dir); + // 7. Launch Chromium with packaged profile, verify cookies via CDP + verify_chromium_loads_profile(&pkg.zip_buffer); } #[test] From eec59798c2a7cf2034b7a4d552d7b6eb6ad3de46 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 26 Mar 2026 20:41:37 +0900 Subject: [PATCH 4/6] chore: format --- src/browser/profile_porter.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 38fabef..495fcc3 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -1280,9 +1280,7 @@ mod tests { Vec::new() } else { let conn = rusqlite::Connection::open(&db_path).unwrap(); - let mut stmt = conn - .prepare("SELECT host_key, name FROM cookies") - .unwrap(); + let mut stmt = conn.prepare("SELECT host_key, name FROM cookies").unwrap(); stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .unwrap() .collect::>() @@ -1344,9 +1342,9 @@ mod tests { // Every cookie from the packaged DB must be visible to Chromium for (host, name) in &expected_cookies { - let found = cdp_cookies.iter().any(|c| { - c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name) - }); + let found = cdp_cookies + .iter() + .any(|c| c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name)); assert!( found, "Cookie {name} on {host} not found via CDP. Got: {cdp_cookies:?}" @@ -1367,7 +1365,8 @@ mod tests { stream .set_read_timeout(Some(std::time::Duration::from_secs(5))) .ok(); - let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); std::io::Write::write_all(&mut stream, request.as_bytes()).unwrap(); let mut response = Vec::new(); @@ -1714,7 +1713,10 @@ mod tests { .unwrap() .collect::>() .unwrap(); - assert!(columns.contains(&"host_key".to_string()), "missing host_key"); + assert!( + columns.contains(&"host_key".to_string()), + "missing host_key" + ); assert!( columns.contains(&"encrypted_value".to_string()), "missing encrypted_value" From 3fa28b7143ef2d71bdc554ce9efffdc60c116791 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 26 Mar 2026 20:47:45 +0900 Subject: [PATCH 5/6] test: remove Sec-WebSocket-Accept --- src/browser/profile_porter.rs | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 495fcc3..f34dd1e 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -1383,8 +1383,6 @@ mod tests { /// Connect to CDP WebSocket and call Storage.getCookies. /// Uses a raw TCP + minimal WebSocket frame implementation. fn cdp_get_all_cookies(ws_url: &str) -> serde_json::Value { - use sha1::{Digest, Sha1}; - // Parse ws://host:port/path let url = ws_url.strip_prefix("ws://").expect("expected ws:// URL"); let (host_port, path) = url.split_once('/').unwrap_or((url, "")); @@ -1396,18 +1394,17 @@ mod tests { .ok(); // WebSocket handshake - let ws_key = "dGhlIHNhbXBsZSBub25jZQ=="; // static key, fine for tests let handshake = format!( "GET {path} HTTP/1.1\r\n\ Host: {host_port}\r\n\ Upgrade: websocket\r\n\ Connection: Upgrade\r\n\ - Sec-WebSocket-Key: {ws_key}\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ Sec-WebSocket-Version: 13\r\n\r\n" ); std::io::Write::write_all(&mut stream, handshake.as_bytes()).unwrap(); - // Read until we get the end of HTTP headers + // Read HTTP response headers let mut header_buf = Vec::new(); loop { let mut byte = [0u8; 1]; @@ -1424,19 +1421,6 @@ mod tests { "WebSocket upgrade failed: {header_str}" ); - // Verify Sec-WebSocket-Accept - let expected_accept = { - let mut hasher = Sha1::new(); - hasher.update(ws_key.as_bytes()); - hasher.update(b"258EAFA5-E914-47DA-95CA-5AB5DC11650B"); - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) - }; - assert!( - header_str.contains(&expected_accept), - "bad Sec-WebSocket-Accept" - ); - // Send CDP command: Storage.getCookies let cmd = serde_json::json!({"id": 1, "method": "Storage.getCookies"}); let payload = serde_json::to_vec(&cmd).unwrap(); From c8dd2e43f85c5ed4d004bf2176418d9d8c3d0bee Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 26 Mar 2026 21:19:31 +0900 Subject: [PATCH 6/6] test: use mock server for real cookies --- .github/workflows/browser-integration.yml | 52 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/browser-integration.yml b/.github/workflows/browser-integration.yml index 0f1744c..192583b 100644 --- a/.github/workflows/browser-integration.yml +++ b/.github/workflows/browser-integration.yml @@ -80,10 +80,24 @@ jobs: Xvfb :99 -screen 0 1920x1080x24 & sleep 2 - - name: Initialize browser profile + - name: Initialize browser profile with cookies run: | - timeout 15 ${{ matrix.binary }} --no-first-run --disable-sync --disable-gpu about:blank || true + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 ${{ matrix.binary }} --no-first-run --disable-sync --disable-gpu http://127.0.0.1:8765/ || true pkill -f "${{ matrix.binary }}" 2>/dev/null || true + kill %1 2>/dev/null || true sleep 1 find ~/.config -name "Cookies" -type f || true @@ -116,8 +130,22 @@ jobs: eval $(dbus-launch --sh-syntax) echo "" | gnome-keyring-daemon --unlock --components=secrets echo -n "my-custom-chrome-password" | secret-tool store --label="Chrome Safe Storage" application chrome - timeout 15 google-chrome-stable --no-first-run --disable-sync --disable-gpu about:blank || true + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 google-chrome-stable --no-first-run --disable-sync --disable-gpu http://127.0.0.1:8765/ || true pkill -f "google-chrome" 2>/dev/null || true + kill %1 2>/dev/null || true sleep 1 STEEL_TEST_GNOME_KEYRING=1 cargo nextest run --run-ignored only -E 'test(integration_gnome_keyring)' @@ -132,10 +160,24 @@ jobs: - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@nextest - - name: Initialize Chrome profile + - name: Initialize Chrome profile with cookies run: | - timeout 15 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --no-first-run --disable-sync about:blank || true + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --no-first-run --disable-sync http://127.0.0.1:8765/ || true pkill -f "Google Chrome" 2>/dev/null || true + kill %1 2>/dev/null || true sleep 1 - name: Run integration tests