Browse Source
online class tests added : sanity tests for online class webhooks added and checked
master
online class tests added : sanity tests for online class webhooks added and checked
master
4 changed files with 651 additions and 1 deletions
-
517apps/course/management/commands/smoke_test_online_class_webhooks.py
-
4apps/course/serializers/online.py
-
3apps/course/views/live_session.py
-
128docs/online_class_webhook_smoke_test.md
@ -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, |
|||
) |
|||
@ -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. |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue