//! Integration tests against an in-process wiremock server. use std::io::Write; use std::time::Duration; use clawdforge::{Client, Error, RunRequest, TokenCreateRequest}; use serde_json::json; use wiremock::matchers::{body_json, body_string_contains, 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") .admin_token("admin_test_token") .timeout(Duration::from_secs(5)) .build() .expect("client builds") } #[tokio::test] async fn healthz_returns_payload() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/healthz")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "claude_present": true, "claude_version": "claude 1.2.3" }))) .mount(&server) .await; let c = make_client(&server); let h = c.healthz().await.unwrap(); assert!(h.ok); assert!(h.claude_present); assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3")); } #[tokio::test] async fn run_success_with_json_result() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/run")) .and(header("authorization", "Bearer cf_test_token")) .and(body_json(json!({ "prompt": "give me json", "model": "sonnet" }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "result": {"hello": "world", "n": 42}, "duration_ms": 1234, "stop_reason": "end_turn" }))) .mount(&server) .await; let c = make_client(&server); let r = c .run(RunRequest { prompt: "give me json".into(), model: Some("sonnet".into()), ..Default::default() }) .await .unwrap(); assert!(r.ok); assert_eq!(r.duration_ms, 1234); assert_eq!(r.stop_reason.as_deref(), Some("end_turn")); #[derive(serde::Deserialize)] struct Reply { hello: String, n: i32, } let parsed: Reply = r.as_json().unwrap(); assert_eq!(parsed.hello, "world"); assert_eq!(parsed.n, 42); assert!(r.as_text().is_none()); } #[tokio::test] async fn run_success_with_text_result() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/run")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "result": "plain string reply", "duration_ms": 50, "stop_reason": "end_turn" }))) .mount(&server) .await; let c = make_client(&server); let r = c .run(RunRequest { prompt: "say hi".into(), ..Default::default() }) .await .unwrap(); assert_eq!(r.as_text(), Some("plain string reply")); let json_attempt: Result, _> = r.as_json(); assert!( json_attempt.is_err(), "string should not deserialize as map" ); } #[tokio::test] async fn run_502_surfaces_api_error() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/run")) .respond_with(ResponseTemplate::new(502).set_body_json(json!({ "ok": false, "error": "claude exited 1", "stderr": "boom", "duration_ms": 10, "stop_reason": null }))) .mount(&server) .await; let c = make_client(&server); let err = c .run(RunRequest { prompt: "fail".into(), ..Default::default() }) .await .expect_err("should fail"); match err { Error::Api { status, body } => { assert_eq!(status, 502); assert!(body.contains("claude exited 1"), "body was {body}"); // Demonstrate caller-side recovery via RunFailure. let parsed: clawdforge::RunFailure = serde_json::from_str(&body).expect("body is RunFailure JSON"); assert!(!parsed.ok); assert_eq!(parsed.error.as_deref(), Some("claude exited 1")); } other => panic!("unexpected error variant: {other:?}"), } } #[tokio::test] async fn run_with_files_passes_through() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/run")) .and(body_json(json!({ "prompt": "use the file", "files": ["ff_abc", "ff_def"] }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "result": "saw 2 files", "duration_ms": 100, "stop_reason": "end_turn" }))) .mount(&server) .await; let c = make_client(&server); let r = c .run(RunRequest { prompt: "use the file".into(), files: Some(vec!["ff_abc".into(), "ff_def".into()]), ..Default::default() }) .await .unwrap(); assert_eq!(r.as_text(), Some("saw 2 files")); } #[tokio::test] async fn upload_file_streams_multipart() { let server = MockServer::start().await; // wiremock can't easily decode multipart, so we fingerprint the bytes: // the file's contents (as a UTF-8 substring) and the form field names. Mock::given(method("POST")) .and(path("/files")) .and(header("authorization", "Bearer cf_test_token")) .and(body_string_contains("hello-from-rust-test")) .and(body_string_contains("name=\"file\"")) .and(body_string_contains("name=\"ttl_secs\"")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "file_token": "ff_xyz", "ttl_secs": 1800, "size": 20 }))) .mount(&server) .await; let mut tmp = tempfile::NamedTempFile::new().unwrap(); write!(tmp, "hello-from-rust-test").unwrap(); tmp.flush().unwrap(); let c = make_client(&server); let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap(); assert_eq!(ft.file_token, "ff_xyz"); assert_eq!(ft.ttl_secs, 1800); assert_eq!(ft.size, 20); } #[tokio::test] async fn admin_create_token() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/admin/tokens")) .and(header("authorization", "Bearer admin_test_token")) .and(body_json(json!({ "name": "cauldron", "ip_cidrs": ["172.24.0.0/16"] }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "name": "cauldron", "token": "cf_brandnew", "ip_cidrs": ["172.24.0.0/16"] }))) .mount(&server) .await; let c = make_client(&server); let t = c .create_token(TokenCreateRequest { name: "cauldron".into(), ip_cidrs: vec!["172.24.0.0/16".into()], }) .await .unwrap(); assert_eq!(t.name, "cauldron"); assert_eq!(t.token, "cf_brandnew"); assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]); } #[tokio::test] async fn admin_list_tokens() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/admin/tokens")) .and(header("authorization", "Bearer admin_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "tokens": [ {"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000}, {"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000} ] }))) .mount(&server) .await; let c = make_client(&server); let list = c.list_tokens().await.unwrap(); assert_eq!(list.tokens.len(), 2); assert_eq!(list.tokens[0].name, "cauldron"); // unknown server-added field captured by `extra`. assert!(list.tokens[1].extra.contains_key("last_seen")); } #[tokio::test] async fn admin_revoke_token() { let server = MockServer::start().await; Mock::given(method("DELETE")) .and(path_regex(r"^/admin/tokens/.+")) .and(header("authorization", "Bearer admin_test_token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) .mount(&server) .await; let c = make_client(&server); c.revoke_token("cauldron").await.unwrap(); } #[tokio::test] async fn unauthorized_response_maps_to_auth_error() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/run")) .respond_with(ResponseTemplate::new(401).set_body_string("missing token")) .mount(&server) .await; let c = make_client(&server); let err = c .run(RunRequest { prompt: "nope".into(), ..Default::default() }) .await .expect_err("should fail"); assert!(matches!(err, Error::Auth(_))); } #[tokio::test] async fn missing_app_token_short_circuits_run() { // Build a client without an app token but with admin set. let server = MockServer::start().await; let c = Client::builder() .base_url(server.uri()) .admin_token("admin_only") .build() .unwrap(); let err = c .run(RunRequest { prompt: "x".into(), ..Default::default() }) .await .expect_err("should fail without app token"); match err { Error::Auth(msg) => assert!(msg.contains("no app token")), other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn error_timeout_constructed_on_reqwest_timeout() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/healthz")) .respond_with( ResponseTemplate::new(200) .set_delay(Duration::from_millis(2_000)) .set_body_json(json!({ "ok": true, "claude_present": true, "claude_version": "x" })), ) .mount(&server) .await; let c = Client::builder() .base_url(server.uri()) .token("cf_x") .timeout(Duration::from_millis(150)) .build() .unwrap(); let err = c.healthz().await.expect_err("should time out"); assert!(matches!(err, Error::Timeout(_)), "got {err:?}"); } #[tokio::test] async fn builder_rejects_missing_base_url() { let err = Client::builder().build().expect_err("should fail"); assert!(matches!(err, Error::Config(_))); } #[tokio::test] async fn builder_rejects_bad_scheme() { let err = Client::builder() .base_url("ftp://nope") .build() .expect_err("should fail"); assert!(matches!(err, Error::Config(_))); } // ---- audit-driven regression tests -------------------------------------- /// H1: 4xx body with multibyte char straddling the truncation cutoff must /// not panic. Build a 503-byte string where `ü` (2 bytes UTF-8) lands at /// offset 499..501, so byte 500 is mid-codepoint. #[tokio::test] async fn truncate_handles_multibyte_boundary() { let server = MockServer::start().await; let mut body = String::new(); for _ in 0..499 { body.push('a'); } body.push('ü'); // bytes 499 and 500 for _ in 0..2 { body.push('b'); } assert_eq!(body.len(), 503); assert!(!body.is_char_boundary(500)); Mock::given(method("POST")) .and(path("/run")) .respond_with(ResponseTemplate::new(401).set_body_string(body.clone())) .mount(&server) .await; let c = make_client(&server); let err = c .run(RunRequest { prompt: "x".into(), ..Default::default() }) .await .expect_err("should fail"); // Just having reached this line — without panicking — is the assertion. assert!(matches!(err, Error::Auth(_)), "got {err:?}"); } /// H2: `Debug` on `Client` must not leak app or admin tokens. #[tokio::test] async fn client_debug_redacts_bearer() { let server = MockServer::start().await; let c = Client::builder() .base_url(server.uri()) .token("cf_super_secret_app_bearer") .admin_token("admin_super_secret_bearer") .build() .unwrap(); let dbg = format!("{c:?}"); assert!( !dbg.contains("cf_super_secret_app_bearer"), "app token leaked: {dbg}" ); assert!( !dbg.contains("admin_super_secret_bearer"), "admin token leaked: {dbg}" ); assert!(dbg.contains(""), "no redaction marker: {dbg}"); // ClientBuilder Debug also redacts. let builder = Client::builder() .base_url("http://x") .token("cf_builder_secret"); let bdbg = format!("{builder:?}"); assert!( !bdbg.contains("cf_builder_secret"), "builder token leaked: {bdbg}" ); assert!(bdbg.contains(""), "no redaction marker: {bdbg}"); } /// H2: `Debug` on `AppToken` must not leak the plaintext `token` field. #[test] fn app_token_debug_redacts_token() { let t = clawdforge::AppToken { name: "cauldron".into(), token: "cf_should_not_appear".into(), ip_cidrs: vec!["172.24.0.0/16".into()], }; let dbg = format!("{t:?}"); assert!(!dbg.contains("cf_should_not_appear"), "leaked: {dbg}"); assert!(dbg.contains(""), "no marker: {dbg}"); // name + ip_cidrs are non-secret and should still print. assert!(dbg.contains("cauldron")); assert!(dbg.contains("172.24.0.0/16")); } /// H3: `revoke_token` must reject path-traversal sequences before issuing /// any HTTP request. #[tokio::test] async fn revoke_token_rejects_path_traversal() { let server = MockServer::start().await; // No mock — if a request escaped client-side validation, wiremock would // 404 and we'd see Error::Api, not Error::Config. let c = make_client(&server); for bad in [ "../foo", "..", "foo/bar", "foo?x=1", "foo#frag", "", "a/../b", ] { let err = c .revoke_token(bad) .await .expect_err(&format!("revoke_token({bad:?}) should reject")); assert!( matches!(err, Error::Config(_)), "{bad:?} produced wrong variant: {err:?}" ); } } /// M2: a 204 No Content response from `revoke_token` must Ok-out. #[tokio::test] async fn revoke_token_accepts_204_no_content() { let server = MockServer::start().await; Mock::given(method("DELETE")) .and(path_regex(r"^/admin/tokens/.+")) .and(header("authorization", "Bearer admin_test_token")) .respond_with(ResponseTemplate::new(204)) .mount(&server) .await; let c = make_client(&server); c.revoke_token("cauldron") .await .expect("204 No Content should be Ok"); } /// M4: `upload_file` with a `max_upload_bytes` cap rejects oversized files /// before any network I/O. #[tokio::test] async fn upload_file_respects_max_upload_bytes() { let server = MockServer::start().await; // No /files mock — if the cap fails to short-circuit, the test will see // a 404 from wiremock instead of Error::Config. let mut tmp = tempfile::NamedTempFile::new().unwrap(); // Write 1024 bytes; cap at 512. write!(tmp, "{}", "x".repeat(1024)).unwrap(); tmp.flush().unwrap(); let c = Client::builder() .base_url(server.uri()) .token("cf_test_token") .max_upload_bytes(512) .build() .unwrap(); let err = c .upload_file(tmp.path(), Some(1800)) .await .expect_err("should reject oversize"); assert!(matches!(err, Error::Config(_)), "got {err:?}"); }