Repository: openfun/marsha

Issue:
🐛(devtools) add an oauth_consumer_key field to the development view form
## Purpose

After changes in the way informations from the LTI request are collected and used, the Django backend is now using the consumer key to retrieve the passport, and, from there, the consumer site.

This means we cannot just bypass LTI checks in development and hand over a consumer site that will work out of the box. We need the LTI request to pass an oauth consumer key that matches to a real passport with a linked consumer site in the database.

## Proposal

Add an `oauth_consumer_key` field to our mock LTI forms in the development view.



Discussion:
@lunika done.

BTW I just noticed I gave the wrong name to the branch, I decided against closing and reopening with a correct name as you already reviewed.

Resolving patch:
diff --git a/src/backend/marsha/core/lti.py b/src/backend/marsha/core/lti.py
--- a/src/backend/marsha/core/lti.py
+++ b/src/backend/marsha/core/lti.py
@@ -1,9 +1,10 @@
 """LTI module that supports LTI 1.0."""
 import re
 
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.utils.datastructures import MultiValueDictKeyError
-from django.utils.functional import cached_property
 
 from pylti.common import LTIException, verify_request_common
 
@@ -27,7 +28,6 @@ def __init__(self, request):
 
         """
         self.request = request
-        self._is_verified = False
 
     def verify(self):
         """Verify the LTI request.
@@ -35,18 +35,29 @@ def verify(self):
         Raises
         ------
         LTIException
-            Exception raised if request validation fails
+            Raised if request validation fails
+        ImproperlyConfigured
+            Raised if BYPASS_LTI_VERIFICATION is True but we are not in DEBUG mode
 
         Returns
         -------
-        boolean
-            True if the request is a valid LTI launch request
+        string
+            It returns the consumer site name related to the passport used in the LTI launch
+            request if it is valid.
+            If the BYPASS_LTI_VERIFICATION and DEBUG settings are True, it creates and return a
+            consumer site with the consumer site name passed in the LTI request.
 
         """
+        if settings.BYPASS_LTI_VERIFICATION:
+            if not settings.DEBUG:
+                raise ImproperlyConfigured(
+                    "Bypassing LTI verification only works in DEBUG mode."
+                )
+            return ConsumerSite.objects.get_or_create(name=self.consumer_site_name)[0]
+
+        passport = self.get_passport()
         consumers = {
-            str(self.passport.oauth_consumer_key): {
-                "secret": str(self.passport.shared_secret)
-            }
+            str(passport.oauth_consumer_key): {"secret": str(passport.shared_secret)}
         }
         # A call to the verification function should raise an LTIException but
         # we can further check that it returns True.
@@ -62,8 +73,7 @@ def verify(self):
         ):
             raise LTIException()
 
-        self._is_verified = True
-        return True
+        return passport.consumer_site or passport.playlist.consumer_site
 
     def __getattr__(self, name):
         """Look for attributes in the request's POST parameters as a last resort.
@@ -89,8 +99,7 @@ def __getattr__(self, name):
         except MultiValueDictKeyError:
             raise AttributeError(name)
 
-    @cached_property
-    def passport(self):
+    def get_passport(self):
         """Find and return the passport targeted by the LTI request or raise an LTIException."""
         consumer_key = self.request.POST.get("oauth_consumer_key", None)
 
