clawdforge/clients/ruby/test/test_client.rb
Kayos 6b8bccfb8d clients/ruby: apply audit findings (b1d6e3f -> new)
- S1: Client#inspect redacts @token (default ruby inspect walks ivars);
  to_s aliased and pretty_print overridden so PP doesn't bypass it.
- S2: AppToken#inspect/#to_s/#pretty_print redact :token member; nil
  token still rendered as token=nil for list-row clarity.
- S3: validate token name vs [a-z0-9_-]+ in revoke_token and create_token;
  drops URI.encode_www_form_component path-encoding dependency.
- C1: upload_timeout_secs parameter on upload_file (default 60), decoupled
  from default_timeout/http_timeout_margin so big uploads aren't capped
  by the run-subprocess timeout.
- Q6: clearer multipart filename escape via gsub block form.
- C7: dropped unused @uri ivar.
- A3: YARD note clarifying http_client: bypasses base_url host/port
  routing.

Test gaps closed: Client/AppToken inspect+pp redaction, AppToken nil-token
inspect, revoke_token name validation (path traversal, uppercase, empty,
valid), create_token name validation, upload_timeout_secs independence
from default_timeout (incl. default==60), Array-form ip_cidrs round-trip,
non-JSON 5xx error body kept as String, empty 200 body raises Error.

35 runs / 104 assertions / 0 failures.

Audit: memory/clawdforge-audits/ruby-b1d6e3f.md
2026-04-28 23:07:49 -07:00

518 lines
15 KiB
Ruby

