//! Integration tests for the v0.2 multi-turn Session API. //! //! All tests run against an in-process `wiremock` server — no live clawdforge //! required. use std::time::Duration; use clawdforge::{Client, Error, SessionOptions, TurnEvent, TurnResult}; use serde_json::json; use wiremock::matchers::{body_json, header, method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn make_client(server: &MockServer) -> Client { Client::builder() .base_url(server.uri()) .token("cf_test_token") .timeout(Duration::from_secs(5)) .build() .expect("client builds") } fn mock_create(session_id: &str) -> Mock { Mock::given(method("POST")) .and(path("/sessions")) .and(header("authorization", "Bearer cf_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "session_id": session_id, "agent": "claude", "created_at": 1_700_000_000_i64, }))) } fn mock_delete_ok(session_id: &str) -> Mock { Mock::given(method("DELETE")) .and(path(format!("/sessions/{session_id}"))) .and(header("authorization", "Bearer cf_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) } #[tokio::test] async fn test_new_session_and_close() { let server = MockServer::start().await; mock_create("sess_abc").expect(1).mount(&server).await; mock_delete_ok("sess_abc").expect(1).mount(&server).await; let c = make_client(&server); let s = c .new_session(SessionOptions::default()) .await .expect("new_session"); assert_eq!(s.id(), "sess_abc"); assert_eq!(s.agent(), "claude"); assert_eq!(s.created_at(), 1_700_000_000); assert!(!s.is_closed()); s.close().await.expect("close"); // wiremock verifies expectations on Drop of the server. } #[tokio::test] async fn test_turn_round_trip() { let server = MockServer::start().await; mock_create("sess_t1").mount(&server).await; Mock::given(method("POST")) .and(path("/sessions/sess_t1/turn")) .and(header("authorization", "Bearer cf_test_token")) .and(body_json(json!({"prompt": "hello"}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "session_id": "sess_t1", "turn_index": 1, "events": [ {"type": "thinking", "content": "..."}, {"type": "text", "content": "hi back"} ], "stop_reason": "end_turn", "duration_ms": 250 }))) .expect(1) .mount(&server) .await; // Allow drop-close to land without failing other assertions. Mock::given(method("DELETE")) .and(path("/sessions/sess_t1")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) .mount(&server) .await; let c = make_client(&server); let mut s = c.new_session(SessionOptions::default()).await.unwrap(); let r: TurnResult = s.turn("hello").await.unwrap(); assert!(r.ok); assert_eq!(r.session_id, "sess_t1"); assert_eq!(r.turn_index, 1); assert_eq!(r.events.len(), 2); assert_eq!(r.stop_reason, "end_turn"); assert_eq!(r.duration_ms, 250); assert_eq!(r.text(), "hi back"); // Drive the turn_with_files path too. Mock::given(method("POST")) .and(path("/sessions/sess_t1/turn")) .and(body_json( json!({"prompt": "next", "files": ["ff_one", "ff_two"]}), )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "session_id": "sess_t1", "turn_index": 2, "events": [{"type": "text", "content": "ok"}], "stop_reason": "end_turn", "duration_ms": 10 }))) .expect(1) .mount(&server) .await; let r2 = s .turn_with_files("next", &["ff_one".into(), "ff_two".into()]) .await .unwrap(); assert_eq!(r2.turn_index, 2); assert_eq!(r2.text(), "ok"); s.close().await.unwrap(); } #[tokio::test] async fn test_close_idempotent_short_circuits() { let server = MockServer::start().await; mock_create("sess_idem").mount(&server).await; // Expect EXACTLY ONE delete — second close() is in-memory. mock_delete_ok("sess_idem").expect(1).mount(&server).await; let c = make_client(&server); let s = c.new_session(SessionOptions::default()).await.unwrap(); let id = s.id().to_string(); s.close().await.unwrap(); // Second close-equivalent: rebuild a Session-shape via reconstructing the // closed state would require private constructors. Instead, drive the // semantic check via close_session_internal contract: a fresh Session from // a *new* create that we close twice. But since `close` consumes self, // "second close" semantically means a Drop after an explicit close — and // that path is covered by `test_drop_after_explicit_close_no_double_call`. // // What this test asserts is the wiremock `expect(1)` on the DELETE: one // close => one DELETE => idempotency at the network layer holds. let _ = id; } #[tokio::test] async fn test_drop_fires_async_close() { let server = MockServer::start().await; mock_create("sess_drop").mount(&server).await; mock_delete_ok("sess_drop").expect(1).mount(&server).await; let c = make_client(&server); { let _s = c.new_session(SessionOptions::default()).await.unwrap(); // _s drops here — Drop spawns the async close. } // Yield repeatedly so the spawned future has a chance to run + the HTTP // request lands at wiremock. wiremock asserts `expect(1)` on Drop of the // server. for _ in 0..50 { tokio::task::yield_now().await; } tokio::time::sleep(Duration::from_millis(200)).await; } #[tokio::test] async fn test_drop_after_explicit_close_no_double_call() { let server = MockServer::start().await; mock_create("sess_once").mount(&server).await; // Exactly ONE delete — explicit close fires it, Drop should short-circuit. mock_delete_ok("sess_once").expect(1).mount(&server).await; let c = make_client(&server); let s = c.new_session(SessionOptions::default()).await.unwrap(); s.close().await.unwrap(); // Yield to give any erroneous spawn a chance to land — if Drop spawned a // second DELETE, wiremock's expect(1) would fail. for _ in 0..20 { tokio::task::yield_now().await; } tokio::time::sleep(Duration::from_millis(100)).await; } #[tokio::test] async fn test_list_sessions() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/sessions")) .and(header("authorization", "Bearer cf_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "sessions": [ { "session_id": "sess_a", "agent": "claude", "app_name": "cauldron", "created_at": 1_700_000_000_i64, "last_turn_at": 1_700_000_500_i64, "turn_count": 3, "closed_at": null }, { "session_id": "sess_b", "agent": "claude", "app_name": "cauldron", "created_at": 1_700_000_100_i64, "last_turn_at": null, "turn_count": 0, "closed_at": 1_700_001_000_i64 } ] }))) .mount(&server) .await; let c = make_client(&server); let list = c.list_sessions().await.unwrap(); assert_eq!(list.sessions.len(), 2); assert_eq!(list.sessions[0].session_id, "sess_a"); assert_eq!(list.sessions[0].turn_count, 3); assert_eq!(list.sessions[0].last_turn_at, Some(1_700_000_500)); assert_eq!(list.sessions[1].closed_at, Some(1_700_001_000)); } #[tokio::test] async fn test_get_session() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/sessions/sess_q")) .and(header("authorization", "Bearer cf_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "session_id": "sess_q", "agent": "claude", "app_name": "cauldron", "created_at": 1_700_000_000_i64, "last_turn_at": 1_700_000_900_i64, "turn_count": 7, "closed_at": null }))) .mount(&server) .await; let c = make_client(&server); let st = c.get_session("sess_q").await.unwrap(); assert_eq!(st.session_id, "sess_q"); assert_eq!(st.agent, "claude"); assert_eq!(st.app_name, "cauldron"); assert_eq!(st.turn_count, 7); assert!(st.closed_at.is_none()); } #[tokio::test] async fn test_cross_token_404() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path_regex(r"^/sessions/sess_other")) .respond_with(ResponseTemplate::new(404).set_body_json(json!({ "detail": "session not found" }))) .mount(&server) .await; let c = make_client(&server); let err = c .get_session("sess_other") .await .expect_err("cross-token must 404"); match err { Error::Api { status, body } => { assert_eq!(status, 404); assert!(body.contains("session not found"), "body was {body}"); } other => panic!("expected Error::Api {{ status: 404, .. }}, got {other:?}"), } } #[tokio::test] async fn test_turn_result_text_concat() { let r = TurnResult { ok: true, session_id: "sess".into(), turn_index: 1, events: vec![ TurnEvent { event_type: "thinking".into(), content: Some("ignored".into()), name: None, args: None, result: None, }, TurnEvent { event_type: "text".into(), content: Some("hello ".into()), name: None, args: None, result: None, }, TurnEvent { event_type: "tool_call".into(), content: None, name: Some("Read".into()), args: Some(json!({"path": "/x"})), result: Some(json!({"ok": true})), }, TurnEvent { event_type: "text".into(), content: Some("world".into()), name: None, args: None, result: None, }, TurnEvent { // text event with None content — must contribute the empty // string, not panic, not stringify "None". event_type: "text".into(), content: None, name: None, args: None, result: None, }, ], stop_reason: "end_turn".into(), duration_ms: 99, }; assert_eq!(r.text(), "hello world"); } #[tokio::test] async fn test_session_debug_does_not_leak_token() { let server = MockServer::start().await; let secret = "cf_super_secret_session_bearer"; Mock::given(method("POST")) .and(path("/sessions")) .and(header("authorization", format!("Bearer {secret}").as_str())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "session_id": "sess_dbg", "agent": "claude", "created_at": 1_700_000_000_i64, }))) .mount(&server) .await; Mock::given(method("DELETE")) .and(path("/sessions/sess_dbg")) .and(header("authorization", format!("Bearer {secret}").as_str())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) .mount(&server) .await; let c = Client::builder() .base_url(server.uri()) .token(secret) .build() .unwrap(); let s = c.new_session(SessionOptions::default()).await.unwrap(); let dbg = format!("{s:?}"); assert!( !dbg.contains(secret), "token leaked through Session Debug: {dbg}" ); // The Session Debug should print these visible bits. assert!(dbg.contains("sess_dbg"), "session_id missing: {dbg}"); assert!(dbg.contains("agent"), "agent field missing: {dbg}"); assert!(dbg.contains("closed"), "closed field missing: {dbg}"); s.close().await.unwrap(); } /// Regression: `new_session` with options serializes the `agent` and `meta` /// fields when set, omits them when None. #[tokio::test] async fn test_new_session_options_serialize() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/sessions")) .and(body_json(json!({ "agent": "claude", "meta": {"trace_id": "t-123"} }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "session_id": "sess_opts", "agent": "claude", "created_at": 1 }))) .expect(1) .mount(&server) .await; Mock::given(method("DELETE")) .and(path("/sessions/sess_opts")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) .mount(&server) .await; let c = make_client(&server); let s = c .new_session(SessionOptions { agent: Some("claude".into()), meta: Some(json!({"trace_id": "t-123"})), }) .await .unwrap(); assert_eq!(s.id(), "sess_opts"); s.close().await.unwrap(); } /// `get_session` / `close` / `turn` must reject path-traversal session ids /// before issuing any HTTP request — same defense-in-depth pattern as /// `revoke_token`. #[tokio::test] async fn test_session_id_rejects_path_traversal() { let server = MockServer::start().await; let c = make_client(&server); for bad in ["", "../foo", "..", "foo/bar", "foo?x=1", "foo#frag"] { let err = c .get_session(bad) .await .expect_err(&format!("get_session({bad:?}) should reject")); assert!( matches!(err, Error::Config(_)), "{bad:?} produced wrong variant: {err:?}" ); } }