Properly lookup 1.3->1.1 mapping during 1.3 launch

This reverts commit 0e703b84c0.

why:
- Previously, work was done to add the oauth_consumer_key and
  oauth_consumer_key_sign to LTI 1.3 launches that are associated with a
  1.1 tool.
- This work is important, as it improves the migration experience for
  tools.
- However, the previous attempt at this did not account for tools within
  production Canvas having invalid URLs/domains. While we do validate
  URLs now, we didn't always, so there are some bad tools still hanging
  out in the wild.
- Additionally, while the previous commit did add a feature flag, it
  didn't add it properly, meaning that even though the flag was off, it
  was still looking up the association, it just wasn't adding the
  property to the 1.3 Message. This commit does it properly now.

fixes INTEROP-8124

flag=include_oauth_consumer_key_in_lti_launch

test-plan:
- The first half of this test plan is taken verbatim from the previous
  commit:
  ------ Start Old Test Plan
- Clone Xander's handy dandy Remix 1.1/1.3 test tool and run it locally.
  https://github.com/xandroxygen/lti_1p1_test_tool
- Run the tool and then install it locally by following the directions
  for both 1.1 and 1.3.
- Launch the 1.3 tool and make sure that under the 1.1 claims section,
  there is an oauth_consumer_key with value key and an
  oauth_consumer_key_sign section. You don't have to check the
  signature, as the algorithm for it is unit tested using values from
  IMS's examples from the spec itself.
- Now delete the 1.1 tool and launch the 1.3 tool again. You should
  still see the oauth_consumer_key info.
  ------ End Old Test Plan
- Now, to test that things won't broke when there are malformed tools,
  do the following:
- Create a new course.
- Manually create a new tool in the rails console in this course with
  malformed data. We have to bypass some rails validations to mock prod.
  ```ruby
  tool = Course.last.context_external_tools.new(url: "http://url path>};/invalidurl}",

          domain: "url path>};"
          settings: {
            "text"=>"LTI 1.1 Tool with Garbage Data",
             "visibility"=>"public",
             "custom_fields"=>{"user_id"=>"$Canvas.user.id"},
             "course_navigation"=>
              {"enabled"=>true,
               "url"=>"http://url path>};/invalidurl}/launch?placement=course_navigation",
               "text"=>"LTI 1.1 Garbage Data",
               "selection_width"=>500,
               "selection_height"=>500,
               "message_type"=>"basic_lti_request",
               "visibility"=>"none"},
             "vendor_extensions"=>[]
             })
  tool.save(validate: false)
  ```
- Launch the LTI 1.3 test tool from this new course. You should NOT see
  an oauth_consumer_key & oauth_consumer_key_sign in the LTI 1.1 claims.
  More importantly, the launch should succeed without a 500.
- Delete the manually created tool and launch the 1.3 tool again. All
  should be the same as above.

Change-Id: Icea1ed5fd0a316fac51ba87591ea79b2002d5a9d
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/322172
Reviewed-by: Xander Moffatt <xmoffatt@instructure.com>
QA-Review: Xander Moffatt <xmoffatt@instructure.com>
Product-Review: Ryan Hawkins <ryan.hawkins@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Ryan Hawkins 2023-07-07 16:02:44 +00:00
parent 4485ba0c0d
commit 8d74397b9b
9 changed files with 352 additions and 78 deletions

View File