# frozen_string_literal: true
require "minitest/autorun"
require "webmock/minitest"
require "json"
require "pp"
require "stringio"
require "tempfile"
require "clawdforge"
BASE_URL = "http://forge.test:8800"
TOKEN = "cf_test_token_xxxxxxxx"
def build_client(**overrides)
Clawdforge::Client.new(
base_url: BASE_URL,
token: TOKEN,
default_timeout: 60,
**overrides,
)
end
class HealthzTest < Minitest::Test
def test_healthz_returns_parsed_json
stub_request(:get, "#{BASE_URL}/healthz")
.with(headers: { "Authorization" => "Bearer #{TOKEN}" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true, "claude_present" => true, "claude_version" => "1.2.3",
),
)
out = build_client.healthz
assert_equal true, out["ok"]
assert_equal "1.2.3", out["claude_version"]
end
end
class RunTest < Minitest::Test
def test_run_success_with_json_result
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true,
"result" => { "hello" => "world" },
"duration_ms" => 1234,
"stop_reason" => "end_turn",
),
)
r = build_client.run(prompt: 'Reply with JSON: {"hello": "world"}')
assert_kind_of Clawdforge::RunResult, r
assert_equal true, r.ok
assert_equal({ "hello" => "world" }, r.result)
assert_equal 1234, r.duration_ms
assert_equal "end_turn", r.stop_reason
end
def test_run_success_with_string_result
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true, "result" => "plain text reply",
"duration_ms" => 800, "stop_reason" => "end_turn",
),
)
r = build_client.run(prompt: "hello")
assert_equal "plain text reply", r.result
end
def test_run_sends_full_body_and_auth
captured = {}
stub_request(:post, "#{BASE_URL}/run").with do |req|
captured[:auth] = req.headers["Authorization"]
captured[:body] = JSON.parse(req.body)
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true, "result" => {}, "duration_ms" => 1, "stop_reason" => "end_turn"),
)
build_client.run(
prompt: "hi", model: "opus", system: "be terse",
files: ["ff_abc"], timeout_secs: 42,
)
assert_equal "Bearer #{TOKEN}", captured[:auth]
assert_equal(
{
"prompt" => "hi", "model" => "opus", "system" => "be terse",
"files" => ["ff_abc"], "timeout_secs" => 42,
},
captured[:body],
)
end
def test_run_uses_default_model_and_omits_optional_fields
captured = {}
stub_request(:post, "#{BASE_URL}/run").with do |req|
captured[:body] = JSON.parse(req.body)
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true, "result" => "x", "duration_ms" => 1, "stop_reason" => "end_turn"),
)
build_client.run(prompt: "hi")
assert_equal "sonnet", captured[:body]["model"]
refute captured[:body].key?("system")
refute captured[:body].key?("files")
refute captured[:body].key?("timeout_secs")
end
def test_run_502_raises_api_error_with_body
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 502,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => false, "error" => "subprocess timed out",
"stderr" => "...", "duration_ms" => 60_000, "stop_reason" => "timeout",
),
)
err = assert_raises(Clawdforge::APIError) do
build_client.run(prompt: "hi", timeout_secs: 60)
end
assert_equal 502, err.status
assert_kind_of Hash, err.body
assert_equal "timeout", err.body["stop_reason"]
assert_match(/subprocess timed out/, err.message)
end
def test_run_401_raises_auth_error
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 401,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "missing bearer"),
)
err = assert_raises(Clawdforge::AuthError) { build_client.run(prompt: "hi") }
assert_equal 401, err.status
assert_kind_of Clawdforge::APIError, err
assert_kind_of Clawdforge::Error, err
end
def test_run_transport_error_when_unreachable
stub_request(:post, "#{BASE_URL}/run").to_raise(SocketError.new("getaddrinfo: nope"))
assert_raises(Clawdforge::TransportError) { build_client.run(prompt: "hi") }
end
def test_run_empty_prompt_rejected_locally
assert_raises(ArgumentError) { build_client.run(prompt: "") }
end
end
class FilesTest < Minitest::Test
def test_upload_file_from_path
stub_request(:post, "#{BASE_URL}/files")
.with(headers: { "Content-Type" => %r{\Amultipart/form-data; boundary=} })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("file_token" => "ff_abc123", "ttl_secs" => 3600, "size" => 11),
)
Tempfile.create(["upload", ".txt"]) do |tf|
tf.binmode
tf.write("hello world")
tf.flush
ft = build_client.upload_file(tf.path, ttl_secs: 3600)
assert_kind_of Clawdforge::FileToken, ft
assert_equal "ff_abc123", ft.file_token
assert_equal 3600, ft.ttl_secs
assert_equal 11, ft.size
end
end
def test_upload_file_sends_multipart_with_ttl_and_file_parts
captured = {}
stub_request(:post, "#{BASE_URL}/files").with do |req|
captured[:content_type] = req.headers["Content-Type"]
captured[:body] = req.body
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("file_token" => "ff_xyz", "ttl_secs" => 60, "size" => 5),
)
Tempfile.create(["snippet", ".bin"]) do |tf|
tf.binmode
tf.write("hello")
tf.flush
build_client.upload_file(tf.path, ttl_secs: 60, filename: "snippet.txt")
end
assert_match(%r{\Amultipart/form-data; boundary=}, captured[:content_type])
assert_match(/name="ttl_secs"/, captured[:body])
assert_match(/name="file"; filename="snippet\.txt"/, captured[:body])
assert_match(/hello/, captured[:body])
end
def test_upload_file_400_raises_api_error
stub_request(:post, "#{BASE_URL}/files").to_return(
status: 400,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "ttl_secs out of range (60..86400)"),
)
Tempfile.create(["bad", ".bin"]) do |tf|
tf.binmode
tf.write("x")
tf.flush
err = assert_raises(Clawdforge::APIError) do
build_client.upload_file(tf.path, ttl_secs: 10)
end
assert_equal 400, err.status
end
end
end
class AdminTokensTest < Minitest::Test
def test_create_token
stub_request(:post, "#{BASE_URL}/admin/tokens").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"name" => "cauldron",
"token" => "cf_brandnew_xxx",
"ip_cidrs" => ["172.24.0.0/16"],
),
)
t = build_client.create_token("cauldron", ip_cidrs: ["172.24.0.0/16"])
assert_kind_of Clawdforge::AppToken, t
assert_equal "cauldron", t.name
assert_equal "cf_brandnew_xxx", t.token
assert_equal ["172.24.0.0/16"], t.ip_cidrs
end
def test_list_tokens_normalizes_csv_ip_cidrs
stub_request(:get, "#{BASE_URL}/admin/tokens").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"tokens" => [
{
"name" => "cauldron", "ip_cidrs" => "172.24.0.0/16",
"created_at" => 100, "last_used" => 200, "enabled" => 1,
},
{
"name" => "petalparse", "ip_cidrs" => "",
"created_at" => 50, "last_used" => nil, "enabled" => 0,
},
],
),
)
toks = build_client.list_tokens
assert_equal 2, toks.length
assert_equal "cauldron", toks[0].name
assert_equal ["172.24.0.0/16"], toks[0].ip_cidrs
assert_equal true, toks[0].enabled
assert_nil toks[0].token
assert_equal [], toks[1].ip_cidrs
assert_equal false, toks[1].enabled
end
def test_revoke_token_ok
stub_request(:delete, "#{BASE_URL}/admin/tokens/cauldron").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true),
)
assert_equal true, build_client.revoke_token("cauldron")
end
def test_revoke_token_404
stub_request(:delete, "#{BASE_URL}/admin/tokens/nosuch").to_return(
status: 404,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "no such token"),
)
err = assert_raises(Clawdforge::APIError) { build_client.revoke_token("nosuch") }
assert_equal 404, err.status
end
end
class ClientConstructionTest < Minitest::Test
def test_requires_base_url
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "", token: "cf_x") }
end
def test_requires_token
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "http://x", token: "") }
end
def test_strips_trailing_slash
c = Clawdforge::Client.new(base_url: "http://x:8800/", token: "cf_x")
assert_equal "http://x:8800", c.base_url
end
end
class InspectRedactionTest < Minitest::Test
SECRET = "cf_supersecret_DEADBEEF_must_not_leak"
def test_client_inspect_redacts_token
client = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET)
out = client.inspect
refute_includes out, SECRET, "Client#inspect leaked bearer"
assert_includes out, "[REDACTED]"
assert_includes out, BASE_URL
end
def test_client_to_s_redacts_token
client = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET)
refute_includes client.to_s, SECRET
end
def test_pp_client_does_not_leak_token
client = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET)
buf = StringIO.new
PP.pp(client, buf)
refute_includes buf.string, SECRET, "PP.pp leaked bearer"
assert_includes buf.string, "[REDACTED]"
end
def test_app_token_inspect_redacts_token
plaintext = "cf_brandnew_zzz_KEEPME_PRIVATE"
t = Clawdforge::AppToken.from_create_response(
"name" => "cauldron",
"token" => plaintext,
"ip_cidrs" => ["10.0.0.0/8"],
)
refute_includes t.inspect, plaintext, "AppToken#inspect leaked plaintext token"
refute_includes t.to_s, plaintext, "AppToken#to_s leaked plaintext token"
assert_includes t.inspect, "[REDACTED]"
assert_includes t.inspect, "cauldron"
end
def test_app_token_inspect_with_nil_token
# List-row form: token is nil. inspect should be informative without
# any "[REDACTED]" noise (there's nothing to redact).
t = Clawdforge::AppToken.from_list_row(
"name" => "petalparse", "ip_cidrs" => "",
"created_at" => 50, "last_used" => nil, "enabled" => 1,
)
out = t.inspect
assert_includes out, "petalparse"
assert_includes out, "token=nil"
refute_includes out, "[REDACTED]"
end
def test_pp_app_token_does_not_leak_token
plaintext = "cf_pp_leak_check_xyz"
t = Clawdforge::AppToken.from_create_response(
"name" => "cauldron", "token" => plaintext, "ip_cidrs" => [],
)
buf = StringIO.new
PP.pp(t, buf)
refute_includes buf.string, plaintext
end
end
class RevokeTokenValidationTest < Minitest::Test
def test_revoke_token_validates_name_rejects_path_traversal
assert_raises(ArgumentError) { build_client.revoke_token("../etc/passwd") }
end
def test_revoke_token_validates_name_rejects_uppercase
assert_raises(ArgumentError) { build_client.revoke_token("Cauldron") }
end
def test_revoke_token_validates_name_rejects_empty
assert_raises(ArgumentError) { build_client.revoke_token("") }
end
def test_revoke_token_accepts_valid_name
stub_request(:delete, "#{BASE_URL}/admin/tokens/cauldron-1_a").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true),
)
assert_equal true, build_client.revoke_token("cauldron-1_a")
end
def test_create_token_validates_name
assert_raises(ArgumentError) { build_client.create_token("Bad Name!") }
end
end
class UploadTimeoutTest < Minitest::Test
# Tap into Net::HTTP timeouts the same way the client does — we want to
# prove `upload_timeout_secs` flows through to the HTTP layer and is
# *independent* of `default_timeout`.
class TimeoutCapturingHTTP
attr_accessor :open_timeout, :read_timeout, :write_timeout
def request(_req)
Struct.new(:code, :message, :body).new("200", "OK", JSON.generate(
"file_token" => "ff_x", "ttl_secs" => 60, "size" => 1,
))
end
end
def test_upload_timeout_secs_independent_from_default_timeout
http = TimeoutCapturingHTTP.new
client = Clawdforge::Client.new(
base_url: BASE_URL, token: TOKEN,
default_timeout: 5, # short
http_client: http,
)
Tempfile.create(["t", ".bin"]) do |tf|
tf.binmode
tf.write("x")
tf.flush
client.upload_file(tf.path, ttl_secs: 60, upload_timeout_secs: 300)
end
# Timeout used must reflect upload_timeout_secs, not default_timeout +
# http_timeout_margin (which would be 5 + 30 = 35).
assert_equal 300, http.read_timeout
assert_equal 300, http.open_timeout
assert_equal 300, http.write_timeout
end
def test_upload_timeout_secs_default_is_60
http = TimeoutCapturingHTTP.new
client = Clawdforge::Client.new(
base_url: BASE_URL, token: TOKEN,
default_timeout: 5,
http_client: http,
)
Tempfile.create(["t", ".bin"]) do |tf|
tf.binmode
tf.write("x")
tf.flush
client.upload_file(tf.path, ttl_secs: 60)
end
assert_equal 60, http.read_timeout
end
end
class ListTokensArrayCidrsTest < Minitest::Test
def test_list_tokens_array_form_ip_cidrs_round_trip
stub_request(:get, "#{BASE_URL}/admin/tokens").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"tokens" => [
{
"name" => "multi", "ip_cidrs" => ["10.0.0.0/8", "192.168.0.0/16"],
"created_at" => 100, "last_used" => 200, "enabled" => 1,
},
],
),
)
toks = build_client.list_tokens
assert_equal 1, toks.length
assert_equal ["10.0.0.0/8", "192.168.0.0/16"], toks[0].ip_cidrs
end
end
class NonJsonErrorBodyTest < Minitest::Test
def test_non_json_error_body_kept_as_string
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 503,
headers: { "Content-Type" => "text/html" },
body: "<html><body>upstream down</body></html>",
)
err = assert_raises(Clawdforge::APIError) { build_client.run(prompt: "hi") }
assert_equal 503, err.status
assert_kind_of String, err.body
assert_match(/upstream down/, err.body)
end
end
class EmptyResponseBodyTest < Minitest::Test
def test_empty_200_body_on_run_raises_unexpected_response_type
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: "",
)
err = assert_raises(Clawdforge::Error) { build_client.run(prompt: "hi") }
assert_match(/unexpected \/run response type/, err.message)
end
end