Fix multiple Set-Cookie headers not being preserved

Changed HttpResponse.headers from HashMap<String, String> to
Vec<(String, String)> to preserve duplicate headers. This fixes
the bug where only the last Set-Cookie header was stored when
servers sent multiple cookies.

Added test case for multiple Set-Cookie headers to prevent
regression.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Gregory Schier
2026-01-04 19:44:33 -08:00
parent a09437018e
commit 6c9791cf0b
2 changed files with 100 additions and 31 deletions

View File

@@ -4,7 +4,6 @@ use crate::types::{SendableBody, SendableHttpRequest};
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::{Client, Method, Version};
use std::collections::HashMap;
use std::fmt::Display;
use std::pin::Pin;
use std::task::{Context, Poll};
@@ -153,10 +152,10 @@ pub struct HttpResponse {
pub status: u16,
/// HTTP status reason phrase (e.g., "OK", "Not Found")
pub status_reason: Option<String>,
/// Response headers
pub headers: HashMap<String, String>,
/// Request headers
pub request_headers: HashMap<String, String>,
/// Response headers (Vec to support multiple headers with same name, e.g., Set-Cookie)
pub headers: Vec<(String, String)>,
/// Request headers (Vec to support multiple headers with same name)
pub request_headers: Vec<(String, String)>,
/// Content-Length from headers (may differ from actual body size)
pub content_length: Option<u64>,
/// Final URL (after redirects)
@@ -194,8 +193,8 @@ impl HttpResponse {
pub fn new(
status: u16,
status_reason: Option<String>,
headers: HashMap<String, String>,
request_headers: HashMap<String, String>,
headers: Vec<(String, String)>,
request_headers: Vec<(String, String)>,
content_length: Option<u64>,
url: String,
remote_addr: Option<String>,
@@ -395,10 +394,10 @@ impl HttpSender for ReqwestSender {
method: sendable_req.method().to_string(),
});
let mut request_headers = HashMap::new();
let mut request_headers = Vec::new();
for (name, value) in sendable_req.headers() {
let v = value.to_str().unwrap_or_default().to_string();
request_headers.insert(name.to_string(), v.clone());
request_headers.push((name.to_string(), v.clone()));
send_event(HttpResponseEvent::HeaderUp(name.to_string(), v));
}
send_event(HttpResponseEvent::Info("Sending request to server".to_string()));
@@ -426,12 +425,12 @@ impl HttpSender for ReqwestSender {
status: response.status().to_string(),
});
// Extract headers
let mut headers = HashMap::new();
// Extract headers (use Vec to preserve duplicates like Set-Cookie)
let mut headers = Vec::new();
for (key, value) in response.headers() {
if let Ok(v) = value.to_str() {
send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));
headers.insert(key.to_string(), v.to_string());
headers.push((key.to_string(), v.to_string()));
}
}

View File

@@ -264,7 +264,6 @@ mod tests {
use crate::decompress::ContentEncoding;
use crate::sender::{HttpResponseEvent, HttpSender};
use async_trait::async_trait;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::AsyncRead;
@@ -277,7 +276,7 @@ mod tests {
struct MockResponse {
status: u16,
headers: HashMap<String, String>,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
@@ -306,7 +305,7 @@ mod tests {
mock.status,
None, // status_reason
mock.headers,
HashMap::new(),
Vec::new(),
None, // content_length
"https://example.com".to_string(), // url
None, // remote_addr
@@ -320,7 +319,7 @@ mod tests {
#[tokio::test]
async fn test_transaction_no_redirect() {
let response = MockResponse { status: 200, headers: HashMap::new(), body: b"OK".to_vec() };
let response = MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() };
let sender = MockSender::new(vec![response]);
let transaction = HttpTransaction::new(sender);
@@ -343,12 +342,11 @@ mod tests {
#[tokio::test]
async fn test_transaction_single_redirect() {
let mut redirect_headers = HashMap::new();
redirect_headers.insert("Location".to_string(), "https://example.com/new".to_string());
let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
let responses = vec![
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
MockResponse { status: 200, headers: HashMap::new(), body: b"Final".to_vec() },
MockResponse { status: 200, headers: Vec::new(), body: b"Final".to_vec() },
];
let sender = MockSender::new(responses);
@@ -375,8 +373,7 @@ mod tests {
#[tokio::test]
async fn test_transaction_max_redirects_exceeded() {
let mut redirect_headers = HashMap::new();
redirect_headers.insert("Location".to_string(), "https://example.com/loop".to_string());
let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
// Create more redirects than allowed
let responses: Vec<MockResponse> = (0..12)
@@ -474,8 +471,8 @@ mod tests {
Ok(HttpResponse::new(
200,
None,
HashMap::new(),
HashMap::new(),
Vec::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,
@@ -528,8 +525,7 @@ mod tests {
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
let mut headers = HashMap::new();
headers.insert("set-cookie".to_string(), "session=xyz789; Path=/".to_string());
let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
@@ -537,7 +533,7 @@ mod tests {
200,
None,
headers,
HashMap::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,
@@ -569,6 +565,79 @@ mod tests {
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
}
#[tokio::test]
async fn test_multiple_set_cookie_headers() {
// Create a cookie store
let cookie_store = CookieStore::new();
// Mock sender that returns multiple Set-Cookie headers
struct MultiSetCookieSender;
#[async_trait]
impl HttpSender for MultiSetCookieSender {
async fn send(
&self,
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
// Multiple Set-Cookie headers (this is standard HTTP behavior)
let headers = vec![
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
("set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string()),
];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
Ok(HttpResponse::new(
200,
None,
headers,
Vec::new(),
None,
"https://example.com".to_string(),
None,
Some("HTTP/1.1".to_string()),
body_stream,
ContentEncoding::Identity,
))
}
}
let sender = MultiSetCookieSender;
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());
let request = SendableHttpRequest {
url: "https://example.com/login".to_string(),
method: "POST".to_string(),
headers: vec![],
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
assert!(result.is_ok());
// Verify all three cookies were stored
let cookies = cookie_store.get_all_cookies();
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
assert!(
cookie_values.iter().any(|c| c.contains("session=abc123")),
"session cookie should be stored"
);
assert!(
cookie_values.iter().any(|c| c.contains("user_id=42")),
"user_id cookie should be stored"
);
assert!(
cookie_values.iter().any(|c| c.contains("preferences=dark")),
"preferences cookie should be stored"
);
}
#[tokio::test]
async fn test_cookies_across_redirects() {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -595,9 +664,10 @@ mod tests {
let (status, headers) = if count == 0 {
// First request: return redirect with Set-Cookie
let mut h = HashMap::new();
h.insert("location".to_string(), "https://example.com/final".to_string());
h.insert("set-cookie".to_string(), "redirect_cookie=value1".to_string());
let h = vec![
("location".to_string(), "https://example.com/final".to_string()),
("set-cookie".to_string(), "redirect_cookie=value1".to_string()),
];
(302, h)
} else {
// Second request: verify cookie was sent
@@ -610,7 +680,7 @@ mod tests {
"Redirect cookie should be included"
);
(200, HashMap::new())
(200, Vec::new())
};
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
@@ -619,7 +689,7 @@ mod tests {
status,
None,
headers,
HashMap::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,