@ -704,6 +704,7 @@ class ContextExternalTool < ActiveRecord::Base
# launch_url overrides are only considered when include_launch_url: true is
# provided, and are preferred over domain overrides. Query strings from the
# base_url and launch_url override will be merged together.
# @param base_url [String]
def url_with_environment_overrides(base_url, include_launch_url: false)
return base_url unless use_environment_overrides?
@ -854,13 +855,17 @@ class ContextExternalTool < ActiveRecord::Base
end
def self.standardize_url(url)
return "" if url.blank?
return nil if url.blank?
url = url.gsub(/[[:space:]]/, "")
url = "http://" + url unless url.include?("://")
res = Addressable::URI.parse(url).normalize
res.query = res.query.split("&").sort.join("&") unless res.query.blank?
res.to_s
begin
res = Addressable::URI.parse(url)&.normalize
res.query = res.query.split("&").sort.join("&") if res&.query.present?
res
rescue Addressable::URI::InvalidURIError
nil
end
end
alias_method :destroy_permanently!, :destroy
@ -882,10 +887,10 @@ class ContextExternalTool < ActiveRecord::Base
end
def standard_url(use_environment_overrides = false)
standard_url = url.present? && ContextExternalTool.standardize_url(url)
standard_url = ContextExternalTool.standardize_url(url)
if use_environment_overrides
ContextExternalTool.standardize_url(url_with_environment_overrides(standard_url, include_launch_url: true))
ContextExternalTool.standardize_url(url_with_environment_overrides(standard_url.to_s, include_launch_url: true))
else
standard_url
end
@ -903,27 +908,28 @@ class ContextExternalTool < ActiveRecord::Base
# risking breaking Canvas flows, we introduced this
# new method.
def matches_host?(url, use_environment_overrides: false)
standard_url = standard_url(use_environment_overrides)
matches_tool_domain?(url) ||
(standard_url(use_environment_overrides).present? &&
Addressable::URI.parse(standard_url(use_environment_overrides))&.normalize&.host ==
Addressable::URI.parse(url).normalize.host)
(standard_url.present? &&
standard_url.host == ContextExternalTool.standardize_url(url)&.host)
end
def matches_url?(url, match_queries_exactly = true, use_environment_overrides: false)
tool_url = standard_url(use_environment_overrides)
if match_queries_exactly
url = ContextExternalTool.standardize_url(url)
return true if url == tool_url
url == tool_url
elsif tool_url.present?
unless defined?(@url_params)
res = Addressable::URI.parse(tool_url)
@url_params = res.query.present? ? res.query.split("&") : []
@url_params ||= tool_url.query&.split("&") || []
res = ContextExternalTool.standardize_url(url)
return false if res.blank?
if res.query.present?
res.query = res.query.split("&").select { |p| @url_params.include?(p) }.sort.join("&")
end
res = Addressable::URI.parse(url).normalize
res.query = res.query.split("&").select { |p| @url_params.include?(p) }.sort.join("&") if res.query.present?
res.query = nil if res.query.blank?
res.normalize!
return true if res.to_s == tool_url
res == tool_url
end
end
@ -932,20 +938,20 @@ class ContextExternalTool < ActiveRecord::Base
return false if domain.blank?
url = ContextExternalTool.standardize_url(url)
host = Addressable::URI.parse(url).normalize.host rescue nil
port = Addressable::URI.parse(url).normalize.port rescue nil
host = url&.host
port = url&.port
d = domain.downcase.gsub(%r{https?://}, "")
!!(host && ("." + host + (port ? ":#{port}" : "")).match(/\.#{d}\z/))
end
def matches_domain?(url, use_environment_overrides: false)
url = ContextExternalTool.standardize_url(url)
host = Addressable::URI.parse(url).host
host = ContextExternalTool.standardize_url(url)&.host
domain = use_environment_overrides ? domain_with_environment_overrides : self.domain
standard_url = standard_url(use_environment_overrides)
if domain
domain.casecmp?(host)
elsif standard_url(use_environment_overrides)
Addressable::URI.parse(standard_url(use_environment_overrides)).host == host
domain == host
elsif standard_url.present?
standard_url.host == host
else
false
end
@ -1098,46 +1104,7 @@ class ContextExternalTool < ActiveRecord::Base
)
# Check for a tool that exactly matches the given URL
match = find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url) },
->(t) { t.url.present? }
)
# If exactly match doesn't work, try to match by ignoring extra query parameters
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, false) },
->(t) { t.url.present? }
)
# If still no matches, use domain matching to try to find a tool
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_tool_domain?(url) },
->(t) { t.domain.present? }
)
# repeat matches with environment-specific url and domain overrides
if ApplicationController.test_cluster? && Account.site_admin.feature_enabled?(:dynamic_lti_environment_overrides)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, use_environment_overrides: true) },
->(t) { t.url.present? }
)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, false, use_environment_overrides: true) },
->(t) { t.url.present? }
)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_tool_domain?(url, use_environment_overrides: true) },
->(t) { t.domain.present? }
)
end
match = find_matching_tool(url, sorted_external_tools)
# always use the preferred tool id *unless* the preferred tool is a 1.1 tool
# and the matched tool is a 1.3 tool, since 1.3 is the preferred version of a tool
@ -1243,6 +1210,51 @@ class ContextExternalTool < ActiveRecord::Base
end
end
def self.find_matching_tool(url, sorted_external_tools)
# Check for a tool that exactly matches the given URL
match = find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url) },
->(t) { t.url.present? }
)
# If exactly match doesn't work, try to match by ignoring extra query parameters
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, false) },
->(t) { t.url.present? }
)
# If still no matches, use domain matching to try to find a tool
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_tool_domain?(url) },
->(t) { t.domain.present? }
)
# repeat matches with environment-specific url and domain overrides
if ApplicationController.test_cluster? && Account.site_admin.feature_enabled?(:dynamic_lti_environment_overrides)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, use_environment_overrides: true) },
->(t) { t.url.present? }
)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_url?(url, false, use_environment_overrides: true) },
->(t) { t.url.present? }
)
match ||= find_tool_match(
sorted_external_tools,
->(t) { t.matches_tool_domain?(url, use_environment_overrides: true) },
->(t) { t.domain.present? }
)
end
match
end
scope :having_setting, lambda { |setting|
if setting
joins(:context_external_tool_placements)
@ -1539,6 +1551,44 @@ class ContextExternalTool < ActiveRecord::Base
[Canvas::ICU.collation_key(name), global_id]
end
def self.associated_1_1_tool(tool, context, launch_url)
return nil unless launch_url && tool.use_1_3?
# Finding tools is expensive and this relationship doesn't change very often, so
# it's worth it to maintain this possibly "incorrect" relationship for 5 minutes.
id = Rails.cache.fetch([tool.global_asset_string, context.global_asset_string, launch_url.slice(0..1024)].cache_key, expires_in: 5.minutes) do
# Rails themselves recommends against caching ActiveRecord models directly
# https://guides.rubyonrails.org/caching_with_rails.html#avoid-caching-instances-of-active-record-objects
GuardRail.activate(:secondary) do
sorted_external_tools = context.shard.activate do
contexts = contexts_to_search(context)
context_order = contexts.map.with_index { |c, i| "(#{c.id},'#{c.class.polymorphic_name}',#{i})" }.join(",")
order_clauses = [
# prefer tools that are not duplicates
sort_by_sql_string("identity_hash != 'duplicate'"),
# prefer tools from closer contexts
"context_order.ordering",
# prefer tools with more subdomains
precedence_sql_string
]
query = ContextExternalTool.where(context: contexts, lti_version: "1.1")
query.joins(sanitize_sql("INNER JOIN (values #{context_order}) as context_order (context_id, class, ordering)
ON #{quoted_table_name}.context_id = context_order.context_id AND #{quoted_table_name}.context_type = context_order.class"))
.order(Arel.sql(sanitize_sql_for_order(order_clauses.join(","))))
end
find_matching_tool(launch_url, sorted_external_tools)&.id
end
end
ContextExternalTool.find_by(id:)
end
def associated_1_1_tool(context, launch_url = nil)
ContextExternalTool.associated_1_1_tool(self, context, launch_url || url || domain)
end
private
# Locally and in OSS installations, this can be configured in config/dynamic_settings.yml.

View File

@ -183,6 +183,19 @@ disable_oembed_retrieve:
If enabled, the deprecated oembed_retrieve endpoint will be unavailable.
This is to ease transitioning any potential users off this endpoint before
removing it altogether.
include_oauth_consumer_key_in_lti_launch:
state: hidden
applies_to: SiteAdmin
display_name: Enable Sending OAuth Consumer Key
environments:
development:
state: allowed_on
ci:
state: allowed_on
description: |-
When enabled, LTI 1.3 launches that would be associated with LTI 1.1 tool
if the LTI 1.3 tool wasn't present will include the oauth_consumer_key and
oauth_consumer_key_sign properties as part of the LTI 1.1 claims section.
resource_link_uuid_in_custom_substitution:
state: hidden
display_name: Use a Resource link's resource_link_uuid in the $ResourceLink.id substitution parameter

View File

@ -180,16 +180,24 @@ module CanvasSecurity
end
def self.sign_hmac_sha512(string_to_sign, signing_secret = services_signing_secret)
OpenSSL::HMAC.digest("sha512", signing_secret, string_to_sign)
sign_hmac(string_to_sign, signing_secret, "sha512")
end
def self.verify_hmac_sha512(message, signature, signing_secret = services_signing_secret)
verify_hmac(message, signature, signing_secret, "sha512")
end
def self.sign_hmac(string_to_sign, signing_secret = services_signing_secret, hashing_alg = "sha512")
OpenSSL::HMAC.digest(hashing_alg, signing_secret, string_to_sign)
end
def self.verify_hmac(message, signature, signing_secret = services_signing_secret, hashing_alg = "sha512")
secrets_to_check = [signing_secret]
if signing_secret == services_signing_secret && services_previous_signing_secret
secrets_to_check << services_previous_signing_secret
end
secrets_to_check.each do |cur_secret|
comparison = sign_hmac_sha512(message, cur_secret)
comparison = sign_hmac(message, cur_secret, hashing_alg)
return true if ActiveSupport::SecurityUtils.secure_compare(signature, comparison)
end
false

View File

@ -27,7 +27,7 @@ module LtiAdvantage::Claims
class Lti1p1
include ActiveModel::Model
attr_accessor :user_id, :resource_link_id
attr_accessor :user_id, :resource_link_id, :oauth_consumer_key, :oauth_consumer_key_sign
validates_presence_of :user_id
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# Separates out more complicated functionality from the JwtMessage class to make for
# easier testing, separation of concerns, and just general understanding.
module Lti::Helpers::JwtMessageHelper
# Following the spec https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key_sign
# This value MAY be included by a platform. However, it is recommended.
def self.generate_oauth_consumer_key_sign(associated_1_1_tool, message)
return nil if associated_1_1_tool.blank?
CanvasSecurity.base64_encode(CanvasSecurity.sign_hmac([associated_1_1_tool.consumer_key,
message.deployment_id,
message.iss,
message.aud,
message.exp,
message.nonce].join("&").encode("utf-8"),
associated_1_1_tool.shared_secret.to_s.encode("utf-8"),
"sha256"))
end
end

View File

@ -174,6 +174,10 @@ module Lti::Messages
def add_lti1p1_claims!
@message.lti1p1.user_id = @user&.lti_context_id
if associated_1_1_tool.present?
@message.lti1p1.oauth_consumer_key = associated_1_1_tool.consumer_key
@message.lti1p1.oauth_consumer_key_sign = Lti::Helpers::JwtMessageHelper.generate_oauth_consumer_key_sign(associated_1_1_tool, @message)
end
end
# Following the spec https://www.imsglobal.org/spec/lti/v1p3/migr#remapping-parameters
@ -181,7 +185,9 @@ module Lti::Messages
# platform MUST include the parameter and its LTI 1.1 value. Otherwise the
# platform MAY omit that attribute.
def include_lti1p1_claims?
@user&.lti_context_id && @user.lti_context_id != @user.lti_id
user_ids_differ = @user&.lti_context_id && @user.lti_context_id != @user.lti_id
user_ids_differ || associated_1_1_tool.present?
end
# Follows the spec at https://www.imsglobal.org/spec/lti-ags/v2p0/#assignment-and-grade-service-claim
@ -207,6 +213,12 @@ module Lti::Messages
end
end
def associated_1_1_tool
return nil unless Account.site_admin.feature_enabled?(:include_oauth_consumer_key_in_lti_launch)
@associated_1_1_tool ||= @tool&.associated_1_1_tool(@context, target_link_uri)
end
# Used to construct URLs for AGS endpoints like line item index, or line item show
# assumes @context is either Group or Course, per #include_assignment_and_grade_service_claims?
def course_id_for_ags_url

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
describe Lti::Helpers::JwtMessageHelper do
# This set of tests uses data directly from the spec, to ensure our signature generation algorithm
# follows it precisely
describe "generate_oauth_consumer_key_sign" do
subject { Lti::Helpers::JwtMessageHelper.generate_oauth_consumer_key_sign(associated_1_1_tool, message) }
let(:associated_1_1_tool) do
t = double("associated_1_1_tool")
allow(t).to receive(:consumer_key).and_return("179248902")
allow(t).to receive(:shared_secret).and_return("my-lti11-secret")
t
end
let(:message) do
m = double("message")
allow(m).to receive(:deployment_id).and_return("689302")
allow(m).to receive(:iss).and_return("https://lmsvendor.com")
allow(m).to receive(:aud).and_return("PM48OJSfGDTAzAo")
allow(m).to receive(:exp).and_return(1_551_290_856)
allow(m).to receive(:nonce).and_return("172we8671fd8z")
m
end
it "generates the appropriate signature according to the IMS spec" do
expect(subject).to eq("lWd54kFo5qU7xshAna6v8BwoBm6tmUjc6GTax6+12ps=")
end
end
end

View File

@ -1011,23 +1011,66 @@ describe Lti::Messages::JwtMessage do
end
describe "lti1p1 claims" do
subject { decoded_jwt[lti1p1_claim] }
let(:lti1p1_claim) { "https://purl.imsglobal.org/spec/lti/claim/lti1p1" }
context "when user does not have lti_context_id" do
subject { decoded_jwt }
before do
allow(user).to receive(:lti_context_id).and_return(nil)
end
it "does not include the claim" do
expect(decoded_jwt).to_not include lti1p1_claim
end
it { is_expected.not_to include lti1p1_claim }
end
context "when user has lti_context_id" do
let(:message_lti1p1) { decoded_jwt[lti1p1_claim] }
it "adds user_id" do
expect(message_lti1p1["user_id"]).to eq user.lti_context_id
expect(subject["user_id"]).to eq user.lti_context_id
end
end
context "when there is an associated LTI 1.1 tool" do
let!(:associated_1_1_tool) { external_tool_model(context: course, opts: { url: "http://www.example.com/basic_lti" }) }
before do
allow(Lti::Helpers::JwtMessageHelper).to receive(:generate_oauth_consumer_key_sign).and_return("avalidsignature")
end
context "the include_oauth_consumer_key_in_lti_launch flag is enabled" do
before do
Account.site_admin.enable_feature!(:include_oauth_consumer_key_in_lti_launch)
end
it "includes the oauth_consumer_key related claims" do
expect(subject["oauth_consumer_key"]).to eq associated_1_1_tool.consumer_key
expect(subject["oauth_consumer_key_sign"]).to eq "avalidsignature"
end
end
context "the include_oauth_consumer_key_in_lti_launch flag is disabled" do
before do
Account.site_admin.disable_feature!(:include_oauth_consumer_key_in_lti_launch)
end
it "doesn't include the oauth_consumer_key related claims" do
expect(subject).not_to include "oauth_consumer_key"
expect(subject).not_to include "oauth_consumer_key_sign"
end
it "doesn't attempt to perform any lookups" do
expect_any_instance_of(ContextExternalTool).not_to receive(:associated_1_1_tool)
expect(subject).not_to include "oauth_consumer_key"
expect(subject).not_to include "oauth_consumer_key_sign"
end
end
end
context "when there isn't an associated LTI 1.1 tool" do
it "doesn't include the oauth_consumer_key related claims" do
expect(subject).not_to include "oauth_consumer_key"
expect(subject).not_to include "oauth_consumer_key_sign"
end
end
end

View File

@ -2743,22 +2743,22 @@ describe ContextExternalTool do
end
it "handles spaces in front of url" do
url = ContextExternalTool.standardize_url(" http://sub_underscore.google.com?a=1&b=2")
url = ContextExternalTool.standardize_url(" http://sub_underscore.google.com?a=1&b=2").to_s
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
end
it "handles tabs in front of url" do
url = ContextExternalTool.standardize_url("\thttp://sub_underscore.google.com?a=1&b=2")
url = ContextExternalTool.standardize_url("\thttp://sub_underscore.google.com?a=1&b=2").to_s
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
end
it "handles unicode whitespace" do
url = ContextExternalTool.standardize_url("\u00A0http://sub_underscore.go\u2005ogle.com?a=1\u2002&b=2")
url = ContextExternalTool.standardize_url("\u00A0http://sub_underscore.go\u2005ogle.com?a=1\u2002&b=2").to_s
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
end
it "handles underscores in the domain" do
url = ContextExternalTool.standardize_url("http://sub_underscore.google.com?a=1&b=2")
url = ContextExternalTool.standardize_url("http://sub_underscore.google.com?a=1&b=2").to_s
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
end
end
@ -3670,4 +3670,69 @@ describe ContextExternalTool do
expect([sk1, sk2, sk3].sort).to eq([sk1, sk3, sk2])
end
end
describe "associated_1_1_tool" do
specs_require_cache(:redis_cache_store)
subject { lti_1_3_tool.associated_1_1_tool(context, requested_url) }
let(:context) { @course }
let(:domain) { "test.com" }
let(:opts) { { url:, domain: } }
let(:requested_url) { nil }
let(:url) { "https://test.com/foo?bar=1" }
let!(:lti_1_1_tool) { external_tool_model(context:, opts:) }
let!(:lti_1_3_tool) { external_tool_1_3_model(context:, opts:) }
it { is_expected.to eq lti_1_1_tool }
it "caches the result" do
expect(subject).to eq lti_1_1_tool
allow(ContextExternalTool).to receive(:find_external_tool)
lti_1_3_tool.associated_1_1_tool(context)
expect(ContextExternalTool).not_to have_received(:find_external_tool)
end
it "finds deleted 1.1 tools" do
lti_1_1_tool.destroy
expect(subject).to eq(lti_1_1_tool)
end
it "finds nil and doesn't error on tools with invalid URL & Domains" do
lti_1_1_tool.update_column(:url, "http://url path>/invalidurl}")
lti_1_1_tool.update_column(:domain, "url path>/invalidurl}")
expect { subject }.not_to raise_error
expect(subject).to be_nil
end
it "finds tools in a higher level context" do
lti_1_1_tool.update!(context: context.account)
expect(subject).to eq(lti_1_1_tool)
end
it "ignores duplicate tools" do
lti_1_1_tool.dup.save!
expect(subject).to eq(lti_1_1_tool)
end
context "the request is to a subdomain of the tools' domain" do
let(:requested_url) { "https://morespecific.test.com/foo?bar=1" }
it { is_expected.to eq(lti_1_1_tool) }
context "there's another 1.1 tool with that subdomain" do
let(:specific_opts) do
{
url: "https://morespecific.test.com/foo?bar=1",
domain: "https://morespecific.test.com"
}
end
let!(:specific_1_1_tool) { external_tool_model(context:, opts: specific_opts) }
it { is_expected.to eq(specific_1_1_tool) }
end
end
end
end