mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* feat(snaptrade): add device-flow OAuth alongside the browser redirect SnapTrade could only be connected through the authorization-code + PKCE flow, which needs a confidential OAuth client: SNAPTRADE_OAUTH_CLIENT_SECRET and a redirect URI registered on the OAuth app. A deployment that cannot register one had no path at all. Add the device grant (RFC 8628) as a second way to obtain the same token, so people can pick the flow that suits their deployment. Both grants end at SnaptradeItem#apply_oauth_tokens!, so a device-authorized item is indistinguishable from a redirect-authorized one from there on -- same Bearer data calls, refresh, revocation and sync. Nothing about existing authorized items changes: no schema change, no migration, and the PKCE path is untouched. - Provider::Snaptrade gains start_device_authorization and poll_device_token, with endpoints read from SnapTrade's OAuth metadata document (cached). - oauth_configured? now means "some flow is available" (public client id), which is what gates syncing and the provider panel; the new authorization_code_configured? gates the redirect flow specifically. - Token and revocation requests authenticate as a public client when no secret is configured -- client_id in the body instead of HTTP Basic. Without this a device-authorized item would authorize fine and then fail at its first token rotation. - The settings panel offers both when both are available; every other entry point picks one through SnaptradeItemsHelper#snaptrade_authorize_path. - The device page carries a failed attempt's code back into the form, so "not confirmed yet" is a retry rather than a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): keep the provider panel's setup-step keys and cover both flows Two test_unit failures from the panel change. The setup steps were reordered and their keys renamed, which orphaned the translations twelve locales already had for them and broke the test asserting `oauth_setup_step_3`. The rename bought nothing: reword the steps in place instead, leaving the callback URL on step 2 where the interpolation lives. The panel tests stubbed `oauth_configured?`, which no longer decides which buttons render -- that is now `authorization_code_configured?`. Stub both, so the "configured" cases test the deployment they name, and add the device-only case that was previously unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): address device-flow review findings Two real bugs from the bot reviews, plus consistency work. The completion form posts into the `drawer` frame so errors re-render in place, but a successful redirect was then followed as a frame navigation. Both destinations carry the layout's empty `drawer` frame, so Turbo swapped that in and merely closed the dialog: the notice was lost and `return_to=setup_accounts` never advanced. Success now breaks out with a redirect stream action, the same mechanism holdings and categorizes already use, while errors keep rendering in the drawer. RFC 8628 §3.1 requires a confidential client to authenticate its device authorization request, and the panel offers the device code on deployments that configured a secret. That request now carries the same client authentication as the token request. Token endpoint resolution is now shared by all three grants, since whatever issued a token has to be what refreshes it. It reads the discovery document only when already cached and never fetches it, so the browser flow keeps working off the constant it has always used -- no new network call on refresh and no new way for an existing authorized item to fail. Also: the drawer no longer asks the provider whether it is configured, the controller tells it; and the test helpers restore the previous OAuth config rather than clearing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): reject a device authorization response that cannot drive the flow A 2xx missing device_code, user_code or a verification URI was passed straight to the drawer, which then rendered a blank code and a link to nowhere -- a dead end the user could only abandon. Every one of those fields is load-bearing, and a response without them is partial or schema-changed, so fail with a message instead. Same reasoning as the results-array check in get_positions. verification_uri_complete substitutes for verification_uri when present, since the drawer prefers it for the link anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): filter device-flow codes from request logs complete_oauth_device_flow receives the device code as a request parameter, and none of the existing filter_parameters patterns is a substring of "device_code" -- ParameterFilter matches on substrings, and "token", "_key", "secret", "code_verifier" and "code_challenge" all miss it. So Rails' default "Processing by ... Parameters: {...}" line was writing it in plaintext. That matters more here than ordinary log hygiene: the device code is the only capability check on redemption. Unlike the redirect flow's state, nothing binds a device code to the family that requested it, so anyone who can read the logs could redeem another family's in-flight authorization into their own item and pick up a token for that family's brokerage data. Adds :device_code, :user_code and :verification_uri_complete (which embeds the user code) to the filter list, with a regression test in the style of the existing Sophtron credential-filtering test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): bind a pending device authorization to its session The device code was posted back from the drawer as a form field, so the request body was the only thing deciding which item a pending authorization redeemed into. Nothing tied a code to the family that asked for it -- the guarantee `state` gives the redirect flow -- so a code recovered from anywhere could be redeemed into an item belonging to someone else, handing them a token for the victim's brokerage data. Hold the pending authorization in the session instead, where oauth_callback already keeps its code_verifier and state: - start_oauth_device_flow records the code, what the page displays, the family, the item and the return_to context under :snaptrade_device_flow. - complete_oauth_device_flow reads the code from there and refuses unless the flow was started by this session for this family and this item. A device_code parameter is no longer read at all, so there is no longer a way to inject one. - return_to and accountable_type come from the session too, so completion needs nothing from the form to find its way back. The code now never reaches the browser, which also makes the previous commit's log filtering a second line of defence rather than the only one. A failed attempt keeps the code only while it is still redeemable: expired_token and access_denied clear it so the page offers a fresh start, while a transient failure leaves it in place to retry. expires_in and interval are no longer carried anywhere, since nothing ever read them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): use one token endpoint for every grant poll_device_token resolved the token endpoint from the cached discovery document while exchange_code and refresh_tokens used TOKEN_URL, so which URL a device-issued token was refreshed at depended on whether the 12h metadata cache was still warm. If the discovered endpoint ever differed from the constant, a device-authorized item would work until the cache lapsed and then fail its first rotation -- and fail invisibly, since a refresh failure marks the connection requires_update. Resolve it by removing the choice rather than by making refresh depend on discovery. RFC 8628 §3.4 redeems a device code at the authorization server's token endpoint, the same one the authorization code grant uses: there is one token endpoint, not one per grant, and nothing to keep in sync between issuing a token and refreshing it. TOKEN_URL is also the endpoint the browser flow has been using in production, so it is the one with evidence behind it. Discovery is still consulted, but only for device_authorization_endpoint, which has no hardcoded equivalent. This also keeps refresh free of any network dependency it did not already have: reintroducing discovery there would have put a fetch, with retries and backoff, in front of every token rotation on items that never needed one. Also restore the previous OAuth configuration in the missing-client-id test instead of leaving the client id nil, which made it order-dependent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk --------- Co-authored-by: Claude <noreply@anthropic.com>
717 lines
28 KiB
Ruby
717 lines
28 KiB
Ruby
require "test_helper"
|
|
|
|
class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@snaptrade_item = snaptrade_items(:configured_item)
|
|
end
|
|
|
|
def sign_out
|
|
@user.sessions.each do |session|
|
|
delete session_path(session)
|
|
end
|
|
end
|
|
|
|
# A deployment with a confidential OAuth client: both the browser redirect and
|
|
# the device code are available.
|
|
def with_oauth_app(&block)
|
|
with_snaptrade_oauth("client-id", "client-secret", &block)
|
|
end
|
|
|
|
# A deployment that only registered a public client: device code only.
|
|
def with_device_flow_only(&block)
|
|
with_snaptrade_oauth("client-id", nil, &block)
|
|
end
|
|
|
|
# Restores whatever was configured rather than clearing, so a test env that
|
|
# does set SnapTrade credentials doesn't leak nils into later tests.
|
|
def with_snaptrade_oauth(client_id, client_secret)
|
|
original_id = Rails.configuration.x.snaptrade.oauth_client_id
|
|
original_secret = Rails.configuration.x.snaptrade.oauth_client_secret
|
|
Rails.configuration.x.snaptrade.oauth_client_id = client_id
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = client_secret
|
|
yield
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = original_id
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = original_secret
|
|
end
|
|
|
|
DEVICE_AUTHORIZATION = {
|
|
"device_code" => "dev-c0de",
|
|
"user_code" => "WXYZ-1234",
|
|
"verification_uri" => "https://app.snaptrade.com/device",
|
|
"verification_uri_complete" => "https://app.snaptrade.com/device?code=WXYZ-1234",
|
|
"expires_in" => 600,
|
|
"interval" => 5
|
|
}.freeze
|
|
|
|
# The device code is held in the session, so completing a flow means starting
|
|
# one first -- the same two requests a user makes.
|
|
def start_device_flow(item, **params)
|
|
Provider::Snaptrade.stubs(:start_device_authorization).returns(DEVICE_AUTHORIZATION.dup)
|
|
post start_oauth_device_flow_snaptrade_items_url, params: { item_id: item.id }.merge(params)
|
|
end
|
|
|
|
test "connect redirects to portal when successful" do
|
|
portal_url = "https://app.snaptrade.com/portal/test123"
|
|
|
|
SnaptradeItem.any_instance.stubs(:connection_portal_url).returns(portal_url)
|
|
|
|
get connect_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_redirected_to portal_url
|
|
end
|
|
|
|
test "connect handles decryption error gracefully" do
|
|
SnaptradeItem.any_instance
|
|
.stubs(:connection_portal_url)
|
|
.raises(ActiveRecord::Encryption::Errors::Decryption.new("cannot decrypt"))
|
|
|
|
get connect_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_match(/Unable to read SnapTrade credentials/, flash[:alert])
|
|
end
|
|
|
|
test "connect handles general error gracefully" do
|
|
SnaptradeItem.any_instance
|
|
.stubs(:connection_portal_url)
|
|
.raises(StandardError.new("something broke"))
|
|
|
|
get connect_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_match(/Failed to connect/, flash[:alert])
|
|
end
|
|
|
|
test "oauth_authorize stores state and verifier in session and redirects to SnapTrade" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
|
|
get oauth_authorize_snaptrade_items_url
|
|
|
|
assert_response :redirect
|
|
redirect = URI.parse(response.location)
|
|
assert_equal "dashboard.snaptrade.com", redirect.host
|
|
params = Rack::Utils.parse_query(redirect.query)
|
|
oauth_session = session[:snaptrade_oauth]
|
|
assert_equal oauth_session["state"], params["state"]
|
|
assert_equal "S256", params["code_challenge_method"]
|
|
assert oauth_session["code_verifier"].present?
|
|
assert oauth_session["item_id"].present?
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_authorize redirects to settings when OAuth is not configured" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
|
|
get oauth_authorize_snaptrade_items_url
|
|
assert_redirected_to settings_providers_path
|
|
end
|
|
|
|
test "oauth_authorize refuses when only the public client id is configured" do
|
|
with_device_flow_only do
|
|
get oauth_authorize_snaptrade_items_url
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_match(/device code/, flash[:alert])
|
|
end
|
|
end
|
|
|
|
test "oauth_device_authorize offers to start authorization" do
|
|
Provider::Snaptrade.expects(:start_device_authorization).never
|
|
|
|
with_device_flow_only do
|
|
get oauth_device_authorize_snaptrade_items_url
|
|
|
|
assert_response :success
|
|
assert_select "form[action='#{start_oauth_device_flow_snaptrade_items_path}']"
|
|
end
|
|
end
|
|
|
|
test "oauth_device_authorize explains what to configure when the client id is missing" do
|
|
with_snaptrade_oauth(nil, nil) do
|
|
get oauth_device_authorize_snaptrade_items_url
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match(/SNAPTRADE_OAUTH_CLIENT_ID/, response.body)
|
|
end
|
|
end
|
|
|
|
test "start_oauth_device_flow shows the code the user confirms on SnapTrade" do
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:start_device_authorization).with(scope: "read").returns({
|
|
"device_code" => "dev-c0de",
|
|
"user_code" => "WXYZ-1234",
|
|
"verification_uri" => "https://app.snaptrade.com/device",
|
|
"verification_uri_complete" => "https://app.snaptrade.com/device?code=WXYZ-1234",
|
|
"expires_in" => 600,
|
|
"interval" => 5
|
|
})
|
|
|
|
post start_oauth_device_flow_snaptrade_items_url, params: { item_id: @snaptrade_item.id }
|
|
|
|
assert_response :success
|
|
assert_match "WXYZ-1234", response.body
|
|
# The user code is for the user to read; the device code redeems into
|
|
# tokens, so it stays in the session and never reaches the page.
|
|
assert_select "input[name='device_code']", count: 0
|
|
assert_no_match "dev-c0de", response.body
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow stores tokens and queues a sync" do
|
|
item = snaptrade_items(:unauthorized_item)
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).with(device_code: "dev-c0de").returns({
|
|
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900,
|
|
"token_type" => "Bearer", "scope" => "read"
|
|
})
|
|
|
|
start_device_flow(item)
|
|
|
|
assert_difference "Sync.count", 1 do
|
|
post complete_oauth_device_flow_snaptrade_item_url(item)
|
|
end
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_equal "device-at", item.reload.oauth_access_token
|
|
assert item.good?
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow leaves the drawer instead of navigating inside it" do
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.stubs(:poll_device_token).returns({
|
|
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900
|
|
})
|
|
|
|
start_device_flow(@snaptrade_item)
|
|
|
|
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item),
|
|
headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
# A plain redirect would be followed as a frame navigation and swap in the
|
|
# layout's empty drawer frame, closing the dialog and going nowhere.
|
|
assert_response :success
|
|
assert_match "turbo-stream", response.body
|
|
assert_match "redirect", response.body
|
|
assert_match settings_providers_path, response.body
|
|
assert_equal I18n.t("snaptrade_items.complete_oauth_device_flow.success"), flash[:notice]
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow resumes account setup when asked to" do
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.stubs(:poll_device_token).returns({
|
|
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900
|
|
})
|
|
|
|
start_device_flow(@snaptrade_item, return_to: "setup_accounts", accountable_type: "Investment")
|
|
|
|
# Where to return to was recorded when the flow started, so completion
|
|
# needs nothing from the form to find its way back.
|
|
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment")
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow keeps the code when the user has not confirmed it yet" do
|
|
item = snaptrade_items(:unauthorized_item)
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).raises(
|
|
Provider::Snaptrade::AuthenticationError.new("SnapTrade OAuth token request failed: authorization_pending", oauth_error: "authorization_pending")
|
|
)
|
|
|
|
start_device_flow(item)
|
|
|
|
post complete_oauth_device_flow_snaptrade_item_url(item)
|
|
|
|
assert_response :unprocessable_entity
|
|
# Still redeemable, so the page comes back with the same code rather than
|
|
# sending the user back to the start.
|
|
assert_match "WXYZ-1234", response.body
|
|
assert_select "form[action='#{complete_oauth_device_flow_snaptrade_item_path(item)}']"
|
|
assert_nil item.reload.oauth_access_token
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow drops a code the server says is spent" do
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).raises(
|
|
Provider::Snaptrade::AuthenticationError.new("SnapTrade OAuth token request failed: expired_token", oauth_error: "expired_token")
|
|
)
|
|
|
|
start_device_flow(@snaptrade_item)
|
|
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_response :unprocessable_entity
|
|
# Nothing left to retry with, so the page offers a fresh start instead of
|
|
# a button that can only fail again.
|
|
assert_no_match "WXYZ-1234", response.body
|
|
assert_select "form[action='#{start_oauth_device_flow_snaptrade_items_path}']"
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow requires a flow started in this session" do
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).never
|
|
|
|
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
# The device code is a bearer credential that says nothing about who asked for
|
|
# it. If the request body could supply one, anyone who came by a code -- from a
|
|
# log, a shared screen -- could redeem someone else's pending authorization
|
|
# into an item of their own. Only the session that started the flow can finish
|
|
# it.
|
|
test "complete_oauth_device_flow ignores a device code supplied as a parameter" do
|
|
item = snaptrade_items(:unauthorized_item)
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).never
|
|
|
|
post complete_oauth_device_flow_snaptrade_item_url(item), params: { device_code: "someone-elses-code" }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_nil item.reload.oauth_access_token
|
|
end
|
|
end
|
|
|
|
test "complete_oauth_device_flow will not redeem a code into a different item" do
|
|
other_item = @user.family.snaptrade_items.create!(name: "Another SnapTrade")
|
|
|
|
with_device_flow_only do
|
|
Provider::Snaptrade.expects(:poll_device_token).never
|
|
|
|
start_device_flow(@snaptrade_item)
|
|
post complete_oauth_device_flow_snaptrade_item_url(other_item)
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_nil other_item.reload.oauth_access_token
|
|
end
|
|
end
|
|
|
|
# The device code is the whole capability check on complete_oauth_device_flow;
|
|
# nothing binds it to the family that requested it, the way state does in the
|
|
# redirect flow. So it must not reach the request log.
|
|
test "device flow codes are filtered from logs" do
|
|
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
|
|
filtered_params = parameter_filter.filter(
|
|
device_code: "dev-c0de",
|
|
user_code: "WXYZ-1234",
|
|
verification_uri_complete: "https://app.snaptrade.com/device?code=WXYZ-1234"
|
|
)
|
|
|
|
assert_equal "[FILTERED]", filtered_params[:device_code]
|
|
assert_equal "[FILTERED]", filtered_params[:user_code]
|
|
assert_equal "[FILTERED]", filtered_params[:verification_uri_complete]
|
|
end
|
|
|
|
test "oauth_callback rejects state mismatch without exchanging the code" do
|
|
Provider::Snaptrade.expects(:exchange_code).never
|
|
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
get oauth_authorize_snaptrade_items_url
|
|
|
|
get oauth_callback_snaptrade_items_url(code: "c0de", state: "wrong-state")
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert flash[:alert].present?
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_callback handles access_denied" do
|
|
get oauth_callback_snaptrade_items_url(error: "access_denied", state: "whatever")
|
|
assert_redirected_to settings_providers_path
|
|
assert flash[:alert].present?
|
|
end
|
|
|
|
test "oauth_callback fails when code param is missing" do
|
|
Provider::Snaptrade.expects(:exchange_code).never
|
|
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
get oauth_authorize_snaptrade_items_url
|
|
oauth_session = session[:snaptrade_oauth]
|
|
|
|
get oauth_callback_snaptrade_items_url(state: oauth_session["state"])
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_equal "Unable to complete SnapTrade authorization. Please try again.", flash[:alert]
|
|
assert_nil session[:snaptrade_oauth]
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_callback handles exchange failures without applying tokens" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
get oauth_authorize_snaptrade_items_url
|
|
oauth_session = session[:snaptrade_oauth]
|
|
item = SnaptradeItem.find(oauth_session["item_id"])
|
|
original_token = item.oauth_access_token
|
|
|
|
Provider::Snaptrade.expects(:exchange_code)
|
|
.with(code: "c0de", redirect_uri: oauth_callback_snaptrade_items_url, code_verifier: oauth_session["code_verifier"])
|
|
.raises(Provider::Snaptrade::Error.new("upstream exchange failed"))
|
|
|
|
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_equal "Unable to complete SnapTrade authorization. Please try again.", flash[:alert]
|
|
assert_nil session[:snaptrade_oauth]
|
|
assert_equal original_token, item.reload.oauth_access_token
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_callback exchanges code, stores tokens, queues sync, and resumes setup" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
get oauth_authorize_snaptrade_items_url(return_to: "setup_accounts")
|
|
oauth_session = session[:snaptrade_oauth]
|
|
item = SnaptradeItem.find(oauth_session["item_id"])
|
|
|
|
Provider::Snaptrade.expects(:exchange_code)
|
|
.with(code: "c0de", redirect_uri: oauth_callback_snaptrade_items_url, code_verifier: oauth_session["code_verifier"])
|
|
.returns({ "access_token" => "at", "refresh_token" => "rt", "expires_in" => 900 })
|
|
|
|
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
|
|
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(item, accountable_type: nil)
|
|
assert_equal "at", item.reload.oauth_access_token
|
|
assert_nil session[:snaptrade_oauth]
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_callback clears a reconnect warning after successful authorization" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
@snaptrade_item.update!(status: :requires_update)
|
|
|
|
get oauth_authorize_snaptrade_items_url(item_id: @snaptrade_item.id)
|
|
oauth_session = session[:snaptrade_oauth]
|
|
Provider::Snaptrade.expects(:exchange_code).returns({ "access_token" => "at", "refresh_token" => "rt" })
|
|
|
|
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
|
|
|
|
assert @snaptrade_item.reload.good?
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "oauth_callback queues a sync even while activities are fetching" do
|
|
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
|
|
get oauth_authorize_snaptrade_items_url(item_id: @snaptrade_item.id)
|
|
oauth_session = session[:snaptrade_oauth]
|
|
Provider::Snaptrade.expects(:exchange_code).returns({ "access_token" => "at", "refresh_token" => "rt" })
|
|
active_sync = @snaptrade_item.syncs.create!
|
|
active_sync.start!
|
|
|
|
assert_enqueued_with job: SnaptradeFollowUpSyncJob do
|
|
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
|
|
end
|
|
ensure
|
|
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
|
Rails.configuration.x.snaptrade.oauth_client_secret = nil
|
|
end
|
|
|
|
test "Reconnect starts OAuth authorization instead of adding a brokerage" do
|
|
@snaptrade_item.update!(status: :requires_update)
|
|
|
|
with_oauth_app do
|
|
get accounts_url
|
|
|
|
assert_select "a[href='#{oauth_authorize_snaptrade_items_path(item_id: @snaptrade_item.id)}'][data-turbo-frame='_top']", text: /Reconnect/
|
|
assert_select "a[href='#{connect_snaptrade_item_path(@snaptrade_item)}']", text: /Reconnect/, count: 0
|
|
end
|
|
end
|
|
|
|
test "Reconnect uses the device flow when the deployment has no confidential client" do
|
|
@snaptrade_item.update!(status: :requires_update)
|
|
|
|
with_device_flow_only do
|
|
get accounts_url
|
|
|
|
assert_select "a[href='#{oauth_device_authorize_snaptrade_items_path(item_id: @snaptrade_item.id)}'][data-turbo-frame='drawer']", text: /Reconnect/
|
|
end
|
|
end
|
|
|
|
test "select_accounts redirects unregistered users into connect flow" do
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
snaptrade_item = snaptrade_items(:unauthorized_item)
|
|
|
|
with_oauth_app do
|
|
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
|
|
|
|
assert_redirected_to oauth_authorize_snaptrade_items_path(
|
|
item_id: snaptrade_item.id,
|
|
accountable_type: "Investment",
|
|
return_to: "setup_accounts"
|
|
)
|
|
end
|
|
end
|
|
|
|
test "select_accounts sends unregistered users to the device flow without a confidential client" do
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
snaptrade_item = snaptrade_items(:unauthorized_item)
|
|
|
|
with_device_flow_only do
|
|
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
|
|
|
|
assert_redirected_to oauth_device_authorize_snaptrade_items_path(
|
|
item_id: snaptrade_item.id,
|
|
accountable_type: "Investment",
|
|
return_to: "setup_accounts"
|
|
)
|
|
end
|
|
end
|
|
|
|
test "select_accounts redirects registered users to setup flow" do
|
|
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
|
|
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
|
|
end
|
|
|
|
test "preload_accounts redirects unregistered users into connect flow" do
|
|
sign_out
|
|
sign_in @user = users(:empty)
|
|
|
|
with_oauth_app do
|
|
assert_no_difference "Sync.count" do
|
|
get preload_accounts_snaptrade_items_url
|
|
end
|
|
|
|
assert_redirected_to oauth_authorize_snaptrade_items_path(item_id: snaptrade_items(:unauthorized_item).id)
|
|
end
|
|
end
|
|
|
|
test "preload_accounts redirects registered users to setup flow and queues sync" do
|
|
assert_difference "Sync.count", 1 do
|
|
get preload_accounts_snaptrade_items_url
|
|
end
|
|
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
|
|
end
|
|
|
|
test "entry routing prefers a registered active item over a pending one" do
|
|
pending_item = @user.family.snaptrade_items.create!(
|
|
name: "Pending Registration",
|
|
status: :good,
|
|
scheduled_for_deletion: false,
|
|
pending_account_setup: true
|
|
)
|
|
|
|
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
|
|
|
|
assert_difference "Sync.count", 1 do
|
|
get preload_accounts_snaptrade_items_url
|
|
end
|
|
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
|
|
|
|
assert_not pending_item.oauth_configured?
|
|
end
|
|
|
|
test "setup_accounts shows linkable investment and crypto accounts in dropdown" do
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_response :success
|
|
|
|
# Investment and crypto accounts (no provider) should appear in the link dropdown
|
|
assert_match accounts(:investment).name, response.body
|
|
assert_match accounts(:crypto).name, response.body
|
|
|
|
# Depository should NOT appear in the link dropdown (wrong type)
|
|
# The depository name may appear elsewhere on the page, so check the select options specifically
|
|
refute_match(/option.*#{accounts(:depository).name}/, response.body)
|
|
end
|
|
|
|
test "setup_accounts excludes accounts that already have a provider from dropdown" do
|
|
# Link the investment account to a snaptrade_account
|
|
AccountProvider.create!(
|
|
account: accounts(:investment),
|
|
provider: snaptrade_accounts(:fidelity_401k)
|
|
)
|
|
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_response :success
|
|
|
|
# Investment account is now linked → should NOT appear in link dropdown options
|
|
refute_match(/option.*#{accounts(:investment).name}/, response.body)
|
|
# Crypto still unlinked → should appear
|
|
assert_match accounts(:crypto).name, response.body
|
|
end
|
|
|
|
test "setup_accounts preselects types from SnapTrade account categories" do
|
|
account = snaptrade_accounts(:fidelity_401k)
|
|
account.update!(raw_payload: { "account_category" => "DEPOSIT" })
|
|
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
|
|
assert_select "input[name='account_types[#{account.id}]'][value='Depository']"
|
|
end
|
|
|
|
test "complete_account_setup uses the selected account type" do
|
|
account = snaptrade_accounts(:fidelity_401k)
|
|
@snaptrade_item.stubs(:sync_later)
|
|
|
|
assert_difference "Account.where(accountable_type: 'Depository').count", 1 do
|
|
post complete_account_setup_snaptrade_item_url(@snaptrade_item), params: {
|
|
account_ids: [ account.id ],
|
|
account_types: { account.id => "Depository" }
|
|
}
|
|
end
|
|
|
|
assert_equal "Depository", account.reload.current_account.accountable_type
|
|
end
|
|
|
|
test "select_existing_account prefers registered active item over pending one" do
|
|
pending_item = @user.family.snaptrade_items.create!(
|
|
name: "Pending Registration",
|
|
status: :good,
|
|
scheduled_for_deletion: false,
|
|
pending_account_setup: true
|
|
)
|
|
pending_item.snaptrade_accounts.create!(
|
|
snaptrade_account_id: "pending_snaptrade_account",
|
|
name: "Pending Brokerage Account",
|
|
brokerage_name: "Pending Broker",
|
|
currency: "USD",
|
|
current_balance: 0
|
|
)
|
|
|
|
get select_existing_account_snaptrade_items_url, params: { account_id: accounts(:investment).id }
|
|
|
|
assert_response :success
|
|
assert_includes response.body, snaptrade_accounts(:fidelity_401k).name
|
|
refute_includes response.body, "Pending Brokerage Account"
|
|
end
|
|
|
|
test "link_existing_account links account to snaptrade_account" do
|
|
account = accounts(:investment)
|
|
snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
|
|
|
assert_difference "AccountProvider.count", 1 do
|
|
post link_existing_account_snaptrade_items_url, params: {
|
|
account_id: account.id,
|
|
snaptrade_account_id: snaptrade_account.id,
|
|
snaptrade_item_id: @snaptrade_item.id
|
|
}
|
|
end
|
|
|
|
assert_redirected_to account_path(account)
|
|
assert_match(/Successfully linked/, flash[:notice])
|
|
|
|
snaptrade_account.reload
|
|
assert_equal account, snaptrade_account.current_account
|
|
end
|
|
|
|
test "link_existing_account handles missing account gracefully" do
|
|
snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
|
|
|
assert_no_difference "AccountProvider.count" do
|
|
post link_existing_account_snaptrade_items_url, params: {
|
|
account_id: "nonexistent",
|
|
snaptrade_account_id: snaptrade_account.id,
|
|
snaptrade_item_id: @snaptrade_item.id
|
|
}
|
|
end
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert_match(/not found/i, flash[:alert])
|
|
end
|
|
|
|
# --- setup_accounts throttle-sync fix ---
|
|
#
|
|
# The fix on setup_accounts ensures sync_later is only called when there are no
|
|
# accounts AND the item has never been synced (last_synced_at.blank?). This
|
|
# prevents the infinite-spinner loop where every page load re-triggered a sync
|
|
# even after SnapTrade already confirmed 0 linked accounts.
|
|
#
|
|
# Three view-state branches we need to cover:
|
|
# A) No accounts + never synced → trigger sync, render spinner
|
|
# B) No accounts + synced once, now idle → skip sync, show "no accounts found"
|
|
# C) No accounts + synced once, still syncing → show spinner, do NOT re-queue
|
|
|
|
test "setup_accounts triggers sync and shows spinner when item has no accounts and has never been synced" do
|
|
# Pre-condition: no snaptrade_accounts and no completed syncs (last_synced_at is nil)
|
|
@snaptrade_item.snaptrade_accounts.destroy_all
|
|
@snaptrade_item.syncs.destroy_all
|
|
|
|
assert_difference "Sync.count", 1 do
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
end
|
|
|
|
assert_response :success
|
|
assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown on first visit with no accounts"
|
|
assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while syncing"
|
|
end
|
|
|
|
test "setup_accounts shows no-accounts-found state after a completed sync returns zero accounts" do
|
|
# Pre-condition: no snaptrade_accounts, but there IS a past completed sync
|
|
@snaptrade_item.snaptrade_accounts.destroy_all
|
|
@snaptrade_item.syncs.destroy_all
|
|
@snaptrade_item.syncs.create!(status: :completed, completed_at: 1.minute.ago)
|
|
|
|
# Item is not currently syncing → @syncing is false
|
|
assert_not @snaptrade_item.reload.syncing?, "Item should not be syncing for this test"
|
|
|
|
assert_no_difference "Sync.count" do
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
end
|
|
|
|
assert_response :success
|
|
assert_select ".no-accounts-found", count: 1, message: "Expected the no-accounts UI to be shown after a completed sync with zero accounts"
|
|
assert_select "#snaptrade-sync-spinner", count: 0, message: "Expected the spinner to be hidden when there is no active sync"
|
|
assert_select "a[href=?]", connect_snaptrade_item_path(@snaptrade_item, return_to: "setup_accounts", accountable_type: nil), text: /Connect Brokerage/
|
|
assert_no_match oauth_authorize_snaptrade_items_path(item_id: @snaptrade_item.id), response.body
|
|
end
|
|
|
|
test "setup_accounts does not re-queue a sync when a sync is already in progress" do
|
|
# Pre-condition: no accounts, one past completed sync, + one visible (in-flight) sync
|
|
@snaptrade_item.snaptrade_accounts.destroy_all
|
|
@snaptrade_item.syncs.destroy_all
|
|
@snaptrade_item.syncs.create!(status: :completed, completed_at: 5.minutes.ago)
|
|
@snaptrade_item.syncs.create!(status: :pending, created_at: 1.minute.ago) # visible/in-flight
|
|
|
|
assert @snaptrade_item.reload.syncing?, "Item should be syncing for this test"
|
|
|
|
assert_no_difference "Sync.count" do
|
|
get setup_accounts_snaptrade_item_url(@snaptrade_item)
|
|
end
|
|
|
|
assert_response :success
|
|
assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown while sync is in progress"
|
|
assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while a sync is active"
|
|
end
|
|
end
|