@@ -209,33 +218,27 @@ def get_or_create_video(self):
             The video instance targeted by the `resource_link_id` or None.
 
         """
-        # Make sure LTI verification has run successfully
-        assert getattr(self, "_is_verified", False) or self.verify()
+        # Make sure LTI verification has run successfully. It raises an LTIException otherwise.
+        consumer_site = self.verify()
 
         try:
             assert self.context_id
         except AssertionError:
             raise LTIException("A context ID is required.")
 
-        consumer_site_name = (
-            self.passport.consumer_site.name
-            if self.passport.consumer_site
-            else self.passport.playlist.consumer_site.name
-        )
-
         # If the video already exist, retrieve it from database
         try:
             return Video.objects.get(
                 lti_id=self.resource_link_id,
                 playlist__lti_id=self.context_id,
-                playlist__consumer_site__name=consumer_site_name,
+                playlist__consumer_site=consumer_site,
             )
         except Video.DoesNotExist:
             # Look for the same resource id from another playlist on the same consumer site
             origin_video = (
                 Video.objects.filter(
                     lti_id=self.resource_link_id,
-                    playlist__consumer_site__name=consumer_site_name,
+                    playlist__consumer_site=consumer_site,
                     playlist__is_portable_to_playlist=True,
                     state=READY,
                 )
@@ -275,9 +278,6 @@ def get_or_create_video(self):
                 return None
 
         # Creating the video...
-        # - Get the consumer site (we know it exists because the passport verified)
-        consumer_site = ConsumerSite.objects.get(name=consumer_site_name)
-
         # - Get the playlist if it exists or create it
         playlist, _ = Playlist.objects.get_or_create(
             lti_id=self.context_id,
diff --git a/src/backend/marsha/settings.py b/src/backend/marsha/settings.py
--- a/src/backend/marsha/settings.py
+++ b/src/backend/marsha/settings.py
@@ -167,6 +167,8 @@ class Base(Configuration):
     CLOUDFRONT_SIGNED_URLS_ACTIVE = True
     CLOUDFRONT_SIGNED_URLS_VALIDITY = 2 * 60 * 60  # 2 hours
 
+    BYPASS_LTI_VERIFICATION = values.BooleanValue(False)
+
     # pylint: disable=invalid-name
     @property
     def SIMPLE_JWT(self):


Test patch:
diff --git a/src/backend/marsha/core/tests/test_views_lti_video.py b/src/backend/marsha/core/tests/test_views_lti_video.py
--- a/src/backend/marsha/core/tests/test_views_lti_video.py
+++ b/src/backend/marsha/core/tests/test_views_lti_video.py
@@ -5,13 +5,15 @@
 import re
 from unittest import mock
 
-from django.test import TestCase
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase, override_settings
 
 from pylti.common import LTIException
 from rest_framework_simplejwt.tokens import AccessToken
 
 from ..factories import ConsumerSiteLTIPassportFactory, VideoFactory
 from ..lti import LTI
+from ..models import Video
 
 
 # We don't enforce arguments documentation in tests
@@ -21,8 +23,7 @@
 class ViewsTestCase(TestCase):
     """Test the views in the ``core`` app of the Marsha project."""
 
-    @mock.patch.object(LTI, "verify", return_value=True)
-    def test_views_video_lti_post_instructor(self, mock_initialize):
+    def test_views_video_lti_post_instructor(self):
         """Validate the format of the response returned by the view for an instructor request."""
         passport = ConsumerSiteLTIPassportFactory(oauth_consumer_key="ABC123")
         video = VideoFactory(
@@ -34,10 +35,12 @@ def test_views_video_lti_post_instructor(self, mock_initialize):
             "resource_link_id": "123",
             "roles": "instructor",
             "context_id": "abc",
-            "tool_consumer_instance_guid": "example.com",
             "oauth_consumer_key": "ABC123",
         }
-        response = self.client.post("/lti-video/", data)
+        with mock.patch.object(
+            LTI, "verify", return_value=passport.consumer_site
+        ) as mock_verify:
+            response = self.client.post("/lti-video/", data)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "<html>")
         content = response.content.decode("utf-8")
@@ -71,9 +74,11 @@ def test_views_video_lti_post_instructor(self, mock_initialize):
                 "urls": None,
             },
         )
+        # Make sure we only go through LTI verification once as it is costly (getting passport +
+        # signature)
+        self.assertEqual(mock_verify.call_count, 1)
 
-    @mock.patch.object(LTI, "verify", return_value=True)
-    def test_views_video_lti_post_student(self, mock_initialize):
+    def test_views_video_lti_post_student(self):
         """Validate the format of the response returned by the view for a student request."""
         passport = ConsumerSiteLTIPassportFactory(oauth_consumer_key="ABC123")
         video = VideoFactory(
@@ -85,10 +90,12 @@ def test_views_video_lti_post_student(self, mock_initialize):
             "resource_link_id": "123",
             "roles": "student",
             "context_id": "abc",
-            "tool_consumer_instance_guid": "example.com",
             "oauth_consumer_key": "ABC123",
         }
-        response = self.client.post("/lti-video/", data)
+        with mock.patch.object(
+            LTI, "verify", return_value=passport.consumer_site
+        ) as mock_verify:
+            response = self.client.post("/lti-video/", data)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "<html>")
         content = response.content.decode("utf-8")
@@ -101,7 +108,6 @@ def test_views_video_lti_post_student(self, mock_initialize):
         data_video = re.search(
             '<div class="marsha-frontend-data" id="video" data-video="(.*)">', content
         ).group(1)
-
         self.assertEqual(
             json.loads(unescape(data_video)),
             {
@@ -114,19 +120,23 @@ def test_views_video_lti_post_student(self, mock_initialize):
                 "urls": None,
             },
         )
+        # Make sure we only go through LTI verification once as it is costly (getting passport +
+        # signature)
+        self.assertEqual(mock_verify.call_count, 1)
 
-    @mock.patch.object(LTI, "verify", return_value=True)
-    def test_views_video_lti_post_student_no_video(self, mock_initialize):
+    def test_views_video_lti_post_student_no_video(self):
         """Validate the response returned for a student request when there is no video."""
-        ConsumerSiteLTIPassportFactory(oauth_consumer_key="ABC123")
+        passport = ConsumerSiteLTIPassportFactory(oauth_consumer_key="ABC123")
         data = {
             "resource_link_id": "123",
             "roles": "student",
             "context_id": "abc",
-            "tool_consumer_instance_guid": "example.com",
             "oauth_consumer_key": "ABC123",
         }
-        response = self.client.post("/lti-video/", data)
+        with mock.patch.object(
+            LTI, "verify", return_value=passport.consumer_site
+        ) as mock_verify:
+            response = self.client.post("/lti-video/", data)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "<html>")
         content = response.content.decode("utf-8")
@@ -141,13 +151,56 @@ def test_views_video_lti_post_student_no_video(self, mock_initialize):
         ).group(1)
         self.assertEqual(data_video, "null")
 
+        # Make sure we only go through LTI verification once as it is costly (getting passport +
+        # signature)
+        self.assertEqual(mock_verify.call_count, 1)
+
     @mock.patch.object(LTI, "verify", side_effect=LTIException)
-    def test_views_video_lti_post_error(self, mock_initialize):
+    def test_views_video_lti_post_error(self, mock_verify):
         """Validate the response returned in case of an LTI exception."""
         role = random.choice(["instructor", "student"])
+        data = {"resource_link_id": "123", "roles": role, "context_id": "abc"}
+        response = self.client.post("/lti-video/", data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+
+        data_state = re.search(
+            '<div class="marsha-frontend-data" data-state="(.*)">', content
+        ).group(1)
+        self.assertEqual(data_state, "error")
+
+        data_video = re.search(
+            '<div class="marsha-frontend-data" id="video" data-video="(.*)">', content
+        ).group(1)
+        self.assertEqual(data_video, "null")
+
+
+class DevelopmentViewsTestCase(TestCase):
+    """Test the views in the ``core`` app of the Marsha project for development use cases.
+
+    When developing on marsha, we are using the LTIDevelopmentView to simulate a launch request
+    from an iframe in an LMS. However:
+    - This simple view does not compute the LTI signature,
+    - We don't want to have to create an LTI passport to make it work.
+
+    Setting the "BYPASS_LTI_VERIFICATION" setting to True, allows Marsha to work "normally" without
+    a passport and without the LTI verification. This is only allowed when DEBUG is True.
+    """
+
+    @override_settings(DEBUG=True)
+    @override_settings(BYPASS_LTI_VERIFICATION=True)
+    def test_views_video_lti_post_bypass_lti_student(self):
+        """In development, passport creation and LTI verification can be bypassed for a student."""
+        video = VideoFactory(
+            lti_id="123",
+            playlist__lti_id="abc",
+            playlist__consumer_site__name="example.com",
+        )
+        # There is no need to provide an "oauth_consumer_key"
         data = {
             "resource_link_id": "123",
-            "roles": role,
+            "roles": "student",
             "context_id": "abc",
             "tool_consumer_instance_guid": "example.com",
         }
@@ -159,9 +212,130 @@ def test_views_video_lti_post_error(self, mock_initialize):
         data_state = re.search(
             '<div class="marsha-frontend-data" data-state="(.*)">', content
         ).group(1)
-        self.assertEqual(data_state, "error")
+        self.assertEqual(data_state, "student")
 
         data_video = re.search(
             '<div class="marsha-frontend-data" id="video" data-video="(.*)">', content
         ).group(1)
-        self.assertEqual(data_video, "null")
+
+        self.assertEqual(
+            json.loads(unescape(data_video)),
+            {
+                "active_stamp": None,
+                "description": video.description,
+                "id": str(video.id),
+                "state": "pending",
+                "subtitle_tracks": [],
+                "title": video.title,
+                "urls": None,
+            },
+        )
+
+    @override_settings(DEBUG=True)
+    @override_settings(BYPASS_LTI_VERIFICATION=True)
+    def test_views_video_lti_post_bypass_lti_instructor(self):
+        """In development, passport creation and LTI verif can be bypassed for a instructor."""
+        video = VideoFactory(
+            lti_id="123",
+            playlist__lti_id="abc",
+            playlist__consumer_site__name="example.com",
+        )
+        data = {
+            "resource_link_id": "123",
+            "roles": "instructor",
+            "context_id": "abc",
+            "tool_consumer_instance_guid": "example.com",
+        }
+        response = self.client.post("/lti-video/", data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+
+        # Extract the JWT Token and state
+        match = re.search(
+            '<div class="marsha-frontend-data" data-jwt="(.*)" data-state="(.*)">',
+            content,
+        )
+        data_jwt = match.group(1)
+        jwt_token = AccessToken(data_jwt)
+        self.assertEqual(jwt_token.payload["video_id"], str(video.id))
+
+        data_state = match.group(2)
+        self.assertEqual(data_state, "instructor")
+
+        # Extract the video data
+        data_video = re.search(
+            '<div class="marsha-frontend-data" id="video" data-video="(.*)">', content
+        ).group(1)
+
+        self.assertEqual(
+            json.loads(unescape(data_video)),
+            {
+                "active_stamp": None,
+                "description": video.description,
+                "id": str(video.id),
+                "state": "pending",
+                "subtitle_tracks": [],
+                "title": video.title,
+                "urls": None,
+            },
+        )
+
+    @override_settings(DEBUG=True)
+    @override_settings(BYPASS_LTI_VERIFICATION=True)
+    def test_views_video_lti_post_bypass_lti_instructor_no_video(self):
+        """When bypassing LTI, the "example.com" consumer site is automatically created."""
+        data = {
+            "resource_link_id": "123",
+            "roles": "instructor",
+            "context_id": "abc",
+            "tool_consumer_instance_guid": "example.com",
+        }
+        response = self.client.post("/lti-video/", data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+        # Extract the JWT Token and state
+        match = re.search(
+            '<div class="marsha-frontend-data" data-jwt="(.*)" data-state="(.*)">',
+            content,
+        )
+        data_jwt = match.group(1)
+        jwt_token = AccessToken(data_jwt)
+        video = Video.objects.get()
+        self.assertEqual(jwt_token.payload["video_id"], str(video.id))
+
+        data_state = match.group(2)
+        self.assertEqual(data_state, "instructor")
+
+        # Extract the video data
+        data_video = re.search(
+            '<div class="marsha-frontend-data" id="video" data-video="(.*)">', content
+        ).group(1)
+
+        self.assertEqual(
+            json.loads(unescape(data_video)),
+            {
+                "active_stamp": None,
+                "description": video.description,
+                "id": str(video.id),
+                "state": "pending",
+                "subtitle_tracks": [],
+                "title": video.title,
+                "urls": None,
+            },
+        )
+
+    @override_settings(BYPASS_LTI_VERIFICATION=True)
+    def test_views_video_lti_post_bypass_lti_no_debug_mode(self):
+        """Bypassing LTI verification is only allowed in debug mode."""
+        VideoFactory(
+            lti_id="123",
+            playlist__lti_id="abc",
+            playlist__consumer_site__name="example.com",
+        )
+        role = random.choice(["instructor", "student"])
+        data = {"resource_link_id": "123", "roles": role, "context_id": "abc"}
+
+        with self.assertRaises(ImproperlyConfigured):
+            self.client.post("/lti-video/", data)