test/integration/oauth_delegate_resources_test.rb
Rama main · réplica de lectura del repositorio original.
require "test_helper"
# An OAuth grant is a delegate: the user handed an app authority to act for
# them. What it sets up falls into two kinds. Plumbing that delivers to the
# app itself, like a push subscription for the app's own device, belongs to
# the app and ends when the identity's last grant to it ends. Resources the
# delegate set up for the account, like a webhook, belong to the account:
# they outlive the grant, attributed to the person and the app, and the
# person may choose to remove them when they disconnect the app.
class OauthDelegateResourcesTest < ActionDispatch::IntegrationTest
include OauthAvailabilityTestHelper
ENDPOINT = "https://fcm.googleapis.com/fcm/send/app-device"
BROWSER = "https://fcm.googleapis.com/fcm/send/browser"
setup do
stub_web_push_dns_resolution
@client = oauth_clients(:mcp_client)
@grant = identities(:kevin).access_tokens.create!(oauth_client: @client, permission: :write)
end
test "a push subscription created through a grant belongs to its client" do
subscribe_push bearer(@grant)
assert_response :created
assert_equal @client, app_subscription.oauth_client
end
test "a push subscription records its app as it's inserted, not in a second write" do
Push::Subscription.any_instance.stubs(:claim_for)
subscribe_push bearer(@grant)
assert_response :created
assert_equal @client, app_subscription.oauth_client
end
test "disconnecting the app removes its push subscriptions" do
subscribe_push bearer(@grant)
sign_in_as :kevin
delete my_connected_app_path(@client)
assert_redirected_to my_connected_apps_path
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
end
test "revoking the grant removes the app's push subscriptions" do
subscribe_push bearer(@grant)
untenanted { post oauth_revocation_path, params: { token: @grant.raw_token, client_id: @client.client_id } }
assert_response :success
assert_not Identity::AccessToken.exists?(@grant.id)
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
end
test "a grant lapsing idle removes the app's push subscriptions" do
subscribe_push bearer(@grant)
travel Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second
Identity::AccessToken.cleanup
assert_not Identity::AccessToken.exists?(@grant.id)
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
end
test "a replayed refresh token revoking the grant removes the app's push subscriptions" do
subscribe_push bearer(@grant)
presented = @grant.raw_refresh_token
refresh presented
assert_response :success
travel 1.day
refresh presented
assert_equal "invalid_grant", response.parsed_body["error"]
assert_not Identity::AccessToken.exists?(@grant.id)
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
end
test "the app's push subscriptions last until its last grant to the identity ends" do
other_grant = identities(:kevin).access_tokens.create!(oauth_client: @client, permission: :read)
subscribe_push bearer(@grant)
@grant.destroy
assert Push::Subscription.exists?(endpoint: ENDPOINT)
other_grant.destroy
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
end
test "registering an endpoint again hands it to whoever registered it last" do
subscribe_push bearer(@grant)
reregistered = Oauth::Client.create!(name: "Test MCP Client", redirect_uris: %w[ http://127.0.0.1:8888/callback ], dynamically_registered: true)
subscribe_push bearer(identities(:kevin).access_tokens.create!(oauth_client: reregistered, permission: :write))
assert_equal reregistered, app_subscription.oauth_client
@grant.destroy
assert Push::Subscription.exists?(endpoint: ENDPOINT)
sign_in_as :kevin
post user_push_subscriptions_path(users(:kevin)), params: { push_subscription: push_params(ENDPOINT) }
assert_nil app_subscription.oauth_client
end
# Delivery is where the leak would be, so the guarantee lives there: an app's
# subscription delivers only while the user's identity holds a live grant to
# that app. Cleanup on grant destroy keeps the table tidy; whatever it can't
# see goes inert rather than delivering.
test "an app's push subscription delivers while its grant is live, alongside the browser's" do
subscribe_push bearer(@grant)
users(:kevin).push_subscriptions.create!(push_params(BROWSER))
assert_equal [ BROWSER, ENDPOINT ].sort, delivered_endpoints.sort
end
test "an app's push subscription stops delivering while OAuth is dark, unless the app is piloting" do
subscribe_push bearer(@grant)
with_oauth_availability acceptance: false do
assert_not_includes delivered_endpoints, ENDPOINT
end
with_oauth_availability acceptance: false, pilot_client_ids: [ @client.client_id ] do
assert_includes delivered_endpoints, ENDPOINT
end
end
test "an app's push subscription keeps delivering while connecting apps is in preview and the user is a listed reviewer" do
subscribe_push bearer(@grant)
identities(:kevin).update!(staff: false)
with_oauth_availability preview: true, reviewer_ids: [ identities(:kevin).id ] do
assert_equal [ ENDPOINT ], delivered_endpoints
end
end
test "an app's push subscription stops delivering while connecting apps is in preview and the user isn't a previewer, alongside the browser's" do
subscribe_push bearer(@grant)
users(:kevin).push_subscriptions.create!(push_params(BROWSER))
with_oauth_availability preview: true do
assert_equal [ BROWSER, ENDPOINT ].sort, delivered_endpoints.sort
identities(:kevin).update!(staff: false)
assert_equal [ BROWSER ], delivered_endpoints
end
end
test "delivering push looks up no identity while connecting apps isn't in preview" do
subscribe_push bearer(@grant)
user = User.find(users(:kevin).id)
with_oauth_availability preview: false do
Push::Subscription.deliverable_to(user).load
end
assert_not user.association(:identity).loaded?
end
test "an app's push subscription stops delivering when the user leaves the account" do
subscribe_push bearer(@grant)
users(:kevin).push_subscriptions.create!(push_params(BROWSER))
users(:kevin).deactivate
assert_equal [ BROWSER ], delivered_endpoints
end
test "an app's push subscription delivers through a grant bound to its own account" do
@grant.update!(account: accounts("37s"))
subscribe_push bearer(@grant)
assert_includes delivered_endpoints, ENDPOINT
end
test "an app's push subscription doesn't deliver through a grant bound to another account" do
subscribe_push bearer(@grant)
@grant.update!(account: accounts(:initech))
assert_not_includes delivered_endpoints, ENDPOINT
end
test "an app's push subscription stops delivering when the user moves to another identity" do
subscribe_push bearer(@grant)
users(:kevin).change_email_address("kevin.elsewhere@example.com")
assert Identity::AccessToken.exists?(@grant.id)
assert_empty delivered_endpoints
end
test "an app's push subscription stops delivering once its grant lapses, before the sweep" do
subscribe_push bearer(@grant)
travel Identity::AccessToken::REFRESH_IDLE_LIMIT + 1.second
assert Identity::AccessToken.exists?(@grant.id)
assert_empty delivered_endpoints
end
test "a subscription claimed for an app with no grant left never delivers" do
subscription = users(:kevin).push_subscriptions.create!(push_params(ENDPOINT))
@grant.destroy
subscription.claim_for @client
assert_empty delivered_endpoints
end
test "push subscriptions made by a session or a personal access token survive disconnecting" do
pat = identities(:kevin).access_tokens.create!(permission: :write)
subscribe_push bearer(pat), endpoint: "https://fcm.googleapis.com/fcm/send/pat-device"
sign_in_as :kevin
post user_push_subscriptions_path(users(:kevin)), params: { push_subscription: push_params("https://fcm.googleapis.com/fcm/send/browser") }
delete my_connected_app_path(@client)
assert_nil Push::Subscription.find_by!(endpoint: "https://fcm.googleapis.com/fcm/send/pat-device").oauth_client
assert_nil Push::Subscription.find_by!(endpoint: "https://fcm.googleapis.com/fcm/send/browser").oauth_client
end
test "another client's revocation leaves this client's push subscriptions and webhooks alone" do
other = oauth_clients(:trusted_client)
other_grant = identities(:kevin).access_tokens.create!(oauth_client: other, permission: :write)
subscribe_push bearer(@grant)
webhook = create_webhook bearer(@grant)
untenanted { post oauth_revocation_path, params: { token: other_grant.raw_token, client_id: other.client_id } }
assert_not Identity::AccessToken.exists?(other_grant.id)
assert Push::Subscription.exists?(endpoint: ENDPOINT)
assert Webhook.exists?(webhook.id)
end
test "a webhook created through a grant records who created it and through which app" do
webhook = create_webhook bearer(@grant)
assert_equal users(:kevin), webhook.creator
assert_equal @client, webhook.created_via
end
test "a webhook created in a session records its creator and no app" do
sign_in_as :kevin
post board_webhooks_path(boards(:writebook)), params: { webhook: { name: "Mine", url: "https://example.com/mine" } }
webhook = Webhook.find_by!(name: "Mine")
assert_equal users(:kevin), webhook.creator
assert_nil webhook.created_via
end
test "an app editing a webhook set up in a session doesn't become its author" do
webhook = Webhook.create!(board: boards(:writebook), name: "Mine", url: "https://example.com/mine", creator: users(:kevin))
patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Renamed" } }, env: bearer(@grant), as: :json
assert_response :success
assert_equal "Renamed", webhook.reload.name
assert_equal users(:kevin), webhook.creator
assert_nil webhook.created_via
assert_not_includes Webhook.set_up_through(@client, identity: identities(:kevin)), webhook
end
test "Connected Apps leaves alone an app's webhooks on a board the person was taken off" do
webhook = create_webhook bearer(@grant)
Access.where(user: users(:kevin), board: webhook.board).delete_all
assert_not_includes Webhook.set_up_through(@client, identity: identities(:kevin)), webhook
sign_in_as :kevin
delete my_connected_app_path(@client), params: { remove_webhooks: "1", webhook_ids: [ webhook.id ] }
assert Webhook.exists?(webhook.id)
end
test "a webhook created through a grant survives disconnecting, attributed to the person and the app" do
webhook = create_webhook bearer(@grant)
sign_in_as :kevin
delete my_connected_app_path(@client)
assert Webhook.exists?(webhook.id)
get board_webhook_path(webhook.board, webhook)
assert_response :success
assert_select "p", text: "Created by #{users(:kevin).name} via #{@client.name}"
get board_webhooks_path(webhook.board)
assert_response :success
assert_select "li", text: /via #{Regexp.escape(@client.name)}/
end
test "Connected Apps lists the webhooks an app set up, with removal off by default" do
webhook = create_webhook bearer(@grant)
sign_in_as :kevin
get my_connected_apps_path
assert_response :success
assert_select "dialog li", text: /#{Regexp.escape(webhook.name)}/
assert_select "dialog[aria-labelledby=?] ##{ActionView::RecordIdentifier.dom_id(@client, :disconnect_title)}", ActionView::RecordIdentifier.dom_id(@client, :disconnect_title), text: "Revoke access for #{@client.name}?"
assert_select "input[type=checkbox][name=remove_webhooks]:not([checked])"
end
test "disconnecting with also-remove removes exactly that app's webhooks" do
app_webhook = create_webhook bearer(@grant)
other_app_webhook = create_webhook bearer(identities(:kevin).access_tokens.create!(oauth_client: oauth_clients(:trusted_client), permission: :write)), name: "Other app"
someone_elses_webhook = create_webhook bearer(another_admins_grant), name: "David's"
sign_in_as :kevin
post board_webhooks_path(boards(:writebook)), params: { webhook: { name: "By hand", url: "https://example.com/by-hand" } }
by_hand = Webhook.find_by!(name: "By hand")
assert_difference -> { Webhook.count }, -1 do
delete my_connected_app_path(@client), params: { remove_webhooks: "1", webhook_ids: [ app_webhook.id ] }
end
assert_not Webhook.exists?(app_webhook.id)
assert Webhook.exists?(other_app_webhook.id)
assert Webhook.exists?(someone_elses_webhook.id)
assert Webhook.exists?(by_hand.id)
assert_not identities(:kevin).access_tokens.exists?(oauth_client: @client)
end
test "also-remove reaches only webhooks the app set up for this person, whatever ids are posted" do
app_webhook = create_webhook bearer(@grant)
someone_elses_webhook = create_webhook bearer(another_admins_grant), name: "David's"
sign_in_as :kevin
delete my_connected_app_path(@client), params: { remove_webhooks: "1", webhook_ids: [ app_webhook.id, someone_elses_webhook.id, webhooks(:active).id ] }
assert_not Webhook.exists?(app_webhook.id)
assert Webhook.exists?(someone_elses_webhook.id)
assert Webhook.exists?(webhooks(:active).id)
end
test "also-remove removes only the webhooks that were listed, not one the app added since" do
listed = create_webhook bearer(@grant)
added_since = create_webhook bearer(@grant), name: "Added since"
sign_in_as :kevin
delete my_connected_app_path(@client), params: { remove_webhooks: "1", webhook_ids: [ listed.id ] }
assert_not Webhook.exists?(listed.id)
assert Webhook.exists?(added_since.id)
end
test "a cancelled account's webhooks are neither listed nor removed at revoke time" do
elsewhere = User.create!(name: "Kevin", identity: identities(:kevin), account: accounts(:initech), role: :admin)
webhook = Current.set(user: elsewhere) do
boards(:miltons_wish_list).webhooks.create!(name: "Cancelled feed", url: "https://app.example.com/hooks", created_via: @client)
end
accounts(:initech).create_cancellation!(initiated_by: users(:mike))
sign_in_as :kevin
get my_connected_apps_path
assert_select "dialog li", text: /Cancelled feed/, count: 0
delete my_connected_app_path(@client), params: { remove_webhooks: "1", webhook_ids: [ webhook.id ] }
assert Webhook.exists?(webhook.id)
end
test "disconnecting without also-remove keeps the app's webhooks" do
webhook = create_webhook bearer(@grant)
sign_in_as :kevin
delete my_connected_app_path(@client), params: { webhook_ids: [ webhook.id ] }
assert Webhook.exists?(webhook.id)
end
private
def bearer(token)
{ "HTTP_AUTHORIZATION" => "Bearer #{token.raw_token}" }
end
def another_admins_grant
users(:david).update!(role: :admin)
identities(:david).access_tokens.create!(oauth_client: @client, permission: :write)
end
def push_params(endpoint)
{ endpoint: endpoint, p256dh_key: "p256dh", auth_key: "auth" }
end
def subscribe_push(env, endpoint: ENDPOINT)
post user_push_subscriptions_path(users(:kevin)), params: { push_subscription: push_params(endpoint) }, env: env, as: :json
end
def delivered_endpoints
delivered = []
pool = mock("web_push_pool")
pool.stubs(:queue).with { |_payload, subscriptions| delivered = subscriptions.map(&:endpoint) }
Rails.configuration.x.stubs(:web_push_pool).returns(pool)
notification = stub(user: users(:kevin).reload, payload: stub(to_h: {}))
Notification::PushTarget::Web.new(notification).process
delivered
end
def app_subscription
Push::Subscription.find_by!(endpoint: ENDPOINT)
end
def create_webhook(env, name: "App feed")
post board_webhooks_path(boards(:writebook)), params: { webhook: { name: name, url: "https://app.example.com/hooks" } }, env: env, as: :json
assert_response :created
Webhook.find(response.parsed_body["id"])
end
def refresh(refresh_token)
untenanted do
post oauth_token_path, params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: @client.client_id }
end
end
end