Stop the vault tools promising verification they don't perform

Three findings from the automated review passes, all confirmed against the
code before changing anything.

The download URL was dead on arrival for the caller it was built for. Sure
serves stored files through Active Storage controllers that
config/initializers/active_storage_authorization.rb gates on
`viewable_by?(Current.user)` — a signed-in browser session. An MCP client has
a bearer token and no session, so following the URL would have redirected to
sign-in. Removed it rather than leaving a link that cannot work, and the
description now points at search_family_files or the vault UI.

Coverage called a month `covered` when a document merely existed. An
unreconciled statement is not mismatched, so it took the `covered` branch, and
the payload carried nothing to correct the reading — the same "advertised
verification that never happened" bug fixed last round in
get_account_statement, in a second place. Months now carry their own
reconciliation_status, and the description says covered means presence, not
agreement.

Listing filtered visibility after limiting. Beyond underfilling a page, with
no cursor and a 100-row cap an accessible statement behind enough newer
invisible ones was unreachable. Visibility now lives in the query, mirroring
viewable_by? for a statement manager.

Also: rescue unexpected upload failures into a tool error instead of a raw
exception string, derive the documented size limit from MAX_FILE_SIZE, list
every coverage status in mcp.md, and cover the failed-reconciliation and
base64-normalisation branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
This commit is contained in:
Claude
2026-08-01 20:10:34 +00:00
parent 3d5e9efa01
commit 89a2f53126
9 changed files with 122 additions and 54 deletions

View File

@@ -1,10 +1,6 @@
require "test_helper"
class Assistant::Function::GetAccountStatementTest < ActiveSupport::TestCase
include ActiveSupport::Testing::TimeHelpers
DOWNLOAD_TTL_OVERSHOOT = Assistant::Function::GetAccountStatement::DOWNLOAD_URL_TTL + 1.minute
setup do
@user = users(:family_admin)
@account = accounts(:depository)
@@ -69,24 +65,6 @@ class Assistant::Function::GetAccountStatementTest < ActiveSupport::TestCase
assert_nil result[:statement][:reconciliation_note]
end
test "download url carries an expiring signed id" do
statement = create_statement(account: @account)
Rails.application.config.action_mailer.stubs(:default_url_options).returns({ host: "example.com" })
url = @function.call("statement_id" => statement.id).dig(:statement, :download_url)
assert_not_nil url, "expected a download URL when a host is configured"
signed_id = url[%r{/blobs/redirect/([^/]+)/}, 1]
assert_not_nil signed_id, "expected a signed id in #{url}"
assert_equal statement.original_file.blob,
ActiveStorage::Blob.find_signed(signed_id)
travel DOWNLOAD_TTL_OVERSHOOT do
assert_nil ActiveStorage::Blob.find_signed(signed_id),
"signed id must expire — the tool description promises 15 minutes"
end
end
test "returns not_found for an unknown id" do
result = @function.call("statement_id" => SecureRandom.uuid)

View File

@@ -32,6 +32,25 @@ class Assistant::Function::GetStatementCoverageTest < ActiveSupport::TestCase
assert_equal "missing", february[:status]
end
# "covered" means a document exists, not that it agrees with the ledger — an
# unreconciled statement still counts as covered, so the month has to carry its
# own reconciliation status or the agent cannot tell the two apart.
test "a covered month reports unavailable reconciliation when no balances are entered" do
january = Date.current.prev_year.beginning_of_year
statement = AccountStatement.create_from_upload!(
family: @user.family,
account: @account,
file: uploaded_file(filename: "unreconciled.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
statement.update!(period_start_on: january, period_end_on: january.end_of_month)
result = @function.call("account_id" => @account.id, "year" => january.year)
covered = result[:months].find { |m| m[:month] == january.strftime("%Y-%m") }
assert_equal "covered", covered[:status]
assert_equal "unavailable", covered[:reconciliation_status]
end
test "rejects a non-uuid account id" do
result = @function.call("account_id" => "nope")

View File

@@ -80,6 +80,20 @@ class Assistant::Function::RecordValuationTest < ActiveSupport::TestCase
assert_equal "invalid_source_citation", result[:error]
end
test "reports a failed reconciliation and creates no entry" do
failure = OpenStruct.new(success?: false, error_message: "Balance is invalid")
Account.any_instance.stubs(:create_reconciliation).returns(failure)
result = nil
assert_no_difference "@account.entries.valuations.count" do
result = @function.call(params)
end
assert_not result[:success]
assert_equal "valuation_failed", result[:error]
assert_equal "Balance is invalid", result[:message]
end
test "rejects an unparseable date" do
result = @function.call(params(date: "June 30th"))

View File

@@ -88,6 +88,41 @@ class Assistant::Function::UploadAccountStatementTest < ActiveSupport::TestCase
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
encoded = Base64.urlsafe_encode64(@content, padding: false)
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 content that decodes to zero bytes" do
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 as a tool error" do
AccountStatement.stubs(:create_from_prepared_upload!).raises(StandardError, "storage exploded")
result = @function.call(params(filename: "statement.csv"))
assert_not result[:success]
assert_equal "upload_failed", result[:error]
assert_match(/storage 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))