Ctrl K
ComunidadGuías…

test/integration/oauth_delegate_resources_test.rb

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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