From 12ab46353c41fe61f3f5dfa6406ea8a67a8dd42f Mon Sep 17 00:00:00 2001 From: Marcos Prieto Date: Fri, 13 Dec 2024 10:16:18 +0100 Subject: [PATCH 1/2] Basic functional test for DeepLinking launches --- tests/conftest.py | 2 + tests/functional/conftest.py | 74 ++++++++--- .../views/lti/basic_lti_launch_test.py | 36 ++--- .../functional/views/lti/deep_linking_test.py | 123 ++++++++++++++++++ 4 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 tests/functional/views/lti/deep_linking_test.py diff --git a/tests/conftest.py b/tests/conftest.py index 568bef8881..9267c30029 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ "h_api_url_private": "https://h.example.com/private/api/", "rpc_allowed_origins": ["http://localhost:5000"], "oauth2_state_secret": "test_oauth2_state_secret", + "onedrive_client_id": "test_one_drive_client_id", "session_cookie_secret": "notasecret", "via_secret": "not_a_secret", "blackboard_api_client_id": "test_blackboard_api_client_id", @@ -34,6 +35,7 @@ "disable_key_rotation": False, "admin_users": [], "email_preferences_secret": "test_email_preferences_secret", + "youtube_api_key": "test_youtube_api_key", } diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index e4a87fcec3..f1c6646371 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -1,9 +1,12 @@ import contextlib +import functools +import json +import os import re -from os import environ from urllib.parse import urlencode import httpretty +import oauthlib.oauth1 import pytest from _pytest.monkeypatch import MonkeyPatch from h_matchers import Any @@ -13,9 +16,10 @@ from lms import db from lms.app import create_app +from tests import factories from tests.conftest import TEST_SETTINGS -TEST_SETTINGS["database_url"] = environ["DATABASE_URL"] +TEST_SETTINGS["database_url"] = os.environ["DATABASE_URL"] TEST_ENVIRONMENT = { key.upper(): value for key, value in TEST_SETTINGS.items() if isinstance(value, str) @@ -25,6 +29,14 @@ ) +@pytest.fixture +def environ(): + environ = dict(os.environ) + environ.update(TEST_ENVIRONMENT) + + return environ + + @pytest.fixture(autouse=True) def clean_database(db_engine): """Delete any data added by the previous test.""" @@ -71,24 +83,52 @@ def db_session(db_engine, db_sessionfactory): connection.close() +def _lti_v11_launch(app, url, post_params, get_params=None, **kwargs): + if get_params: + url += f"?{urlencode(get_params)}" + + return app.post( + url, + params=post_params, + headers={ + "Accept": "text/html", + "Content-Type": "application/x-www-form-urlencoded", + }, + **kwargs, + ) + + @pytest.fixture def do_lti_launch(app): - def do_lti_launch(post_params, get_params=None, **kwargs): - url = "/lti_launches" - if get_params: - url += f"?{urlencode(get_params)}" - - return app.post( - url, - params=post_params, - headers={ - "Accept": "text/html", - "Content-Type": "application/x-www-form-urlencoded", - }, - **kwargs, - ) + return functools.partial(_lti_v11_launch, app, "/lti_launches") + + +@pytest.fixture +def do_deep_link_launch(app): + return functools.partial(_lti_v11_launch, app, "/content_item_selection") + + +@pytest.fixture +def get_client_config(): + def _get_client_config(response): + return json.loads(response.html.find("script", {"class": "js-config"}).string) + + return _get_client_config - return do_lti_launch + +@pytest.fixture +def application_instance(db_session): # noqa: ARG001 + return factories.ApplicationInstance( + tool_consumer_instance_guid="IMS Testing", + organization=factories.Organization(), + ) + + +@pytest.fixture +def oauth_client(application_instance): + return oauthlib.oauth1.Client( + application_instance.consumer_key, application_instance.shared_secret + ) @pytest.fixture(autouse=True) diff --git a/tests/functional/views/lti/basic_lti_launch_test.py b/tests/functional/views/lti/basic_lti_launch_test.py index 5d4561891f..903ebc12b1 100644 --- a/tests/functional/views/lti/basic_lti_launch_test.py +++ b/tests/functional/views/lti/basic_lti_launch_test.py @@ -1,4 +1,3 @@ -import json import time from urllib.parse import urlencode @@ -9,7 +8,6 @@ from lms.models import Assignment from lms.resources._js_config import JSConfig -from tests import factories class TestBasicLTILaunch: @@ -23,25 +21,27 @@ def test_requests_with_no_oauth_signature_are_forbidden( assert response.headers["Content-Type"] == Any.string.matching("^text/html") assert response.html - def test_unconfigured_basic_lti_launch(self, lti_params, do_lti_launch): + def test_unconfigured_basic_lti_launch( + self, lti_params, do_lti_launch, get_client_config + ): response = do_lti_launch( post_params=lti_params, status=200, ) - assert self.get_client_config(response)["mode"] == JSConfig.Mode.FILE_PICKER + assert get_client_config(response)["mode"] == JSConfig.Mode.FILE_PICKER def test_db_configured_basic_lti_launch( - self, lti_params, assignment, do_lti_launch + self, lti_params, assignment, do_lti_launch, get_client_config ): response = do_lti_launch(post_params=lti_params, status=200) - js_config = self.get_client_config(response) + js_config = get_client_config(response) assert js_config["mode"] == JSConfig.Mode.BASIC_LTI_LAUNCH assert urlencode({"url": assignment.document_url}) in js_config["viaUrl"] def test_basic_lti_launch_canvas_deep_linking_url( - self, do_lti_launch, url_launch_params, db_session + self, do_lti_launch, url_launch_params, db_session, get_client_config ): get_params, post_params = url_launch_params @@ -49,7 +49,7 @@ def test_basic_lti_launch_canvas_deep_linking_url( get_params=get_params, post_params=post_params, status=200 ) - js_config = self.get_client_config(response) + js_config = get_client_config(response) assert js_config["mode"] == JSConfig.Mode.BASIC_LTI_LAUNCH assert ( urlencode({"url": "https://url-configured.com/document.pdf"}) @@ -63,7 +63,7 @@ def test_basic_lti_launch_canvas_deep_linking_url( ) def test_basic_lti_launch_canvas_deep_linking_canvas_file( - self, do_lti_launch, db_session, canvas_file_launch_params + self, do_lti_launch, db_session, canvas_file_launch_params, get_client_config ): get_params, post_params = canvas_file_launch_params @@ -71,7 +71,7 @@ def test_basic_lti_launch_canvas_deep_linking_canvas_file( get_params=get_params, post_params=post_params, status=200 ) - js_config = self.get_client_config(response) + js_config = get_client_config(response) assert js_config["mode"] == JSConfig.Mode.BASIC_LTI_LAUNCH assert ( js_config["api"]["viaUrl"]["path"] @@ -84,13 +84,6 @@ def test_basic_lti_launch_canvas_deep_linking_canvas_file( == 1 ) - @pytest.fixture(autouse=True) - def application_instance(self, db_session): # noqa: ARG002 - return factories.ApplicationInstance( - tool_consumer_instance_guid="IMS Testing", - organization=factories.Organization(), - ) - @pytest.fixture def assignment(self, db_session, application_instance, lti_params): assignment = Assignment( @@ -104,12 +97,6 @@ def assignment(self, db_session, application_instance, lti_params): return assignment - @pytest.fixture - def oauth_client(self, application_instance): - return oauthlib.oauth1.Client( - application_instance.consumer_key, application_instance.shared_secret - ) - @pytest.fixture def lti_params(self, application_instance, sign_lti_params): params = { @@ -184,6 +171,3 @@ def _sign(params): return params return _sign - - def get_client_config(self, response): - return json.loads(response.html.find("script", {"class": "js-config"}).string) diff --git a/tests/functional/views/lti/deep_linking_test.py b/tests/functional/views/lti/deep_linking_test.py new file mode 100644 index 0000000000..4c6ca1eb0b --- /dev/null +++ b/tests/functional/views/lti/deep_linking_test.py @@ -0,0 +1,123 @@ +import time + +import oauthlib.common +import oauthlib.oauth1 +import pytest +from h_matchers import Any + +from lms.resources._js_config import JSConfig + + +class TestDeepLinkingLaunch: + def test_basic_lti_launch_canvas_deep_linking_url( + self, + do_deep_link_launch, + lti_params, + application_instance, + get_client_config, + environ, + ): + response = do_deep_link_launch(post_params=lti_params, status=200) + + js_config = get_client_config(response) + assert js_config["mode"] == JSConfig.Mode.FILE_PICKER + assert js_config["filePicker"] == { + "autoGradingEnabled": True, + "blackboard": {"enabled": None}, + "canvas": { + "enabled": None, + "foldersEnabled": None, + "listFiles": { + "authUrl": "http://localhost/api/canvas/oauth/authorize", + "path": "/api/canvas/courses/None/files", + }, + "pagesEnabled": None, + }, + "canvasStudio": {"enabled": False}, + "d2l": {"enabled": False}, + "deepLinkingAPI": { + "data": { + "content_item_return_url": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + "context_id": "con-182", + "opaque_data_lti11": None, + }, + "path": "/lti/1.1/deep_linking/form_fields", + }, + "formAction": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + "formFields": { + "lti_message_type": "ContentItemSelection", + "lti_version": "LTI-1p0", + }, + "google": { + "clientId": environ["GOOGLE_CLIENT_ID"], + "developerKey": environ["GOOGLE_DEVELOPER_KEY"], + "enabled": True, + "origin": application_instance.lms_url, + }, + "jstor": {"enabled": False}, + "ltiLaunchUrl": "http://localhost/lti_launches", + "microsoftOneDrive": { + "clientId": environ["ONEDRIVE_CLIENT_ID"], + "enabled": True, + "redirectURI": "http://localhost/onedrive/filepicker/redirect", + }, + "moodle": {"enabled": None, "pagesEnabled": None}, + "promptForTitle": True, + "vitalSource": {"enabled": False}, + "youtube": {"enabled": Any()}, + } + + +@pytest.fixture +def lti_params(application_instance, sign_lti_params): + params = { + "context_id": "con-182", + "context_label": "SI182", + "context_title": "Design of Personal Environments", + "context_type": "CourseSection", + "custom_context_memberships_url": "https://apps.imsglobal.org/lti/cert/tp/tp_membership.php/context/con-182/membership?b64=a2puNjk3b3E5YTQ3Z28wZDRnbW5xYzZyYjU%3D", + "custom_context_setting_url": "https://apps.imsglobal.org/lti/cert/tp/tp_settings.php/lis/CourseSection/con-182/bindings/ims/cert/custom?b64=a2puNjk3b3E5YTQ3Z28wZDRnbW5xYzZyYjU%3D", + "custom_link_setting_url": "$LtiLink.custom.url", + "custom_system_setting_url": "https://apps.imsglobal.org/lti/cert/tp/tp_settings.php/ToolProxy/Hypothesis1b40eafba184a131307049e01e9c147d/custom?b64=a2puNjk3b3E5YTQ3Z28wZDRnbW5xYzZyYjU%3D", + "custom_tc_profile_url": "https://apps.imsglobal.org/lti/cert/tp/tp_tcprofile.php?b64=a2puNjk3b3E5YTQ3Z28wZDRnbW5xYzZyYjU%3D", + "launch_presentation_document_target": "iframe", + "launch_presentation_locale": "en_US", + "launch_presentation_return_url": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + "lis_course_section_sourcedid": "id-182", + "lis_person_contact_email_primary": "jane@school.edu", + "lis_person_name_family": "Lastname", + "lis_person_name_full": "Jane Q. Lastname", + "lis_person_name_given": "Jane", + "lis_person_sourcedid": "school.edu:jane", + "lti_message_type": "ContentItemSelectionRequest", + "lti_version": "LTI-1p0", + "oauth_callback": "about:blank", + "oauth_consumer_key": application_instance.consumer_key, + "oauth_nonce": "38d6db30e395417659d068164ca95169", + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": str(int(time.time())), + "oauth_version": "1.0", + "roles": "Instructor", + "tool_consumer_info_product_family_code": "imsglc", + "tool_consumer_info_version": "1.1", + "tool_consumer_instance_description": "IMS Testing Description", + "tool_consumer_instance_guid": application_instance.tool_consumer_instance_guid, + "tool_consumer_instance_name": "IMS Testing Instance", + "user_id": "123456", + "content_item_return_url": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + } + + return sign_lti_params(params) + + +@pytest.fixture +def sign_lti_params(oauth_client): + def _sign(params): + params["oauth_signature"] = oauth_client.get_oauth_signature( + oauthlib.common.Request( + "http://localhost/content_item_selection", "POST", body=params + ) + ) + return params + + return _sign From 9b1737299b9a29ebd82da374729551ebac27e728 Mon Sep 17 00:00:00 2001 From: Marcos Prieto Date: Fri, 13 Dec 2024 16:27:38 +0100 Subject: [PATCH 2/2] Functional test for the DeepLinking fields API Just covering the LTI1.1 version on this commit. --- .../authentication/_bearer_token.py | 2 +- tests/conftest.py | 2 +- tests/factories/lti_user.py | 8 ++- .../functional/views/lti/deep_linking_test.py | 69 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/lms/validation/authentication/_bearer_token.py b/lms/validation/authentication/_bearer_token.py index 1a13a504f9..61d0aa5c18 100644 --- a/lms/validation/authentication/_bearer_token.py +++ b/lms/validation/authentication/_bearer_token.py @@ -41,7 +41,7 @@ class BearerTokenSchema(PyramidRequestSchema): def __init__(self, request): super().__init__(request) - self._jwt_service = request.find_service(iface=JWTService) + self._jwt_service: JWTService = request.find_service(iface=JWTService) self._lti_user_service = request.find_service(iface=LTIUserService) self._secret = request.registry.settings["jwt_secret"] diff --git a/tests/conftest.py b/tests/conftest.py index 9267c30029..afa72a862d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ "jwt_secret": "test_secret", "google_client_id": "fake_client_id", "google_developer_key": "fake_developer_key", - "lms_secret": "TEST_LMS_SECRET", + "lms_secret": "test_secret", "aes_secret": b"TSeQ7E3dzbHgu5ydX2xCrKJiXTmfJbOe", "jinja2.filters": { "static_path": "pyramid_jinja2.filters:static_path_filter", diff --git a/tests/factories/lti_user.py b/tests/factories/lti_user.py index 0630228509..f4a3905943 100644 --- a/tests/factories/lti_user.py +++ b/tests/factories/lti_user.py @@ -4,6 +4,12 @@ from tests.factories.application_instance import ApplicationInstance from tests.factories.attributes import TOOL_CONSUMER_INSTANCE_GUID, USER_ID +_LTI = make_factory( + LTI, + course_id=Faker("hexify", text="^" * 40), + product_family="UNKNOWN", +) + LTIUser = make_factory( LTIUser, user_id=USER_ID, @@ -13,6 +19,6 @@ effective_lti_roles=[], tool_consumer_instance_guid=TOOL_CONSUMER_INSTANCE_GUID, display_name=Faker("name"), - lti=LTI(course_id=Faker("hexify", text="^" * 40), product_family="UNKNOWN"), + lti=SubFactory(_LTI), application_instance=SubFactory(ApplicationInstance), ) diff --git a/tests/functional/views/lti/deep_linking_test.py b/tests/functional/views/lti/deep_linking_test.py index 4c6ca1eb0b..e9937c7d3e 100644 --- a/tests/functional/views/lti/deep_linking_test.py +++ b/tests/functional/views/lti/deep_linking_test.py @@ -1,4 +1,6 @@ +import json import time +from datetime import timedelta import oauthlib.common import oauthlib.oauth1 @@ -6,6 +8,10 @@ from h_matchers import Any from lms.resources._js_config import JSConfig +from lms.services.jwt import JWTService +from lms.services.lti_user import LTIUserService +from tests import factories +from tests.conftest import TEST_SETTINGS class TestDeepLinkingLaunch: @@ -68,6 +74,69 @@ def test_basic_lti_launch_canvas_deep_linking_url( } +class TestDeepLinkingFieldsViews: + def test_file_picker_to_form_fields_v11( + self, app, authorization_param, application_instance + ): + response = app.post_json( + "/lti/1.1/deep_linking/form_fields", + params={ + "content_item_return_url": "https://apps.imsglobal.org/lti/cert/tp/tp_return.php/basic-lti-launch-request", + "content": {"type": "url", "url": "https://example.com"}, + }, + headers={"Authorization": f"Bearer {authorization_param}"}, + status=200, + ) + + response_json = response.json + content_items = response_json.pop( + "content_items" + ) # We'll assert this separately + assert response_json == { + "oauth_version": "1.0", + "oauth_nonce": Any.string(), + "oauth_timestamp": Any.string(), + "oauth_consumer_key": application_instance.consumer_key, + "oauth_signature_method": "HMAC-SHA1", + "lti_message_type": "ContentItemSelection", + "lti_version": "LTI-1p0", + "oauth_signature": Any.string(), + } + assert json.loads(content_items) == { + "@context": "http://purl.imsglobal.org/ctx/lti/v1/ContentItem", + "@graph": [ + { + "@type": "LtiLinkItem", + "mediaType": "application/vnd.ims.lti.v1.ltilink", + "url": "http://localhost/lti_launches", + "custom": { + "deep_linking_uuid": Any.string(), + "url": "https://example.com", + }, + } + ], + } + + @pytest.fixture + def lti_user(self, application_instance, lti_params): + return factories.LTIUser( + application_instance_id=application_instance.id, + application_instance=application_instance, + user_id=lti_params["user_id"], + roles=lti_params["roles"], + ) + + @pytest.fixture + def authorization_param(self, lti_user): + return JWTService.encode_with_secret( + LTIUserService( + lti_role_service=None, application_instance_service=None + ).serialize(lti_user), + secret=TEST_SETTINGS["lms_secret"], + lifetime=timedelta(days=1), + ) + + @pytest.fixture def lti_params(application_instance, sign_lti_params): params = {