clawdforge/clients/rust/tests/client.rs
Kayos ebbd7cc553 clients/rust: apply audit findings — UTF-8 panic + Debug redaction + path-traversal (062d405 → next)
HIGH:
- H1: truncate() uses floor_char_boundary (was panicking on multibyte boundaries)
- H2: hand-written Debug for Client/ClientBuilder/AppToken redacts bearer (was leaking via dbg!()/tracing)
- H3: revoke_token validates name client-side (rejects path traversal sequences)

MEDIUM:
- M1: From<reqwest::Error> maps timeouts to Error::Timeout (was always Transport)
- M2: revoke_token accepts 2xx empty body (was rejecting RFC-correct 204 No Content)
- M3: tests use assert!(matches!) instead of matches!().then_some().unwrap()
- M4: ClientBuilder.max_upload_bytes optional cap
- M5: lib.rs deny(missing_docs)

LOW:
- L1: cargo fmt
- L2: drop dead AUTHORIZATION import

Audit: memory/clawdforge-audits/rust-062d405.md
2026-04-28 23:26:22 -07:00

517 lines
16 KiB
Rust

//! 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<serde_json::Map<String, serde_json::Value>, _> = 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("<redacted>"), "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("<redacted>"), "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("<redacted>"), "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:?}");
}