diff --git a/apps/course/management/commands/smoke_test_online_class_webhooks.py b/apps/course/management/commands/smoke_test_online_class_webhooks.py new file mode 100644 index 0000000..aa1ff50 --- /dev/null +++ b/apps/course/management/commands/smoke_test_online_class_webhooks.py @@ -0,0 +1,517 @@ +import hashlib +import hmac +import json +import time +from typing import Callable, Dict, List, Optional, Tuple + +import requests +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.account.models import User +from apps.course.models import Course, CourseLiveSession, LiveSessionUser, Participant + + +class Command(BaseCommand): + help = ( + "Run a real HTTP smoke test against the public PlugNMeet webhook endpoint " + "and verify the expected database side effects for online-class webhooks." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--base-url", + dest="base_url", + default="", + help="Public backend base URL. Defaults to SITE_DOMAIN.", + ) + parser.add_argument( + "--session-id", + dest="session_id", + type=int, + help="Use an existing active live session by database id.", + ) + parser.add_argument( + "--course-id", + dest="course_id", + type=int, + help="Course id to use when creating a disposable smoke-test live session.", + ) + parser.add_argument( + "--create-session", + action="store_true", + dest="create_session", + help="Create a disposable active live session for the smoke test.", + ) + parser.add_argument( + "--participant-user-id", + dest="participant_user_id", + type=int, + help="Student user id to use for participant_joined/left checks.", + ) + parser.add_argument( + "--moderator-user-id", + dest="moderator_user_id", + type=int, + help="Professor/admin user id to use for moderator join/leave checks.", + ) + parser.add_argument( + "--final-event", + dest="final_event", + choices=["room_finished", "session_ended"], + default="room_finished", + help="Final closing webhook to send at the end of the flow.", + ) + parser.add_argument( + "--skip-final-close", + action="store_true", + dest="skip_final_close", + help="Skip the final closing webhook. Useful if you only want join/leave checks.", + ) + parser.add_argument( + "--timeout", + dest="timeout", + type=float, + default=10.0, + help="HTTP timeout in seconds for each webhook request.", + ) + parser.add_argument( + "--include-invalid-signature", + action="store_true", + dest="include_invalid_signature", + help="Also verify that the endpoint rejects a bad signature with HTTP 403.", + ) + + def handle(self, *args, **options): + secret = getattr(settings, "PLUGNMEET_API_SECRET", "") + if not secret: + raise CommandError("PLUGNMEET_API_SECRET is not configured.") + + webhook_url = self._build_webhook_url(options["base_url"]) + session, created_session = self._resolve_session( + session_id=options.get("session_id"), + course_id=options.get("course_id"), + create_session=options.get("create_session", False), + ) + moderator, participant = self._resolve_users( + session=session, + moderator_user_id=options.get("moderator_user_id"), + participant_user_id=options.get("participant_user_id"), + ) + + self.stdout.write( + self.style.WARNING( + f"Webhook smoke test target: {webhook_url}\n" + f"Session #{session.id} room_id={session.room_id}\n" + f"Moderator user_id={moderator.id} Participant user_id={participant.id}" + ) + ) + + results: List[Tuple[str, bool, str]] = [] + + if options.get("include_invalid_signature"): + results.append( + self._run_invalid_signature_check( + webhook_url=webhook_url, + session=session, + timeout=options["timeout"], + ) + ) + + try: + results.append( + self._run_step( + name="participant_joined(participant)", + webhook_url=webhook_url, + payload=self._participant_payload( + event="participant_joined", + room_id=session.room_id, + user=participant, + state="ACTIVE", + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_live_session_user_state( + session_id=session.id, + user_id=participant.id, + expected_online=True, + expected_role="participant", + ), + ) + ) + results.append( + self._run_step( + name="participant_joined(moderator)", + webhook_url=webhook_url, + payload=self._participant_payload( + event="participant_joined", + room_id=session.room_id, + user=moderator, + state="ACTIVE", + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_moderator_join_state( + session_id=session.id, + user_id=moderator.id, + ), + ) + ) + results.append( + self._run_step( + name="participant_left(moderator)", + webhook_url=webhook_url, + payload=self._participant_payload( + event="participant_left", + room_id=session.room_id, + user=moderator, + state="DISCONNECTED", + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_moderator_leave_state(session_id=session.id), + ) + ) + results.append( + self._run_step( + name="participant_joined(moderator-again)", + webhook_url=webhook_url, + payload=self._participant_payload( + event="participant_joined", + room_id=session.room_id, + user=moderator, + state="ACTIVE", + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_moderator_join_state( + session_id=session.id, + user_id=moderator.id, + ), + ) + ) + results.append( + self._run_step( + name="participant_left(participant)", + webhook_url=webhook_url, + payload=self._participant_payload( + event="participant_left", + room_id=session.room_id, + user=participant, + state="DISCONNECTED", + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_live_session_user_state( + session_id=session.id, + user_id=participant.id, + expected_online=False, + expected_role="participant", + ), + ) + ) + + if not options.get("skip_final_close"): + results.append( + self._run_step( + name=options["final_event"], + webhook_url=webhook_url, + payload=self._room_payload( + event=options["final_event"], + room_id=session.room_id, + ), + secret=secret, + timeout=options["timeout"], + expected_status=200, + verify=lambda: self._assert_session_closed(session_id=session.id), + ) + ) + finally: + if created_session and options.get("skip_final_close"): + self._close_session_locally(session) + + failed = [name for name, ok, _ in results if not ok] + for name, ok, details in results: + style = self.style.SUCCESS if ok else self.style.ERROR + prefix = "PASS" if ok else "FAIL" + self.stdout.write(style(f"[{prefix}] {name}: {details}")) + + if failed: + raise CommandError( + "Webhook smoke test failed for: " + ", ".join(failed) + ) + + self.stdout.write(self.style.SUCCESS("All webhook smoke-test checks passed.")) + + def _build_webhook_url(self, base_url: str) -> str: + normalized = (base_url or getattr(settings, "SITE_DOMAIN", "")).strip().rstrip("/") + if not normalized: + raise CommandError( + "Unable to determine public base URL. Provide --base-url or configure SITE_DOMAIN." + ) + if not normalized.startswith(("http://", "https://")): + normalized = f"https://{normalized}" + return f"{normalized}/api/courses/plugnmeet/webhook/" + + def _resolve_session( + self, + *, + session_id: Optional[int], + course_id: Optional[int], + create_session: bool, + ) -> Tuple[CourseLiveSession, bool]: + if session_id and create_session: + raise CommandError("Use either --session-id or --create-session with --course-id, not both.") + + if session_id: + try: + session = CourseLiveSession.objects.select_related("course").get( + id=session_id, + ended_at__isnull=True, + ) + except CourseLiveSession.DoesNotExist as exc: + raise CommandError(f"Active live session #{session_id} was not found.") from exc + return session, False + + if create_session: + if not course_id: + raise CommandError("--course-id is required when using --create-session.") + try: + course = Course.objects.prefetch_related("professors", "participants").get(id=course_id) + except Course.DoesNotExist as exc: + raise CommandError(f"Course #{course_id} was not found.") from exc + + room_id = f"smoke-room-{course.id}-{int(time.time())}" + next_session_number = course.live_sessions.count() + 1 + session = CourseLiveSession.objects.create( + course=course, + room_id=room_id, + subject=f"Webhook Smoke Test {next_session_number}", + started_at=timezone.now(), + ) + return session, True + + raise CommandError( + "Provide --session-id for an existing active session, or use --create-session with --course-id." + ) + + def _resolve_users( + self, + *, + session: CourseLiveSession, + moderator_user_id: Optional[int], + participant_user_id: Optional[int], + ) -> Tuple[User, User]: + if moderator_user_id: + moderator = self._get_user_or_error(moderator_user_id, "moderator") + else: + moderator = session.course.professors.first() + if not moderator: + raise CommandError( + f"Course #{session.course_id} has no professor. Pass --moderator-user-id explicitly." + ) + + if not moderator.can_manage_course(session.course): + raise CommandError( + f"User #{moderator.id} cannot manage course #{session.course_id}; cannot use as moderator." + ) + + if participant_user_id: + participant = self._get_user_or_error(participant_user_id, "participant") + else: + participant_relation = ( + Participant.objects.select_related("student") + .filter(course=session.course, is_active=True) + .first() + ) + if not participant_relation: + raise CommandError( + f"Course #{session.course_id} has no active participant. Pass --participant-user-id explicitly." + ) + participant = participant_relation.student + + if participant.can_manage_course(session.course): + raise CommandError( + f"User #{participant.id} can manage course #{session.course_id}; choose a non-moderator participant." + ) + + return moderator, participant + + def _get_user_or_error(self, user_id: int, role_label: str) -> User: + try: + return User.objects.get(id=user_id) + except User.DoesNotExist as exc: + raise CommandError(f"{role_label.title()} user #{user_id} was not found.") from exc + + def _run_invalid_signature_check( + self, + *, + webhook_url: str, + session: CourseLiveSession, + timeout: float, + ) -> Tuple[str, bool, str]: + payload = self._room_payload(event="room_created", room_id=session.room_id) + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + response = requests.post( + webhook_url, + data=body, + headers={ + "Content-Type": "application/webhook+json", + "Hash-Token": "invalid-signature", + }, + timeout=timeout, + ) + ok = response.status_code == 403 + detail = f"expected 403, got {response.status_code}" + return ("invalid_signature", ok, detail) + + def _run_step( + self, + *, + name: str, + webhook_url: str, + payload: Dict, + secret: str, + timeout: float, + expected_status: int, + verify: Callable[[], str], + ) -> Tuple[str, bool, str]: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + signature = hmac.new( + secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + response = requests.post( + webhook_url, + data=body, + headers={ + "Content-Type": "application/webhook+json", + "Hash-Token": signature, + }, + timeout=timeout, + ) + if response.status_code != expected_status: + return ( + name, + False, + f"expected HTTP {expected_status}, got {response.status_code}: {response.text[:300]}", + ) + + try: + verification_message = verify() + except AssertionError as exc: + return (name, False, str(exc)) + + return (name, True, verification_message) + + def _participant_payload(self, *, event: str, room_id: str, user: User, state: str) -> Dict: + return { + "event": event, + "room": { + "name": room_id, + }, + "participant": { + "identity": str(user.id), + "name": getattr(user, "fullname", None) or getattr(user, "email", str(user.id)), + "state": state, + }, + } + + def _room_payload(self, *, event: str, room_id: str) -> Dict: + return { + "event": event, + "room": { + "name": room_id, + }, + } + + def _assert_live_session_user_state( + self, + *, + session_id: int, + user_id: int, + expected_online: bool, + expected_role: str, + ) -> str: + try: + session_user = LiveSessionUser.objects.get(session_id=session_id, user_id=user_id) + except LiveSessionUser.DoesNotExist as exc: + raise AssertionError( + f"LiveSessionUser missing for session={session_id} user={user_id}." + ) from exc + + if session_user.role != expected_role: + raise AssertionError( + f"Expected role={expected_role} for user={user_id}, got role={session_user.role}." + ) + if session_user.is_online != expected_online: + raise AssertionError( + f"Expected is_online={expected_online} for user={user_id}, got {session_user.is_online}." + ) + if expected_online and session_user.exited_at is not None: + raise AssertionError(f"Expected exited_at to be null for online user={user_id}.") + if not expected_online and session_user.exited_at is None: + raise AssertionError(f"Expected exited_at to be set for offline user={user_id}.") + return ( + f"user={user_id} role={session_user.role} " + f"is_online={session_user.is_online}" + ) + + def _assert_moderator_join_state(self, *, session_id: int, user_id: int) -> str: + message = self._assert_live_session_user_state( + session_id=session_id, + user_id=user_id, + expected_online=True, + expected_role="moderator", + ) + session = CourseLiveSession.objects.get(id=session_id) + if session.last_moderator_left_at is not None or session.auto_close_after_moderator_exit_at is not None: + raise AssertionError( + "Expected moderator rejoin to clear pending auto-close fields, but they are still set." + ) + return f"{message}; auto-close fields cleared" + + def _assert_moderator_leave_state(self, *, session_id: int) -> str: + session = CourseLiveSession.objects.get(id=session_id) + moderator_online = LiveSessionUser.objects.filter( + session_id=session_id, + role="moderator", + is_online=True, + ).exists() + if moderator_online: + raise AssertionError("Expected no moderators online after moderator leave webhook.") + if session.last_moderator_left_at is None: + raise AssertionError("Expected last_moderator_left_at to be set after moderator leave.") + if session.auto_close_after_moderator_exit_at is None: + raise AssertionError("Expected auto_close_after_moderator_exit_at to be set after moderator leave.") + return "moderator offline; auto-close fields scheduled" + + def _assert_session_closed(self, *, session_id: int) -> str: + session = CourseLiveSession.objects.get(id=session_id) + if session.ended_at is None: + raise AssertionError("Expected session.ended_at to be set after closing webhook.") + online_users = LiveSessionUser.objects.filter(session_id=session_id, is_online=True).count() + if online_users: + raise AssertionError( + f"Expected all session users to be offline after close; found {online_users} online." + ) + return f"session ended_at={session.ended_at.isoformat()} and all users offline" + + def _close_session_locally(self, session: CourseLiveSession) -> None: + now = timezone.now() + CourseLiveSession.objects.filter(id=session.id, ended_at__isnull=True).update( + ended_at=now, + updated_at=now, + ) + LiveSessionUser.objects.filter(session=session, is_online=True).update( + is_online=False, + exited_at=now, + updated_at=now, + ) diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py index 0ed1eb9..8893174 100644 --- a/apps/course/serializers/online.py +++ b/apps/course/serializers/online.py @@ -43,6 +43,10 @@ class LiveSessionTokenSerializer(serializers.Serializer): return value +class LiveSessionRecordingControlSerializer(serializers.Serializer): + action = serializers.ChoiceField(choices=["start", "stop"], required=True) + + class LiveSessionRecordedFileSerializer(serializers.Serializer): recorded_file = serializers.FileField(required=True) diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index eb37b7b..0b3ae02 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -16,7 +16,7 @@ import time import jwt from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording, LiveSessionUser from apps.course.models.lesson import Lesson, CourseLesson -from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer +from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer, LiveSessionRecordingControlSerializer from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from apps.course.services.web_egress import WebEgressClient, WebEgressError, stop_session_web_egress_if_active from utils.exceptions import AppAPIException @@ -759,6 +759,7 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): class CourseLiveSessionRecordingAPIView(GenericAPIView): permission_classes = [AllowAny] authentication_classes = [] + serializer_class = LiveSessionRecordingControlSerializer @swagger_auto_schema( operation_description="Get or control WebEgress recording state for the current room.", diff --git a/docs/online_class_webhook_smoke_test.md b/docs/online_class_webhook_smoke_test.md new file mode 100644 index 0000000..c24f997 --- /dev/null +++ b/docs/online_class_webhook_smoke_test.md @@ -0,0 +1,128 @@ +# Online Class Webhook Smoke Test + +This smoke test sends **real HTTP requests** to the public PlugNMeet webhook endpoint and then verifies the expected database side effects. + +It is useful when you want to validate: + +- Nginx/public routing to `/api/courses/plugnmeet/webhook/` +- signature verification with `PLUGNMEET_API_SECRET` +- Django webhook processing +- `CourseLiveSession` updates +- `LiveSessionUser` join/leave updates +- moderator auto-close scheduling + +## Command + +Run from the backend project: + +```bash +python manage.py smoke_test_online_class_webhooks --help +``` + +## Recommended Safe Mode + +Use a **disposable** live session instead of a real class session: + +```bash +python manage.py smoke_test_online_class_webhooks \ + --create-session \ + --course-id 16 \ + --base-url https://imamjavad.online \ + --include-invalid-signature +``` + +What this does: + +1. creates a temporary active `CourseLiveSession` +2. sends real webhook HTTP requests to the public endpoint +3. verifies DB side effects after each webhook +4. closes the temporary session at the end + +## Use an Existing Active Session + +If you want to test against a currently active session: + +```bash +python manage.py smoke_test_online_class_webhooks \ + --session-id 123 \ + --base-url https://imamjavad.online +``` + +Be careful: the final closing webhook will close that session unless you pass `--skip-final-close`. + +## Optional User Overrides + +By default, the command auto-picks: + +- moderator: first professor of the course +- participant: first active participant of the course + +You can override them: + +```bash +python manage.py smoke_test_online_class_webhooks \ + --create-session \ + --course-id 16 \ + --moderator-user-id 279 \ + --participant-user-id 315 \ + --base-url https://imamjavad.online +``` + +## Webhooks Covered + +The smoke test currently verifies this flow: + +1. `participant_joined` for a student +2. `participant_joined` for a moderator +3. `participant_left` for the moderator +4. `participant_joined` for the moderator again +5. `participant_left` for the student +6. final close event: `room_finished` or `session_ended` + +Optional: + +- invalid signature should return `403` + +## What Each Step Verifies + +- participant join creates or updates `LiveSessionUser` with `is_online=True` +- moderator join creates a moderator `LiveSessionUser` and clears pending auto-close fields +- moderator leave sets `last_moderator_left_at` and `auto_close_after_moderator_exit_at` +- participant leave marks the participant offline +- final close sets `ended_at` and forces all session users offline + +## Useful Flags + +- `--include-invalid-signature` + - sends one bad-signature request and expects HTTP `403` +- `--skip-final-close` + - skips the final closing webhook +- `--final-event room_finished` + - default final close event +- `--final-event session_ended` + - use the alternate close event +- `--timeout 20` + - set HTTP timeout in seconds + +## How To Read Results + +The command prints one line per step: + +- `[PASS] ...` +- `[FAIL] ...` + +If any step fails, the command exits with a non-zero status. + +## Recommended Production Usage + +Prefer this pattern: + +1. choose a dedicated test course +2. ensure the course has at least one professor and one active participant +3. run the command with `--create-session` +4. review: + - command output + - Django logs + - Nginx logs + +This avoids mutating a real student-facing live class.