Repository: openfun/marsha

Issue:
Create a classroom invite link for an instructor
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

We already have for a classroom an invite link. This invite link is dedicated to people without access to the standalone site nor the LTI application. When they use it they are like a student and can only join the meeting when running.
We would like the same feature but for an instructor. When they use a dedicated invite link for them they can use the classroom dashboard.

**Describe the solution you'd like**

When an invite link for instructor is used, the user can use the classroom dashboard.



Discussion:

Resolving patch:
diff --git a/src/backend/marsha/bbb/serializers.py b/src/backend/marsha/bbb/serializers.py
--- a/src/backend/marsha/bbb/serializers.py
+++ b/src/backend/marsha/bbb/serializers.py
@@ -19,6 +19,7 @@
     get_url as get_document_url,
 )
 from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt
+from marsha.core.models import INSTRUCTOR
 from marsha.core.serializers import (
     BaseInitiateUploadSerializer,
     UploadableFileWithExtensionSerializerMixin,
@@ -74,6 +75,7 @@ class Meta:  # noqa
             # specific generated fields
             "infos",
             "invite_token",
+            "instructor_token",
         )
         read_only_fields = (
             "id",
@@ -90,6 +92,7 @@ class Meta:  # noqa
     recordings = ClassroomRecordingSerializer(many=True, read_only=True)
     infos = serializers.SerializerMethodField()
     invite_token = serializers.SerializerMethodField()
+    instructor_token = serializers.SerializerMethodField()
     recordings = serializers.SerializerMethodField()
 
     def get_infos(self, obj):
@@ -105,6 +108,18 @@ def get_invite_token(self, obj):
             return str(create_classroom_stable_invite_jwt(obj))
         return None
 
+    def get_instructor_token(self, obj):
+        """Get the instructor token for the classroom."""
+        if self.context.get("is_admin", False):
+            return str(
+                create_classroom_stable_invite_jwt(
+                    obj,
+                    role=INSTRUCTOR,
+                    permissions={"can_update": True, "can_access_dashboard": True},
+                )
+            )
+        return None
+
     def get_recordings(self, obj):
         """Get the recordings for the classroom.
 
diff --git a/src/backend/marsha/bbb/utils/tokens.py b/src/backend/marsha/bbb/utils/tokens.py
--- a/src/backend/marsha/bbb/utils/tokens.py
+++ b/src/backend/marsha/bbb/utils/tokens.py
@@ -4,10 +4,11 @@
 from django.conf import settings
 from django.utils import timezone
 
+from marsha.core.models import NONE
 from marsha.core.simple_jwt.tokens import ResourceAccessToken
 
 
-def create_classroom_stable_invite_jwt(classroom):
+def create_classroom_stable_invite_jwt(classroom, role=NONE, permissions=None):
     """Create a resource JWT to be used in classroom invite links.
 
     Parameters
@@ -15,6 +16,12 @@ def create_classroom_stable_invite_jwt(classroom):
     classroom : Type[models.Classroom]
         The classroom for which we want to create a JWT.
 
+    role : str
+        The role to use in the JWT. If not set, the no role is used.
+
+    permissions : dict
+        The permissions to use in the JWT. If not set, no permissions are used.
+
     Returns
     -------
     ResourceAccessToken
@@ -24,6 +31,8 @@ def create_classroom_stable_invite_jwt(classroom):
     resource_jwt = ResourceAccessToken.for_resource_id(
         resource_id=str(classroom.id),
         session_id=f"{classroom.id}-invite",
+        roles=[role],
+        permissions=permissions or {},
     )
 
     # Set a fixed JWT ID


Test patch:
diff --git a/src/backend/marsha/bbb/tests/api/classroom/test_create.py b/src/backend/marsha/bbb/tests/api/classroom/test_create.py
--- a/src/backend/marsha/bbb/tests/api/classroom/test_create.py
+++ b/src/backend/marsha/bbb/tests/api/classroom/test_create.py
@@ -137,6 +137,7 @@ def test_api_classroom_create_instructor_with_playlist_token(
                 "title": "Some classroom",
                 "welcome_text": "Welcome!",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
         )
@@ -207,6 +208,7 @@ def test_api_classroom_create_user_access_token_organization_admin(
                 "title": "Some classroom",
                 "welcome_text": "Welcome!",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
         )
@@ -243,6 +245,7 @@ def test_api_classroom_create_user_access_token_organization_admin(
                 "title": "classroom two",
                 "welcome_text": "Welcome!",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
         )
@@ -295,6 +298,7 @@ def test_api_classroom_create_user_access_token_playlist_admin(
                 "title": "Some classroom",
                 "welcome_text": "Welcome!",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
         )
@@ -331,6 +335,7 @@ def test_api_classroom_create_user_access_token_playlist_admin(
                 "title": "classroom two",
                 "welcome_text": "Welcome!",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
         )
diff --git a/src/backend/marsha/bbb/tests/api/classroom/test_retrieve.py b/src/backend/marsha/bbb/tests/api/classroom/test_retrieve.py
--- a/src/backend/marsha/bbb/tests/api/classroom/test_retrieve.py
+++ b/src/backend/marsha/bbb/tests/api/classroom/test_retrieve.py
@@ -13,7 +13,7 @@
     PlaylistAccessFactory,
     PlaylistFactory,
 )
