Files
sure/test/models/assistant/function/upload_account_statement_test.rb
Claude 2e417d4aaa Keep storage exception detail out of the MCP response
The upload_failed message interpolated the exception text, which crosses out
to an external agent. A storage failure can carry bucket names, object keys,
paths or request details, so the agent now gets a fixed message and the
exception stays in the server log. The test asserts the absence of detail
rather than pinning the leaked string into the contract.

Also fixes a test that did not test what it claimed: the urlsafe-base64 case
used a fixture encoding to plain base64, so it exercised the padding branch
and never the "-_" translation. It now uses content whose encoding contains
both characters and asserts that up front.

Renames "rejects content that decodes to zero bytes" to "rejects blank
content", which is what it actually covers — Base64.strict_encode64("") is
"", which is blank and returns before the decoder runs, so invalid_content is
correct and empty_file is not reachable from this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
2026-08-01 20:19:09 +00:00

161 lines
5.9 KiB
Ruby

require "test_helper"
class Assistant::Function::UploadAccountStatementTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@account = accounts(:depository)
@function = Assistant::Function::UploadAccountStatement.new(@user)
@content = "date,amount\n2024-01-01,1\n"
end
test "has correct name and is not strict" do
assert_equal "upload_account_statement", @function.name
assert_not @function.strict_mode?
assert_includes @function.params_schema[:required], "content_base64"
end
test "stores a statement in the vault" do
result = nil
assert_difference "AccountStatement.count", 1 do
result = @function.call(params(filename: "statement.csv"))
end
assert result[:success]
assert_not result[:duplicate]
assert_equal Digest::SHA256.hexdigest(@content), result[:statement][:content_sha256]
assert_equal "statement.csv", result[:statement][:filename]
end
test "re-uploading identical bytes returns the existing statement without creating a row" do
first = @function.call(params(filename: "statement.csv"))
assert_no_difference "AccountStatement.count" do
second = @function.call(params(filename: "different-name.csv"))
assert second[:success]
assert second[:duplicate]
assert_equal first[:statement][:id], second[:statement][:id]
end
end
test "links to an account when one is given" do
result = @function.call(params(filename: "statement.csv", account_id: @account.id))
assert result[:success]
assert_equal @account.id, result[:statement][:account][:id]
assert_equal "linked", result[:statement][:review_status]
end
test "leaves the statement unmatched when no account is given" do
result = @function.call(params(filename: "statement.csv"))
assert_equal "unmatched", result[:statement][:review_status]
assert_nil result[:statement][:account]
end
test "reports a duplicate without disclosing a statement filed against a hidden account" do
@function.call(params(filename: "statement.csv", account_id: accounts(:other_asset).id))
result = Assistant::Function::UploadAccountStatement.new(users(:family_member))
.call(params(filename: "statement.csv"))
assert result[:success]
assert result[:duplicate]
assert_equal Digest::SHA256.hexdigest(@content), result[:statement][:content_sha256]
assert_nil result[:statement][:account]
assert_nil result[:statement][:filename]
end
test "refuses a user who cannot manage the vault" do
result = Assistant::Function::UploadAccountStatement.new(family_guest).call(params(filename: "statement.csv"))
assert_not result[:success]
assert_equal "forbidden", result[:error]
end
test "rejects an unsupported file type" do
result = @function.call(params(filename: "notes.txt"))
assert_not result[:success]
assert_equal "unsupported_file_type", result[:error]
end
test "rejects content that is not base64" do
result = @function.call("filename" => "statement.csv", "content_base64" => "not base64 @@@")
assert_not result[:success]
assert_equal "invalid_content", result[:error]
end
test "accepts base64 wrapped across lines" do
wrapped = Base64.strict_encode64(@content).scan(/.{1,8}/).join("\n")
result = @function.call("filename" => "statement.csv", "content_base64" => wrapped)
assert result[:success]
assert_equal Digest::SHA256.hexdigest(@content), result[:statement][:content_sha256]
end
test "accepts urlsafe base64 without padding" do
# Chosen so the encoding actually uses the URL-safe alphabet; the usual
# fixture encodes to plain base64, which would exercise the padding branch
# while leaving the "-_" translation untested.
content = "a,b\n1,2\n~~~???\n"
encoded = Base64.urlsafe_encode64(content, padding: false)
assert_match(/-/, encoded, "fixture must exercise the urlsafe alphabet")
assert_match(/_/, encoded, "fixture must exercise the urlsafe alphabet")
assert_no_match(/=/, encoded, "fixture must be unpadded")
result = @function.call("filename" => "statement.csv", "content_base64" => encoded)
assert result[:success]
assert_equal Digest::SHA256.hexdigest(content), result[:statement][:content_sha256]
end
test "rejects blank content" do
# Base64.strict_encode64("") is "", which is blank, so this never reaches
# the decoder — hence invalid_content rather than empty_file.
result = @function.call("filename" => "statement.csv", "content_base64" => Base64.strict_encode64(""))
assert_not result[:success]
assert_equal "invalid_content", result[:error]
end
test "reports an unexpected storage failure without leaking the exception" do
AccountStatement.stubs(:create_from_prepared_upload!).raises(StandardError, "s3://bucket/secret-key exploded")
result = @function.call(params(filename: "statement.csv"))
assert_not result[:success]
assert_equal "upload_failed", result[:error]
# The response crosses out to an external agent, so it must carry none of
# the exception's detail.
assert_no_match(/s3:|bucket|secret-key|exploded/, result[:message])
end
test "rejects an unknown account_id rather than silently uploading unlinked" do
result = @function.call(params(filename: "statement.csv", account_id: SecureRandom.uuid))
assert_not result[:success]
assert_equal "account_not_found", result[:error]
end
test "rejects a file whose contents do not match its extension" do
result = @function.call(
"filename" => "statement.pdf",
"content_base64" => Base64.strict_encode64("this is not a pdf")
)
assert_not result[:success]
assert_equal "invalid_file", result[:error]
end
private
def params(filename:, account_id: nil, content: @content)
{ "filename" => filename, "content_base64" => Base64.strict_encode64(content) }.tap do |p|
p["account_id"] = account_id if account_id
end
end
end