Files
sure/test/controllers/api/v1/holdings_controller_test.rb
pro3958 8b2d792dab fix(api): scope holdings API to accessible accounts (#2706)
Api::V1::HoldingsController built both the index and show queries from
current_resource_owner.family.holdings, filtering only on account status.
A read-scoped API key could therefore read holdings (account id/name,
quantity, price, market value) for accounts owned by other family members
that were never shared with the key owner, and the account_id / account_ids
filters made targeted enumeration of those accounts trivial.

Scope the query through accounts.accessible_by(current_resource_owner) so
results are limited to accounts the token owner owns or has been granted
access to. Route both index and set_holding through the new
accessible_holdings scope. This matches Api::V1::BalancesController and
the web HoldingsController, which already scope the same way.

Fixes #2467

Co-authored-by: agentloop <agentloop@localhost>
2026-07-25 04:44:27 +02:00

96 lines
2.6 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class Api::V1::HoldingsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
source: "web",
display_key: "test_read_#{SecureRandom.hex(8)}"
)
@account = accounts(:investment)
@holding = holdings(:one)
# An account owned by another family member, never shared with @user.
@other_member = users(:family_member)
@private_account = @family.accounts.create!(
name: "Member Brokerage",
accountable: Investment.new,
balance: 5000,
currency: "USD",
owner: @other_member
)
@private_holding = @private_account.holdings.create!(
security: securities(:msft),
date: Date.current,
qty: 5,
price: 100,
amount: 500,
currency: "USD"
)
end
test "lists holdings scoped to accessible accounts" do
get api_v1_holdings_url, headers: api_headers(@api_key)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |holding| holding["id"] }
assert_includes holding_ids, @holding.id
assert_not_includes holding_ids, @private_holding.id
end
test "account_ids filter cannot reach inaccessible accounts" do
get api_v1_holdings_url,
params: { account_ids: [ @private_account.id ] },
headers: api_headers(@api_key)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |holding| holding["id"] }
assert_not_includes holding_ids, @private_holding.id
end
test "account_id filter cannot reach inaccessible accounts" do
get api_v1_holdings_url,
params: { account_id: @private_account.id },
headers: api_headers(@api_key)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |holding| holding["id"] }
assert_not_includes holding_ids, @private_holding.id
end
test "shows an accessible holding" do
get api_v1_holding_url(@holding), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @holding.id, response_data["id"]
end
test "returns not found for an inaccessible holding" do
get api_v1_holding_url(@private_holding), headers: api_headers(@api_key)
assert_response :not_found
end
test "requires authentication" do
get api_v1_holdings_url
assert_response :unauthorized
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
end