-from marsha.core.models import ADMINISTRATOR, NONE
+from marsha.core.models import ADMINISTRATOR, INSTRUCTOR, NONE
 from marsha.core.simple_jwt.factories import (
     InstructorOrAdminLtiTokenFactory,
     StudentLtiTokenFactory,
@@ -78,6 +78,7 @@ def test_api_classroom_fetch_student(self, mock_get_meeting_infos):
                 "starting_at": None,
                 "estimated_duration": None,
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
             content,
@@ -129,6 +130,7 @@ def test_api_classroom_fetch_student_with_recordings(self, mock_get_meeting_info
                 "estimated_duration": None,
                 "recordings": [],
                 "invite_token": None,
+                "instructor_token": None,
             },
             content,
         )
@@ -192,6 +194,7 @@ def test_api_classroom_fetch_student_scheduled(self, mock_get_meeting_infos):
                 "starting_at": "2018-08-08T01:00:00Z",
                 "estimated_duration": "00:01:00",
                 "invite_token": None,
+                "instructor_token": None,
                 "recordings": [],
             },
             content,
@@ -216,6 +219,7 @@ def test_api_classroom_fetch_instructor(self, mock_get_meeting_infos):
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -236,6 +240,7 @@ def test_api_classroom_fetch_instructor(self, mock_get_meeting_infos):
                 "estimated_duration": None,
                 "recordings": [],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -248,6 +253,13 @@ def test_api_classroom_fetch_instructor(self, mock_get_meeting_infos):
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
     @mock.patch.object(serializers, "get_meeting_infos")
     def test_api_classroom_fetch_user_access_token(self, mock_get_meeting_infos):
         """A user with UserAccessToken should not be able to fetch a classroom."""
