mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +00:00
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>
96 lines
2.6 KiB
Ruby
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
|