@@ -290,6 +302,7 @@ def test_api_classroom_fetch_user_access_token_organization_admin(
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -310,6 +323,7 @@ def test_api_classroom_fetch_user_access_token_organization_admin(
                 "estimated_duration": None,
                 "recordings": [],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -322,6 +336,16 @@ def test_api_classroom_fetch_user_access_token_organization_admin(
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
     @mock.patch.object(serializers, "get_meeting_infos")
     def test_api_classroom_fetch_user_access_token_playlist_admin(
         self, mock_get_meeting_infos
@@ -344,6 +368,7 @@ def test_api_classroom_fetch_user_access_token_playlist_admin(
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -364,6 +389,7 @@ def test_api_classroom_fetch_user_access_token_playlist_admin(
                 "estimated_duration": None,
                 "recordings": [],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -376,6 +402,16 @@ def test_api_classroom_fetch_user_access_token_playlist_admin(
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
         response = self.client.get(
             f"/api/classrooms/{classroom.id!s}/",
             HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
@@ -413,6 +449,7 @@ def test_api_classroom_fetch_with_recordings(self, mock_get_meeting_infos):
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -448,6 +485,7 @@ def test_api_classroom_fetch_with_recordings(self, mock_get_meeting_infos):
                     },
                 ],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -459,3 +497,13 @@ def test_api_classroom_fetch_with_recordings(self, mock_get_meeting_infos):
             decoded_invite_token.payload["permissions"],
             {"can_update": False, "can_access_dashboard": False},
         )
+
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
diff --git a/src/backend/marsha/bbb/tests/api/classroom/test_update.py b/src/backend/marsha/bbb/tests/api/classroom/test_update.py
--- a/src/backend/marsha/bbb/tests/api/classroom/test_update.py
+++ b/src/backend/marsha/bbb/tests/api/classroom/test_update.py
@@ -16,7 +16,7 @@
     PlaylistAccessFactory,
     PlaylistFactory,
 )
-from marsha.core.models import ADMINISTRATOR, NONE
+from marsha.core.models import ADMINISTRATOR, INSTRUCTOR, NONE
 from marsha.core.simple_jwt.factories import (
     InstructorOrAdminLtiTokenFactory,
     StudentLtiTokenFactory,
@@ -153,6 +153,7 @@ def test_api_classroom_update_instructor_scheduling(self, mock_get_meeting_infos
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -176,6 +177,7 @@ def test_api_classroom_update_instructor_scheduling(self, mock_get_meeting_infos
                 "estimated_duration": "00:01:00",
                 "recordings": [],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -189,6 +191,17 @@ def test_api_classroom_update_instructor_scheduling(self, mock_get_meeting_infos
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        # don't verify here, because of time travel (tested elsewhere)
+        decoded_instructor_token = ResourceAccessToken(instructor_token, verify=False)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
         classroom.refresh_from_db()
         self.assertEqual(starting_at, classroom.starting_at)
         self.assertEqual(estimated_duration, classroom.estimated_duration)
@@ -277,6 +290,7 @@ def test_api_classroom_update_put_instructor_scheduling(
 
         content = json.loads(response.content)
         invite_token = content.pop("invite_token")
+        instructor_token = content.pop("instructor_token")
         self.assertDictEqual(
             {
                 "id": str(classroom.id),
@@ -297,6 +311,7 @@ def test_api_classroom_update_put_instructor_scheduling(
                 "estimated_duration": "00:01:00",
                 "recordings": [],
                 # invite_token is tested below
+                # instructor_token is tested below
             },
             content,
         )
@@ -310,6 +325,16 @@ def test_api_classroom_update_put_instructor_scheduling(
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token, verify=False)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
         classroom.refresh_from_db()
         self.assertEqual(starting_at, classroom.starting_at)
         self.assertEqual(estimated_duration, classroom.estimated_duration)
diff --git a/src/backend/marsha/bbb/tests/test_utils_tokens.py b/src/backend/marsha/bbb/tests/test_utils_tokens.py
--- a/src/backend/marsha/bbb/tests/test_utils_tokens.py
+++ b/src/backend/marsha/bbb/tests/test_utils_tokens.py
@@ -8,6 +8,7 @@
 
 from marsha.bbb.factories import ClassroomFactory
 from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt
+from marsha.core.models import INSTRUCTOR
 from marsha.core.tests.testing_utils import reload_urlconf
 
 
@@ -190,3 +191,56 @@ def test_classroom_stable_invite_jwt_allows_access_to_classroom(self):
             "role=viewer&userID=None_None&redirect=true",
             response.data.get("url"),
         )
+
+    def test_jwt_content_instructor(self):
+        """Assert the payload contains the expected role."""
+        now_fixed = datetime(2020, 8, 8, tzinfo=timezone.utc)
+        with mock.patch.object(timezone, "now", return_value=now_fixed):
+            classroom = ClassroomFactory(
+                pk="ad0395fd-3023-45da-8801-93d1ce64acd5",
+                created_on=timezone.now(),
+                starting_at=datetime(2020, 8, 20, 14, tzinfo=timezone.utc),
+                estimated_duration=timedelta(hours=2),
+            )
+            jwt = create_classroom_stable_invite_jwt(classroom, role=INSTRUCTOR)
+
+        self.assertDictEqual(
+            jwt.payload,
+            {
+                "exp": 1598104800,  # Saturday 22 August 2020 14:00:00 UTC
+                "iat": 1596844800,  # Saturday 8 August 2020 00:00:00 UTC
+                "jti": "classroom-invite-ad0395fd-3023-45da-8801-93d1ce64acd5-2020-08-08",
+                "locale": "en_US",
+                "maintenance": False,
+                "permissions": {"can_access_dashboard": False, "can_update": False},
+                "resource_id": "ad0395fd-3023-45da-8801-93d1ce64acd5",
+                "roles": ["instructor"],
+                "session_id": "ad0395fd-3023-45da-8801-93d1ce64acd5-invite",
+                "token_type": "resource_access",
+            },
+        )
+
+    @override_settings(BBB_API_ENDPOINT="https://10.7.7.1/bigbluebutton/api")
+    @override_settings(BBB_API_SECRET="SuperSecret")
+    @override_settings(BBB_ENABLED=True)
+    def test_classroom_stable_invite_jwt_allows_instructor_access_to_classroom(self):
+        """Assert the JWT allows to join to the classroom as instructor."""
+        # Force URLs reload to use BBB_ENABLED
+        reload_urlconf()
+
+        classroom = ClassroomFactory()
+        jwt = create_classroom_stable_invite_jwt(classroom, role=INSTRUCTOR)
+
+        response = self.client.patch(
+            f"/api/classrooms/{classroom.id}/join/",
+            data=json.dumps({"fullname": "John Doe"}),
+            content_type="application/json",
+            HTTP_AUTHORIZATION=f"Bearer {jwt}",
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(
+            "https://10.7.7.1/bigbluebutton/api/join?"
+            f"fullName=John+Doe&meetingID={classroom.meeting_id}&"
+            "role=moderator&userID=None_None&redirect=true",
+            response.data.get("url"),
+        )
diff --git a/src/backend/marsha/bbb/tests/test_views_lti.py b/src/backend/marsha/bbb/tests/test_views_lti.py
--- a/src/backend/marsha/bbb/tests/test_views_lti.py
+++ b/src/backend/marsha/bbb/tests/test_views_lti.py
@@ -20,14 +20,14 @@
     UserFactory,
 )
 from marsha.core.lti import LTI
-from marsha.core.models import ADMINISTRATOR, NONE
+from marsha.core.models import ADMINISTRATOR, INSTRUCTOR, NONE
 from marsha.core.simple_jwt.tokens import ResourceAccessToken
 from marsha.core.tests.testing_utils import reload_urlconf
 from marsha.core.tests.views.test_lti_base import BaseLTIViewForPortabilityTestCase
 
 
 # We don't enforce arguments classroomation in tests
-# pylint: disable=unused-argument
+# pylint: disable=unused-argument,too-many-locals
 
 
 @override_settings(BBB_API_ENDPOINT="https://10.7.7.1/bigbluebutton/api")
@@ -277,6 +277,7 @@ def test_views_lti_classroom_instructor_same_playlist(
 
         resource_data = context.get("resource")
         invite_token = resource_data.pop("invite_token")
+        instructor_token = resource_data.pop("instructor_token")
         self.assertEqual(
             {
                 "id": str(classroom.id),
@@ -297,6 +298,7 @@ def test_views_lti_classroom_instructor_same_playlist(
                 "estimated_duration": None,
                 "recordings": [],
                 # "invite_token" is tested separately
+                # "instructor_token" is tested separately
             },
             resource_data,
         )
@@ -308,6 +310,16 @@ def test_views_lti_classroom_instructor_same_playlist(
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
         self.assertEqual(context.get("modelName"), "classrooms")
         self.assertEqual(context.get("appName"), "classroom")
         self.assertEqual(
@@ -624,6 +636,7 @@ def test_views_lti_classroom_instructor_same_playlist(
 
         resource_data = context.get("resource")
         invite_token = resource_data.pop("invite_token")
+        instructor_token = resource_data.pop("instructor_token")
         self.assertEqual(
             {
                 "id": str(classroom.id),
@@ -644,6 +657,7 @@ def test_views_lti_classroom_instructor_same_playlist(
                 "estimated_duration": None,
                 "recordings": [],
                 # "invite_token" is tested separately
+                # "instructor_token" is tested separately
             },
             resource_data,
         )
@@ -655,6 +669,16 @@ def test_views_lti_classroom_instructor_same_playlist(
             {"can_update": False, "can_access_dashboard": False},
         )
 
+        decoded_instructor_token = ResourceAccessToken(instructor_token)
+        self.assertEqual(
+            decoded_instructor_token.payload["resource_id"], str(classroom.id)
+        )
+        self.assertEqual(decoded_instructor_token.payload["roles"], [INSTRUCTOR])
+        self.assertEqual(
+            decoded_instructor_token.payload["permissions"],
+            {"can_update": True, "can_access_dashboard": True},
+        )
+
         self.assertEqual(context.get("modelName"), "classrooms")
         self.assertEqual(context.get("appName"), "classroom")
         self.assertEqual(
diff --git a/src/backend/marsha/e2e/test_lti_bbb.py b/src/backend/marsha/e2e/test_lti_bbb.py
--- a/src/backend/marsha/e2e/test_lti_bbb.py
+++ b/src/backend/marsha/e2e/test_lti_bbb.py
@@ -360,7 +360,7 @@ def test_lti_bbb_create_enabled(page: Page, live_server: LiveServer, settings):
 
     expect(page.get_by_text("LTI link for this classroom:")).to_be_visible()
     invite_link_button = page.get_by_role(
-        "button", name="Invite someone with this link:"
+        "button", name="Invite a viewer with this link:"
     )
     invite_link_button.click()
     expect(page.get_by_text("Url copied in clipboard !")).to_be_visible()
diff --git a/src/backend/marsha/e2e/test_site.py b/src/backend/marsha/e2e/test_site.py
--- a/src/backend/marsha/e2e/test_site.py
+++ b/src/backend/marsha/e2e/test_site.py
@@ -157,7 +157,7 @@ def test_site_classroom_invite_link(context: BrowserContext, live_server: LiveSe
 
     expect(page.get_by_text("LTI link for this classroom:")).to_be_visible()
     invite_link_button = page.get_by_role(
-        "button", name="Invite someone with this link:"
+        "button", name="Invite a viewer with this link:"
     )
     invite_link_button.click()
     expect(page.get_by_text("Url copied in clipboard !")).to_be_visible()
@@ -175,6 +175,103 @@ def test_site_classroom_invite_link(context: BrowserContext, live_server: LiveSe
     expect(invite_page.get_by_text("Classroom not started yet.")).to_be_visible()
 
 
+@pytest.mark.usefixtures("user_logged_in")
+@responses.activate
+@pytest.mark.django_db()
+@override_settings(
+    DEBUG=True,
+    FRONT_UPLOAD_POLL_INTERVAL="1",
+    STORAGE_BACKEND="marsha.core.storage.dummy",
+    X_FRAME_OPTIONS="",
+    BBB_API_ENDPOINT="https://10.7.7.1/bigbluebutton/api",
+    BBB_API_SECRET="SuperSecret",
+)
+def test_site_classroom_moderator_link(
+    context: BrowserContext, live_server: LiveServer
+):
+    """Test site moderator link."""
+
+    # Backend requests mocking
+    responses.add(
+        responses.GET,
+        "https://10.7.7.1/bigbluebutton/api/create",
+        body="""<response>
+            <returncode>SUCCESS</returncode>
+            <meetingID>12345</meetingID>
+            <internalMeetingID>232a8ab5dbfde4d33a2bd9d5bbc08bd74d04e163-1628693645640</internalMeetingID>
+            <parentMeetingID>bbb-none</parentMeetingID>
+            <attendeePW>9#R1kuUl3R</attendeePW>
+            <moderatorPW>0$C7Aaz0o</moderatorPW>
+            <createTime>1628693645640</createTime>
+            <voiceBridge>83267</voiceBridge>
+            <dialNumber>613-555-1234</dialNumber>
+            <createDate>Wed Aug 11 14:54:05 UTC 2021</createDate>
+            <hasUserJoined>false</hasUserJoined>
+            <duration>0</duration>
+            <hasBeenForciblyEnded>false</hasBeenForciblyEnded>
+            <messageKey></messageKey>
+            <message></message>
+        </response>
+        """,
+        status=200,
+    )
+    responses.add(
+        responses.GET,
+        "https://10.7.7.1/bigbluebutton/api/getMeetingInfo",
+        body="""<response>
+            <returncode>FAILED</returncode>
+            <messageKey>notFound</messageKey>
+            <message>We could not find a meeting with that meeting ID</message>
+        </response>
+        """,
+        status=200,
+    )
+    responses.add(
+        responses.GET,
+        "https://10.7.7.1/bigbluebutton/api/join",
+        body="""
+        <response>
+            <returncode>SUCCESS</returncode>
+            <messageKey>successfullyJoined</messageKey>
+            <message>You have joined successfully.</message>
+            <meeting_id>74f4b5fefa00d05889a9095d1c81c51f704a74c0-1632323106549</meeting_id>
+            <user_id>w_cmlgpuqzkqez</user_id>
+            <auth_token>pcwfqes0ugkb</auth_token>
+            <session_token>4vtuguoqsolsqkqi</session_token>
+            <guestStatus>ALLOW</guestStatus>
+            <url>https://10.7.7.1/html5client/join?sessionToken=4vtuguoqsolsqkqi</url>
+        </response>
+        """,
+        status=200,
+    )
+    page = context.pages[0]
+    expect(page.get_by_role("menuitem", name="Dashboard")).to_be_visible()
+    expect(page.get_by_role("menuitem", name="My Playlists")).to_be_visible()
+    expect(page.get_by_role("menuitem", name="My Contents")).to_be_visible()
+    page.get_by_text("Classroom test").click()
+
+    expect(page.get_by_text("LTI link for this classroom:")).to_be_visible()
+    moderator_link_button = page.get_by_role(
+        "button", name="Invite a moderator with this link:"
+    )
+    moderator_link_button.click()
+    expect(page.get_by_text("Url copied in clipboard !")).to_be_visible()
+
+    moderator_page = page.context.new_page()
+    # tries to get the clipboard content, if it fails, it uses the data-clipboard-text
+    # attribute of the button
+    # see https://github.com/microsoft/playwright/issues/8114
+    try:
+        context.grant_permissions(["clipboard-read", "clipboard-write"])
+        moderator_link = page.evaluate("navigator.clipboard.readText()")
+    except Error:
+        moderator_link = moderator_link_button.get_attribute("data-clipboard-text")
+    moderator_page.goto(moderator_link)
+    expect(
+        moderator_page.get_by_text("Launch the classroom now in BBB")
+    ).to_be_visible()
+
+
 @pytest.mark.usefixtures("user_logged_in")
 @responses.activate
 @pytest.mark.django_db()
diff --git a/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts b/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts
--- a/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts
+++ b/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts
@@ -27,6 +27,7 @@ export const classroomMockFactory = <T extends Partial<Classroom>>(
     starting_at: null,
     estimated_duration: null,
     invite_token: null,
+    instructor_token: null,
     recordings: [],
     ...classroom,
   };